Skip to content

Commit 806a0dd

Browse files
authored
review: batch-end quality round (#24)
* review: comment budget pass Trim doc comments to one-line WHY across cocoonset and hibernation: drop restated behavior and multi-line justification prose. * review: declaration layout and modern idioms Move TestSetupWithManagerRejectsInvalidConcurrency above the unexported concurrencyProbe helper; use slices.Concat in place of manual append chains in the logr sink. * review: loc-justify cuts Remove two dead-in-practice guards identified by the loc-justify audit: - cocoonset/suspend.go: allOwnedPodsHibernated's nil-Registry early return. main.go's only construction site always supplies a non-nil Registry (buildRegistry fails fast on empty OCI_REGISTRY), so the guard never fires. - cocoonset/delete.go: vmNamesForGC's Status fallback branch. stashDeleteVMNames always folds Status into the stashed annotation before any GC pass reads it, so the annotation is never empty when the fallback would matter. * fix(cocoonset): reject numeric toolbox names colliding with agent slots A toolbox named e.g. "1" produces the same pod name as sub-agent slot 1 ("<cs>-1"), so creating the sub-agent first silently no-ops on AlreadyExists and the slot stays missing forever with no error. * fix(cocoonset): keep hibernate annotation owned during reverse-desire window applyUnsuspend only excluded pods with Desire=Hibernate, so a CR that flips to Wake while still Hibernating (a state the hibernation reconciler itself routes back into reconcileHibernate) lost its exclusion and applyUnsuspend cleared the annotation mid-transition, racing the hibernation reconciler's own re-assertion. * fix(cocoonset): recreate a drifted main agent instead of sticking in Failed lifecycle-state=Failed short-circuited before the spec-drift recreate path, so a soft vk-cocoon failure that never sets the pod Ready again parked the CocoonSet in Failed permanently with no escape short of a manual pod delete, even after the operator fixed the spec. * review: fit the new fix comments to the one-line budget * fix(hibernation): let a failing registry probe still hit the hibernate deadline HasHibernateSnapshot errors returned before the hibernateTimeout check, so a registry outage pinned the phase at Hibernating forever; with podsHibernatedByCR also matching that phase, a reverse-desire wake could never reclaim the hibernate annotation. An expired deadline now converts the probe error into the Failed phase; the success path stays ahead of the deadline so a late-but-pushed snapshot still completes as Hibernated.
1 parent 4ae5f7d commit 806a0dd

13 files changed

Lines changed: 209 additions & 61 deletions

File tree

cocoonset/agents.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,14 @@ import (
2121
"github.com/cocoonstack/cocoon-operator/metrics"
2222
)
2323

24-
// subAgentCreateConcurrency caps parallel pod creates during fan-out so a
25-
// large scale-up (e.g. 1→N) does not burst the apiserver. Empirically the
26-
// rate limiter in controller-runtime plus apiserver QPS accommodate 8 in
27-
// flight without priority-fairness throttling.
24+
// subAgentCreateConcurrency caps parallel pod creates during a batch scale-up
25+
// so it does not burst the apiserver.
2826
const subAgentCreateConcurrency = 8
2927

3028
// ensureSubAgents creates/deletes sub-agent pods to match [1..Replicas].
3129
// Returns changed (true when cluster state was mutated) and requeueAfter
3230
// (non-zero when a sub-agent is in rebuild backoff and the caller should
33-
// re-reconcile when backoff elapses). Missing slots are created concurrently
34-
// so batch scale-ups do not serialize N apiserver round trips.
31+
// re-reconcile when backoff elapses).
3532
func (r *Reconciler) ensureSubAgents(ctx context.Context, cs *cocoonv1.CocoonSet, classified classifiedPods, mainVMName, mainNodeName string, intent restoreIntent) (bool, time.Duration, error) {
3633
logger := log.WithFunc("cocoonset.Reconciler.ensureSubAgents")
3734
changed := false

cocoonset/delete.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,9 @@ func (r *Reconciler) stashDeleteVMNames(ctx context.Context, cs *cocoonv1.Cocoon
116116
return r.Patch(ctx, cs, patch)
117117
}
118118

119-
// vmNamesForGC returns the canonical GC list — read from the stashed annotation,
120-
// falling back to Status when the annotation is somehow missing.
119+
// vmNamesForGC returns the canonical GC list, read from the stashed annotation.
121120
func vmNamesForGC(cs *cocoonv1.CocoonSet) []string {
122-
if names := parseVMNamesAnnotation(cs.Annotations[annotationDeleteVMNames]); len(names) > 0 {
123-
return names
124-
}
125-
names := statusVMNames(cs)
126-
slices.Sort(names)
127-
return names
121+
return parseVMNamesAnnotation(cs.Annotations[annotationDeleteVMNames])
128122
}
129123

130124
// statusVMNames collects the non-empty VM names recorded in the CocoonSet

cocoonset/migrate.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ import (
1616
"github.com/cocoonstack/cocoon-operator/snapshot"
1717
)
1818

19-
// reconcileMigration drives cross-node migration of the main agent (slot 0):
20-
// quiesce -> snapshot -> recreate on the target with restore-from-hibernate ->
21-
// drop the snapshot; idempotent over durable state, handled=false hands back.
22-
// Never lose live state: the old pod dies only after the snapshot exists AND
23-
// this controller quiesced it; the snapshot drops only once the new VM runs.
19+
// reconcileMigration drives cross-node migration of the main agent (slot 0);
20+
// handled=false hands back to the normal flow. Never lose live state: the old
21+
// pod dies only after the snapshot exists AND this controller quiesced it;
22+
// the snapshot drops only once the new VM runs.
2423
func (r *Reconciler) reconcileMigration(ctx context.Context, cs *cocoonv1.CocoonSet, classified classifiedPods) (bool, ctrl.Result, error) {
2524
desired := cs.Spec.NodeName
2625
migrating := cs.Status.Phase == cocoonv1.CocoonSetPhaseMigrating

cocoonset/predicate.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import (
99
"github.com/cocoonstack/cocoon-common/meta"
1010
)
1111

12-
// podRelevantChange filters pod events to those that affect CocoonSet
13-
// reconciliation: creation, deletion, and readiness transitions.
14-
// Ignores pure status churn (VK notify loops, condition timestamp updates).
12+
// podRelevantChange ignores pure status churn (VK notify loops, condition
13+
// timestamp updates) that would otherwise storm reconciles.
1514
type podRelevantChange struct{}
1615

1716
func (podRelevantChange) Create(_ event.CreateEvent) bool { return true }

cocoonset/reconciler.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,8 @@ type Reconciler struct {
4343
Concurrency int
4444
}
4545

46-
// SetupWithManager registers the reconciler. `For` uses GenerationChangedPredicate
47-
// to avoid status-update loops; Owns filters pod events to creation, deletion,
48-
// and readiness transitions to prevent reconcile storms from VK status churn.
46+
// SetupWithManager registers the reconciler with predicates that filter out
47+
// status-only churn to avoid reconcile storms.
4948
func (r *Reconciler) SetupWithManager(_ context.Context, mgr ctrl.Manager) error {
5049
if r.Concurrency < 1 {
5150
return fmt.Errorf("cocoonset concurrency must be at least 1, got %d", r.Concurrency)
@@ -97,9 +96,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
9796
// Pod Phase flips); IsPodTerminal is the kubelet-driven one.
9897
if classified.main != nil {
9998
if reason := mainPodFailedReason(classified.main); reason != "" {
100-
r.observeMainPodFailed(&cs, classified.main, reason)
101-
return ctrl.Result{}, r.patchStatus(ctx, &cs,
102-
buildStatus(&cs, classified, cocoonv1.CocoonSetPhaseFailed))
99+
return r.handleFailedMainAgent(ctx, &cs, classified, reason)
103100
}
104101
if cs.Status.Phase == cocoonv1.CocoonSetPhaseFailed && meta.IsPodReady(classified.main) && r.Recorder != nil {
105102
r.Recorder.Eventf(&cs, corev1.EventTypeNormal, "RecoveredFromFailure",
@@ -160,6 +157,18 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
160157
return ctrl.Result{RequeueAfter: subRequeue}, nil
161158
}
162159

160+
// handleFailedMainAgent recreates a terminal main agent whose spec has drifted; parking in Failed would wait for a Ready the drifted pod can never reach.
161+
func (r *Reconciler) handleFailedMainAgent(ctx context.Context, cs *cocoonv1.CocoonSet, classified classifiedPods, reason string) (ctrl.Result, error) {
162+
if !podSpecMatchesAgent(classified.main, cs, 0) {
163+
if err := r.Delete(ctx, classified.main); err != nil && !apierrors.IsNotFound(err) {
164+
return ctrl.Result{}, fmt.Errorf("delete terminal drifted main agent: %w", err)
165+
}
166+
return ctrl.Result{Requeue: true}, nil
167+
}
168+
r.observeMainPodFailed(cs, classified.main, reason)
169+
return ctrl.Result{}, r.patchStatus(ctx, cs, buildStatus(cs, classified, cocoonv1.CocoonSetPhaseFailed))
170+
}
171+
163172
// createMainAgent builds and creates the missing main agent pod, stamping
164173
// restore-from-hibernate when the agent is hibernated so a cross-node recreate
165174
// restores from the :hibernate snapshot instead of booting fresh. It always

cocoonset/reconciler_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
corev1 "k8s.io/api/core/v1"
12+
apierrors "k8s.io/apimachinery/pkg/api/errors"
1213
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1314
"k8s.io/apimachinery/pkg/types"
1415
ctrl "sigs.k8s.io/controller-runtime"
@@ -127,6 +128,28 @@ func TestEnsureToolboxesCollisionReturnsError(t *testing.T) {
127128
}
128129
}
129130

131+
func TestEnsureToolboxesRejectsIntegerName(t *testing.T) {
132+
scheme := testScheme(t)
133+
cs := newCocoonSet("demo", func(cs *cocoonv1.CocoonSet) {
134+
cs.Spec.Toolboxes = []cocoonv1.ToolboxSpec{
135+
{Name: "1", Image: "ghcr.io/cocoonstack/cocoon/toolbox:latest"},
136+
}
137+
})
138+
139+
cli := ctrlfake.NewClientBuilder().WithScheme(scheme).Build()
140+
r := &Reconciler{Client: cli, Scheme: scheme}
141+
classified := classifiedPods{
142+
sub: map[int32]*corev1.Pod{},
143+
toolbox: map[string]*corev1.Pod{},
144+
allByName: map[string]*corev1.Pod{},
145+
}
146+
147+
_, err := r.ensureToolboxes(t.Context(), cs, classified, r.newRestoreIntent(t.Context(), cs.Namespace))
148+
if err == nil {
149+
t.Fatal("ensureToolboxes must reject a toolbox name that collides with agent slot pod naming")
150+
}
151+
}
152+
130153
func TestEnsureToolboxesRejectsDuplicateNames(t *testing.T) {
131154
scheme := testScheme(t)
132155
cs := newCocoonSet("demo", func(cs *cocoonv1.CocoonSet) {
@@ -493,6 +516,39 @@ func TestReconcileMainLifecycleFailedTransitionsToFailed(t *testing.T) {
493516
}
494517
}
495518

519+
// A Failed main pod whose spec has drifted from the current CocoonSet spec
520+
// must be deleted for recreate, not parked in Failed forever.
521+
func TestReconcileMainLifecycleFailedWithDriftRecreatesPod(t *testing.T) {
522+
scheme := testScheme(t)
523+
cs := newCocoonSet("demo", func(cs *cocoonv1.CocoonSet) {
524+
cs.Finalizers = []string{finalizerName}
525+
})
526+
mainPod := mustBuildAgentPod(t, cs, 0, "", "", scheme)
527+
mainPod.Status.Phase = corev1.PodRunning
528+
if mainPod.Annotations == nil {
529+
mainPod.Annotations = map[string]string{}
530+
}
531+
mainPod.Annotations[meta.AnnotationLifecycleState] = string(meta.LifecycleStateFailed)
532+
533+
cs.Spec.Agent.Image = "ghcr.io/cocoonstack/cocoon/ubuntu:26.04"
534+
535+
cli := ctrlfake.NewClientBuilder().
536+
WithScheme(scheme).
537+
WithObjects(cs, mainPod).
538+
WithStatusSubresource(&cocoonv1.CocoonSet{}).
539+
Build()
540+
r := &Reconciler{Client: cli, Scheme: scheme, Registry: &fakeRegistry{}}
541+
542+
if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: cs.Namespace, Name: cs.Name}}); err != nil {
543+
t.Fatalf("Reconcile: %v", err)
544+
}
545+
if err := cli.Get(t.Context(), types.NamespacedName{Namespace: mainPod.Namespace, Name: mainPod.Name}, &corev1.Pod{}); err == nil {
546+
t.Error("Failed main pod with drifted spec should have been deleted for recreate")
547+
} else if !apierrors.IsNotFound(err) {
548+
t.Fatalf("get main pod: %v", err)
549+
}
550+
}
551+
496552
// A sub-agent carrying lifecycle-state=Failed but still PodPhase=Running must
497553
// be rebuilt so the backoff / dead-letter logic runs.
498554
func TestEnsureSubAgentsTreatsLifecycleFailedAsTerminal(t *testing.T) {
@@ -793,6 +849,53 @@ func TestApplyUnsuspendSkipsPodHibernatedByCR(t *testing.T) {
793849
}
794850
}
795851

852+
// A Wake desire mid-Hibernating transition (the hibernation reconciler's own
853+
// reverse-desire window) must still exclude the pod: the annotation is owned
854+
// by the in-flight hibernate, not by applyUnsuspend.
855+
func TestApplyUnsuspendSkipsPodMidHibernateOnReverseDesire(t *testing.T) {
856+
scheme := testScheme(t)
857+
858+
hibernated := &corev1.Pod{
859+
ObjectMeta: metav1.ObjectMeta{Name: "demo-0", Namespace: "ns"},
860+
}
861+
meta.HibernateState(true).Apply(hibernated)
862+
863+
hibCR := &cocoonv1.CocoonHibernation{
864+
ObjectMeta: metav1.ObjectMeta{Name: "demo-hib", Namespace: "ns"},
865+
Spec: cocoonv1.CocoonHibernationSpec{
866+
Desire: cocoonv1.HibernationDesireWake,
867+
PodRef: cocoonv1.HibernationPodRef{Name: "demo-0"},
868+
},
869+
Status: cocoonv1.CocoonHibernationStatus{
870+
Phase: cocoonv1.CocoonHibernationPhaseHibernating,
871+
},
872+
}
873+
874+
cli := ctrlfake.NewClientBuilder().
875+
WithScheme(scheme).
876+
WithObjects(hibernated, hibCR).
877+
Build()
878+
r := &Reconciler{Client: cli, Scheme: scheme}
879+
classified := classifiedPods{
880+
main: hibernated,
881+
sub: map[int32]*corev1.Pod{},
882+
toolbox: map[string]*corev1.Pod{},
883+
allByName: map[string]*corev1.Pod{"demo-0": hibernated},
884+
}
885+
886+
if err := r.applyUnsuspend(t.Context(), "ns", classified); err != nil {
887+
t.Fatalf("applyUnsuspend: %v", err)
888+
}
889+
890+
var got corev1.Pod
891+
if err := cli.Get(t.Context(), types.NamespacedName{Namespace: "ns", Name: "demo-0"}, &got); err != nil {
892+
t.Fatalf("get demo-0: %v", err)
893+
}
894+
if !bool(meta.ReadHibernateState(&got)) {
895+
t.Errorf("demo-0 is mid-Hibernating under a reverse Wake desire; applyUnsuspend must leave it set")
896+
}
897+
}
898+
796899
// A nil manager suffices: the guard rejects before mgr is touched.
797900
func TestSetupWithManagerRejectsInvalidConcurrency(t *testing.T) {
798901
for _, n := range []int{0, -1} {

cocoonset/suspend.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,8 @@ import (
1717
"github.com/cocoonstack/cocoon-operator/snapshot"
1818
)
1919

20-
// reconcileSuspend ensures the main agent exists, applies the hibernate
21-
// annotation to every owned pod, then polls the registry to observe when all
22-
// managed VMs have been pushed to snapshot. Stays in Suspending with a
23-
// periodic requeue until every required snapshot lands.
20+
// reconcileSuspend polls the registry and stays in Suspending, requeueing
21+
// periodically, until every managed VM's snapshot lands.
2422
func (r *Reconciler) reconcileSuspend(ctx context.Context, cs *cocoonv1.CocoonSet, classified classifiedPods) (ctrl.Result, error) {
2523
logger := log.WithFunc("cocoonset.Reconciler.reconcileSuspend")
2624
if classified.main == nil {
@@ -60,11 +58,6 @@ func (r *Reconciler) reconcileSuspend(ctx context.Context, cs *cocoonv1.CocoonSe
6058
// Returns (false, nil) whenever the expected state is not yet observed so
6159
// the caller requeues rather than treats it as an error.
6260
func (r *Reconciler) allOwnedPodsHibernated(ctx context.Context, cs *cocoonv1.CocoonSet, classified classifiedPods) (bool, error) {
63-
if r.Registry == nil {
64-
// No registry configured; such deployments have no snapshot to
65-
// observe, so treat the annotation write as authoritative.
66-
return true, nil
67-
}
6861
for _, name := range slices.Sorted(maps.Keys(classified.allByName)) {
6962
if ctxErr := ctx.Err(); ctxErr != nil {
7063
return false, ctxErr
@@ -113,8 +106,7 @@ func (r *Reconciler) applySuspend(ctx context.Context, classified classifiedPods
113106

114107
// applyUnsuspend clears HibernateState from owned pods, skipping pods that are
115108
// targets of an active CocoonHibernation CR to avoid racing the hibernation
116-
// reconciler. The unsorted pre-scan keeps the steady path (nothing hibernated)
117-
// zero-alloc: no key sort, no CR list.
109+
// reconciler.
118110
func (r *Reconciler) applyUnsuspend(ctx context.Context, namespace string, classified classifiedPods) error {
119111
var hibernated []*corev1.Pod
120112
for _, pod := range classified.allByName {
@@ -150,6 +142,6 @@ func (r *Reconciler) applyUnsuspend(ctx context.Context, namespace string, class
150142
// podsHibernatedByCR returns pod names targeted by a desire=Hibernate CR.
151143
func (r *Reconciler) podsHibernatedByCR(ctx context.Context, namespace string) (map[string]struct{}, error) {
152144
return r.hibernationPodNames(ctx, namespace, func(h *cocoonv1.CocoonHibernation) bool {
153-
return h.Spec.Desire == cocoonv1.HibernationDesireHibernate
145+
return h.Spec.Desire == cocoonv1.HibernationDesireHibernate || h.Status.Phase == cocoonv1.CocoonHibernationPhaseHibernating
154146
})
155147
}

cocoonset/toolboxes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"maps"
77
"slices"
8+
"strconv"
89

910
"github.com/projecteru2/core/log"
1011
corev1 "k8s.io/api/core/v1"
@@ -25,6 +26,9 @@ func (r *Reconciler) ensureToolboxes(ctx context.Context, cs *cocoonv1.CocoonSet
2526
if desired[tb.Name] {
2627
return false, fmt.Errorf("duplicate toolbox name %q in spec", tb.Name)
2728
}
29+
if _, convErr := strconv.Atoi(tb.Name); convErr == nil {
30+
return false, fmt.Errorf("toolbox name %q must not be an integer: collides with agent slot pod naming", tb.Name)
31+
}
2832
desired[tb.Name] = true
2933
}
3034
changed := false

hibernation/hibernate.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ func (r *Reconciler) reconcileHibernate(ctx context.Context, hib *cocoonv1.Cocoo
2828
if st := meta.ReadLifecycleStatus(pod); st.State == meta.LifecycleStateHibernated &&
2929
st.ObservedGeneration >= meta.ReadCocoonSetGeneration(pod) {
3030
present, err := snapshot.HasHibernateSnapshot(ctx, r.Registry, vmName)
31-
if err != nil {
31+
// A persistently failing probe must still hit the deadline below, or the phase starves in Hibernating.
32+
if err != nil && !phaseDeadlineExceeded(hib, cocoonv1.CocoonHibernationPhaseHibernating, hibernateTimeout) {
3233
return ctrl.Result{}, err
3334
}
34-
if present {
35+
if err == nil && present {
3536
if r.firstTransitionAt(hib) {
3637
observePhaseExit(hib, "ok")
3738
r.emitEventf(hib, corev1.EventTypeNormal, "Hibernated", "snapshot %s pushed to the registry", vmName)
@@ -42,10 +43,10 @@ func (r *Reconciler) reconcileHibernate(ctx context.Context, hib *cocoonv1.Cocoo
4243
if phaseDeadlineExceeded(hib, cocoonv1.CocoonHibernationPhaseHibernating, hibernateTimeout) {
4344
if r.firstTransitionAt(hib) {
4445
observePhaseExit(hib, "timeout")
45-
r.emitEventf(hib, corev1.EventTypeWarning, "HibernateTimedOut", "vk-cocoon did not push snapshot %s within %s", vmName, hibernateTimeout)
46+
r.emitEventf(hib, corev1.EventTypeWarning, "HibernateTimedOut", "snapshot %s not confirmed in the registry within %s", vmName, hibernateTimeout)
4647
}
4748
return ctrl.Result{}, r.markFailed(ctx, hib,
48-
fmt.Sprintf("hibernate timed out after %s; vk-cocoon never pushed the snapshot", hibernateTimeout))
49+
fmt.Sprintf("hibernate not confirmed within %s", hibernateTimeout))
4950
}
5051
if updateErr := r.setPhase(ctx, hib, cocoonv1.CocoonHibernationPhaseHibernating, vmName); updateErr != nil {
5152
return ctrl.Result{}, updateErr

0 commit comments

Comments
 (0)