diff --git a/images/controller/go.mod b/images/controller/go.mod index 2b930590..abd3d196 100644 --- a/images/controller/go.mod +++ b/images/controller/go.mod @@ -4,7 +4,7 @@ go 1.26.5 require ( github.com/deckhouse/deckhouse v1.67.7-0.20251212134859-497a0dab9fc0 - github.com/deckhouse/state-snapshotter/api v0.0.0-20260708003445-b0c4c2e3be2d + github.com/deckhouse/state-snapshotter/api v0.0.0-20260722191958-8a6342e7ce94 github.com/deckhouse/state-snapshotter/pkg/snapshotsdk v0.0.0-20260708003445-b0c4c2e3be2d github.com/deckhouse/storage-foundation/api v0.0.0-00010101000000-000000000000 github.com/go-logr/logr v1.4.3 diff --git a/images/controller/go.sum b/images/controller/go.sum index ea02eff9..21326209 100644 --- a/images/controller/go.sum +++ b/images/controller/go.sum @@ -10,8 +10,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckhouse/deckhouse v1.67.7-0.20251212134859-497a0dab9fc0 h1:FEUYr1ZHMeVy9FNadjAksuSbJ+M9/8iLmCCFLFzoQ00= github.com/deckhouse/deckhouse v1.67.7-0.20251212134859-497a0dab9fc0/go.mod h1:EuTIwDETSs+YiLnJUkq2zPST707gTgWzC5m0P5tub5E= -github.com/deckhouse/state-snapshotter/api v0.0.0-20260708003445-b0c4c2e3be2d h1:Dx9dcdUOQGtitesGZUmX2C5YohsFH09hecNqikvPKF0= -github.com/deckhouse/state-snapshotter/api v0.0.0-20260708003445-b0c4c2e3be2d/go.mod h1:gv1hyZqfWBIUoY0HsKIJHDP+j1GIXrtTYkgFaz08og0= +github.com/deckhouse/state-snapshotter/api v0.0.0-20260722191958-8a6342e7ce94 h1:tnlLgD2R5qZgSS5YQgIiBsEunOP/WjtkiLCUCOTvCfc= +github.com/deckhouse/state-snapshotter/api v0.0.0-20260722191958-8a6342e7ce94/go.mod h1:E0jKylgy4E57S/ZWWD6Cl1Y5iXxJfcVzIwm63WU6vyo= github.com/deckhouse/state-snapshotter/pkg/snapshotsdk v0.0.0-20260708003445-b0c4c2e3be2d h1:CXF1F0G8BVIi940EFGFm5w24sS9NnuaD7hJrq8kTrsE= github.com/deckhouse/state-snapshotter/pkg/snapshotsdk v0.0.0-20260708003445-b0c4c2e3be2d/go.mod h1:E0i9cNTSRK2kdAoy2/Dyv0mk+F/O3ffihdx19cPlm78= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= diff --git a/images/controller/internal/controllers/volumecapturerequest_controller_test.go b/images/controller/internal/controllers/volumecapturerequest_controller_test.go index e1a1b72c..6556d270 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller_test.go @@ -38,6 +38,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" deckhousev1alpha1 "github.com/deckhouse/deckhouse/deckhouse-controller/pkg/apis/deckhouse.io/v1alpha1" + ssstoragev1alpha1 "github.com/deckhouse/state-snapshotter/api/storage/v1alpha1" storagev1alpha1 "github.com/deckhouse/storage-foundation/api/v1alpha1" "github.com/deckhouse/storage-foundation/images/controller/pkg/config" ) @@ -372,6 +373,12 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { // Simulate external-snapshotter setting ReadyToUse=true vsc := &snapshotv1.VolumeSnapshotContent{} Expect(client.Get(ctx, types.NamespacedName{Name: csiVSCName}, vsc)).To(Succeed()) + // The managed VSC must appear in the API already delete-protected: the marker is part of the + // CREATE payload (delete-protection-contract.md §6.2), never a post-create patch. We assert it + // on the very first read, before any status/ownerRef workaround touches the object. + Expect(ssstoragev1alpha1.IsDeleteProtected(vsc)).To(BeTrue(), + "created managed VSC must carry %s=%s in the CREATE payload", + ssstoragev1alpha1.LabelDeleteProtected, ssstoragev1alpha1.LabelDeleteProtectedValue) // The fake client does not assign a UID on Create; set one so we can assert it propagates // into status.dataRef.artifact.uid (a real apiserver always assigns a UID). if vsc.UID == "" { diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot.go b/images/controller/internal/controllers/volumecapturerequest_snapshot.go index c2746a0c..b2f4b6d6 100644 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot.go +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" deckhousev1alpha1 "github.com/deckhouse/deckhouse/deckhouse-controller/pkg/apis/deckhouse.io/v1alpha1" + ssstoragev1alpha1 "github.com/deckhouse/state-snapshotter/api/storage/v1alpha1" storagev1alpha1 "github.com/deckhouse/storage-foundation/api/v1alpha1" ) @@ -270,6 +271,11 @@ func (r *VolumeCaptureRequestController) processSnapshotTarget( VolumeSnapshotRef: corev1.ObjectReference{}, }, } + // Stamp the authoritative delete-protection state into the CREATE payload so the managed VSC + // appears in the Kubernetes API already protected: there is no guaranteed ordering between Create + // and a follow-up Patch, so a post-create patch would leave a short unprotected window + // (delete-protection-contract.md §6.1/§6.2). This does NOT change VCR lifecycle/retry/CSI semantics. + ssstoragev1alpha1.StampDeleteProtected(csiVSC) if err := r.Create(ctx, csiVSC); err != nil { return snapshotTargetResult{}, ctrl.Result{}, fmt.Errorf("failed to create VolumeSnapshotContent: %w", err) } diff --git a/images/controller/internal/controllers/volumesnapshot_domain_controller.go b/images/controller/internal/controllers/volumesnapshot_domain_controller.go index df588a5a..84a02a52 100644 --- a/images/controller/internal/controllers/volumesnapshot_domain_controller.go +++ b/images/controller/internal/controllers/volumesnapshot_domain_controller.go @@ -288,6 +288,13 @@ func (r *VolumeSnapshotDomainReconciler) latchManagedLabel(ctx context.Context, cur.Labels = map[string]string{} } cur.Labels[labelSnapshotManaged] = desired + // A VolumeSnapshot adopted as a domain-managed node (managed=true) is a protected tree node: stamp + // the authoritative delete-protection state in the SAME patch that latches adoption, i.e. before any + // graph edge is published (delete-protection-contract.md §6.1/§8.1). A vetoed (managed=false) + // VolumeSnapshot is a plain CSI snapshot, not a tree node, and MUST NOT be protected. + if desired == managedValueTrue { + storagev1alpha1.StampDeleteProtected(cur) + } if err := r.SnapClient.Patch(ctx, cur, client.MergeFrom(base)); err != nil { return err } diff --git a/images/controller/internal/controllers/volumesnapshot_domain_marker_test.go b/images/controller/internal/controllers/volumesnapshot_domain_marker_test.go new file mode 100644 index 00000000..31f426a7 --- /dev/null +++ b/images/controller/internal/controllers/volumesnapshot_domain_marker_test.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + ssstoragev1alpha1 "github.com/deckhouse/state-snapshotter/api/storage/v1alpha1" + sfsnapv1 "github.com/deckhouse/storage-foundation/api/snapshot/v1" +) + +// newDomainMarkerReconciler builds a VolumeSnapshotDomainReconciler whose SnapClient is a fake client +// carrying the given extended VolumeSnapshot, using the extended scheme the real reconciler relies on. +func newDomainMarkerReconciler(t *testing.T, vs *sfsnapv1.VolumeSnapshot) *VolumeSnapshotDomainReconciler { + t.Helper() + scheme := apiruntime.NewScheme() + if err := sfsnapv1.AddToScheme(scheme); err != nil { + t.Fatalf("add sfsnapv1: %v", err) + } + snapClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vs).Build() + return &VolumeSnapshotDomainReconciler{SnapClient: snapClient} +} + +// TestLatchManagedTrueStampsDeleteProtected asserts a VolumeSnapshot adopted as a managed tree node +// (managed=true) is stamped with the authoritative delete-protection marker in the SAME latch patch, +// i.e. before any graph edge is published (delete-protection-contract.md §6.1/§8.1). +func TestLatchManagedTrueStampsDeleteProtected(t *testing.T) { + ctx := context.Background() + vs := &sfsnapv1.VolumeSnapshot{ObjectMeta: metav1.ObjectMeta{Name: "vs-managed", Namespace: "ns"}} + r := newDomainMarkerReconciler(t, vs) + + got, err := r.latchManagedLabel(ctx, client.ObjectKeyFromObject(vs), managedValueTrue) + if err != nil { + t.Fatalf("latchManagedLabel: %v", err) + } + if got != managedValueTrue { + t.Fatalf("effective managed = %q, want %q", got, managedValueTrue) + } + + cur := &sfsnapv1.VolumeSnapshot{} + if err := r.SnapClient.Get(ctx, client.ObjectKeyFromObject(vs), cur); err != nil { + t.Fatalf("get vs: %v", err) + } + if cur.Labels[labelSnapshotManaged] != managedValueTrue { + t.Fatalf("managed label = %q, want %q", cur.Labels[labelSnapshotManaged], managedValueTrue) + } + if !ssstoragev1alpha1.IsDeleteProtected(cur) { + t.Fatalf("managed VS must be delete-protected, labels=%#v", cur.Labels) + } +} + +// TestLatchManagedFalseDoesNotStampDeleteProtected asserts a vetoed (managed=false) VolumeSnapshot is a +// plain CSI snapshot, NOT a tree node, and therefore MUST NOT be delete-protected (§8.1/§8.5). +func TestLatchManagedFalseDoesNotStampDeleteProtected(t *testing.T) { + ctx := context.Background() + vs := &sfsnapv1.VolumeSnapshot{ObjectMeta: metav1.ObjectMeta{Name: "vs-vetoed", Namespace: "ns"}} + r := newDomainMarkerReconciler(t, vs) + + if _, err := r.latchManagedLabel(ctx, client.ObjectKeyFromObject(vs), managedValueFalse); err != nil { + t.Fatalf("latchManagedLabel: %v", err) + } + + cur := &sfsnapv1.VolumeSnapshot{} + if err := r.SnapClient.Get(ctx, client.ObjectKeyFromObject(vs), cur); err != nil { + t.Fatalf("get vs: %v", err) + } + if cur.Labels[labelSnapshotManaged] != managedValueFalse { + t.Fatalf("managed label = %q, want %q", cur.Labels[labelSnapshotManaged], managedValueFalse) + } + if ssstoragev1alpha1.IsDeleteProtected(cur) { + t.Fatalf("vetoed VS must NOT be delete-protected, labels=%#v", cur.Labels) + } +}