Skip to content

Commit b767618

Browse files
stubbiclaude
andcommitted
fix(bootstrap): make bootstrap Job reconcile idempotent (no immutable-template churn)
The admin bootstrap Job (spec.auth.adminUser) could have its running pod killed ~1s after start by the Job controller itself (SuccessfulDelete immediately after "Started container bootstrap"), then BackoffLimitExceeded, leaving bootstrapStatus at bootstrap_pending (issue #83). A Kubernetes Job's pod template is immutable after creation, so any reconcile that re-renders or patches spec.template makes the Job controller churn and can delete the active pod. Separately, the Job was built with explicit pod-template labels but no explicit selector, so the Job controller could adopt a leftover/orphaned pod from a previous bootstrap Job of the same name (e.g. after a manual `kubectl delete job` + operator recreate) and then reap it. This makes the bootstrap Job lifecycle deterministic and non-churning: - BuildBootstrapJob now sets an explicit, unique spec.selector + manualSelector=true keyed to the Job name (plus a matching per-Job pod label), so this Job's pods can never be adopted from a prior Job. - BuildBootstrapJob stamps a content-hash annotation over the operator-controlled inputs (image, script, admin email, password secret ref). - reconcileBootstrapJob is strictly create-if-absent. If the Job exists and the hash matches, it is left entirely untouched (spec.template is never patched). Only when the hash differs is the stale Job deleted (foreground propagation) and recreated on a later reconcile -- never an illegal in-place template update. Adds an envtest regression test that reconciles the bootstrap Job N times and asserts the Job is neither updated (same resourceVersion) nor recreated (same UID), plus a test that a config change triggers delete+recreate. Closes #83 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de2005d commit b767618

3 files changed

Lines changed: 208 additions & 6 deletions

File tree

internal/controller/instance_controller.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,22 +1279,52 @@ func (r *InstanceReconciler) reconcileBootstrapJob(ctx context.Context, instance
12791279
return nil
12801280
}
12811281

1282-
// Check if Job already exists (it should only run once)
1282+
// A Job's pod template is immutable after creation, so this reconcile is
1283+
// strictly create-if-absent and never patches an existing Job in place.
1284+
// Patching spec.template (even a no-op re-render) makes the Job controller
1285+
// churn and can delete the running bootstrap pod, tripping
1286+
// BackoffLimitExceeded (issue #83). If the operator-controlled inputs change
1287+
// we delete and recreate the Job instead, gated on a content-hash so a
1288+
// steady-state reconcile is a no-op.
12831289
existing := &batchv1.Job{}
12841290
err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: desired.Namespace}, existing)
12851291
if err == nil {
1286-
// Job already exists, nothing to do
1292+
// Job already exists. If its content hash matches the desired spec,
1293+
// leave it completely alone (do NOT touch spec.template).
1294+
if existing.Annotations[resources.BootstrapHashAnnotation] ==
1295+
desired.Annotations[resources.BootstrapHashAnnotation] {
1296+
return nil
1297+
}
1298+
1299+
// The bootstrap config changed. A Job's template cannot be updated, so
1300+
// delete the stale Job (foreground propagation so its pods are removed
1301+
// before we recreate) and recreate on a later reconcile. We do not
1302+
// recreate in the same pass to avoid racing the API server's
1303+
// asynchronous cascade delete.
1304+
logf.FromContext(ctx).Info("bootstrap Job content changed; replacing",
1305+
"job", desired.Name)
1306+
propagation := metav1.DeletePropagationForeground
1307+
if delErr := r.Delete(ctx, existing, &client.DeleteOptions{
1308+
PropagationPolicy: &propagation,
1309+
}); delErr != nil && !apierrors.IsNotFound(delErr) {
1310+
return fmt.Errorf("deleting stale bootstrap Job: %w", delErr)
1311+
}
12871312
return nil
12881313
}
12891314
if !apierrors.IsNotFound(err) {
12901315
return fmt.Errorf("checking bootstrap Job: %w", err)
12911316
}
12921317

1293-
// Job does not exist, create it
1318+
// Job does not exist, create it.
12941319
if err := controllerutil.SetControllerReference(instance, desired, r.Scheme); err != nil {
12951320
return fmt.Errorf("setting owner reference on bootstrap Job: %w", err)
12961321
}
12971322
if err := r.Create(ctx, desired); err != nil { // reconcile-guard:allow
1323+
if apierrors.IsAlreadyExists(err) {
1324+
// Lost a create race with the just-deleted Job's cascade; the next
1325+
// reconcile will converge.
1326+
return nil
1327+
}
12981328
return fmt.Errorf("creating bootstrap Job: %w", err)
12991329
}
13001330

internal/controller/instance_controller_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
appsv1 "k8s.io/api/apps/v1"
2727
autoscalingv1 "k8s.io/api/autoscaling/v1"
2828
autoscalingv2 "k8s.io/api/autoscaling/v2"
29+
batchv1 "k8s.io/api/batch/v1"
2930
corev1 "k8s.io/api/core/v1"
3031
"k8s.io/apimachinery/pkg/api/errors"
3132
"k8s.io/apimachinery/pkg/api/meta"
@@ -662,4 +663,117 @@ var _ = Describe("Instance Controller", func() {
662663
Expect(meta.FindStatusCondition(updated.Status.Conditions, ConditionSchedulerGatingValid)).To(BeNil())
663664
})
664665
})
666+
667+
// Regression for issue #83: the bootstrap Job (spec.auth.adminUser) must be
668+
// reconciled idempotently. A Job's pod template is immutable, so re-rendering
669+
// or patching it on every reconcile makes the Job controller churn and kill
670+
// the running bootstrap pod (SuccessfulDelete ~1s after start ->
671+
// BackoffLimitExceeded). Steady-state reconciles must leave the Job entirely
672+
// untouched; only a real config change may replace it (delete + recreate).
673+
Context("When reconciling the admin bootstrap Job", func() {
674+
ctx := context.Background()
675+
676+
newBootstrapInstance := func(name, email string) *paperclipv1alpha1.Instance {
677+
return &paperclipv1alpha1.Instance{
678+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
679+
Spec: paperclipv1alpha1.InstanceSpec{
680+
Image: paperclipv1alpha1.ImageSpec{Tag: "v1.0.0"},
681+
Auth: paperclipv1alpha1.AuthSpec{
682+
AdminUser: &paperclipv1alpha1.AdminUserSpec{
683+
Email: email,
684+
PasswordSecretRef: corev1.SecretKeySelector{
685+
LocalObjectReference: corev1.LocalObjectReference{Name: "admin-secret"},
686+
Key: "password",
687+
},
688+
},
689+
},
690+
},
691+
}
692+
}
693+
694+
It("does not update or recreate the Job across repeated reconciles", func() {
695+
const bootName = "bootstrap-idem"
696+
nn := types.NamespacedName{Name: bootName, Namespace: "default"}
697+
jobNN := types.NamespacedName{Name: bootName + "-bootstrap", Namespace: "default"}
698+
699+
Expect(k8sClient.Create(ctx, newBootstrapInstance(bootName, "admin@test.com"))).To(Succeed())
700+
DeferCleanup(func() {
701+
resource := &paperclipv1alpha1.Instance{}
702+
if err := k8sClient.Get(ctx, nn, resource); err == nil {
703+
_ = k8sClient.Delete(ctx, resource)
704+
}
705+
})
706+
r := &InstanceReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()}
707+
708+
By("reconciling twice so the finalizer requeue passes and the Job is created")
709+
reconcileN(ctx, r, nn, 2)
710+
711+
job := &batchv1.Job{}
712+
Expect(k8sClient.Get(ctx, jobNN, job)).To(Succeed())
713+
originalUID := job.UID
714+
originalRV := job.ResourceVersion
715+
Expect(job.Annotations).To(HaveKey(resources.BootstrapHashAnnotation))
716+
717+
By("asserting the Job has an explicit, unique selector so its pods cannot be adopted")
718+
Expect(job.Spec.ManualSelector).NotTo(BeNil())
719+
Expect(*job.Spec.ManualSelector).To(BeTrue())
720+
Expect(job.Spec.Selector).NotTo(BeNil())
721+
Expect(job.Spec.Selector.MatchLabels).To(HaveKeyWithValue(resources.BootstrapJobLabel, jobNN.Name))
722+
Expect(job.Spec.Template.Labels).To(HaveKeyWithValue(resources.BootstrapJobLabel, jobNN.Name))
723+
724+
By("reconciling several more times")
725+
reconcileN(ctx, r, nn, 5)
726+
727+
By("verifying the Job was neither updated (same resourceVersion) nor recreated (same UID)")
728+
after := &batchv1.Job{}
729+
Expect(k8sClient.Get(ctx, jobNN, after)).To(Succeed())
730+
Expect(after.UID).To(Equal(originalUID), "bootstrap Job was recreated on a steady-state reconcile")
731+
Expect(after.ResourceVersion).To(Equal(originalRV), "bootstrap Job spec was mutated on a steady-state reconcile")
732+
})
733+
734+
It("replaces the Job (delete + recreate) only when the bootstrap config changes", func() {
735+
const bootName = "bootstrap-replace"
736+
nn := types.NamespacedName{Name: bootName, Namespace: "default"}
737+
jobNN := types.NamespacedName{Name: bootName + "-bootstrap", Namespace: "default"}
738+
739+
Expect(k8sClient.Create(ctx, newBootstrapInstance(bootName, "admin@test.com"))).To(Succeed())
740+
DeferCleanup(func() {
741+
resource := &paperclipv1alpha1.Instance{}
742+
if err := k8sClient.Get(ctx, nn, resource); err == nil {
743+
_ = k8sClient.Delete(ctx, resource)
744+
}
745+
})
746+
r := &InstanceReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()}
747+
reconcileN(ctx, r, nn, 2)
748+
749+
job := &batchv1.Job{}
750+
Expect(k8sClient.Get(ctx, jobNN, job)).To(Succeed())
751+
originalUID := job.UID
752+
753+
By("changing the admin email, which changes the bootstrap content hash")
754+
updated := &paperclipv1alpha1.Instance{}
755+
Expect(k8sClient.Get(ctx, nn, updated)).To(Succeed())
756+
updated.Spec.Auth.AdminUser.Email = "different@test.com"
757+
Expect(k8sClient.Update(ctx, updated)).To(Succeed())
758+
759+
By("the next reconcile deletes the stale Job (template is immutable, cannot patch)")
760+
reconcileN(ctx, r, nn, 1)
761+
// Foreground deletion may leave the object briefly with a deletion
762+
// timestamp; remove any finalizers the envtest GC won't process.
763+
stale := &batchv1.Job{}
764+
if err := k8sClient.Get(ctx, jobNN, stale); err == nil && stale.DeletionTimestamp != nil {
765+
stale.Finalizers = nil
766+
_ = k8sClient.Update(ctx, stale)
767+
}
768+
Eventually(func() bool {
769+
return errors.IsNotFound(k8sClient.Get(ctx, jobNN, &batchv1.Job{}))
770+
}).Should(BeTrue(), "stale bootstrap Job should be deleted")
771+
772+
By("a subsequent reconcile recreates the Job with a new UID")
773+
reconcileN(ctx, r, nn, 1)
774+
recreated := &batchv1.Job{}
775+
Expect(k8sClient.Get(ctx, jobNN, recreated)).To(Succeed())
776+
Expect(recreated.UID).NotTo(Equal(originalUID), "Job should have been recreated, not patched in place")
777+
})
778+
})
665779
})

internal/resources/bootstrap.go

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package resources
22

33
import (
4+
"crypto/sha256"
5+
"encoding/hex"
46
"fmt"
57
"strings"
68

@@ -11,6 +13,20 @@ import (
1113
paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1"
1214
)
1315

16+
// BootstrapHashAnnotation records a content hash of the operator-controlled
17+
// inputs to the bootstrap Job. Because a Job's pod template is immutable after
18+
// creation, the reconciler never patches an existing Job in place; instead it
19+
// compares this annotation and, only when it differs, deletes and recreates the
20+
// Job. A steady-state reconcile sees an unchanged hash and is a no-op.
21+
const BootstrapHashAnnotation = "paperclip.ai/bootstrap-spec-hash"
22+
23+
// BootstrapJobLabel is a per-Job unique pod label. It is part of the Job's
24+
// explicit selector (manualSelector) so the Job controller can never adopt a
25+
// pod left over from a previous bootstrap Job of the same name. Adopting a
26+
// stale/orphaned pod is what made the Job controller reap its own pod ~1s after
27+
// start and then report BackoffLimitExceeded (issue #83).
28+
const BootstrapJobLabel = "paperclip.ai/bootstrap-job"
29+
1430
// sanitizeJSONString escapes special characters for safe embedding in a JSON string literal
1531
// inside a shell script. Prevents JSON injection via user-controlled CRD fields.
1632
func sanitizeJSONString(s string) string {
@@ -156,18 +172,34 @@ echo "Admin bootstrap finished successfully."
156172
backoffLimit := int32(3)
157173
ttl := int32(3600) // Clean up completed job after 1 hour
158174

159-
return &batchv1.Job{
175+
jobName := BootstrapJobName(instance)
176+
177+
// Pod template labels: the standard component labels plus a per-Job unique
178+
// label. The unique label is also the Job's explicit selector
179+
// (manualSelector=true) so this Job's pods can never be confused with — or
180+
// adopted from — a previous bootstrap Job of the same name. Without an
181+
// explicit, unique selector the Job controller could adopt a leftover/
182+
// orphaned pod and then delete it, killing the running bootstrap pod ~1s
183+
// after start and tripping BackoffLimitExceeded (issue #83).
184+
podLabels := LabelsWithComponent(instance, "bootstrap")
185+
podLabels[BootstrapJobLabel] = jobName
186+
187+
job := &batchv1.Job{
160188
ObjectMeta: metav1.ObjectMeta{
161-
Name: BootstrapJobName(instance),
189+
Name: jobName,
162190
Namespace: instance.Namespace,
163191
Labels: LabelsWithComponent(instance, "bootstrap"),
164192
},
165193
Spec: batchv1.JobSpec{
166194
BackoffLimit: &backoffLimit,
167195
TTLSecondsAfterFinished: &ttl,
196+
ManualSelector: Ptr(true),
197+
Selector: &metav1.LabelSelector{
198+
MatchLabels: map[string]string{BootstrapJobLabel: jobName},
199+
},
168200
Template: corev1.PodTemplateSpec{
169201
ObjectMeta: metav1.ObjectMeta{
170-
Labels: LabelsWithComponent(instance, "bootstrap"),
202+
Labels: podLabels,
171203
},
172204
Spec: corev1.PodSpec{
173205
RestartPolicy: corev1.RestartPolicyOnFailure,
@@ -210,4 +242,30 @@ echo "Admin bootstrap finished successfully."
210242
},
211243
},
212244
}
245+
246+
// Stamp a content hash of the operator-controlled inputs. The reconciler
247+
// uses this to decide whether an existing Job is up to date (no-op) or must
248+
// be replaced (delete + recreate); it never patches the immutable template
249+
// in place.
250+
if job.Annotations == nil {
251+
job.Annotations = map[string]string{}
252+
}
253+
job.Annotations[BootstrapHashAnnotation] = bootstrapSpecHash(image, script, admin)
254+
255+
return job
256+
}
257+
258+
// bootstrapSpecHash returns a stable hash over the operator-controlled inputs to
259+
// the bootstrap Job. It intentionally excludes server-defaulted/mutable fields
260+
// so a steady-state reconcile produces an identical hash and is a no-op.
261+
func bootstrapSpecHash(image, script string, admin *paperclipv1alpha1.AdminUserSpec) string {
262+
// The script already embeds the resolved admin name, service/base URLs and
263+
// the JSON sign-up payload, so it captures most config drift. Include the
264+
// image, admin email and the password secret reference explicitly so a
265+
// change to credentials forces a fresh Job.
266+
payload := fmt.Sprintf("image=%s\x00script=%s\x00email=%s\x00secret=%s/%s\x00",
267+
image, script, admin.Email,
268+
admin.PasswordSecretRef.Name, admin.PasswordSecretRef.Key)
269+
sum := sha256.Sum256([]byte(payload))
270+
return hex.EncodeToString(sum[:])
213271
}

0 commit comments

Comments
 (0)