Skip to content

Commit f68d4b2

Browse files
authored
Merge pull request #423 from pdettori/fix/reconciler-conflict-retry-422
Fix: wrap status update with RetryOnConflict to prevent linkedSkills loss
2 parents f13eba2 + 85616c9 commit f68d4b2

3 files changed

Lines changed: 71 additions & 16 deletions

File tree

kagenti-operator/internal/controller/agentruntime_controller.go

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,9 @@ func (r *AgentRuntimeReconciler) Reconcile(ctx context.Context, req ctrl.Request
248248

249249
// 6.5. Discover linked skills from workload annotation (set by kagenti backend or user)
250250
fg := r.getFeatureGates()
251+
var linkedSkills []string
251252
if fg.SkillDiscovery {
252-
rt.Status.LinkedSkills = r.readLinkedSkills(ctx, rt)
253-
if len(rt.Status.LinkedSkills) > 0 {
254-
r.setCondition(rt, ConditionTypeSkillsDiscovered, metav1.ConditionTrue, "SkillsFound",
255-
fmt.Sprintf("%d linked skill(s) discovered from workload annotation", len(rt.Status.LinkedSkills)))
256-
} else {
257-
meta.RemoveStatusCondition(&rt.Status.Conditions, ConditionTypeSkillsDiscovered)
258-
}
259-
} else {
260-
rt.Status.LinkedSkills = nil
261-
meta.RemoveStatusCondition(&rt.Status.Conditions, ConditionTypeSkillsDiscovered)
253+
linkedSkills = r.readLinkedSkills(ctx, rt)
262254
}
263255

264256
// 7. Count configured pods
@@ -267,12 +259,31 @@ func (r *AgentRuntimeReconciler) Reconcile(ctx context.Context, req ctrl.Request
267259
logger.V(1).Info("Failed to count configured pods", "error", err)
268260
}
269261

270-
// 8. Update status
262+
// 8. Update status (retry on conflict to preserve all conditions computed above)
271263
rt.Status.ConfiguredPods = configuredPods
272264
r.setPhase(rt, agentv1alpha1.RuntimePhaseActive)
273265
r.setCondition(rt, ConditionTypeReady, metav1.ConditionTrue, "Configured",
274266
fmt.Sprintf("Workload %s configured with config-hash %s", rt.Spec.TargetRef.Name, configResult.Hash[:12]))
275-
if err := r.Status().Update(ctx, rt); err != nil {
267+
if fg.SkillDiscovery {
268+
rt.Status.LinkedSkills = linkedSkills
269+
if len(linkedSkills) > 0 {
270+
r.setCondition(rt, ConditionTypeSkillsDiscovered, metav1.ConditionTrue, "SkillsFound",
271+
fmt.Sprintf("%d linked skill(s) discovered from workload annotation", len(linkedSkills)))
272+
} else {
273+
meta.RemoveStatusCondition(&rt.Status.Conditions, ConditionTypeSkillsDiscovered)
274+
}
275+
} else {
276+
rt.Status.LinkedSkills = nil
277+
meta.RemoveStatusCondition(&rt.Status.Conditions, ConditionTypeSkillsDiscovered)
278+
}
279+
desired := rt.Status.DeepCopy()
280+
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
281+
if err := r.Get(ctx, req.NamespacedName, rt); err != nil {
282+
return err
283+
}
284+
rt.Status = *desired // safe: this controller is the sole status owner
285+
return r.Status().Update(ctx, rt)
286+
}); err != nil {
276287
logger.Error(err, "Failed to update status")
277288
return ctrl.Result{}, err
278289
}
@@ -708,10 +719,6 @@ func (r *AgentRuntimeReconciler) handleDeletion(ctx context.Context, rt *agentv1
708719
delete(workloadLabels, LabelManagedBy)
709720
acc.obj.SetLabels(workloadLabels)
710721

711-
// Remove skills annotation from workload metadata.
712-
workloadAnnotations := acc.obj.GetAnnotations()
713-
delete(workloadAnnotations, AnnotationSkills)
714-
acc.obj.SetAnnotations(workloadAnnotations)
715722

716723
// Remove kagenti.io/type from PodTemplateSpec pod labels so future pods
717724
// are not presented to the webhook with the type label.

kagenti-operator/internal/controller/agentruntime_controller_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,47 @@ var _ = Describe("AgentRuntime Controller", func() {
209209
Expect(k8sClient.Get(ctx, nn, updatedRT)).To(Succeed())
210210
Expect(updatedRT.Status.LinkedSkills).To(ConsistOf("summarizer", "translator"))
211211
})
212+
213+
It("should persist linkedSkills even after a concurrent status modification", func() {
214+
dep := newDeployment("skills-conflict-deploy", namespace)
215+
dep.Annotations = map[string]string{
216+
AnnotationSkills: `["skill-a","skill-b"]`,
217+
}
218+
Expect(k8sClient.Create(ctx, dep)).To(Succeed())
219+
defer func() { _ = k8sClient.Delete(ctx, dep) }()
220+
221+
rt := newAgentRuntime("skills-conflict-rt", namespace, "skills-conflict-deploy", agentv1alpha1.RuntimeTypeAgent)
222+
Expect(k8sClient.Create(ctx, rt)).To(Succeed())
223+
defer func() { _ = k8sClient.Delete(ctx, rt) }()
224+
225+
r := newReconciler()
226+
r.GetFeatureGates = func() *webhookconfig.FeatureGates {
227+
return &webhookconfig.FeatureGates{SkillDiscovery: true}
228+
}
229+
nn := types.NamespacedName{Name: "skills-conflict-rt", Namespace: namespace}
230+
231+
// First reconcile to set up finalizer
232+
_, _ = r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
233+
234+
// Simulate a concurrent modification by updating the object's status
235+
// from another controller (this bumps resourceVersion)
236+
current := &agentv1alpha1.AgentRuntime{}
237+
Expect(k8sClient.Get(ctx, nn, current)).To(Succeed())
238+
current.Status.ConfiguredPods = 99
239+
Expect(k8sClient.Status().Update(ctx, current)).To(Succeed())
240+
241+
// Now reconcile — the status update inside Reconcile will initially
242+
// hit a conflict (stale resourceVersion), but retry should succeed
243+
_, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
244+
Expect(err).NotTo(HaveOccurred())
245+
246+
updatedRT := &agentv1alpha1.AgentRuntime{}
247+
Expect(k8sClient.Get(ctx, nn, updatedRT)).To(Succeed())
248+
Expect(updatedRT.Status.LinkedSkills).To(ConsistOf("skill-a", "skill-b"))
249+
250+
configCond := meta.FindStatusCondition(updatedRT.Status.Conditions, ConditionTypeConfigResolved)
251+
Expect(configCond).NotTo(BeNil(), "ConfigResolved condition must survive the retry re-fetch")
252+
})
212253
})
213254

214255
Context("When setting status", func() {

kagenti-operator/test/e2e/e2e_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,13 @@ rules:
21332133

21342134
Context("Feature gate enabled", Ordered, func() {
21352135
BeforeAll(func() {
2136+
By("re-applying target Deployment to restore skills annotation after prior deletion cleanup")
2137+
_, err := utils.KubectlApplyStdin(skillDiscoveryDeploymentFixture(), skillDiscoveryTestNamespace)
2138+
Expect(err).NotTo(HaveOccurred())
2139+
Expect(utils.WaitForDeploymentReady(
2140+
"skill-discovery-agent", skillDiscoveryTestNamespace, 2*time.Minute,
2141+
)).To(Succeed())
2142+
21362143
By("enabling skillDiscovery feature gate")
21372144
Expect(utils.EnableSkillDiscovery(controllerNamespace, controllerDeployment)).To(Succeed())
21382145

0 commit comments

Comments
 (0)