From 809214d3920ae8f37942d9349d2dbb3378295dde Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Mon, 15 Jun 2026 14:43:00 +0800 Subject: [PATCH 1/3] feat: expose codeinterpreter warm pool health Signed-off-by: ranxi2001 --- cmd/workload-manager/main.go | 5 +- .../codeinterpreter_controller.go | 127 ++++++- .../codeinterpreter_controller_test.go | 356 +++++++++++++++++- 3 files changed, 474 insertions(+), 14 deletions(-) diff --git a/cmd/workload-manager/main.go b/cmd/workload-manager/main.go index 7cfe2ea0e..4a39d0067 100644 --- a/cmd/workload-manager/main.go +++ b/cmd/workload-manager/main.go @@ -103,8 +103,9 @@ func main() { } codeInterpreterReconciler := &workloadmanager.CodeInterpreterReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("codeinterpreter-controller"), } if err := setupControllers(mgr, sandboxReconciler, codeInterpreterReconciler); err != nil { diff --git a/pkg/workloadmanager/codeinterpreter_controller.go b/pkg/workloadmanager/codeinterpreter_controller.go index da010cbb1..1412f53a9 100644 --- a/pkg/workloadmanager/codeinterpreter_controller.go +++ b/pkg/workloadmanager/codeinterpreter_controller.go @@ -28,6 +28,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -40,10 +41,22 @@ import ( extensionsv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" ) +const ( + codeInterpreterReadyCondition = "Ready" + codeInterpreterWarmPoolCondition = "WarmPoolAvailable" + codeInterpreterReadyReason = "Reconciled" + codeInterpreterWarmPoolDisabled = "WarmPoolDisabled" + codeInterpreterWarmPoolReady = "WarmPoolReady" + codeInterpreterWarmPoolBelowWatermark = "WarmPoolBelowWatermark" + codeInterpreterWarmPoolEmpty = "WarmPoolEmpty" + codeInterpreterWarmPoolNotFound = "WarmPoolNotFound" +) + // CodeInterpreterReconciler reconciles a CodeInterpreter object type CodeInterpreterReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + Recorder record.EventRecorder } // Reconcile is part of the main kubernetes reconciliation loop which aims to @@ -101,27 +114,118 @@ func (r *CodeInterpreterReconciler) Reconcile(ctx context.Context, req ctrl.Requ // when the status is already up-to-date to avoid triggering a new watch event // that would re-enqueue the object unnecessarily. func (r *CodeInterpreterReconciler) updateStatus(ctx context.Context, ci *runtimev1alpha1.CodeInterpreter) error { - existing := apimeta.FindStatusCondition(ci.Status.Conditions, "Ready") - if ci.Status.Ready && - existing != nil && - existing.Status == metav1.ConditionTrue && - existing.ObservedGeneration == ci.Generation { - return nil - } + oldReady := ci.Status.Ready + oldConditions := append([]metav1.Condition(nil), ci.Status.Conditions...) + previousWarmPoolCondition := apimeta.FindStatusCondition(oldConditions, codeInterpreterWarmPoolCondition) ci.Status.Ready = true // SetStatusCondition only updates LastTransitionTime when the condition // Status actually changes, preventing spurious status writes that would // trigger an infinite reconciliation loop. apimeta.SetStatusCondition(&ci.Status.Conditions, metav1.Condition{ - Type: "Ready", + Type: codeInterpreterReadyCondition, Status: metav1.ConditionTrue, - Reason: "Reconciled", + Reason: codeInterpreterReadyReason, Message: "CodeInterpreter is ready", ObservedGeneration: ci.Generation, }) - return r.Status().Update(ctx, ci) + warmPoolCondition, err := r.warmPoolAvailableCondition(ctx, ci) + if err != nil { + return err + } + apimeta.SetStatusCondition(&ci.Status.Conditions, warmPoolCondition) + + if oldReady == ci.Status.Ready && reflect.DeepEqual(oldConditions, ci.Status.Conditions) { + return nil + } + + if err := r.Status().Update(ctx, ci); err != nil { + return err + } + if r.shouldRecordWarmPoolWarningEvent(previousWarmPoolCondition, warmPoolCondition) { + r.recordEvent(ci, corev1.EventTypeWarning, warmPoolCondition.Reason, warmPoolCondition.Message) + } + return nil +} + +func (r *CodeInterpreterReconciler) warmPoolAvailableCondition(ctx context.Context, ci *runtimev1alpha1.CodeInterpreter) (metav1.Condition, error) { + desired := int32(0) + if ci.Spec.WarmPoolSize != nil { + desired = *ci.Spec.WarmPoolSize + } + if desired <= 0 { + return metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionUnknown, + Reason: codeInterpreterWarmPoolDisabled, + Message: "Warm pool is not configured", + ObservedGeneration: ci.Generation, + }, nil + } + + warmPool := &extensionsv1alpha1.SandboxWarmPool{} + err := r.Get(ctx, types.NamespacedName{Name: ci.Name, Namespace: ci.Namespace}, warmPool) + if errors.IsNotFound(err) { + return metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolNotFound, + Message: fmt.Sprintf("SandboxWarmPool %s/%s has not been created", ci.Namespace, ci.Name), + ObservedGeneration: ci.Generation, + }, nil + } + if err != nil { + return metav1.Condition{}, fmt.Errorf("failed to get SandboxWarmPool for status: %w", err) + } + + ready := warmPool.Status.ReadyReplicas + lowWatermark := (desired + 1) / 2 + if ready == 0 { + return metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolEmpty, + Message: fmt.Sprintf("SandboxWarmPool has 0 ready replicas out of %d desired", desired), + ObservedGeneration: ci.Generation, + }, nil + } + if ready < lowWatermark { + return metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolBelowWatermark, + Message: fmt.Sprintf("SandboxWarmPool has %d ready replicas out of %d desired, below low watermark %d", ready, desired, lowWatermark), + ObservedGeneration: ci.Generation, + }, nil + } + + return metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionTrue, + Reason: codeInterpreterWarmPoolReady, + Message: fmt.Sprintf("SandboxWarmPool has %d ready replicas out of %d desired", ready, desired), + ObservedGeneration: ci.Generation, + }, nil +} + +func (r *CodeInterpreterReconciler) shouldRecordWarmPoolWarningEvent(previous *metav1.Condition, current metav1.Condition) bool { + if current.Type != codeInterpreterWarmPoolCondition || current.Status != metav1.ConditionFalse { + return false + } + if current.Reason != codeInterpreterWarmPoolEmpty && + current.Reason != codeInterpreterWarmPoolBelowWatermark && + current.Reason != codeInterpreterWarmPoolNotFound { + return false + } + return previous == nil || previous.Status != current.Status || previous.Reason != current.Reason +} + +func (r *CodeInterpreterReconciler) recordEvent(ci *runtimev1alpha1.CodeInterpreter, eventType, reason, message string) { + if r.Recorder == nil { + return + } + r.Recorder.Event(ci, eventType, reason, message) } // ensureSandboxTemplate ensures that a SandboxTemplate exists for this CodeInterpreter @@ -339,5 +443,6 @@ func (r *CodeInterpreterReconciler) podTemplateEqual(a, b sandboxv1alpha1.PodTem func (r *CodeInterpreterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&runtimev1alpha1.CodeInterpreter{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Owns(&extensionsv1alpha1.SandboxWarmPool{}). Complete(r) } diff --git a/pkg/workloadmanager/codeinterpreter_controller_test.go b/pkg/workloadmanager/codeinterpreter_controller_test.go index 1404c8611..7d7d781ef 100644 --- a/pkg/workloadmanager/codeinterpreter_controller_test.go +++ b/pkg/workloadmanager/codeinterpreter_controller_test.go @@ -17,24 +17,36 @@ limitations under the License. package workloadmanager import ( + "context" "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" runtimev1alpha1 "github.com/volcano-sh/agentcube/pkg/apis/runtime/v1alpha1" sandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" + extensionsv1alpha1 "sigs.k8s.io/agent-sandbox/extensions/api/v1alpha1" ) func setupTestReconciler() *CodeInterpreterReconciler { scheme := runtime.NewScheme() _ = runtimev1alpha1.AddToScheme(scheme) _ = sandboxv1alpha1.AddToScheme(scheme) + _ = extensionsv1alpha1.AddToScheme(scheme) _ = corev1.AddToScheme(scheme) - client := fake.NewClientBuilder().WithScheme(scheme).Build() + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&runtimev1alpha1.CodeInterpreter{}). + WithStatusSubresource(&extensionsv1alpha1.SandboxWarmPool{}). + Build() return &CodeInterpreterReconciler{ Client: client, @@ -42,6 +54,17 @@ func setupTestReconciler() *CodeInterpreterReconciler { } } +func setupTestReconcilerWithRecorder(bufferSize int) (*CodeInterpreterReconciler, *record.FakeRecorder) { + reconciler := setupTestReconciler() + recorder := record.NewFakeRecorder(bufferSize) + reconciler.Recorder = recorder + return reconciler, recorder +} + +func int32Ptr(v int32) *int32 { + return &v +} + func stringPtr(s string) *string { return &s } @@ -182,3 +205,334 @@ func TestConvertToPodTemplate_AuthMode(t *testing.T) { // Note: TestConvertToPodTemplate_EmptyCommandAndArgs and // TestConvertToPodTemplate_NilCommandAndArgs removed - they only verified that // empty/nil values are preserved, which is trivial field copying behavior. + +func TestWarmPoolAvailableCondition(t *testing.T) { + tests := []struct { + name string + warmPoolSize *int32 + warmPool *extensionsv1alpha1.SandboxWarmPool + wantStatus metav1.ConditionStatus + wantReason string + wantErr bool + }{ + { + name: "disabled when warm pool is not configured", + warmPoolSize: nil, + wantStatus: metav1.ConditionUnknown, + wantReason: codeInterpreterWarmPoolDisabled, + }, + { + name: "false when configured warm pool is missing", + warmPoolSize: int32Ptr(2), + wantStatus: metav1.ConditionFalse, + wantReason: codeInterpreterWarmPoolNotFound, + }, + { + name: "false when no ready replicas", + warmPoolSize: int32Ptr(3), + warmPool: testSandboxWarmPool(3, 0), + wantStatus: metav1.ConditionFalse, + wantReason: codeInterpreterWarmPoolEmpty, + }, + { + name: "false when below low watermark", + warmPoolSize: int32Ptr(4), + warmPool: testSandboxWarmPool(4, 1), + wantStatus: metav1.ConditionFalse, + wantReason: codeInterpreterWarmPoolBelowWatermark, + }, + { + name: "true when low watermark is met", + warmPoolSize: int32Ptr(4), + warmPool: testSandboxWarmPool(4, 2), + wantStatus: metav1.ConditionTrue, + wantReason: codeInterpreterWarmPoolReady, + }, + { + name: "true when single warm pool replica is ready", + warmPoolSize: int32Ptr(1), + warmPool: testSandboxWarmPool(1, 1), + wantStatus: metav1.ConditionTrue, + wantReason: codeInterpreterWarmPoolReady, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reconciler := setupTestReconciler() + if tt.warmPool != nil { + assert.NoError(t, reconciler.Create(context.Background(), tt.warmPool)) + } + + ci := &runtimev1alpha1.CodeInterpreter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + Generation: 7, + }, + Spec: runtimev1alpha1.CodeInterpreterSpec{ + WarmPoolSize: tt.warmPoolSize, + }, + } + + condition, err := reconciler.warmPoolAvailableCondition(context.Background(), ci) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, codeInterpreterWarmPoolCondition, condition.Type) + assert.Equal(t, tt.wantStatus, condition.Status) + assert.Equal(t, tt.wantReason, condition.Reason) + assert.Equal(t, ci.Generation, condition.ObservedGeneration) + }) + } +} + +func TestShouldRecordWarmPoolWarningEvent(t *testing.T) { + tests := []struct { + name string + previous *metav1.Condition + current metav1.Condition + want bool + }{ + { + name: "records first empty warning", + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolEmpty, + }, + want: true, + }, + { + name: "does not repeat same warning", + previous: &metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolEmpty, + }, + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolEmpty, + }, + want: false, + }, + { + name: "records warning when reason changes", + previous: &metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolBelowWatermark, + }, + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolEmpty, + }, + want: true, + }, + { + name: "does not record ready condition", + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionTrue, + Reason: codeInterpreterWarmPoolReady, + }, + want: false, + }, + { + name: "does not record disabled condition", + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionUnknown, + Reason: codeInterpreterWarmPoolDisabled, + }, + want: false, + }, + } + + reconciler := setupTestReconciler() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, reconciler.shouldRecordWarmPoolWarningEvent(tt.previous, tt.current)) + }) + } +} + +func TestUpdateStatusSetsWarmPoolAvailableCondition(t *testing.T) { + reconciler := setupTestReconciler() + ci := &runtimev1alpha1.CodeInterpreter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + Generation: 3, + }, + Spec: runtimev1alpha1.CodeInterpreterSpec{ + WarmPoolSize: int32Ptr(2), + }, + } + assert.NoError(t, reconciler.Create(context.Background(), ci)) + assert.NoError(t, reconciler.Create(context.Background(), testSandboxWarmPool(2, 2))) + + assert.NoError(t, reconciler.updateStatus(context.Background(), ci)) + + updated := &runtimev1alpha1.CodeInterpreter{} + assert.NoError(t, reconciler.Get(context.Background(), clientObjectKey(ci.Namespace, ci.Name), updated)) + assert.True(t, updated.Status.Ready) + + ready := apimeta.FindStatusCondition(updated.Status.Conditions, codeInterpreterReadyCondition) + if assert.NotNil(t, ready) { + assert.Equal(t, metav1.ConditionTrue, ready.Status) + } + + warmPool := apimeta.FindStatusCondition(updated.Status.Conditions, codeInterpreterWarmPoolCondition) + if assert.NotNil(t, warmPool) { + assert.Equal(t, metav1.ConditionTrue, warmPool.Status) + assert.Equal(t, codeInterpreterWarmPoolReady, warmPool.Reason) + assert.Equal(t, ci.Generation, warmPool.ObservedGeneration) + } +} + +func TestReconcileReportsWarmPoolEmptyInsteadOfOnlyReady(t *testing.T) { + ctx := context.Background() + reconciler, recorder := setupTestReconcilerWithRecorder(10) + ci := testCodeInterpreterWithWarmPool(2) + assert.NoError(t, reconciler.Create(ctx, ci)) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: clientObjectKey(ci.Namespace, ci.Name)}) + assert.NoError(t, err) + + updated := &runtimev1alpha1.CodeInterpreter{} + assert.NoError(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), updated)) + assert.True(t, updated.Status.Ready) + + ready := apimeta.FindStatusCondition(updated.Status.Conditions, codeInterpreterReadyCondition) + if assert.NotNil(t, ready) { + assert.Equal(t, metav1.ConditionTrue, ready.Status) + } + + warmPool := apimeta.FindStatusCondition(updated.Status.Conditions, codeInterpreterWarmPoolCondition) + if assert.NotNil(t, warmPool) { + assert.Equal(t, metav1.ConditionFalse, warmPool.Status) + assert.Equal(t, codeInterpreterWarmPoolEmpty, warmPool.Reason) + assert.Contains(t, warmPool.Message, "0 ready replicas out of 2 desired") + } + + assertEventContains(t, recorder, corev1.EventTypeWarning, codeInterpreterWarmPoolEmpty) +} + +func TestReconcileReportsWarmPoolBelowWatermark(t *testing.T) { + ctx := context.Background() + reconciler, recorder := setupTestReconcilerWithRecorder(10) + ci := testCodeInterpreterWithWarmPool(4) + assert.NoError(t, reconciler.Create(ctx, ci)) + assert.NoError(t, reconciler.Create(ctx, testSandboxWarmPool(4, 1))) + + _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: clientObjectKey(ci.Namespace, ci.Name)}) + assert.NoError(t, err) + + updated := &runtimev1alpha1.CodeInterpreter{} + assert.NoError(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), updated)) + warmPool := apimeta.FindStatusCondition(updated.Status.Conditions, codeInterpreterWarmPoolCondition) + if assert.NotNil(t, warmPool) { + assert.Equal(t, metav1.ConditionFalse, warmPool.Status) + assert.Equal(t, codeInterpreterWarmPoolBelowWatermark, warmPool.Reason) + assert.Contains(t, warmPool.Message, "below low watermark 2") + } + + assertEventContains(t, recorder, corev1.EventTypeWarning, codeInterpreterWarmPoolBelowWatermark) +} + +func TestReconcileUpdatesWarmPoolAvailableWhenPoolRecovers(t *testing.T) { + ctx := context.Background() + reconciler, recorder := setupTestReconcilerWithRecorder(10) + ci := testCodeInterpreterWithWarmPool(2) + assert.NoError(t, reconciler.Create(ctx, ci)) + + request := ctrl.Request{NamespacedName: clientObjectKey(ci.Namespace, ci.Name)} + _, err := reconciler.Reconcile(ctx, request) + assert.NoError(t, err) + assertEventContains(t, recorder, corev1.EventTypeWarning, codeInterpreterWarmPoolEmpty) + + warmPool := &extensionsv1alpha1.SandboxWarmPool{} + assert.NoError(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), warmPool)) + warmPool.Status.Replicas = 2 + warmPool.Status.ReadyReplicas = 2 + assert.NoError(t, reconciler.Status().Update(ctx, warmPool)) + + _, err = reconciler.Reconcile(ctx, request) + assert.NoError(t, err) + + updated := &runtimev1alpha1.CodeInterpreter{} + assert.NoError(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), updated)) + condition := apimeta.FindStatusCondition(updated.Status.Conditions, codeInterpreterWarmPoolCondition) + if assert.NotNil(t, condition) { + assert.Equal(t, metav1.ConditionTrue, condition.Status) + assert.Equal(t, codeInterpreterWarmPoolReady, condition.Reason) + assert.Contains(t, condition.Message, "2 ready replicas out of 2 desired") + } + assertNoEvent(t, recorder) +} + +func testCodeInterpreterWithWarmPool(size int32) *runtimev1alpha1.CodeInterpreter { + return &runtimev1alpha1.CodeInterpreter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + Generation: 1, + }, + Spec: runtimev1alpha1.CodeInterpreterSpec{ + WarmPoolSize: int32Ptr(size), + AuthMode: runtimev1alpha1.AuthModeNone, + Template: &runtimev1alpha1.CodeInterpreterSandboxTemplate{ + Image: "test-image:latest", + ImagePullPolicy: corev1.PullIfNotPresent, + }, + }, + } +} + +func testSandboxWarmPool(desired, ready int32) *extensionsv1alpha1.SandboxWarmPool { + return &extensionsv1alpha1.SandboxWarmPool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + }, + Spec: extensionsv1alpha1.SandboxWarmPoolSpec{ + Replicas: desired, + TemplateRef: extensionsv1alpha1.SandboxTemplateRef{ + Name: "test-ci", + }, + }, + Status: extensionsv1alpha1.SandboxWarmPoolStatus{ + Replicas: desired, + ReadyReplicas: ready, + }, + } +} + +func clientObjectKey(namespace, name string) client.ObjectKey { + return client.ObjectKey{Namespace: namespace, Name: name} +} + +func assertEventContains(t *testing.T, recorder *record.FakeRecorder, eventType, reason string) { + t.Helper() + select { + case event := <-recorder.Events: + assert.Contains(t, event, eventType) + assert.Contains(t, event, reason) + default: + t.Fatalf("expected %s event with reason %s", eventType, reason) + } +} + +func assertNoEvent(t *testing.T, recorder *record.FakeRecorder) { + t.Helper() + select { + case event := <-recorder.Events: + t.Fatalf("expected no event, got %q", event) + default: + } +} From 8344a9f72303f70846eb9e117b7a5d95314208a4 Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Mon, 15 Jun 2026 14:55:34 +0800 Subject: [PATCH 2/3] test: cover warm pool status edge cases Signed-off-by: ranxi2001 --- .../codeinterpreter_controller_test.go | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/pkg/workloadmanager/codeinterpreter_controller_test.go b/pkg/workloadmanager/codeinterpreter_controller_test.go index 7d7d781ef..998dac49a 100644 --- a/pkg/workloadmanager/codeinterpreter_controller_test.go +++ b/pkg/workloadmanager/codeinterpreter_controller_test.go @@ -18,6 +18,7 @@ package workloadmanager import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -29,6 +30,7 @@ import ( 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" runtimev1alpha1 "github.com/volcano-sh/agentcube/pkg/apis/runtime/v1alpha1" sandboxv1alpha1 "sigs.k8s.io/agent-sandbox/api/v1alpha1" @@ -36,6 +38,10 @@ import ( ) func setupTestReconciler() *CodeInterpreterReconciler { + return setupTestReconcilerWithInterceptors(interceptor.Funcs{}) +} + +func setupTestReconcilerWithInterceptors(interceptors interceptor.Funcs) *CodeInterpreterReconciler { scheme := runtime.NewScheme() _ = runtimev1alpha1.AddToScheme(scheme) _ = sandboxv1alpha1.AddToScheme(scheme) @@ -46,6 +52,7 @@ func setupTestReconciler() *CodeInterpreterReconciler { WithScheme(scheme). WithStatusSubresource(&runtimev1alpha1.CodeInterpreter{}). WithStatusSubresource(&extensionsv1alpha1.SandboxWarmPool{}). + WithInterceptorFuncs(interceptors). Build() return &CodeInterpreterReconciler{ @@ -289,6 +296,31 @@ func TestWarmPoolAvailableCondition(t *testing.T) { } } +func TestWarmPoolAvailableConditionReturnsGetError(t *testing.T) { + reconciler := setupTestReconcilerWithInterceptors(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*extensionsv1alpha1.SandboxWarmPool); ok { + return fmt.Errorf("temporary client error") + } + return c.Get(ctx, key, obj, opts...) + }, + }) + + ci := &runtimev1alpha1.CodeInterpreter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + }, + Spec: runtimev1alpha1.CodeInterpreterSpec{ + WarmPoolSize: int32Ptr(2), + }, + } + + _, err := reconciler.warmPoolAvailableCondition(context.Background(), ci) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get SandboxWarmPool for status") +} + func TestShouldRecordWarmPoolWarningEvent(t *testing.T) { tests := []struct { name string @@ -351,6 +383,15 @@ func TestShouldRecordWarmPoolWarningEvent(t *testing.T) { }, want: false, }, + { + name: "does not record unrelated false reason", + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: "OtherReason", + }, + want: false, + }, } reconciler := setupTestReconciler() @@ -395,6 +436,76 @@ func TestUpdateStatusSetsWarmPoolAvailableCondition(t *testing.T) { } } +func TestUpdateStatusSkipsUnchangedStatus(t *testing.T) { + ctx := context.Background() + reconciler := setupTestReconciler() + ci := &runtimev1alpha1.CodeInterpreter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + Generation: 3, + }, + Spec: runtimev1alpha1.CodeInterpreterSpec{ + WarmPoolSize: int32Ptr(2), + }, + Status: runtimev1alpha1.CodeInterpreterStatus{ + Ready: true, + Conditions: []metav1.Condition{ + { + Type: codeInterpreterReadyCondition, + Status: metav1.ConditionTrue, + Reason: codeInterpreterReadyReason, + Message: "CodeInterpreter is ready", + ObservedGeneration: 3, + }, + { + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionTrue, + Reason: codeInterpreterWarmPoolReady, + Message: "SandboxWarmPool has 2 ready replicas out of 2 desired", + ObservedGeneration: 3, + }, + }, + }, + } + assert.NoError(t, reconciler.Create(ctx, ci)) + assert.NoError(t, reconciler.Create(ctx, testSandboxWarmPool(2, 2))) + + assert.NoError(t, reconciler.updateStatus(ctx, ci)) +} + +func TestRecordEventSkipsNilRecorder(t *testing.T) { + reconciler := setupTestReconciler() + ci := testCodeInterpreterWithWarmPool(1) + + assert.NotPanics(t, func() { + reconciler.recordEvent(ci, corev1.EventTypeWarning, codeInterpreterWarmPoolEmpty, "warm pool empty") + }) +} + +func TestUpdateStatusReturnsStatusUpdateError(t *testing.T) { + ctx := context.Background() + reconciler := setupTestReconcilerWithInterceptors(interceptor.Funcs{ + SubResourceUpdate: func(ctx context.Context, c client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if subResourceName == "status" { + return fmt.Errorf("status update failed") + } + return c.SubResource(subResourceName).Update(ctx, obj, opts...) + }, + }) + ci := &runtimev1alpha1.CodeInterpreter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ci", + Namespace: "default", + }, + } + assert.NoError(t, reconciler.Create(ctx, ci)) + + err := reconciler.updateStatus(ctx, ci) + assert.Error(t, err) + assert.Contains(t, err.Error(), "status update failed") +} + func TestReconcileReportsWarmPoolEmptyInsteadOfOnlyReady(t *testing.T) { ctx := context.Background() reconciler, recorder := setupTestReconcilerWithRecorder(10) @@ -476,6 +587,29 @@ func TestReconcileUpdatesWarmPoolAvailableWhenPoolRecovers(t *testing.T) { assertNoEvent(t, recorder) } +func TestReconcileDeletesWarmPoolWhenDisabled(t *testing.T) { + ctx := context.Background() + reconciler := setupTestReconciler() + ci := testCodeInterpreterWithWarmPool(2) + assert.NoError(t, reconciler.Create(ctx, ci)) + + request := ctrl.Request{NamespacedName: clientObjectKey(ci.Namespace, ci.Name)} + _, err := reconciler.Reconcile(ctx, request) + assert.NoError(t, err) + + warmPool := &extensionsv1alpha1.SandboxWarmPool{} + assert.NoError(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), warmPool)) + + updated := &runtimev1alpha1.CodeInterpreter{} + assert.NoError(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), updated)) + updated.Spec.WarmPoolSize = nil + assert.NoError(t, reconciler.Update(ctx, updated)) + + _, err = reconciler.Reconcile(ctx, request) + assert.NoError(t, err) + assert.Error(t, reconciler.Get(ctx, clientObjectKey(ci.Namespace, ci.Name), warmPool)) +} + func testCodeInterpreterWithWarmPool(size int32) *runtimev1alpha1.CodeInterpreter { return &runtimev1alpha1.CodeInterpreter{ ObjectMeta: metav1.ObjectMeta{ From d885b4e32b903cd6315c938fc5d0372aca25654f Mon Sep 17 00:00:00 2001 From: ranxi2001 Date: Tue, 16 Jun 2026 20:05:22 +0800 Subject: [PATCH 3/3] fix: suppress transient warm pool not found warning Signed-off-by: ranxi2001 --- pkg/workloadmanager/codeinterpreter_controller.go | 3 +-- pkg/workloadmanager/codeinterpreter_controller_test.go | 9 +++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/workloadmanager/codeinterpreter_controller.go b/pkg/workloadmanager/codeinterpreter_controller.go index 1412f53a9..27f0505ad 100644 --- a/pkg/workloadmanager/codeinterpreter_controller.go +++ b/pkg/workloadmanager/codeinterpreter_controller.go @@ -214,8 +214,7 @@ func (r *CodeInterpreterReconciler) shouldRecordWarmPoolWarningEvent(previous *m return false } if current.Reason != codeInterpreterWarmPoolEmpty && - current.Reason != codeInterpreterWarmPoolBelowWatermark && - current.Reason != codeInterpreterWarmPoolNotFound { + current.Reason != codeInterpreterWarmPoolBelowWatermark { return false } return previous == nil || previous.Status != current.Status || previous.Reason != current.Reason diff --git a/pkg/workloadmanager/codeinterpreter_controller_test.go b/pkg/workloadmanager/codeinterpreter_controller_test.go index 998dac49a..2cff62782 100644 --- a/pkg/workloadmanager/codeinterpreter_controller_test.go +++ b/pkg/workloadmanager/codeinterpreter_controller_test.go @@ -383,6 +383,15 @@ func TestShouldRecordWarmPoolWarningEvent(t *testing.T) { }, want: false, }, + { + name: "does not record transient not found condition", + current: metav1.Condition{ + Type: codeInterpreterWarmPoolCondition, + Status: metav1.ConditionFalse, + Reason: codeInterpreterWarmPoolNotFound, + }, + want: false, + }, { name: "does not record unrelated false reason", current: metav1.Condition{