Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

2727 lines
89 KiB
Go

// Copyright 2025 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controller
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1"
taskscheduler "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/scheduler"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/expectations"
"github.com/alibaba/OpenSandbox/sandbox-k8s/internal/utils/fieldindex"
taskexecutor "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/task-executor"
)
// newTestReconciler creates a BatchSandboxReconciler with a fake client for testing.
func newTestReconciler(objs ...client.Object) *BatchSandboxReconciler {
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithIndex(&corev1.Pod{}, fieldindex.IndexNameForOwnerRefUID, fieldindex.OwnerIndexFunc).
WithStatusSubresource(
&sandboxv1alpha1.BatchSandbox{},
&sandboxv1alpha1.SandboxSnapshot{},
).
WithObjects(objs...).
Build()
return &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
}
type forbiddenTaskScheduler struct {
t *testing.T
}
func (f *forbiddenTaskScheduler) Schedule() error {
f.t.Fatalf("task scheduler should not be invoked while sandbox is paused")
return nil
}
func (f *forbiddenTaskScheduler) UpdatePods(_ []*corev1.Pod) {
f.t.Fatalf("task scheduler should not receive pod updates while sandbox is paused")
}
func (f *forbiddenTaskScheduler) ListTask() []taskscheduler.Task {
f.t.Fatalf("task scheduler should not list tasks while sandbox is paused")
return nil
}
func (f *forbiddenTaskScheduler) StopTask() []taskscheduler.Task {
f.t.Fatalf("task scheduler should not stop tasks while sandbox is paused")
return nil
}
func (f *forbiddenTaskScheduler) AddTasks(_ []*taskexecutor.Task) error {
f.t.Fatalf("task scheduler should not add tasks while sandbox is paused")
return nil
}
type fakeSchedulerTask struct {
name string
state taskscheduler.TaskState
podName string
released bool
}
func (f fakeSchedulerTask) GetName() string {
return f.name
}
func (f fakeSchedulerTask) GetState() taskscheduler.TaskState {
return f.state
}
func (f fakeSchedulerTask) GetPodName() string {
return f.podName
}
func (f fakeSchedulerTask) IsResourceReleased() bool {
return f.released
}
type recordingTaskScheduler struct {
updatePodsCalls int
scheduleCalls int
stopCalls int
tasks []taskscheduler.Task
}
func (r *recordingTaskScheduler) Schedule() error {
r.scheduleCalls++
return nil
}
func (r *recordingTaskScheduler) UpdatePods(_ []*corev1.Pod) {
r.updatePodsCalls++
}
func (r *recordingTaskScheduler) ListTask() []taskscheduler.Task {
return r.tasks
}
func (r *recordingTaskScheduler) StopTask() []taskscheduler.Task {
r.stopCalls++
return r.tasks
}
func (r *recordingTaskScheduler) AddTasks(_ []*taskexecutor.Task) error {
return nil
}
// ---------- dispatchPauseResume 5-case tests ----------
func TestDispatchPauseResume_Case1_PauseTrue(t *testing.T) {
// gen > pauseObservedGen, pause=true → handlePause dispatched
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
},
}
r := newTestReconciler(bs)
result, handled, err := r.dispatchPauseResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, handled, "should be handled by handlePause")
assert.True(t, result.RequeueAfter > 0, "handlePause should requeue")
// Verify ACK: phase should be Pausing
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePausing, updated.Status.Phase)
assert.Equal(t, int64(2), updated.Status.PauseObservedGeneration)
}
func TestDispatchPauseResume_Case2_PauseFalse(t *testing.T) {
// gen > pauseObservedGen, pause=false → handleResume dispatched
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhasePaused,
},
}
r := newTestReconciler(bs, snapshot)
result, handled, err := r.dispatchPauseResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, handled, "should be handled by handleResume")
assert.True(t, result.RequeueAfter > 0, "handleResume should requeue")
// Verify ACK: phase=Resuming
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseResuming, updated.Status.Phase)
}
func TestDispatchPauseResume_Case3_PauseNil_ACKOnly(t *testing.T) {
// gen > pauseObservedGen, pause=nil → no dedicated ACK API call, continue normal flow (handled=false).
// The ACK is deferred to persistRuntimeView in the main reconcile loop.
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
},
}
r := newTestReconciler(bs)
result, handled, err := r.dispatchPauseResume(context.Background(), bs)
require.NoError(t, err)
assert.False(t, handled, "ACK only should not block normal flow")
assert.Equal(t, ctrl.Result{}, result)
// Verify ACK is NOT written to server by dispatch (deferred to persistRuntimeView).
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int64(1), updated.Status.PauseObservedGeneration, "server should not be updated by dispatch; ACK is deferred")
}
func TestDispatchPauseResume_Case4_GenEqual_PauseSet(t *testing.T) {
// gen == pauseObservedGen, pause != nil → syncPauseOrClear
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseCommitting,
},
}
r := newTestReconciler(bs, snapshot)
result, handled, err := r.dispatchPauseResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, handled, "syncPauseOrClear should handle this")
assert.True(t, result.RequeueAfter > 0, "committing snapshot should requeue")
}
func TestDispatchPauseResume_Case5_GenEqual_PauseNil(t *testing.T) {
// gen == pauseObservedGen, pause == nil → normal flow (handled=false)
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
},
}
r := newTestReconciler(bs)
result, handled, err := r.dispatchPauseResume(context.Background(), bs)
require.NoError(t, err)
assert.False(t, handled, "normal flow should not be blocked")
assert.Equal(t, ctrl.Result{}, result)
}
// ---------- handlePause tests ----------
func TestHandlePause_NormalFlow(t *testing.T) {
// Normal pause: ACK, create SandboxSnapshot, verify phase=Pausing
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
},
}
r := newTestReconciler(bs)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify ACK: phase=Pausing, pauseObservedGeneration=2
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePausing, updated.Status.Phase)
assert.Equal(t, int64(2), updated.Status.PauseObservedGeneration)
// Verify SandboxSnapshot was created under the reserved internal name
snap := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap))
assert.Equal(t, "test-bs", snap.Spec.SandboxName)
// Verify OwnerRef
assert.Equal(t, "test-bs", snap.OwnerReferences[0].Name)
}
func TestHandlePause_WaitsForTaskCleanupBeforeCreatingSnapshot(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{
Spec: sandboxv1alpha1.TaskSpec{
Process: &sandboxv1alpha1.ProcessTask{Command: []string{"sleep", "3600"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: sandboxv1alpha1.GroupVersion.String(),
Kind: "BatchSandbox",
Name: "test-bs",
UID: "test-uid",
}},
},
Status: corev1.PodStatus{PodIP: "10.0.0.1"},
}
r := newTestReconciler(bs, pod)
scheduler := &recordingTaskScheduler{
tasks: []taskscheduler.Task{fakeSchedulerTask{
name: "test-bs-0",
state: taskscheduler.RunningTaskState,
podName: "test-bs-0",
released: false,
}},
}
r.taskSchedulers.Store(types.NamespacedName{Namespace: "default", Name: "test-bs"}.String(), scheduler)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
assert.Equal(t, 1, scheduler.updatePodsCalls)
assert.Equal(t, 1, scheduler.stopCalls)
assert.Equal(t, 1, scheduler.scheduleCalls)
snap := &sandboxv1alpha1.SandboxSnapshot{}
err = r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap)
assert.True(t, apierrors.IsNotFound(err), "snapshot must wait until tasks are released")
}
func TestHandlePause_CreatesSnapshotAfterTaskCleanup(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{
Spec: sandboxv1alpha1.TaskSpec{
Process: &sandboxv1alpha1.ProcessTask{Command: []string{"sleep", "3600"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: sandboxv1alpha1.GroupVersion.String(),
Kind: "BatchSandbox",
Name: "test-bs",
UID: "test-uid",
}},
},
Status: corev1.PodStatus{PodIP: "10.0.0.1"},
}
r := newTestReconciler(bs, pod)
scheduler := &recordingTaskScheduler{
tasks: []taskscheduler.Task{fakeSchedulerTask{
name: "test-bs-0",
state: taskscheduler.RunningTaskState,
podName: "test-bs-0",
released: true,
}},
}
r.taskSchedulers.Store(types.NamespacedName{Namespace: "default", Name: "test-bs"}.String(), scheduler)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
assert.Equal(t, 1, scheduler.stopCalls)
assert.Equal(t, 1, scheduler.scheduleCalls)
snap := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap))
assert.Equal(t, "test-bs", snap.Spec.SandboxName)
}
func TestHandlePause_RejectsUnsupportedReplicas(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(2)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
},
}
r := newTestReconciler(bs)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseSucceed, updated.Status.Phase)
assert.Equal(t, int64(2), updated.Status.PauseObservedGeneration)
var pauseFailed *sandboxv1alpha1.BatchSandboxCondition
for i := range updated.Status.Conditions {
if updated.Status.Conditions[i].Type == sandboxv1alpha1.BatchSandboxConditionPauseFailed {
pauseFailed = &updated.Status.Conditions[i]
break
}
}
require.NotNil(t, pauseFailed)
assert.Equal(t, sandboxv1alpha1.ConditionTrue, pauseFailed.Status)
assert.Equal(t, "UnsupportedReplicas", pauseFailed.Reason)
assert.Contains(t, pauseFailed.Message, "spec.replicas=1")
snap := &sandboxv1alpha1.SandboxSnapshot{}
err = r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap)
assert.True(t, apierrors.IsNotFound(err), "unsupported replicas should be rejected before creating a snapshot")
}
func TestHandlePause_PoolMode(t *testing.T) {
// Pool mode: pause should snapshot the allocated pod without mutating poolRef/template first.
pool := &sandboxv1alpha1.Pool{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pool",
Namespace: "default",
},
Spec: sandboxv1alpha1.PoolSpec{
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "main", Image: "pool-image:latest"},
},
},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "test-pool",
// Template is nil (pool mode)
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
},
}
r := newTestReconciler(bs, pool)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify template was solidified from Pool
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Nil(t, updated.Spec.Template)
assert.Equal(t, "test-pool", updated.Spec.PoolRef)
// Verify SandboxSnapshot was created under the reserved internal name
snap := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap))
assert.Equal(t, "test-bs", snap.Spec.SandboxName)
}
func TestHandlePause_PoolModeDoesNotRequirePoolCR(t *testing.T) {
// The source pod allocation is enough to snapshot a pooled BatchSandbox.
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "nonexistent-pool",
Replicas: ptr.To(int32(1)),
// Template is nil - pool mode
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
},
}
setSandboxAllocation(bs, SandboxAllocation{Pods: []string{"pool-pod"}})
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod",
Namespace: "default",
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "pool-image:latest"}},
},
}
r := newTestReconciler(bs, poolPod)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePausing, updated.Status.Phase)
assert.Nil(t, updated.Spec.Template)
assert.Equal(t, "nonexistent-pool", updated.Spec.PoolRef)
snap := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap))
assert.Equal(t, "test-bs", snap.Spec.SandboxName)
}
func TestHandlePause_FailedRetry(t *testing.T) {
// Old snapshot is Failed → delete it first, but do not ACK a new pause attempt
// until the stale failed snapshot is gone.
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
Conditions: []sandboxv1alpha1.BatchSandboxCondition{
{
Type: sandboxv1alpha1.BatchSandboxConditionPauseFailed,
Status: sandboxv1alpha1.ConditionTrue,
Reason: "SnapshotFailed",
Message: "previous attempt failed",
LastTransitionTime: ptr.To(metav1.Now()),
},
},
},
}
oldSnapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseFailed,
},
}
r := newTestReconciler(bs, oldSnapshot)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify old snapshot was deleted
snap := &sandboxv1alpha1.SandboxSnapshot{}
err = r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap)
assert.True(t, err == nil || len(snap.UID) == 0 || snap.DeletionTimestamp != nil,
"old Failed snapshot should be deleted or being deleted")
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int64(1), updated.Status.PauseObservedGeneration, "retry should not ACK until stale failed snapshot is removed")
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseSucceed, updated.Status.Phase, "retry cleanup should not transition to Pausing yet")
}
func TestHandlePause_SucceededSnapshotRetry(t *testing.T) {
// Old snapshot is Succeed from a previous resume cleanup failure. It must be
// deleted before ACKing a new pause attempt, otherwise pause reuses stale state.
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
},
}
oldSnapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
},
}
r := newTestReconciler(bs, oldSnapshot)
result, err := r.handlePause(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
snap := &sandboxv1alpha1.SandboxSnapshot{}
err = r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap)
assert.True(t, apierrors.IsNotFound(err), "old Succeed snapshot should be deleted before retrying pause")
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int64(1), updated.Status.PauseObservedGeneration, "retry cleanup should not ACK until stale succeeded snapshot is removed")
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseSucceed, updated.Status.Phase, "retry cleanup should not transition to Pausing yet")
}
// ---------- handleResume tests ----------
func TestHandleResume_NormalFlow(t *testing.T) {
// handleResume now only ACKs Resuming phase and requeues
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(0)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhasePaused,
},
}
r := newTestReconciler(bs)
result, err := r.handleResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0, "should requeue after ACK")
// Verify ACK: phase=Resuming
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseResuming, updated.Status.Phase)
assert.Equal(t, int64(2), updated.Status.PauseObservedGeneration)
}
func TestContinueResume_NormalFlow(t *testing.T) {
// continueResume does the actual work: read snapshot and replace images without changing replicas.
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
r := newTestReconciler(bs, snapshot)
r.ResumePullSecret = "my-pull-secret"
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify images replaced
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, "registry/test-bs-main:snap-gen1", updated.Spec.Template.Spec.Containers[0].Image)
// Verify replicas are preserved.
assert.Equal(t, int32(1), *updated.Spec.Replicas)
// Verify controller does not clear spec.pause
require.NotNil(t, updated.Spec.Pause)
assert.False(t, *updated.Spec.Pause)
// Verify imagePullSecrets added
found := false
for _, s := range updated.Spec.Template.Spec.ImagePullSecrets {
if s.Name == "my-pull-secret" {
found = true
}
}
assert.True(t, found, "imagePullSecrets should contain resume-pull-secret")
}
func TestContinueResume_PreservesExistingReplicas(t *testing.T) {
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(2)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
r := newTestReconciler(bs, snapshot)
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int32(2), *updated.Spec.Replicas)
assert.Equal(t, "registry/test-bs-main:snap-gen1", updated.Spec.Template.Spec.Containers[0].Image)
}
func TestHandleResume_PoolMode(t *testing.T) {
// handleResume only ACKs Resuming phase, poolRef is cleared by continueResume
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
PoolRef: "test-pool",
Replicas: ptr.To(int32(0)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 1,
Phase: sandboxv1alpha1.BatchSandboxPhasePaused,
},
}
r := newTestReconciler(bs)
result, err := r.handleResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify ACK: phase=Resuming
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseResuming, updated.Status.Phase)
}
func TestContinueResume_PoolMode(t *testing.T) {
// continueResume clears poolRef without changing replicas.
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
Finalizers: []string{FinalizerPoolAllocation},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
r := newTestReconciler(bs, snapshot)
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify poolRef cleared
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, "", updated.Spec.PoolRef)
assert.NotContains(t, updated.Finalizers, FinalizerPoolAllocation)
}
func TestContinueResume_UsesPatchedTemplateWhenCacheReturnsStaleObject(t *testing.T) {
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
staleBatchSandbox := bs.DeepCopy()
returnStaleBatchSandbox := false
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}, &sandboxv1alpha1.SandboxSnapshot{}).
WithObjects(bs.DeepCopy(), snapshot).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
err := c.Patch(ctx, obj, patch, opts...)
if err == nil && obj.GetNamespace() == "default" && obj.GetName() == "test-bs" {
returnStaleBatchSandbox = true
}
return err
},
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if returnStaleBatchSandbox && key.Namespace == "default" && key.Name == "test-bs" {
if target, ok := obj.(*sandboxv1alpha1.BatchSandbox); ok {
staleBatchSandbox.DeepCopyInto(target)
return nil
}
}
return c.Get(ctx, key, obj, opts...)
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
require.NotNil(t, bs.Spec.Template)
assert.Equal(t, "registry/test-bs-main:snap-gen1", bs.Spec.Template.Spec.Containers[0].Image)
assert.Equal(t, "", bs.Spec.PoolRef)
require.NotNil(t, bs.Spec.Replicas)
assert.Equal(t, int32(1), *bs.Spec.Replicas)
}
func TestContinueResume_SnapshotNotFound(t *testing.T) {
// continueResume without snapshot → rollback to Paused with ResumeFailed, spec.pause unchanged
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(0)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
r := newTestReconciler(bs)
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
// Verify rollback keeps spec.pause and records retryable failure
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
require.NotNil(t, updated.Spec.Pause)
assert.False(t, *updated.Spec.Pause)
}
func TestContinueResume_SnapshotMissingButReadyPodTreatsResumeAsComplete(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
Labels: map[string]string{
LabelBatchSandboxNameKey: "test-bs",
},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
r := newTestReconciler(bs, pod)
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseSucceed, updated.Status.Phase)
require.NotNil(t, updated.Spec.Pause)
assert.False(t, *updated.Spec.Pause)
for _, cond := range updated.Status.Conditions {
assert.NotEqual(t, sandboxv1alpha1.BatchSandboxConditionResumeFailed, cond.Type)
}
}
func TestContinueResume_SnapshotNotReady(t *testing.T) {
// continueResume with snapshot still Committing → Phase=Paused + ResumeFailed condition, spec.pause unchanged
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseCommitting,
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(0)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
r := newTestReconciler(bs, snapshot)
result, err := r.continueResume(context.Background(), bs)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
// Verify Phase=Paused with ResumeFailed condition (retryable)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
require.NotNil(t, updated.Spec.Pause)
assert.False(t, *updated.Spec.Pause)
// Verify ResumeFailed condition is set
foundCondition := false
for _, cond := range updated.Status.Conditions {
if cond.Type == sandboxv1alpha1.BatchSandboxConditionResumeFailed {
foundCondition = true
assert.Equal(t, sandboxv1alpha1.ConditionTrue, cond.Status)
assert.Equal(t, "SnapshotNotReady", cond.Reason)
assert.Contains(t, cond.Message, "snapshot not ready")
break
}
}
assert.True(t, foundCondition, "ResumeFailed condition should be set")
}
func TestReconcile_ResumingPoolResumeRebuildsStrategyAfterContinueResume(t *testing.T) {
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 5,
UID: "test-uid",
Annotations: map[string]string{
AnnoAllocStatusKey: `{"pods":["pool-pod-0"]}`,
},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 5,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod-0",
Namespace: "default",
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
r := newTestReconciler(bs, snapshot, poolPod)
result, err := r.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-bs"},
})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, "", updated.Spec.PoolRef, "resume should detach the sandbox from the pool")
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseResuming, updated.Status.Phase, "old pooled pod must not short-circuit resume completion")
resumedPod := &corev1.Pod{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-0"}, resumedPod))
assert.Equal(t, "test-bs", resumedPod.Labels[LabelBatchSandboxNameKey])
stillPresent := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, stillPresent))
}
func TestReconcile_ResumingIgnoresDeletingReadyPodWhenCompletingResume(t *testing.T) {
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
Containers: []sandboxv1alpha1.ContainerSnapshot{
{ContainerName: "main", ImageURI: "registry/test-bs-main:snap-gen1"},
},
},
}
now := metav1.Now()
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 3,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 3,
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
terminatingPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
DeletionTimestamp: &now,
Finalizers: []string{"test/finalizer"},
Labels: map[string]string{
LabelBatchSandboxNameKey: "test-bs",
LabelBatchSandboxPodIndexKey: "0",
},
OwnerReferences: []metav1.OwnerReference{{
APIVersion: sandboxv1alpha1.GroupVersion.String(),
Kind: "BatchSandbox",
Name: "test-bs",
UID: "test-uid",
Controller: ptr.To(true),
}},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
r := newTestReconciler(bs, snapshot, terminatingPod)
result, err := r.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-bs"},
})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseResuming, updated.Status.Phase)
stillPresent := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, stillPresent))
}
func TestReconcile_PausedNonPoolDoesNotScalePods(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 3,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "old-img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 3,
Phase: sandboxv1alpha1.BatchSandboxPhasePaused,
},
}
r := newTestReconciler(bs)
result, err := r.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-bs"},
})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
pod := &corev1.Pod{}
err = r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-0"}, pod)
assert.Error(t, err, "paused non-pooled sandbox should not recreate pods")
}
func TestReconcile_PausedSkipsTaskSchedulingAndClearsScheduler(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 4,
UID: "test-uid",
Finalizers: []string{FinalizerTaskCleanup},
Annotations: map[string]string{
AnnoAllocStatusKey: `{"pods":["pool-pod"]}`,
AnnoAllocReleaseKey: `{"pods":["pool-pod"]}`,
},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "pool",
Replicas: ptr.To(int32(1)),
TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{
Spec: sandboxv1alpha1.TaskSpec{
Process: &sandboxv1alpha1.ProcessTask{
Command: []string{"/bin/sh", "-c", "sleep 3600"},
},
},
},
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "sandbox", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 4,
Phase: sandboxv1alpha1.BatchSandboxPhasePaused,
ObservedGeneration: 4,
},
}
r := newTestReconciler(bs)
key := types.NamespacedName{Namespace: bs.Namespace, Name: bs.Name}.String()
r.taskSchedulers.Store(key, &forbiddenTaskScheduler{t: t})
result, err := r.Reconcile(context.Background(), ctrl.Request{
NamespacedName: types.NamespacedName{Namespace: bs.Namespace, Name: bs.Name},
})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
_, ok := r.taskSchedulers.Load(key)
assert.False(t, ok, "paused reconcile should clear stale task scheduler state")
}
func TestCompletePause_PooledModeClearsTaskScheduler(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 3,
UID: "test-uid",
Annotations: map[string]string{
AnnoAllocStatusKey: `{"pods":["pool-pod"]}`,
},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "pool",
Replicas: ptr.To(int32(1)),
TaskTemplate: &sandboxv1alpha1.TaskTemplateSpec{
Spec: sandboxv1alpha1.TaskSpec{
Process: &sandboxv1alpha1.ProcessTask{
Command: []string{"/bin/sh", "-c", "sleep 3600"},
},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 3,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod",
Namespace: "default",
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "pool-image:latest"}},
},
Status: corev1.PodStatus{
PodIP: "10.0.0.8",
},
}
r := newTestReconciler(bs, poolPod)
key := types.NamespacedName{Namespace: bs.Namespace, Name: bs.Name}.String()
r.taskSchedulers = sync.Map{}
r.taskSchedulers.Store(key, fmt.Sprintf("scheduler:%s", key))
err := r.completePause(context.Background(), bs)
require.NoError(t, err)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: bs.Namespace, Name: bs.Name}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
assert.Equal(t, "", updated.Spec.PoolRef)
require.NotNil(t, updated.Spec.Template)
assert.Equal(t, "pool-image:latest", updated.Spec.Template.Spec.Containers[0].Image)
assert.NotContains(t, updated.Annotations, AnnoAllocReleaseKey)
_, ok := r.taskSchedulers.Load(key)
assert.False(t, ok, "completePause should clear the stale in-memory task scheduler")
}
func TestPersistRuntimeView_PreservesPauseFailedConditionFromLatestStatus(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
ObservedGeneration: 1,
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
Replicas: 1,
Allocated: 1,
Ready: 1,
Conditions: []sandboxv1alpha1.BatchSandboxCondition{
{
Type: sandboxv1alpha1.BatchSandboxConditionReady,
Status: sandboxv1alpha1.ConditionTrue,
Reason: "PodsReady",
Message: "Sandbox is running",
LastTransitionTime: ptr.To(metav1.Now()),
},
},
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
Labels: map[string]string{
LabelBatchSandboxNameKey: "test-bs",
LabelBatchSandboxPodIndexKey: "0",
},
OwnerReferences: []metav1.OwnerReference{{
APIVersion: sandboxv1alpha1.GroupVersion.String(),
Kind: "BatchSandbox",
Name: "test-bs",
UID: "test-uid",
Controller: ptr.To(true),
}},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
PodIP: "10.0.0.10",
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
r := newTestReconciler(bs, pod)
// Simulate pause handler writing PauseFailed condition to API server.
latest := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, latest))
latest.Status.Conditions = append(latest.Status.Conditions, sandboxv1alpha1.BatchSandboxCondition{
Type: sandboxv1alpha1.BatchSandboxConditionPauseFailed,
Status: sandboxv1alpha1.ConditionTrue,
Reason: "SnapshotFailed",
Message: "Commit job failed",
LastTransitionTime: ptr.To(metav1.Now()),
})
require.NoError(t, r.Status().Update(context.Background(), latest))
// Simulate second reconcile: informer has caught up, so we read latest state.
freshBS := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, freshBS))
view := buildRuntimeView(freshBS, []*corev1.Pod{pod})
_, errs := r.persistRuntimeView(context.Background(), freshBS, view)
require.Empty(t, errs)
// Verify PauseFailed is preserved after reconcile with fresh cache.
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
foundPauseFailed := false
for _, cond := range updated.Status.Conditions {
if cond.Type == sandboxv1alpha1.BatchSandboxConditionPauseFailed {
foundPauseFailed = true
assert.Equal(t, sandboxv1alpha1.ConditionTrue, cond.Status)
assert.Equal(t, "SnapshotFailed", cond.Reason)
assert.Equal(t, "Commit job failed", cond.Message)
}
}
assert.True(t, foundPauseFailed, "persistRuntimeView should preserve PauseFailed condition once informer cache catches up")
}
func TestPersistRuntimeView_SkipsStatusUpdateWhenRuntimeStatusUnchanged(t *testing.T) {
transitionTime := metav1.NewTime(time.Now().Add(-5 * time.Minute))
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 3,
UID: "test-uid",
Annotations: map[string]string{
AnnotationSandboxEndpoints: `["10.0.0.10"]`,
},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
ObservedGeneration: 3,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
Replicas: 1,
Allocated: 1,
Ready: 1,
Conditions: []sandboxv1alpha1.BatchSandboxCondition{
{
Type: sandboxv1alpha1.BatchSandboxConditionReady,
Status: sandboxv1alpha1.ConditionTrue,
Reason: "PodsReady",
Message: "Sandbox is running",
LastTransitionTime: &transitionTime,
},
},
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
PodIP: "10.0.0.10",
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
statusUpdates := 0
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}).
WithObjects(bs).
WithInterceptorFuncs(interceptor.Funcs{
SubResourceUpdate: func(ctx context.Context, c client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error {
if subResourceName == "status" {
statusUpdates++
}
return c.SubResource(subResourceName).Update(ctx, obj, opts...)
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
view := buildRuntimeView(bs.DeepCopy(), []*corev1.Pod{pod})
_, errs := r.persistRuntimeView(context.Background(), bs.DeepCopy(), view)
require.Empty(t, errs)
assert.Equal(t, 0, statusUpdates, "unchanged runtime status should not be persisted again")
}
func TestPersistRuntimeView_RetriesSucceededPauseSnapshotCleanup(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 3,
UID: "test-uid",
Annotations: map[string]string{
AnnotationSandboxEndpoints: "null",
},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(false),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
ObservedGeneration: 3,
PauseObservedGeneration: 3,
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
Replicas: 1,
Allocated: 1,
Ready: 1,
},
}
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
},
}
r := newTestReconciler(bs, snapshot)
status := bs.Status
view := runtimeView{status: &status}
_, errs := r.persistRuntimeView(context.Background(), bs.DeepCopy(), view)
require.Empty(t, errs)
stillPresent := &sandboxv1alpha1.SandboxSnapshot{}
err := r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, stillPresent)
assert.True(t, apierrors.IsNotFound(err), "successful internal pause snapshot cleanup should be retried after resume")
}
func TestBuildRuntimeView_AggregatesPodFailuresInSteadyState(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
},
Status: sandboxv1alpha1.BatchSandboxStatus{
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
},
}
pods := []*corev1.Pod{
{
ObjectMeta: metav1.ObjectMeta{Name: "err-image-0", Namespace: "default"},
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{{
State: corev1.ContainerState{
Waiting: &corev1.ContainerStateWaiting{
Reason: "ErrImagePull",
Message: "image not found",
},
},
}},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "crash-1", Namespace: "default"},
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{{
State: corev1.ContainerState{
Waiting: &corev1.ContainerStateWaiting{
Reason: "CrashLoopBackOff",
Message: "back-off restarting failed container",
},
},
}},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "err-image-2", Namespace: "default"},
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{{
State: corev1.ContainerState{
Waiting: &corev1.ContainerStateWaiting{
Reason: "ErrImagePull",
Message: "image still not found",
},
},
}},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "healthy-3", Namespace: "default"},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
},
}
view := buildRuntimeView(bs, pods)
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseFailed, view.status.Phase)
var podFailed *sandboxv1alpha1.BatchSandboxCondition
for i := range view.status.Conditions {
if view.status.Conditions[i].Type == sandboxv1alpha1.BatchSandboxConditionPodFailed {
podFailed = &view.status.Conditions[i]
break
}
}
require.NotNil(t, podFailed)
assert.Equal(t, sandboxv1alpha1.ConditionTrue, podFailed.Status)
assert.Equal(t, "ErrImagePull", podFailed.Reason)
assert.Equal(t, "3/4 observed pods failed; primary reason=ErrImagePull; sample pod=err-image-0", podFailed.Message)
}
func TestBuildRuntimeView_AggregatesResumeFailures(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
},
Status: sandboxv1alpha1.BatchSandboxStatus{
Phase: sandboxv1alpha1.BatchSandboxPhaseResuming,
},
}
pods := []*corev1.Pod{
{
ObjectMeta: metav1.ObjectMeta{Name: "imgpull-0", Namespace: "default"},
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{{
State: corev1.ContainerState{
Waiting: &corev1.ContainerStateWaiting{
Reason: "ImagePullBackOff",
Message: "pull back off",
},
},
}},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "imgpull-1", Namespace: "default"},
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{{
State: corev1.ContainerState{
Waiting: &corev1.ContainerStateWaiting{
Reason: "ImagePullBackOff",
Message: "pull back off again",
},
},
}},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "healthy-2", Namespace: "default"},
Status: corev1.PodStatus{
Phase: corev1.PodPending,
},
},
}
view := buildRuntimeView(bs, pods)
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseFailed, view.status.Phase)
var resumeFailed *sandboxv1alpha1.BatchSandboxCondition
var podFailed *sandboxv1alpha1.BatchSandboxCondition
for i := range view.status.Conditions {
switch view.status.Conditions[i].Type {
case sandboxv1alpha1.BatchSandboxConditionResumeFailed:
resumeFailed = &view.status.Conditions[i]
case sandboxv1alpha1.BatchSandboxConditionPodFailed:
podFailed = &view.status.Conditions[i]
}
}
require.NotNil(t, resumeFailed)
require.NotNil(t, podFailed)
assert.Equal(t, sandboxv1alpha1.ConditionTrue, resumeFailed.Status)
assert.Equal(t, "ImagePullBackOff", resumeFailed.Reason)
assert.Equal(t, "2/3 observed pods failed during resume; primary reason=ImagePullBackOff; sample pod=imgpull-0", resumeFailed.Message)
assert.Equal(t, "ImagePullBackOff", podFailed.Reason)
assert.Equal(t, "2/3 observed pods failed; primary reason=ImagePullBackOff; sample pod=imgpull-0", podFailed.Message)
}
func TestBuildRuntimeView_PreservesConditionTransitionTimeWhenUnchanged(t *testing.T) {
transitionTime := metav1.NewTime(time.Now().Add(-5 * time.Minute))
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 3,
},
Status: sandboxv1alpha1.BatchSandboxStatus{
Phase: sandboxv1alpha1.BatchSandboxPhaseSucceed,
Conditions: []sandboxv1alpha1.BatchSandboxCondition{
{
Type: sandboxv1alpha1.BatchSandboxConditionReady,
Status: sandboxv1alpha1.ConditionTrue,
Reason: "PodsReady",
Message: "Sandbox is running",
LastTransitionTime: &transitionTime,
},
},
},
}
pods := []*corev1.Pod{
{
ObjectMeta: metav1.ObjectMeta{Name: "healthy-0", Namespace: "default"},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
PodIP: "10.0.0.10",
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
},
}
view := buildRuntimeView(bs, pods)
var ready *sandboxv1alpha1.BatchSandboxCondition
for i := range view.status.Conditions {
if view.status.Conditions[i].Type == sandboxv1alpha1.BatchSandboxConditionReady {
ready = &view.status.Conditions[i]
break
}
}
require.NotNil(t, ready)
require.NotNil(t, ready.LastTransitionTime)
assert.Equal(t, transitionTime, *ready.LastTransitionTime, "unchanged condition should keep its original transition time")
}
func TestBuildRuntimeView_AddsPausedConditionFromEmptyConditions(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 5,
},
Status: sandboxv1alpha1.BatchSandboxStatus{
Phase: sandboxv1alpha1.BatchSandboxPhasePaused,
},
}
view := buildRuntimeView(bs, nil)
var paused *sandboxv1alpha1.BatchSandboxCondition
for i := range view.status.Conditions {
if view.status.Conditions[i].Type == sandboxv1alpha1.BatchSandboxConditionPaused {
paused = &view.status.Conditions[i]
break
}
}
require.NotNil(t, paused)
require.NotNil(t, paused.LastTransitionTime)
assert.Equal(t, sandboxv1alpha1.ConditionTrue, paused.Status)
assert.Equal(t, "Paused", paused.Reason)
assert.Equal(t, "Sandbox is paused", paused.Message)
}
// ---------- completePause test ----------
func TestCompletePause(t *testing.T) {
// completePause: status.phase=Paused, controller does not clear spec.pause
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
r := newTestReconciler(bs)
err := r.completePause(context.Background(), bs)
require.NoError(t, err)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
// Phase should be Paused
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
var pausedCondition *sandboxv1alpha1.BatchSandboxCondition
for i := range updated.Status.Conditions {
if updated.Status.Conditions[i].Type == sandboxv1alpha1.BatchSandboxConditionType("Paused") {
pausedCondition = &updated.Status.Conditions[i]
break
}
}
require.NotNil(t, pausedCondition, "Paused phase should publish an explicit Paused condition")
assert.Equal(t, sandboxv1alpha1.ConditionTrue, pausedCondition.Status)
// Replicas should remain unchanged (not set to 0 per design doc)
assert.Equal(t, int32(1), *updated.Spec.Replicas)
// Pause remains true until the next external request changes it
require.NotNil(t, updated.Spec.Pause)
assert.True(t, *updated.Spec.Pause)
}
func TestCompletePause_DeleteFailureLeavesPhasePausing(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: sandboxv1alpha1.SchemeBuilder.GroupVersion.String(),
Kind: "BatchSandbox",
Name: "test-bs",
UID: "test-uid",
},
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithIndex(&corev1.Pod{}, fieldindex.IndexNameForOwnerRefUID, fieldindex.OwnerIndexFunc).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}).
WithObjects(bs, pod).
WithInterceptorFuncs(interceptor.Funcs{
Delete: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.DeleteOption) error {
if obj.GetNamespace() == "default" && obj.GetName() == "test-bs-0" {
return fmt.Errorf("delete failed")
}
return c.Delete(ctx, obj, opts...)
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
err := r.completePause(context.Background(), bs)
require.ErrorContains(t, err, "delete failed")
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePausing, updated.Status.Phase)
}
func TestCompletePause_PooledSandboxDetachesForPoolGC(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-bs-uid",
Finalizers: []string{FinalizerPoolAllocation},
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
setSandboxAllocation(bs, SandboxAllocation{Pods: []string{"pool-pod-1"}})
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod-1",
Namespace: "default",
Labels: map[string]string{
LabelPoolName: "test-pool",
LabelPoolRevision: "rev-1",
"app": "demo",
},
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: sandboxv1alpha1.SchemeBuilder.GroupVersion.String(),
Kind: "Pool",
Name: "test-pool",
UID: "test-pool-uid",
},
},
},
Spec: corev1.PodSpec{
NodeName: "node-a",
Containers: []corev1.Container{{Name: "main", Image: "pool-image:latest"}},
},
}
r := newTestReconciler(bs, poolPod)
err := r.completePause(context.Background(), bs)
require.NoError(t, err)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
assert.Equal(t, "", updated.Spec.PoolRef)
require.NotNil(t, updated.Spec.Template)
assert.Equal(t, "pool-image:latest", updated.Spec.Template.Spec.Containers[0].Image)
assert.Equal(t, "demo", updated.Spec.Template.Labels["app"])
assert.NotContains(t, updated.Spec.Template.Labels, LabelPoolName)
assert.NotContains(t, updated.Spec.Template.Labels, LabelPoolRevision)
assert.Equal(t, "", updated.Spec.Template.Spec.NodeName)
assert.NotContains(t, updated.Annotations, AnnoAllocReleaseKey)
assert.NotContains(t, updated.Finalizers, FinalizerPoolAllocation)
}
func TestCompletePause_PooledSandboxDoesNotDeleteSourcePod(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-bs-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
setSandboxAllocation(bs, SandboxAllocation{Pods: []string{"pool-pod-1"}})
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod-1",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: sandboxv1alpha1.SchemeBuilder.GroupVersion.String(),
Kind: "Pool",
Name: "test-pool",
UID: "test-pool-uid",
},
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "pool-image:latest"}},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}).
WithObjects(bs, poolPod).
WithInterceptorFuncs(interceptor.Funcs{
Delete: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.DeleteOption) error {
if obj.GetNamespace() == "default" && obj.GetName() == "pool-pod-1" {
return fmt.Errorf("batchsandbox reconciler must not delete pooled source pod")
}
return c.Delete(ctx, obj, opts...)
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
err := r.completePause(context.Background(), bs)
require.NoError(t, err)
stillPresent := &corev1.Pod{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "pool-pod-1"}, stillPresent))
}
func TestCompletePause_PooledSandboxAcknowledgesSpecPatchGeneration(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-bs-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
setSandboxAllocation(bs, SandboxAllocation{Pods: []string{"pool-pod-1"}})
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod-1",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: sandboxv1alpha1.SchemeBuilder.GroupVersion.String(),
Kind: "Pool",
Name: "test-pool",
UID: "test-pool-uid",
},
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "pool-image:latest"}},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}).
WithObjects(bs, poolPod).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if err := c.Patch(ctx, obj, patch, opts...); err != nil {
return err
}
if obj.GetNamespace() == "default" && obj.GetName() == "test-bs" {
latest := &sandboxv1alpha1.BatchSandbox{}
if err := c.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test-bs"}, latest); err != nil {
return err
}
latest.Generation = 3
return c.Update(ctx, latest)
}
return nil
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
require.NoError(t, r.completePause(context.Background(), bs))
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int64(3), updated.Generation)
assert.Equal(t, updated.Generation, updated.Status.PauseObservedGeneration)
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
}
func TestCompletePause_DoesNotAcknowledgeQueuedResumeGeneration(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
UID: "test-bs-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
PoolRef: "test-pool",
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
setSandboxAllocation(bs, SandboxAllocation{Pods: []string{"pool-pod-1"}})
poolPod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "pool-pod-1",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: sandboxv1alpha1.SchemeBuilder.GroupVersion.String(),
Kind: "Pool",
Name: "test-pool",
UID: "test-pool-uid",
},
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "pool-image:latest"}},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}).
WithObjects(bs, poolPod).
WithInterceptorFuncs(interceptor.Funcs{
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if err := c.Patch(ctx, obj, patch, opts...); err != nil {
return err
}
if obj.GetNamespace() == "default" && obj.GetName() == "test-bs" {
latest := &sandboxv1alpha1.BatchSandbox{}
if err := c.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test-bs"}, latest); err != nil {
return err
}
latest.Generation = 3
latest.Spec.Pause = ptr.To(false)
return c.Update(ctx, latest)
}
return nil
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
require.NoError(t, r.completePause(context.Background(), bs))
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int64(3), updated.Generation)
assert.NotNil(t, updated.Spec.Pause)
assert.False(t, *updated.Spec.Pause)
assert.Equal(t, int64(2), updated.Status.PauseObservedGeneration)
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
}
// ---------- syncPauseOrClear tests ----------
func TestSyncPauseOrClear_SnapshotReady(t *testing.T) {
// Snapshot Succeed → completePause
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseSucceed,
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
r := newTestReconciler(bs, snapshot)
result, err := r.syncPauseOrClear(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0)
// Verify completePause was called
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePaused, updated.Status.Phase)
// Replicas should remain unchanged (not set to 0 per design doc)
assert.Equal(t, int32(1), *updated.Spec.Replicas)
require.NotNil(t, updated.Spec.Pause)
assert.True(t, *updated.Spec.Pause)
}
func TestSyncPauseOrClear_SnapshotFailed(t *testing.T) {
// Snapshot Failed → retryable or terminal failure depending on Pod existence, spec.pause unchanged
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseFailed,
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
r := newTestReconciler(bs, snapshot)
result, err := r.syncPauseOrClear(context.Background(), bs)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
// Verify retryable PauseFailed handling. In this fake-client setup no source Pod exists,
// so the controller escalates the phase to Failed instead of returning to Succeed.
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
require.NotNil(t, updated.Spec.Pause)
assert.True(t, *updated.Spec.Pause)
// Verify PauseFailed condition is set
foundCondition := false
for _, cond := range updated.Status.Conditions {
if cond.Type == sandboxv1alpha1.BatchSandboxConditionPauseFailed {
foundCondition = true
assert.Equal(t, sandboxv1alpha1.ConditionTrue, cond.Status)
assert.Equal(t, "PodNotFound", cond.Reason) // Pod not found in fake client
break
}
}
assert.True(t, foundCondition, "PauseFailed condition should be set")
}
func TestSyncPauseOrClear_SnapshotFailedReturnsStatusUpdateError(t *testing.T) {
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseFailed,
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(testscheme).
WithStatusSubresource(&sandboxv1alpha1.BatchSandbox{}, &sandboxv1alpha1.SandboxSnapshot{}).
WithObjects(bs, snapshot).
WithInterceptorFuncs(interceptor.Funcs{
SubResourceUpdate: func(ctx context.Context, c client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error {
if subResourceName == "status" && obj.GetNamespace() == "default" && obj.GetName() == "test-bs" {
return fmt.Errorf("status update failed")
}
return c.SubResource(subResourceName).Update(ctx, obj, opts...)
},
}).
Build()
r := &BatchSandboxReconciler{
Client: fakeClient,
Scheme: testscheme,
Recorder: record.NewFakeRecorder(10),
StatusRVExpectation: expectations.NewResourceVersionExpectation(),
}
result, err := r.syncPauseOrClear(context.Background(), bs)
require.ErrorContains(t, err, "status update failed")
assert.Equal(t, ctrl.Result{}, result)
}
func TestSyncPauseOrClear_SnapshotCommitting(t *testing.T) {
// Snapshot Committing → requeue
snapshot := &sandboxv1alpha1.SandboxSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-pause",
Namespace: "default",
},
Spec: sandboxv1alpha1.SandboxSnapshotSpec{SandboxName: "test-bs"},
Status: sandboxv1alpha1.SandboxSnapshotStatus{
Phase: sandboxv1alpha1.SandboxSnapshotPhaseCommitting,
},
}
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
Phase: sandboxv1alpha1.BatchSandboxPhasePausing,
},
}
r := newTestReconciler(bs, snapshot)
result, err := r.syncPauseOrClear(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0, "committing snapshot should requeue")
}
func TestSyncPauseOrClear_NoSnapshot(t *testing.T) {
// No snapshot while already Pausing → create it and requeue.
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 2,
},
}
r := newTestReconciler(bs)
result, err := r.syncPauseOrClear(context.Background(), bs)
require.NoError(t, err)
assert.True(t, result.RequeueAfter > 0, "no snapshot should requeue")
snap := &sandboxv1alpha1.SandboxSnapshot{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs-pause"}, snap))
assert.Equal(t, "test-bs", snap.Spec.SandboxName)
}
// ---------- Phase update bug fix verification ----------
func TestPhaseUpdate_Succeed(t *testing.T) {
// When pods are Running+Ready, phase should be Succeed (not stuck at Pending)
// This verifies the Bug 2 fix: phase judgment switch moved AFTER the pod counting loop.
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 1,
UID: "test-uid",
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
Template: &corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
},
},
Status: sandboxv1alpha1.BatchSandboxStatus{},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs-0",
Namespace: "default",
Labels: map[string]string{
LabelBatchSandboxPodIndexKey: "0",
LabelBatchSandboxNameKey: "test-bs",
},
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: sandboxv1alpha1.GroupVersion.String(),
Kind: "BatchSandbox",
Name: "test-bs",
UID: "test-uid",
Controller: ptr.To(true),
},
},
},
Spec: corev1.PodSpec{
NodeName: "node-1",
Containers: []corev1.Container{{Name: "main", Image: "img"}},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
PodIP: "10.0.0.1",
Conditions: []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
},
},
}
_ = newTestReconciler(bs, pod)
// Verify the logic inline (same code path as Reconcile)
newStatus := bs.Status.DeepCopy()
newStatus.ObservedGeneration = bs.Generation
newStatus.Replicas = 0
newStatus.Ready = 0
newStatus.Allocated = 0
// Phase judgment AFTER counting (Bug 2 fix verification)
pods := []*corev1.Pod{pod}
for _, p := range pods {
newStatus.Replicas++
if p.Spec.NodeName != "" {
newStatus.Allocated++
}
if p.Status.Phase == corev1.PodRunning && p.Status.Conditions != nil {
for _, c := range p.Status.Conditions {
if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue {
newStatus.Ready++
}
}
}
}
// Phase should be Succeed because Ready > 0
switch bs.Status.Phase {
case sandboxv1alpha1.BatchSandboxPhasePausing, sandboxv1alpha1.BatchSandboxPhasePaused,
sandboxv1alpha1.BatchSandboxPhaseResuming:
// Don't override
default:
if newStatus.Ready > 0 {
newStatus.Phase = sandboxv1alpha1.BatchSandboxPhaseSucceed
} else {
newStatus.Phase = sandboxv1alpha1.BatchSandboxPhasePending
}
}
assert.Equal(t, int32(1), newStatus.Ready, "Ready should be 1")
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhaseSucceed, newStatus.Phase,
"Phase should be Succeed when Ready > 0")
}
// ---------- ackPauseGeneration test ----------
func TestAckPauseGeneration(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 5,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{
PauseObservedGeneration: 3,
},
}
r := newTestReconciler(bs)
err := r.ackPauseGeneration(context.Background(), bs)
require.NoError(t, err)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, int64(5), updated.Status.PauseObservedGeneration)
}
// ---------- ackPauseWithPhase behavior ----------
func TestAckPauseWithPhase_DoesNotMutateSpecPause(t *testing.T) {
bs := &sandboxv1alpha1.BatchSandbox{
ObjectMeta: metav1.ObjectMeta{
Name: "test-bs",
Namespace: "default",
Generation: 2,
},
Spec: sandboxv1alpha1.BatchSandboxSpec{
Pause: ptr.To(true),
Replicas: ptr.To(int32(1)),
},
Status: sandboxv1alpha1.BatchSandboxStatus{},
}
r := newTestReconciler(bs)
err := r.ackPauseWithPhase(context.Background(), bs, sandboxv1alpha1.BatchSandboxPhasePausing, "")
require.NoError(t, err)
updated := &sandboxv1alpha1.BatchSandbox{}
require.NoError(t, r.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-bs"}, updated))
assert.Equal(t, sandboxv1alpha1.BatchSandboxPhasePausing, updated.Status.Phase)
require.NotNil(t, updated.Spec.Pause)
assert.True(t, *updated.Spec.Pause)
}
// Ensure ctrl.Result type is used
var _ = ctrl.Result{}