Skip to content

Commit e01118e

Browse files
committed
fix(cocoonset): gate :latest GC on snapshotPolicy
PR #5 dropped the ShouldSnapshotVM gate when expanding the GC to also clean :hibernate, which meant CocoonSet teardown wiped the :latest tag vk-cocoon had just pushed under snapshotPolicy=always. Downstream hot-snapshot workflows that race to oras-copy :latest to a stable repo found it gone by the time kubectl delete returned. Keep :hibernate unconditional (CocoonHibernation manages it independently of snapshotPolicy) but only drop :latest when ShouldSnapshotVM reports no push happened for the slot — i.e. policy never, or main-only for slot != 0. Always / main-only-slot-0 keeps :latest so the user-requested snapshot survives finalizer removal. Tests collapse into one table-driven case matching the file's existing t.Run convention.
1 parent 61f2acd commit e01118e

2 files changed

Lines changed: 87 additions & 45 deletions

File tree

cocoonset/delete.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,22 @@ func (r *Reconciler) reconcileDelete(ctx context.Context, cs *cocoonv1.CocoonSet
6767
return ctrl.Result{RequeueAfter: requeueWaitForMain}, nil
6868
}
6969

70-
// :hibernate is pushed regardless of snapshotPolicy, so drop both tags unconditionally.
70+
// :hibernate is pushed independently by CocoonHibernation, so any leftover
71+
// is orphaned at CocoonSet teardown — drop unconditionally. :latest is only
72+
// orphaned when snapshotPolicy says no snapshot was pushed for this slot;
73+
// when ShouldSnapshotVM is true the operator just pushed it on the user's
74+
// behalf and downstream consumers (hot-snapshot workflows) retag it.
7175
if r.Epoch != nil {
76+
policy := string(cs.Spec.SnapshotPolicy)
7277
for _, name := range vmNamesForGC(cs) {
73-
for _, tag := range []string{meta.DefaultSnapshotTag, meta.HibernateSnapshotTag} {
74-
if err := r.Epoch.DeleteManifest(ctx, name, tag); err != nil {
75-
logger.Warnf(ctx, "delete snapshot %s:%s: %v", name, tag, err)
76-
}
78+
if err := r.Epoch.DeleteManifest(ctx, name, meta.HibernateSnapshotTag); err != nil {
79+
logger.Warnf(ctx, "delete snapshot %s:%s: %v", name, meta.HibernateSnapshotTag, err)
80+
}
81+
if meta.ShouldSnapshotVM(meta.VMSpec{VMName: name, SnapshotPolicy: policy}) {
82+
continue
83+
}
84+
if err := r.Epoch.DeleteManifest(ctx, name, meta.DefaultSnapshotTag); err != nil {
85+
logger.Warnf(ctx, "delete snapshot %s:%s: %v", name, meta.DefaultSnapshotTag, err)
7786
}
7887
}
7988
} else {

cocoonset/reconciler_test.go

Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -482,46 +482,86 @@ func TestReconcileDeleteSkipsUnownedPods(t *testing.T) {
482482
}
483483
}
484484

485-
func TestReconcileDeleteRemovesBothSnapshotTags(t *testing.T) {
485+
// :hibernate is always orphaned at CocoonSet teardown so it's always cleaned;
486+
// :latest is only orphaned when snapshotPolicy says no push happened for that
487+
// slot. Always/main-only-slot-0 leave :latest in place for downstream retag.
488+
func TestReconcileDeleteSnapshotPolicyGC(t *testing.T) {
486489
scheme := testScheme(t)
487-
cs := newCocoonSet("demo")
488-
cs.Finalizers = []string{finalizerName}
489-
// snapshotPolicy=never proves the cleanup is not gated by it.
490-
cs.Spec.SnapshotPolicy = cocoonv1.SnapshotPolicyNever
491-
cs.Status.Agents = []cocoonv1.AgentStatus{
492-
{Slot: 0, Role: "main", PodName: "demo-0", VMName: "vk-ns-demo-0"},
493-
}
494-
495-
cli := ctrlfake.NewClientBuilder().
496-
WithScheme(scheme).
497-
WithObjects(cs, mustBuildAgentPod(t, cs, 0, "", "", scheme)).
498-
Build()
499-
reg := &fakeRegistry{}
500-
r := &Reconciler{Client: cli, Scheme: scheme, Epoch: reg}
501-
502-
// The fake client deletes synchronously, so the single reconcile sees the
503-
// pod gone in the re-list and runs the GC inline.
504-
if _, err := r.reconcileDelete(t.Context(), cs); err != nil {
505-
t.Fatalf("reconcileDelete: %v", err)
506-
}
507-
508-
want := []string{
509-
"vk-ns-demo-0:" + meta.DefaultSnapshotTag,
510-
"vk-ns-demo-0:" + meta.HibernateSnapshotTag,
490+
cases := []struct {
491+
name string
492+
policy cocoonv1.SnapshotPolicy
493+
agents []cocoonv1.AgentStatus
494+
toolboxes []cocoonv1.ToolboxStatus
495+
want []string
496+
}{
497+
{
498+
name: "never drops both tags — no push happened",
499+
policy: cocoonv1.SnapshotPolicyNever,
500+
agents: []cocoonv1.AgentStatus{
501+
{Slot: 0, Role: "main", PodName: "demo-0", VMName: "vk-ns-demo-0"},
502+
},
503+
want: []string{
504+
"vk-ns-demo-0:" + meta.HibernateSnapshotTag,
505+
"vk-ns-demo-0:" + meta.DefaultSnapshotTag,
506+
},
507+
},
508+
{
509+
name: "always preserves :latest for downstream retag",
510+
policy: cocoonv1.SnapshotPolicyAlways,
511+
agents: []cocoonv1.AgentStatus{
512+
{Slot: 0, Role: "main", PodName: "demo-0", VMName: "vk-ns-demo-0"},
513+
},
514+
want: []string{"vk-ns-demo-0:" + meta.HibernateSnapshotTag},
515+
},
516+
{
517+
name: "main-only keeps slot 0, drops other slots and toolboxes",
518+
policy: cocoonv1.SnapshotPolicyMainOnly,
519+
agents: []cocoonv1.AgentStatus{
520+
{Slot: 0, Role: "main", PodName: "demo-0", VMName: "vk-ns-demo-0"},
521+
{Slot: 1, Role: "sub", PodName: "demo-1", VMName: "vk-ns-demo-1"},
522+
},
523+
toolboxes: []cocoonv1.ToolboxStatus{
524+
{Name: "tb", PodName: "demo-tb", VMName: "vk-ns-demo-tb"},
525+
},
526+
want: []string{
527+
"vk-ns-demo-0:" + meta.HibernateSnapshotTag,
528+
"vk-ns-demo-1:" + meta.HibernateSnapshotTag,
529+
"vk-ns-demo-1:" + meta.DefaultSnapshotTag,
530+
"vk-ns-demo-tb:" + meta.HibernateSnapshotTag,
531+
"vk-ns-demo-tb:" + meta.DefaultSnapshotTag,
532+
},
533+
},
511534
}
512-
if !slices.Equal(reg.deleted, want) {
513-
t.Errorf("DeleteManifest calls = %v, want %v", reg.deleted, want)
535+
for _, c := range cases {
536+
t.Run(c.name, func(t *testing.T) {
537+
cs := newCocoonSet("demo")
538+
cs.Finalizers = []string{finalizerName}
539+
cs.Spec.SnapshotPolicy = c.policy
540+
cs.Status.Agents = c.agents
541+
cs.Status.Toolboxes = c.toolboxes
542+
543+
cli := ctrlfake.NewClientBuilder().WithScheme(scheme).WithObjects(cs).Build()
544+
reg := &fakeRegistry{}
545+
r := &Reconciler{Client: cli, Scheme: scheme, Epoch: reg}
546+
547+
if _, err := r.reconcileDelete(t.Context(), cs); err != nil {
548+
t.Fatalf("reconcileDelete: %v", err)
549+
}
550+
if !slices.Equal(reg.deleted, c.want) {
551+
t.Errorf("DeleteManifest calls = %v, want %v", reg.deleted, c.want)
552+
}
553+
})
514554
}
515555
}
516556

517557
// Race window: pod created but Status.Agents not yet patched with VMName.
518-
// reconcileDelete's first pass sees the pod and must stash its VMName onto
519-
// the annotation, so the second pass (after the pod is gone) still GCs the tag.
558+
// reconcileDelete's first pass sees the pod and stashes its VMName onto the
559+
// annotation, so the second pass still GCs :hibernate (default policy
560+
// preserves :latest per TestReconcileDeleteAlwaysPolicyPreservesLatest).
520561
func TestReconcileDeleteStashesPodVMNamesEvenWhenStatusIsEmpty(t *testing.T) {
521562
scheme := testScheme(t)
522563
cs := newCocoonSet("demo")
523564
cs.Finalizers = []string{finalizerName}
524-
// Status.Agents intentionally empty — reconciler hadn't patched it yet.
525565

526566
cli := ctrlfake.NewClientBuilder().
527567
WithScheme(scheme).
@@ -534,18 +574,14 @@ func TestReconcileDeleteStashesPodVMNamesEvenWhenStatusIsEmpty(t *testing.T) {
534574
t.Fatalf("reconcileDelete: %v", err)
535575
}
536576

537-
want := []string{
538-
"vk-ns-demo-0:" + meta.DefaultSnapshotTag,
539-
"vk-ns-demo-0:" + meta.HibernateSnapshotTag,
540-
}
577+
want := []string{"vk-ns-demo-0:" + meta.HibernateSnapshotTag}
541578
if !slices.Equal(reg.deleted, want) {
542579
t.Errorf("DeleteManifest calls = %v, want %v", reg.deleted, want)
543580
}
544581
}
545582

546583
// Real clusters terminate pods asynchronously: by the time GC runs, the pod
547-
// list is already empty. VM names must come from Status, not from a re-list
548-
// (which is what the bug fix is about).
584+
// list is already empty. VM names must come from Status, not from a re-list.
549585
func TestReconcileDeleteCleansTagsAfterPodsGone(t *testing.T) {
550586
scheme := testScheme(t)
551587
cs := newCocoonSet("demo")
@@ -558,7 +594,6 @@ func TestReconcileDeleteCleansTagsAfterPodsGone(t *testing.T) {
558594
{Name: "tb", PodName: "demo-tb", VMName: "vk-ns-demo-tb"},
559595
}
560596

561-
// No pods in the fake client — kubelet already terminated them.
562597
cli := ctrlfake.NewClientBuilder().WithScheme(scheme).WithObjects(cs).Build()
563598
reg := &fakeRegistry{}
564599
r := &Reconciler{Client: cli, Scheme: scheme, Epoch: reg}
@@ -567,12 +602,10 @@ func TestReconcileDeleteCleansTagsAfterPodsGone(t *testing.T) {
567602
t.Fatalf("reconcileDelete: %v", err)
568603
}
569604

605+
// Default policy is always, so :latest is preserved for every VM.
570606
want := []string{
571-
"vk-ns-demo-0:" + meta.DefaultSnapshotTag,
572607
"vk-ns-demo-0:" + meta.HibernateSnapshotTag,
573-
"vk-ns-demo-1:" + meta.DefaultSnapshotTag,
574608
"vk-ns-demo-1:" + meta.HibernateSnapshotTag,
575-
"vk-ns-demo-tb:" + meta.DefaultSnapshotTag,
576609
"vk-ns-demo-tb:" + meta.HibernateSnapshotTag,
577610
}
578611
if !slices.Equal(reg.deleted, want) {

0 commit comments

Comments
 (0)