Skip to content

Commit 2ac60b4

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 a3871bf commit 2ac60b4

3 files changed

Lines changed: 209 additions & 6 deletions

File tree

internal/controller/instance_controller.go

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

1211-
// Check if Job already exists (it should only run once)
1211+
// A Job's pod template is immutable after creation, so this reconcile is
1212+
// strictly create-if-absent and never patches an existing Job in place.
1213+
// Patching spec.template (even a no-op re-render) makes the Job controller
1214+
// churn and can delete the running bootstrap pod, tripping
1215+
// BackoffLimitExceeded (issue #83). If the operator-controlled inputs change
1216+
// we delete and recreate the Job instead, gated on a content-hash so a
1217+
// steady-state reconcile is a no-op.
12121218
existing := &batchv1.Job{}
12131219
err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: desired.Namespace}, existing)
12141220
if err == nil {
1215-
// Job already exists, nothing to do
1221+
// Job already exists. If its content hash matches the desired spec,
1222+
// leave it completely alone (do NOT touch spec.template).
1223+
if existing.Annotations[resources.BootstrapHashAnnotation] ==
1224+
desired.Annotations[resources.BootstrapHashAnnotation] {
1225+
return nil
1226+
}
1227+
1228+
// The bootstrap config changed. A Job's template cannot be updated, so
1229+
// delete the stale Job (foreground propagation so its pods are removed
1230+
// before we recreate) and recreate on a later reconcile. We do not
1231+
// recreate in the same pass to avoid racing the API server's
1232+
// asynchronous cascade delete.
1233+
logf.FromContext(ctx).Info("bootstrap Job content changed; replacing",
1234+
"job", desired.Name)
1235+
propagation := metav1.DeletePropagationForeground
1236+
if delErr := r.Delete(ctx, existing, &client.DeleteOptions{
1237+
PropagationPolicy: &propagation,
1238+
}); delErr != nil && !apierrors.IsNotFound(delErr) {
1239+
return fmt.Errorf("deleting stale bootstrap Job: %w", delErr)
1240+
}
12161241
return nil
12171242
}
12181243
if !apierrors.IsNotFound(err) {
12191244
return fmt.Errorf("checking bootstrap Job: %w", err)
12201245
}
12211246

1222-
// Job does not exist, create it
1247+
// Job does not exist, create it.
12231248
if err := controllerutil.SetControllerReference(instance, desired, r.Scheme); err != nil {
12241249
return fmt.Errorf("setting owner reference on bootstrap Job: %w", err)
12251250
}
12261251
if err := r.Create(ctx, desired); err != nil { // reconcile-guard:allow
1252+
if apierrors.IsAlreadyExists(err) {
1253+
// Lost a create race with the just-deleted Job's cascade; the next
1254+
// reconcile will converge.
1255+
return nil
1256+
}
12271257
return fmt.Errorf("creating bootstrap Job: %w", err)
12281258
}
12291259

internal/controller/instance_controller_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import (
2525
appsv1 "k8s.io/api/apps/v1"
2626
autoscalingv1 "k8s.io/api/autoscaling/v1"
2727
autoscalingv2 "k8s.io/api/autoscaling/v2"
28+
batchv1 "k8s.io/api/batch/v1"
29+
corev1 "k8s.io/api/core/v1"
2830
"k8s.io/apimachinery/pkg/api/errors"
2931
"k8s.io/apimachinery/pkg/api/meta"
3032
"k8s.io/apimachinery/pkg/types"
@@ -468,4 +470,117 @@ var _ = Describe("Instance Controller", func() {
468470
Expect(meta.FindStatusCondition(updated.Status.Conditions, ConditionMultiReplicaPreconditions)).To(BeNil())
469471
})
470472
})
473+
474+
// Regression for issue #83: the bootstrap Job (spec.auth.adminUser) must be
475+
// reconciled idempotently. A Job's pod template is immutable, so re-rendering
476+
// or patching it on every reconcile makes the Job controller churn and kill
477+
// the running bootstrap pod (SuccessfulDelete ~1s after start ->
478+
// BackoffLimitExceeded). Steady-state reconciles must leave the Job entirely
479+
// untouched; only a real config change may replace it (delete + recreate).
480+
Context("When reconciling the admin bootstrap Job", func() {
481+
ctx := context.Background()
482+
483+
newBootstrapInstance := func(name, email string) *paperclipv1alpha1.Instance {
484+
return &paperclipv1alpha1.Instance{
485+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
486+
Spec: paperclipv1alpha1.InstanceSpec{
487+
Image: paperclipv1alpha1.ImageSpec{Tag: "v1.0.0"},
488+
Auth: paperclipv1alpha1.AuthSpec{
489+
AdminUser: &paperclipv1alpha1.AdminUserSpec{
490+
Email: email,
491+
PasswordSecretRef: corev1.SecretKeySelector{
492+
LocalObjectReference: corev1.LocalObjectReference{Name: "admin-secret"},
493+
Key: "password",
494+
},
495+
},
496+
},
497+
},
498+
}
499+
}
500+
501+
It("does not update or recreate the Job across repeated reconciles", func() {
502+
const bootName = "bootstrap-idem"
503+
nn := types.NamespacedName{Name: bootName, Namespace: "default"}
504+
jobNN := types.NamespacedName{Name: bootName + "-bootstrap", Namespace: "default"}
505+
506+
Expect(k8sClient.Create(ctx, newBootstrapInstance(bootName, "admin@test.com"))).To(Succeed())
507+
DeferCleanup(func() {
508+
resource := &paperclipv1alpha1.Instance{}
509+
if err := k8sClient.Get(ctx, nn, resource); err == nil {
510+
_ = k8sClient.Delete(ctx, resource)
511+
}
512+
})
513+
r := &InstanceReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()}
514+
515+
By("reconciling twice so the finalizer requeue passes and the Job is created")
516+
reconcileN(ctx, r, nn, 2)
517+
518+
job := &batchv1.Job{}
519+
Expect(k8sClient.Get(ctx, jobNN, job)).To(Succeed())
520+
originalUID := job.UID
521+
originalRV := job.ResourceVersion
522+
Expect(job.Annotations).To(HaveKey(resources.BootstrapHashAnnotation))
523+
524+
By("asserting the Job has an explicit, unique selector so its pods cannot be adopted")
525+
Expect(job.Spec.ManualSelector).NotTo(BeNil())
526+
Expect(*job.Spec.ManualSelector).To(BeTrue())
527+
Expect(job.Spec.Selector).NotTo(BeNil())
528+
Expect(job.Spec.Selector.MatchLabels).To(HaveKeyWithValue(resources.BootstrapJobLabel, jobNN.Name))
529+
Expect(job.Spec.Template.Labels).To(HaveKeyWithValue(resources.BootstrapJobLabel, jobNN.Name))
530+
531+
By("reconciling several more times")
532+
reconcileN(ctx, r, nn, 5)
533+
534+
By("verifying the Job was neither updated (same resourceVersion) nor recreated (same UID)")
535+
after := &batchv1.Job{}
536+
Expect(k8sClient.Get(ctx, jobNN, after)).To(Succeed())
537+
Expect(after.UID).To(Equal(originalUID), "bootstrap Job was recreated on a steady-state reconcile")
538+
Expect(after.ResourceVersion).To(Equal(originalRV), "bootstrap Job spec was mutated on a steady-state reconcile")
539+
})
540+
541+
It("replaces the Job (delete + recreate) only when the bootstrap config changes", func() {
542+
const bootName = "bootstrap-replace"
543+
nn := types.NamespacedName{Name: bootName, Namespace: "default"}
544+
jobNN := types.NamespacedName{Name: bootName + "-bootstrap", Namespace: "default"}
545+
546+
Expect(k8sClient.Create(ctx, newBootstrapInstance(bootName, "admin@test.com"))).To(Succeed())
547+
DeferCleanup(func() {
548+
resource := &paperclipv1alpha1.Instance{}
549+
if err := k8sClient.Get(ctx, nn, resource); err == nil {
550+
_ = k8sClient.Delete(ctx, resource)
551+
}
552+
})
553+
r := &InstanceReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()}
554+
reconcileN(ctx, r, nn, 2)
555+
556+
job := &batchv1.Job{}
557+
Expect(k8sClient.Get(ctx, jobNN, job)).To(Succeed())
558+
originalUID := job.UID
559+
560+
By("changing the admin email, which changes the bootstrap content hash")
561+
updated := &paperclipv1alpha1.Instance{}
562+
Expect(k8sClient.Get(ctx, nn, updated)).To(Succeed())
563+
updated.Spec.Auth.AdminUser.Email = "different@test.com"
564+
Expect(k8sClient.Update(ctx, updated)).To(Succeed())
565+
566+
By("the next reconcile deletes the stale Job (template is immutable, cannot patch)")
567+
reconcileN(ctx, r, nn, 1)
568+
// Foreground deletion may leave the object briefly with a deletion
569+
// timestamp; remove any finalizers the envtest GC won't process.
570+
stale := &batchv1.Job{}
571+
if err := k8sClient.Get(ctx, jobNN, stale); err == nil && stale.DeletionTimestamp != nil {
572+
stale.Finalizers = nil
573+
_ = k8sClient.Update(ctx, stale)
574+
}
575+
Eventually(func() bool {
576+
return errors.IsNotFound(k8sClient.Get(ctx, jobNN, &batchv1.Job{}))
577+
}).Should(BeTrue(), "stale bootstrap Job should be deleted")
578+
579+
By("a subsequent reconcile recreates the Job with a new UID")
580+
reconcileN(ctx, r, nn, 1)
581+
recreated := &batchv1.Job{}
582+
Expect(k8sClient.Get(ctx, jobNN, recreated)).To(Succeed())
583+
Expect(recreated.UID).NotTo(Equal(originalUID), "Job should have been recreated, not patched in place")
584+
})
585+
})
471586
})

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)