Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion images/controller/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions images/controller/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 == "" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading