diff --git a/k8s/clientpatch.go b/k8s/clientpatch.go index b4ff770..26312ee 100644 --- a/k8s/clientpatch.go +++ b/k8s/clientpatch.go @@ -37,6 +37,14 @@ func PatchHibernateState(ctx context.Context, cli client.Client, pod *corev1.Pod }) } +// PatchKeepSnapshotOnDelete flags the pod's deletion as a seat release, short-circuiting if already flagged. +func PatchKeepSnapshotOnDelete(ctx context.Context, cli client.Client, pod *corev1.Pod) error { + if meta.ReadKeepSnapshotOnDelete(pod) { + return nil + } + return Patch(ctx, cli, pod, meta.MarkKeepSnapshotOnDelete) +} + // PatchCocoonSetGeneration stamps the owning CocoonSet's metadata.generation // onto the pod so vk-cocoon can read it back as lifecycle-observed-generation. // Short-circuits when the annotation is already correct. diff --git a/k8s/clientpatch_test.go b/k8s/clientpatch_test.go index 0d24ac0..1a33d67 100644 --- a/k8s/clientpatch_test.go +++ b/k8s/clientpatch_test.go @@ -113,6 +113,26 @@ func TestPatchCocoonSetGenerationShortCircuitsNoOp(t *testing.T) { } } +func TestPatchKeepSnapshotOnDeletePersistsAndShortCircuits(t *testing.T) { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns"}} + cli := newFakeClient(t, pod.DeepCopy()) + + if err := PatchKeepSnapshotOnDelete(t.Context(), cli, pod); err != nil { + t.Fatalf("PatchKeepSnapshotOnDelete: %v", err) + } + var got corev1.Pod + if err := cli.Get(t.Context(), client.ObjectKey{Namespace: "ns", Name: "demo"}, &got); err != nil { + t.Fatalf("get: %v", err) + } + if !meta.ReadKeepSnapshotOnDelete(&got) { + t.Errorf("flag must reach the API server before the delete lands: %v", got.Annotations) + } + // The fake client errors on an empty-body Patch, so success proves the no-op guard. + if err := PatchKeepSnapshotOnDelete(t.Context(), cli, &got); err != nil { + t.Fatalf("re-flagging an already-flagged pod must be a no-op: %v", err) + } +} + func newFakeClient(t *testing.T, objs ...client.Object) client.Client { t.Helper() scheme := runtime.NewScheme() diff --git a/meta/hibernate.go b/meta/hibernate.go index 79b8d19..aed15e9 100644 --- a/meta/hibernate.go +++ b/meta/hibernate.go @@ -34,3 +34,14 @@ func MarkRestoreFromHibernate(pod *corev1.Pod) { a := ensurePodAnnotations(pod) a[AnnotationRestoreFromHibernate] = annotationTrue } + +// ReadKeepSnapshotOnDelete reports whether the pod's deletion is flagged as a seat release. +func ReadKeepSnapshotOnDelete(pod *corev1.Pod) bool { + return pod.Annotations[AnnotationKeepSnapshotOnDelete] == annotationTrue +} + +// MarkKeepSnapshotOnDelete flags a pod's deletion as a seat release. +func MarkKeepSnapshotOnDelete(pod *corev1.Pod) { + a := ensurePodAnnotations(pod) + a[AnnotationKeepSnapshotOnDelete] = annotationTrue +} diff --git a/meta/hibernate_test.go b/meta/hibernate_test.go index b814fbd..fbf8e35 100644 --- a/meta/hibernate_test.go +++ b/meta/hibernate_test.go @@ -80,3 +80,14 @@ func TestDefaultSnapshotTagConstant(t *testing.T) { t.Errorf("DefaultSnapshotTag must differ from HibernateSnapshotTag") } } + +func TestMarkKeepSnapshotOnDelete(t *testing.T) { + pod := &corev1.Pod{} + if ReadKeepSnapshotOnDelete(pod) { + t.Fatal("fresh pod should not be flagged as a seat release") + } + MarkKeepSnapshotOnDelete(pod) + if !ReadKeepSnapshotOnDelete(pod) { + t.Error("MarkKeepSnapshotOnDelete should round-trip through ReadKeepSnapshotOnDelete") + } +} diff --git a/meta/keys.go b/meta/keys.go index 51db498..aa18939 100644 --- a/meta/keys.go +++ b/meta/keys.go @@ -55,6 +55,8 @@ const ( // restore its VM from the :hibernate snapshot (cross-node migration) instead // of cloning from the base image. Written by the operator on the rebuilt pod. AnnotationRestoreFromHibernate = "vm.cocoonstack.io/restore-from-hibernate" + // AnnotationKeepSnapshotOnDelete requests node-local snapshot retention for a seat-release pod deletion. + AnnotationKeepSnapshotOnDelete = "vm.cocoonstack.io/keep-snapshot-on-delete" // AnnotationForkFrom names a VM to fork the new VM from. AnnotationForkFrom = "vm.cocoonstack.io/fork-from" // AnnotationCloneFromDir names a host directory to clone the VM image from (vk-cocoon-specific).