Skip to content

Commit 505e927

Browse files
committed
fix(agentruntime): degrade gracefully when target workload is missing (#2490)
When an AgentRuntime's spec.targetRef workload (Sandbox for agents, Deployment for tools) is deleted but the AgentRuntime CR remains, the operator previously logged an Error and emitted a Warning event every ~30s while leaving a stale Ready: True. resolveTargetRef now wraps the IsNotFound case with a sentinel; Reconcile branches on it and treats a missing target as a recoverable degraded state: sets Ready=False and TargetResolved=False (reason TargetNotFound), clears the now-stale status.Card, logs at V(1) instead of Error, emits the Warning event only on transition into the degraded state, and requeues at 60s (recovery is watch-driven, not bound by the interval). Genuine (non-IsNotFound) API errors keep the loud Error path with a distinct reason, TargetResolveError. Target-resolution reasons are extracted to constants. Adds envtest coverage: degraded sets Ready=False; the Warning event is deduped to once across cycles; status.Card is cleared on degrade; recovery to Ready=True when the target reappears. The context's AfterEach now drains the kagenti.io/cleanup finalizer so specs do not leak state. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
1 parent 86a68b5 commit 505e927

2 files changed

Lines changed: 172 additions & 12 deletions

File tree

operator/internal/controller/agentruntime_controller.go

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"crypto/sha256"
2222
"encoding/hex"
2323
"encoding/json"
24+
"errors"
2425
"fmt"
2526
"strconv"
2627
"strings"
@@ -79,6 +80,11 @@ const (
7980
ConditionTypeConfigResolved = "ConfigResolved"
8081
ConditionTypeMTLSReady = "MTLSReady"
8182

83+
// Condition reasons for AgentRuntime target resolution.
84+
ReasonTargetFound = "TargetFound"
85+
ReasonTargetNotFound = "TargetNotFound"
86+
ReasonTargetResolveError = "TargetResolveError"
87+
8288
// AnnotationLastCardFetchHash stores the change-detection key used to skip
8389
// redundant card fetches when the workload's pod template has not changed.
8490
AnnotationLastCardFetchHash = "agent.rossoctl.dev/last-card-fetch-hash"
@@ -100,6 +106,12 @@ var sandboxGVK = schema.GroupVersionKind{
100106
Kind: KindSandbox,
101107
}
102108

109+
// errTargetNotFound is a sentinel wrapped by resolveTargetRef when the target
110+
// workload referenced by spec.targetRef does not exist. Reconcile treats this
111+
// as a recoverable degraded state rather than an error, to avoid log/event
112+
// spam while the target is absent.
113+
var errTargetNotFound = errors.New("target workload not found")
114+
103115
// AgentRuntimeReconciler reconciles AgentRuntime objects.
104116
type AgentRuntimeReconciler struct {
105117
client.Client
@@ -174,16 +186,37 @@ func (r *AgentRuntimeReconciler) Reconcile(ctx context.Context, req ctrl.Request
174186

175187
// 4. Resolve targetRef (existence check)
176188
if err := r.resolveTargetRef(ctx, rt); err != nil {
189+
if errors.Is(err, errTargetNotFound) {
190+
// Recoverable: the AgentRuntime outlives its target workload (e.g. the
191+
// child Sandbox/Deployment was deleted directly). Treat as a degraded
192+
// state instead of spamming Error logs / Warning events every cycle.
193+
// Emit the Warning event only on transition into the degraded state,
194+
// deduped via the pre-existing TargetResolved=False/TargetNotFound
195+
// condition. Recovery is automatic once the target reappears.
196+
prev := meta.FindStatusCondition(rt.Status.Conditions, ConditionTypeTargetResolved)
197+
alreadyDegraded := prev != nil &&
198+
prev.Status == metav1.ConditionFalse &&
199+
prev.Reason == ReasonTargetNotFound
200+
201+
logger.V(1).Info("Target workload not found; AgentRuntime degraded", "error", err.Error())
202+
r.setDegradedTargetNotFound(ctx, req.NamespacedName, err.Error())
203+
if r.Recorder != nil && !alreadyDegraded {
204+
r.Recorder.Eventf(rt, nil, corev1.EventTypeWarning, ReasonTargetNotFound,
205+
"ResolveTarget", err.Error())
206+
}
207+
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
208+
}
209+
// Genuine API error resolving the target: keep the loud error path.
177210
logger.Error(err, "Failed to resolve targetRef")
178-
r.updateErrorStatus(ctx, req.NamespacedName, ConditionTypeTargetResolved, "TargetNotFound", err.Error())
211+
r.updateErrorStatus(ctx, req.NamespacedName, ConditionTypeTargetResolved, ReasonTargetResolveError, err.Error())
179212
if r.Recorder != nil {
180-
r.Recorder.Eventf(rt, nil, corev1.EventTypeWarning, "TargetNotFound",
213+
r.Recorder.Eventf(rt, nil, corev1.EventTypeWarning, ReasonTargetResolveError,
181214
"ResolveTarget", err.Error())
182215
}
183216
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
184217
}
185218

186-
r.setCondition(rt, ConditionTypeTargetResolved, metav1.ConditionTrue, "TargetFound",
219+
r.setCondition(rt, ConditionTypeTargetResolved, metav1.ConditionTrue, ReasonTargetFound,
187220
fmt.Sprintf("%s %s resolved", rt.Spec.TargetRef.Kind, rt.Spec.TargetRef.Name))
188221

189222
// 4.1. Complete two-phase Sandbox restart if pending.
@@ -348,7 +381,8 @@ func (r *AgentRuntimeReconciler) resolveTargetRef(ctx context.Context, rt *agent
348381
key := client.ObjectKey{Namespace: rt.Namespace, Name: ref.Name}
349382
if err := r.Get(ctx, key, acc.obj); err != nil {
350383
if apierrors.IsNotFound(err) {
351-
return fmt.Errorf("%s/%s %s not found in namespace %s", ref.APIVersion, ref.Kind, ref.Name, rt.Namespace)
384+
return fmt.Errorf("%s/%s %s not found in namespace %s: %w",
385+
ref.APIVersion, ref.Kind, ref.Name, rt.Namespace, errTargetNotFound)
352386
}
353387
return err
354388
}
@@ -889,6 +923,29 @@ func (r *AgentRuntimeReconciler) updateErrorStatus(ctx context.Context, key type
889923
}
890924
}
891925

926+
// setDegradedTargetNotFound marks the AgentRuntime as degraded because its
927+
// target workload is missing: Ready=False and TargetResolved=False, both with
928+
// reason "TargetNotFound". It also clears status.Card, which was discovered
929+
// from the now-absent target and is therefore stale. Other conditions are
930+
// preserved. Recovery is automatic once the target reappears (see
931+
// SetupWithManager workload watches).
932+
func (r *AgentRuntimeReconciler) setDegradedTargetNotFound(ctx context.Context, key types.NamespacedName, message string) {
933+
logger := log.FromContext(ctx)
934+
if statusErr := retry.RetryOnConflict(retry.DefaultRetry, func() error {
935+
latest := &agentv1alpha1.AgentRuntime{}
936+
if err := r.Get(ctx, key, latest); err != nil {
937+
return err
938+
}
939+
r.setCondition(latest, ConditionTypeTargetResolved, metav1.ConditionFalse, ReasonTargetNotFound, message)
940+
r.setCondition(latest, ConditionTypeReady, metav1.ConditionFalse, ReasonTargetNotFound, message)
941+
// The card was discovered from the now-absent target workload; clear it.
942+
latest.Status.Card = nil
943+
return r.Status().Update(ctx, latest)
944+
}); statusErr != nil {
945+
logger.Error(statusErr, "Failed to update degraded status", "reason", ReasonTargetNotFound)
946+
}
947+
}
948+
892949
// fetchAndUpdateCard discovers the agent card from the workload's Service endpoint
893950
// and populates status.card. Skips fetch when the feature flag is disabled or
894951
// when the workload's change-detection key has not changed.

operator/internal/controller/agentruntime_controller_test.go

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,20 @@ package controller
1919
import (
2020
"context"
2121
"fmt"
22+
"strings"
2223

2324
. "github.com/onsi/ginkgo/v2"
2425
. "github.com/onsi/gomega"
2526
appsv1 "k8s.io/api/apps/v1"
2627
corev1 "k8s.io/api/core/v1"
28+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2729
"k8s.io/apimachinery/pkg/api/meta"
2830
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2931
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3032
"k8s.io/apimachinery/pkg/runtime"
3133
"k8s.io/apimachinery/pkg/types"
3234
"k8s.io/client-go/kubernetes/scheme"
35+
"k8s.io/client-go/tools/events"
3336
"k8s.io/utils/ptr"
3437
ctrl "sigs.k8s.io/controller-runtime"
3538
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -382,10 +385,23 @@ var _ = Describe("AgentRuntime Controller", func() {
382385
})
383386

384387
AfterEach(func() {
388+
// The AgentRuntime carries the kagenti.io/cleanup finalizer, so a bare
389+
// Delete only sets a DeletionTimestamp; drive reconciles until the
390+
// deletion reconcile removes the finalizer and the object is gone, so
391+
// specs in this context do not leak state into each other.
392+
r := newReconciler()
385393
_ = k8sClient.Delete(ctx, rt)
394+
Eventually(func() bool {
395+
_, _ = r.Reconcile(ctx, reconcile.Request{
396+
NamespacedName: types.NamespacedName{Name: "rt-no-target", Namespace: namespace},
397+
})
398+
err := k8sClient.Get(ctx, types.NamespacedName{Name: "rt-no-target", Namespace: namespace},
399+
&agentv1alpha1.AgentRuntime{})
400+
return apierrors.IsNotFound(err)
401+
}, "10s", "100ms").Should(BeTrue())
386402
})
387403

388-
It("should set TargetNotFound condition", func() {
404+
It("should set TargetNotFound condition and Ready=False", func() {
389405
r := newReconciler()
390406

391407
// First reconcile: adds finalizer
@@ -402,16 +418,103 @@ var _ = Describe("AgentRuntime Controller", func() {
402418
updated := &agentv1alpha1.AgentRuntime{}
403419
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "rt-no-target", Namespace: namespace}, updated)).To(Succeed())
404420

405-
var targetCond *metav1.Condition
406-
for i := range updated.Status.Conditions {
407-
if updated.Status.Conditions[i].Type == ConditionTypeTargetResolved {
408-
targetCond = &updated.Status.Conditions[i]
409-
break
410-
}
411-
}
421+
targetCond := meta.FindStatusCondition(updated.Status.Conditions, ConditionTypeTargetResolved)
412422
Expect(targetCond).NotTo(BeNil())
413423
Expect(targetCond.Status).To(Equal(metav1.ConditionFalse))
414424
Expect(targetCond.Reason).To(Equal("TargetNotFound"))
425+
426+
readyCond := meta.FindStatusCondition(updated.Status.Conditions, ConditionTypeReady)
427+
Expect(readyCond).NotTo(BeNil())
428+
Expect(readyCond.Status).To(Equal(metav1.ConditionFalse))
429+
Expect(readyCond.Reason).To(Equal("TargetNotFound"))
430+
})
431+
432+
It("should emit the TargetNotFound Warning event at most once across cycles", func() {
433+
fakeRecorder := events.NewFakeRecorder(10)
434+
r := &AgentRuntimeReconciler{
435+
Client: k8sClient,
436+
APIReader: k8sClient,
437+
Scheme: scheme.Scheme,
438+
Recorder: fakeRecorder,
439+
}
440+
nn := types.NamespacedName{Name: "rt-no-target", Namespace: namespace}
441+
442+
// First reconcile: adds finalizer (no target resolution yet)
443+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
444+
// Second reconcile: transitions into degraded -> should emit one event
445+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
446+
// Third reconcile: already degraded -> should NOT emit another event
447+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
448+
449+
// Drain the channel and count TargetNotFound Warning events.
450+
count := 0
451+
for len(fakeRecorder.Events) > 0 {
452+
evt := <-fakeRecorder.Events
453+
if strings.Contains(evt, "TargetNotFound") {
454+
count++
455+
}
456+
}
457+
Expect(count).To(Equal(1), "TargetNotFound event should be emitted only on transition")
458+
})
459+
460+
It("should recover to Ready when the target is created", func() {
461+
r := newReconciler()
462+
nn := types.NamespacedName{Name: "rt-no-target", Namespace: namespace}
463+
464+
// Drive into degraded state.
465+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
466+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
467+
468+
// Create the missing target, then reconcile again.
469+
dep := newDeployment("nonexistent-deploy", namespace)
470+
Expect(k8sClient.Create(ctx, dep)).To(Succeed())
471+
defer func() { _ = k8sClient.Delete(ctx, dep) }()
472+
473+
result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
474+
Expect(err).NotTo(HaveOccurred())
475+
Expect(result.RequeueAfter).To(BeZero(), "healthy reconcile should not requeue")
476+
477+
updated := &agentv1alpha1.AgentRuntime{}
478+
Expect(k8sClient.Get(ctx, nn, updated)).To(Succeed())
479+
480+
targetCond := meta.FindStatusCondition(updated.Status.Conditions, ConditionTypeTargetResolved)
481+
Expect(targetCond).NotTo(BeNil())
482+
Expect(targetCond.Status).To(Equal(metav1.ConditionTrue))
483+
Expect(targetCond.Reason).To(Equal("TargetFound"))
484+
485+
readyCond := meta.FindStatusCondition(updated.Status.Conditions, ConditionTypeReady)
486+
Expect(readyCond).NotTo(BeNil())
487+
Expect(readyCond.Status).To(Equal(metav1.ConditionTrue))
488+
Expect(readyCond.Reason).To(Equal("Configured"))
489+
})
490+
491+
It("should clear stale status.Card when the target is missing", func() {
492+
r := newReconciler()
493+
nn := types.NamespacedName{Name: "rt-no-target", Namespace: namespace}
494+
495+
// First reconcile: adds finalizer.
496+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
497+
498+
// Simulate a card left over from when the target existed.
499+
seed := &agentv1alpha1.AgentRuntime{}
500+
Expect(k8sClient.Get(ctx, nn, seed)).To(Succeed())
501+
seed.Status.Card = &agentv1alpha1.CardStatus{
502+
AgentCardData: agentv1alpha1.AgentCardData{Name: "stale-agent"},
503+
CardHash: "sha256:deadbeef",
504+
}
505+
Expect(k8sClient.Status().Update(ctx, seed)).To(Succeed())
506+
507+
// Second reconcile: target still missing -> degraded path runs.
508+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
509+
510+
updated := &agentv1alpha1.AgentRuntime{}
511+
Expect(k8sClient.Get(ctx, nn, updated)).To(Succeed())
512+
Expect(updated.Status.Card).To(BeNil())
513+
514+
readyCond := meta.FindStatusCondition(updated.Status.Conditions, ConditionTypeReady)
515+
Expect(readyCond).NotTo(BeNil())
516+
Expect(readyCond.Status).To(Equal(metav1.ConditionFalse))
517+
Expect(readyCond.Reason).To(Equal("TargetNotFound"))
415518
})
416519
})
417520

0 commit comments

Comments
 (0)