Skip to content

lifecycle-state=failed on the main pod wedges a CocoonSet forever: reconciler returns before spec.Suspend and the RecoveredFromFailure branch is unreachable #25

Description

@tonicmuroq

Once vk-cocoon annotates a main pod with vm.cocoonstack.io/lifecycle-state: failed, the reconciler returns before evaluating spec.Suspend (and everything else), so the CocoonSet stops responding to any further intent — permanently, even after the VM recovers and the pod reports Ready. The only escape today is deleting the pod (destructive for a stateful VM) or clearing the annotation by hand.

What happened

internal-cocoon-node-1, 2026-07-25, cocoon-operator v0.3.0-3-gfaaba3d. A wake of an 8 GiB win11 VM missed vk's 45 s fresh-NIC DHCP window (vk-cocoon#47) and vk wrote:

vm.cocoonstack.io/lifecycle-state: failed
vm.cocoonstack.io/lifecycle-state-message: wake vk-staging-cocoonset-vm-a3135912-0: dhcp lease for fresh NIC not observed within 45s

About three minutes later the VM converged on its own. State at that point:

pod  vm-a3135912-0   1/1 Running          # guest confirmed 172.22.0.205 on its NIC
CR   generation 60 == observedGeneration 60
     Ready=True  reason=AllAgentsReady  "1/1 agents ready, 0/0 toolboxes ready"
     phase=Failed
     status.agents[0].phase=Running, status.agents[0].ip=""      # empty

I then set spec.suspend=true and waited 274 s: the phase never left Failed and no hibernate ran at all — cocoon_vk_snapshot_save_duration_seconds_count and ..._push_..._count both stayed 0. Clearing the annotation made the operator pick up the queued intent immediately and the hibernate completed normally in 77 s.

Why

cocoonset/reconciler.go:

if classified.main != nil {
	if reason := mainPodFailedReason(classified.main); reason != "" {
		return r.handleFailedMainAgent(ctx, &cs, classified, reason)      // ← always returns
	}
	if cs.Status.Phase == cocoonv1.CocoonSetPhaseFailed && meta.IsPodReady(classified.main) && r.Recorder != nil {
		r.Recorder.Eventf(&cs, corev1.EventTypeNormal, "RecoveredFromFailure", ...)
	}
}

if cs.Spec.Suspend { ... }                                                // ← never reached
  • mainPodFailedReason (reconciler.go:229) returns "PodLifecycleFailed" whenever meta.ReadLifecycleState(pod) == LifecycleStateFailed, i.e. purely from the annotation.
  • handleFailedMainAgent (reconciler.go:171) only has an escape when the pod spec drifted (delete + requeue); otherwise it records the failure, patches phase Failed, and returns with no requeue.
  • The RecoveredFromFailure branch sits after that early return, so it is unreachable for a vk-driven failure. It can only fire for a kubelet-driven failure that has already cleared.

Nothing in the operator or (as far as I can see) in vk clears that annotation on its own, so the CR is wedged until a human intervenes.

Two secondary effects worth noting:

  • status.agents[0].ip stays empty for the whole time even though the pod and guest have an address. vm-service surfaces that field to users as the connection target, so a VM that actually works looks address-less.
  • The failure is silent from the CR's perspective: Ready=True and phase=Failed coexist, with Progressing=False reason=Stable message=Failed.

Suggested behaviour

Some way for a recovered VM to leave the failed state without hand-editing annotations. Options, cheapest first:

  • Treat a failed lifecycle state as stale when the pod is Ready: fall through to normal reconciliation (and let the phase be recomputed) instead of returning early. lifecycle-observed-generation is already on the pod, so "failed, but observed at an older generation than the current spec" is expressible.
  • Or keep the early return but make it a bounded state: requeue with backoff and re-evaluate readiness, so the set self-heals when the VM comes good.
  • At minimum, do not swallow spec.Suspend while failed. Hibernate is the one intent that is still meaningful (and safe) for a VM whose wake went wrong — being unable to put it back to sleep is what turned a transient wake timeout into manual work here.

Recovery for the record

Non-destructive unblock (do not delete the pod — that destroys the stateful VM):

kubectl -n <ns> patch pod <set>-0 --type=merge \
  -p '{"metadata":{"annotations":{"vm.cocoonstack.io/lifecycle-state":null,"vm.cocoonstack.io/lifecycle-state-message":null}}}'

Note for anyone debugging this: these are annotations, not labels — the pod's labels are only app.kubernetes.io/name and the three cocoonset.cocoonstack.io/* ones. Dumping labels and annotations in one jsonpath and grepping makes it easy to patch the wrong map and see a no-op.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions