From 12dea4eb61a1af19dd285bbab160bc9dafc56519 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Tue, 30 Jun 2026 02:19:07 +0300 Subject: [PATCH 01/11] refactor(api): collapse VolumeCaptureRequest to single target/dataRef A snapshot node binds at most one volume, so model the foundation VolumeCaptureRequest as a single target instead of a list: - spec.targets[] -> spec.target (single object), namespace omitted (the captured PVC always lives in the VCR namespace; the controller re-injects it in-memory and fills status.dataRef.target.namespace). - status.dataRefs[] -> status.dataRef (single object). - Drop the bulk capture path (merge/validate/upsert helpers, bulk progress patch) and rename the snapshot capture file accordingly. - Regenerate CRD and deepcopy. Signed-off-by: Aleksandr Zimin --- ...go => volumecapturerequest_schema_test.go} | 140 ++++++------ api/v1alpha1/volumecapturerequest_types.go | 26 +-- api/v1alpha1/zz_generated.deepcopy.go | 16 +- ...ge.deckhouse.io_volumecapturerequests.yaml | 193 ++++++++-------- .../volumecapturerequest_cleanup_test.go | 76 +------ .../volumecapturerequest_controller.go | 145 +++++------- .../volumecapturerequest_controller_test.go | 208 ++++++------------ .../volumecapturerequest_single_target.go | 26 +-- ...lk.go => volumecapturerequest_snapshot.go} | 47 +--- ...volumecapturerequest_snapshot_bulk_test.go | 76 ------- .../volumecapturerequest_snapshot_test.go | 46 ++++ 11 files changed, 383 insertions(+), 616 deletions(-) rename api/v1alpha1/{volumecapturerequest_prf1_test.go => volumecapturerequest_schema_test.go} (55%) rename images/controller/internal/controllers/{volumecapturerequest_snapshot_bulk.go => volumecapturerequest_snapshot.go} (89%) delete mode 100644 images/controller/internal/controllers/volumecapturerequest_snapshot_bulk_test.go create mode 100644 images/controller/internal/controllers/volumecapturerequest_snapshot_test.go diff --git a/api/v1alpha1/volumecapturerequest_prf1_test.go b/api/v1alpha1/volumecapturerequest_schema_test.go similarity index 55% rename from api/v1alpha1/volumecapturerequest_prf1_test.go rename to api/v1alpha1/volumecapturerequest_schema_test.go index b0dc8c3..d5d7276 100644 --- a/api/v1alpha1/volumecapturerequest_prf1_test.go +++ b/api/v1alpha1/volumecapturerequest_schema_test.go @@ -27,25 +27,15 @@ import ( "gopkg.in/yaml.v3" ) -func TestVolumeCaptureRequestSpec_Targets_JSONRoundTrip(t *testing.T) { +func TestVolumeCaptureRequestSpec_Target_JSONRoundTrip(t *testing.T) { vcr := VolumeCaptureRequest{ Spec: VolumeCaptureRequestSpec{ Mode: VolumeCaptureModeSnapshot, - Targets: []VolumeCaptureTarget{ - { - UID: "uid-a", - APIVersion: "v1", - Kind: "PersistentVolumeClaim", - Namespace: "demo", - Name: "data-a", - }, - { - UID: "uid-b", - APIVersion: "v1", - Kind: "PersistentVolumeClaim", - Namespace: "demo", - Name: "data-b", - }, + Target: &VolumeCaptureTarget{ + UID: "uid-a", + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: "data-a", }, }, } @@ -59,11 +49,11 @@ func TestVolumeCaptureRequestSpec_Targets_JSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &out); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(out.Spec.Targets) != 2 { - t.Fatalf("targets len: got %d want 2", len(out.Spec.Targets)) + if out.Spec.Target == nil { + t.Fatal("spec.target must round-trip") } - if out.Spec.Targets[0].UID != "uid-a" || out.Spec.Targets[1].Name != "data-b" { - t.Fatalf("targets mismatch: %#v", out.Spec.Targets) + if out.Spec.Target.UID != "uid-a" || out.Spec.Target.Name != "data-a" { + t.Fatalf("target mismatch: %#v", out.Spec.Target) } var raw map[string]interface{} @@ -71,33 +61,35 @@ func TestVolumeCaptureRequestSpec_Targets_JSONRoundTrip(t *testing.T) { t.Fatalf("unmarshal raw: %v", err) } spec := raw["spec"].(map[string]interface{}) - if _, ok := spec["persistentVolumeClaimRef"]; ok { - t.Fatal("persistentVolumeClaimRef must not appear in JSON") + if _, ok := spec["targets"]; ok { + t.Fatal("legacy spec.targets[] must not appear in JSON") + } + target, ok := spec["target"].(map[string]interface{}) + if !ok { + t.Fatalf("spec.target must be a single object, got %#v", spec["target"]) } - targets := spec["targets"].([]interface{}) - if len(targets) != 2 { - t.Fatalf("raw targets len: got %d want 2", len(targets)) + // Namespace is omitted from spec.target (the PVC lives in the VCR namespace). + if _, ok := target["namespace"]; ok { + t.Fatal("spec.target.namespace must not appear in JSON when empty") } } -func TestVolumeCaptureRequestStatus_DataRefs_JSONRoundTrip(t *testing.T) { +func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { vcr := VolumeCaptureRequest{ Status: VolumeCaptureRequestStatus{ - DataRefs: []VolumeDataBinding{ - { - TargetUID: "uid-a", - Target: VolumeCaptureTarget{ - UID: "uid-a", - APIVersion: "v1", - Kind: "PersistentVolumeClaim", - Namespace: "demo", - Name: "data-a", - }, - Artifact: VolumeDataArtifactRef{ - APIVersion: "snapshot.storage.k8s.io/v1", - Kind: "VolumeSnapshotContent", - Name: "snapcontent-a", - }, + DataRef: &VolumeDataBinding{ + TargetUID: "uid-a", + Target: VolumeCaptureTarget{ + UID: "uid-a", + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Namespace: "demo", + Name: "data-a", + }, + Artifact: VolumeDataArtifactRef{ + APIVersion: "snapshot.storage.k8s.io/v1", + Kind: "VolumeSnapshotContent", + Name: "snapcontent-a", }, }, }, @@ -112,12 +104,16 @@ func TestVolumeCaptureRequestStatus_DataRefs_JSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &out); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(out.Status.DataRefs) != 1 { - t.Fatalf("dataRefs len: got %d want 1", len(out.Status.DataRefs)) + if out.Status.DataRef == nil { + t.Fatal("status.dataRef must round-trip") } - ref := out.Status.DataRefs[0] + ref := out.Status.DataRef if ref.TargetUID != "uid-a" || ref.Artifact.Name != "snapcontent-a" { - t.Fatalf("dataRefs mismatch: %#v", ref) + t.Fatalf("dataRef mismatch: %#v", ref) + } + // Namespace is preserved in status.dataRef.target so the binding is self-contained. + if ref.Target.Namespace != "demo" { + t.Fatalf("status.dataRef.target.namespace = %q, want %q", ref.Target.Namespace, "demo") } if ref.Artifact.Kind == "VolumeCaptureRequest" { t.Fatal("artifact must not reference an execution request") @@ -128,28 +124,33 @@ func TestVolumeCaptureRequestStatus_DataRefs_JSONRoundTrip(t *testing.T) { t.Fatalf("unmarshal raw: %v", err) } status := raw["status"].(map[string]interface{}) - if _, ok := status["dataRef"]; ok { - t.Fatal("dataRef must not appear in JSON") + if _, ok := status["dataRefs"]; ok { + t.Fatal("legacy status.dataRefs[] must not appear in JSON") + } + if _, ok := status["dataRef"].(map[string]interface{}); !ok { + t.Fatalf("status.dataRef must be a single object, got %#v", status["dataRef"]) } } -func TestVolumeCaptureRequestCRD_MapListSemantics(t *testing.T) { +func TestVolumeCaptureRequestCRD_SingleTargetSchema(t *testing.T) { crdPath := filepath.Join("..", "..", "crds", "internal", "storage.deckhouse.io_volumecapturerequests.yaml") data, err := os.ReadFile(crdPath) if err != nil { t.Fatalf("read CRD: %v", err) } content := string(data) - for _, forbidden := range []string{"persistentVolumeClaimRef:", "dataRef:", "volumeSnapshotClassName:"} { + for _, forbidden := range []string{ + "persistentVolumeClaimRef:", + "x-kubernetes-list-type: map", + "x-kubernetes-list-map-keys", + } { if strings.Contains(content, forbidden) { - t.Fatalf("CRD must not contain %q", forbidden) + t.Fatalf("CRD must not contain %q (single-target schema)", forbidden) } } for _, required := range []string{ - "x-kubernetes-list-type: map", - "x-kubernetes-list-map-keys", - "targets:", - "dataRefs:", + "target:", + "dataRef:", "targetUID:", } { if !strings.Contains(content, required) { @@ -164,23 +165,27 @@ func TestVolumeCaptureRequestCRD_MapListSemantics(t *testing.T) { versions := doc["spec"].(map[string]interface{})["versions"].([]interface{}) schema := versions[0].(map[string]interface{})["schema"].(map[string]interface{})["openAPIV3Schema"].(map[string]interface{}) specProps := schema["properties"].(map[string]interface{})["spec"].(map[string]interface{})["properties"].(map[string]interface{}) - targets := specProps["targets"].(map[string]interface{}) - if targets["x-kubernetes-list-type"] != "map" { - t.Fatalf("spec.targets list-type: %#v", targets["x-kubernetes-list-type"]) + target, ok := specProps["target"].(map[string]interface{}) + if !ok { + t.Fatalf("spec.target must be an object schema, got %#v", specProps["target"]) } - mapKeys, ok := targets["x-kubernetes-list-map-keys"].([]interface{}) - if !ok || len(mapKeys) != 1 || mapKeys[0].(string) != "uid" { - t.Fatalf("spec.targets map keys: %#v", targets["x-kubernetes-list-map-keys"]) + if target["type"] != "object" { + t.Fatalf("spec.target type: %#v", target["type"]) + } + if _, ok := specProps["targets"]; ok { + t.Fatal("spec.targets[] must not exist in CRD") } statusProps := schema["properties"].(map[string]interface{})["status"].(map[string]interface{})["properties"].(map[string]interface{}) - dataRefs := statusProps["dataRefs"].(map[string]interface{}) - if dataRefs["x-kubernetes-list-type"] != "map" { - t.Fatalf("status.dataRefs list-type: %#v", dataRefs["x-kubernetes-list-type"]) + dataRef, ok := statusProps["dataRef"].(map[string]interface{}) + if !ok { + t.Fatalf("status.dataRef must be an object schema, got %#v", statusProps["dataRef"]) + } + if dataRef["type"] != "object" { + t.Fatalf("status.dataRef type: %#v", dataRef["type"]) } - dataMapKeys := dataRefs["x-kubernetes-list-map-keys"].([]interface{}) - if len(dataMapKeys) != 1 || dataMapKeys[0].(string) != "targetUID" { - t.Fatalf("status.dataRefs map keys: %#v", dataRefs["x-kubernetes-list-map-keys"]) + if _, ok := statusProps["dataRefs"]; ok { + t.Fatal("status.dataRefs[] must not exist in CRD") } } @@ -191,6 +196,3 @@ func TestVolumeCaptureTarget_ZeroValueNotEqualNonZero(t *testing.T) { t.Fatal("expected distinct zero and non-zero targets") } } - -// Controller validation TODO (PR-F-2): reject duplicate spec.targets[].uid at runtime if apiserver -// does not enforce map-list keys on create in all code paths. diff --git a/api/v1alpha1/volumecapturerequest_types.go b/api/v1alpha1/volumecapturerequest_types.go index 5b81d70..68a33e2 100644 --- a/api/v1alpha1/volumecapturerequest_types.go +++ b/api/v1alpha1/volumecapturerequest_types.go @@ -28,7 +28,7 @@ const ( // +k8s:deepcopy-gen=true // VolumeCaptureTarget identifies a PVC (or future volume target) to capture. type VolumeCaptureTarget struct { - // UID is the map key for spec.targets (PersistentVolumeClaim UID). + // UID is the captured PersistentVolumeClaim UID. // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 UID string `json:"uid"` @@ -42,9 +42,11 @@ type VolumeCaptureTarget struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 Name string `json:"name"` - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Namespace string `json:"namespace"` + // Namespace is intentionally empty in spec.target (the PVC always lives in the VCR namespace). + // The controller fills it in status.dataRef.target from the VCR namespace so the binding is + // self-contained for downstream data retrieval. + // +optional + Namespace string `json:"namespace,omitempty"` } // +k8s:deepcopy-gen=true @@ -66,7 +68,7 @@ type VolumeDataArtifactRef struct { // +k8s:deepcopy-gen=true // VolumeDataBinding associates a capture target with its durable data artifact on one VCR. type VolumeDataBinding struct { - // TargetUID is the map key for status.dataRefs (matches spec.targets[].uid). + // TargetUID matches spec.target.uid (the captured PersistentVolumeClaim UID). // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 TargetUID string `json:"targetUID"` @@ -86,11 +88,9 @@ type VolumeCaptureRequestSpec struct { // +kubebuilder:validation:Required Mode string `json:"mode"` - // Targets lists PVC/volume targets to capture in this request (bulk, one VCR per logical snapshot node). - // +listType=map - // +listMapKey=uid + // Target is the single PVC/volume target to capture (one VCR per logical snapshot node, ≤1 volume). // +optional - Targets []VolumeCaptureTarget `json:"targets,omitempty"` + Target *VolumeCaptureTarget `json:"target,omitempty"` } // +k8s:deepcopy-gen=true @@ -101,16 +101,14 @@ type VolumeCaptureRequestStatus struct { // Conditions represent the latest available observations of the resource's state Conditions []metav1.Condition `json:"conditions,omitempty"` - // DataRefs lists per-target durable data artifacts (for example VolumeSnapshotContent). - // +listType=map - // +listMapKey=targetUID + // DataRef is the durable data artifact for the captured target (for example VolumeSnapshotContent). // +optional - DataRefs []VolumeDataBinding `json:"dataRefs,omitempty"` + DataRef *VolumeDataBinding `json:"dataRef,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status -// +kubebuilder:validation:XValidation:rule="self.spec.mode != 'Snapshot' || (has(self.spec.targets) && size(self.spec.targets) > 0)",message="spec.targets must not be empty when mode is Snapshot" +// +kubebuilder:validation:XValidation:rule="self.spec.mode != 'Snapshot' || has(self.spec.target)",message="spec.target is required when mode is Snapshot" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // VolumeCaptureRequest is the Schema for the volumecapturerequests API type VolumeCaptureRequest struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 50221e4..35b5194 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -408,10 +408,10 @@ func (in *VolumeCaptureRequestList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VolumeCaptureRequestSpec) DeepCopyInto(out *VolumeCaptureRequestSpec) { *out = *in - if in.Targets != nil { - in, out := &in.Targets, &out.Targets - *out = make([]VolumeCaptureTarget, len(*in)) - copy(*out, *in) + if in.Target != nil { + in, out := &in.Target, &out.Target + *out = new(VolumeCaptureTarget) + **out = **in } } @@ -439,10 +439,10 @@ func (in *VolumeCaptureRequestStatus) DeepCopyInto(out *VolumeCaptureRequestStat (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.DataRefs != nil { - in, out := &in.DataRefs, &out.DataRefs - *out = make([]VolumeDataBinding, len(*in)) - copy(*out, *in) + if in.DataRef != nil { + in, out := &in.DataRef, &out.DataRef + *out = new(VolumeDataBinding) + **out = **in } } diff --git a/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml b/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml index 82e8d9f..04f7c06 100644 --- a/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml +++ b/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml @@ -50,41 +50,35 @@ spec: - Snapshot - Detach type: string - targets: - description: Targets lists PVC/volume targets to capture in this request - (bulk, one VCR per logical snapshot node). - items: - description: VolumeCaptureTarget identifies a PVC (or future volume - target) to capture. - properties: - apiVersion: - minLength: 1 - type: string - kind: - minLength: 1 - type: string - name: - minLength: 1 - type: string - namespace: - minLength: 1 - type: string - uid: - description: UID is the map key for spec.targets (PersistentVolumeClaim - UID). - minLength: 1 - type: string - required: - - apiVersion - - kind - - name - - namespace - - uid - type: object - type: array - x-kubernetes-list-map-keys: + target: + description: Target is the single PVC/volume target to capture (one + VCR per logical snapshot node, ≤1 volume). + properties: + apiVersion: + minLength: 1 + type: string + kind: + minLength: 1 + type: string + name: + minLength: 1 + type: string + namespace: + description: |- + Namespace is intentionally empty in spec.target (the PVC always lives in the VCR namespace). + The controller fills it in status.dataRef.target from the VCR namespace so the binding is + self-contained for downstream data retrieval. + type: string + uid: + description: UID is the captured PersistentVolumeClaim UID. + minLength: 1 + type: string + required: + - apiVersion + - kind + - name - uid - x-kubernetes-list-type: map + type: object required: - mode type: object @@ -155,79 +149,72 @@ spec: - type type: object type: array - dataRefs: - description: DataRefs lists per-target durable data artifacts (for - example VolumeSnapshotContent). - items: - description: VolumeDataBinding associates a capture target with - its durable data artifact on one VCR. - properties: - artifact: - description: Artifact references the cluster-scoped durable - data artifact. - properties: - apiVersion: - minLength: 1 - type: string - kind: - minLength: 1 - type: string - name: - minLength: 1 - type: string - required: - - apiVersion - - kind - - name - type: object - target: - description: Target identifies the PVC target captured in this - binding. - properties: - apiVersion: - minLength: 1 - type: string - kind: - minLength: 1 - type: string - name: - minLength: 1 - type: string - namespace: - minLength: 1 - type: string - uid: - description: UID is the map key for spec.targets (PersistentVolumeClaim - UID). - minLength: 1 - type: string - required: - - apiVersion - - kind - - name - - namespace - - uid - type: object - targetUID: - description: TargetUID is the map key for status.dataRefs (matches - spec.targets[].uid). - minLength: 1 - type: string - required: - - artifact - - target - - targetUID - type: object - type: array - x-kubernetes-list-map-keys: + dataRef: + description: DataRef is the durable data artifact for the captured + target (for example VolumeSnapshotContent). + properties: + artifact: + description: Artifact references the cluster-scoped durable data + artifact. + properties: + apiVersion: + minLength: 1 + type: string + kind: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - apiVersion + - kind + - name + type: object + target: + description: Target identifies the PVC target captured in this + binding. + properties: + apiVersion: + minLength: 1 + type: string + kind: + minLength: 1 + type: string + name: + minLength: 1 + type: string + namespace: + description: |- + Namespace is intentionally empty in spec.target (the PVC always lives in the VCR namespace). + The controller fills it in status.dataRef.target from the VCR namespace so the binding is + self-contained for downstream data retrieval. + type: string + uid: + description: UID is the captured PersistentVolumeClaim UID. + minLength: 1 + type: string + required: + - apiVersion + - kind + - name + - uid + type: object + targetUID: + description: TargetUID matches spec.target.uid (the captured PersistentVolumeClaim + UID). + minLength: 1 + type: string + required: + - artifact + - target - targetUID - x-kubernetes-list-type: map + type: object type: object type: object x-kubernetes-validations: - - message: spec.targets must not be empty when mode is Snapshot - rule: self.spec.mode != 'Snapshot' || (has(self.spec.targets) && size(self.spec.targets) - > 0) + - message: spec.target is required when mode is Snapshot + rule: self.spec.mode != 'Snapshot' || has(self.spec.target) served: true storage: true subresources: diff --git a/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go b/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go index 0b97da7..011345c 100644 --- a/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go @@ -13,8 +13,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -func vcrDataRefBinding(targetUID, kind, name, apiVersion string) storagev1alpha1.VolumeDataBinding { - return storagev1alpha1.VolumeDataBinding{ +func vcrDataRefBinding(targetUID, kind, name, apiVersion string) *storagev1alpha1.VolumeDataBinding { + return &storagev1alpha1.VolumeDataBinding{ TargetUID: targetUID, Target: storagev1alpha1.VolumeCaptureTarget{ UID: targetUID, @@ -39,9 +39,7 @@ func TestCleanupArtifactsForVCR_DeletesOrphans(t *testing.T) { vcr := &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{Name: "vcr-1", Namespace: "default"}, Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRefs: []storagev1alpha1.VolumeDataBinding{ - vcrDataRefBinding("uid-1", "VolumeSnapshotContent", "vsc-1", "snapshot.storage.k8s.io/v1"), - }, + DataRef: vcrDataRefBinding("uid-1", "VolumeSnapshotContent", "vsc-1", "snapshot.storage.k8s.io/v1"), }, } vsc := &snapshotv1.VolumeSnapshotContent{ @@ -75,9 +73,7 @@ func TestCleanupArtifactsForVCR_SkipsManaged(t *testing.T) { vcr := &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{Name: "vcr-2", Namespace: "default"}, Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRefs: []storagev1alpha1.VolumeDataBinding{ - vcrDataRefBinding("uid-2", "VolumeSnapshotContent", "vsc-2", "snapshot.storage.k8s.io/v1"), - }, + DataRef: vcrDataRefBinding("uid-2", "VolumeSnapshotContent", "vsc-2", "snapshot.storage.k8s.io/v1"), }, } vsc := &snapshotv1.VolumeSnapshotContent{ @@ -104,9 +100,7 @@ func TestCleanupArtifactsForVCR_DeletesPVOrphans(t *testing.T) { vcr := &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{Name: "vcr-3", Namespace: "default"}, Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRefs: []storagev1alpha1.VolumeDataBinding{ - vcrDataRefBinding("uid-3", "PersistentVolume", "pv-1", "v1"), - }, + DataRef: vcrDataRefBinding("uid-3", "PersistentVolume", "pv-1", "v1"), }, } pv := &corev1.PersistentVolume{ @@ -125,71 +119,19 @@ func TestCleanupArtifactsForVCR_DeletesPVOrphans(t *testing.T) { } } -func TestCleanupArtifactsForVCR_TwoDataRefsOrphansDeleted(t *testing.T) { +func TestCleanupArtifactsForVCR_NilDataRefNoop(t *testing.T) { scheme := runtime.NewScheme() _ = storagev1alpha1.AddToScheme(scheme) _ = snapshotv1.AddToScheme(scheme) vcr := &storagev1alpha1.VolumeCaptureRequest{ - ObjectMeta: metav1.ObjectMeta{Name: "vcr-bulk-cleanup", Namespace: "default"}, - Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRefs: []storagev1alpha1.VolumeDataBinding{ - vcrDataRefBinding("uid-1", "VolumeSnapshotContent", "vsc-orphan-1", "snapshot.storage.k8s.io/v1"), - vcrDataRefBinding("uid-2", "VolumeSnapshotContent", "vsc-orphan-2", "snapshot.storage.k8s.io/v1"), - }, - }, - } - vsc1 := &snapshotv1.VolumeSnapshotContent{ObjectMeta: metav1.ObjectMeta{Name: "vsc-orphan-1"}} - vsc2 := &snapshotv1.VolumeSnapshotContent{ObjectMeta: metav1.ObjectMeta{Name: "vsc-orphan-2"}} - - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vcr, vsc1, vsc2).Build() - controller := &VolumeCaptureRequestController{Client: client} - - if err := controller.cleanupArtifactsForVCR(context.Background(), vcr); err != nil { - t.Fatalf("cleanupArtifactsForVCR failed: %v", err) - } - for _, name := range []string{"vsc-orphan-1", "vsc-orphan-2"} { - if err := client.Get(context.Background(), ctrlclient.ObjectKey{Name: name}, &snapshotv1.VolumeSnapshotContent{}); err == nil { - t.Fatalf("expected VolumeSnapshotContent %s to be deleted", name) - } - } -} - -func TestCleanupArtifactsForVCR_TwoDataRefsSkipsManaged(t *testing.T) { - scheme := runtime.NewScheme() - _ = storagev1alpha1.AddToScheme(scheme) - _ = snapshotv1.AddToScheme(scheme) - - managedOwner := []metav1.OwnerReference{{ - APIVersion: "test.deckhouse.io/v1alpha1", - Kind: "TestSnapshotContent", - Name: "content-managed", - UID: "uid-managed", - }} - orphanOwner := []metav1.OwnerReference{} - - vcr := &storagev1alpha1.VolumeCaptureRequest{ - ObjectMeta: metav1.ObjectMeta{Name: "vcr-bulk-cleanup-2", Namespace: "default"}, - Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRefs: []storagev1alpha1.VolumeDataBinding{ - vcrDataRefBinding("uid-m", "VolumeSnapshotContent", "vsc-managed", "snapshot.storage.k8s.io/v1"), - vcrDataRefBinding("uid-o", "VolumeSnapshotContent", "vsc-orphan", "snapshot.storage.k8s.io/v1"), - }, - }, + ObjectMeta: metav1.ObjectMeta{Name: "vcr-nil-dataref", Namespace: "default"}, } - vscManaged := &snapshotv1.VolumeSnapshotContent{ObjectMeta: metav1.ObjectMeta{Name: "vsc-managed", OwnerReferences: managedOwner}} - vscOrphan := &snapshotv1.VolumeSnapshotContent{ObjectMeta: metav1.ObjectMeta{Name: "vsc-orphan", OwnerReferences: orphanOwner}} - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vcr, vscManaged, vscOrphan).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vcr).Build() controller := &VolumeCaptureRequestController{Client: client} if err := controller.cleanupArtifactsForVCR(context.Background(), vcr); err != nil { - t.Fatalf("cleanupArtifactsForVCR failed: %v", err) - } - if err := client.Get(context.Background(), ctrlclient.ObjectKey{Name: "vsc-managed"}, &snapshotv1.VolumeSnapshotContent{}); err != nil { - t.Fatalf("expected managed VSC to remain: %v", err) - } - if err := client.Get(context.Background(), ctrlclient.ObjectKey{Name: "vsc-orphan"}, &snapshotv1.VolumeSnapshotContent{}); err == nil { - t.Fatal("expected orphan VSC to be deleted") + t.Fatalf("cleanupArtifactsForVCR with nil dataRef must be a no-op, got: %v", err) } } diff --git a/images/controller/internal/controllers/volumecapturerequest_controller.go b/images/controller/internal/controllers/volumecapturerequest_controller.go index ae8a9b4..1341791 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller.go @@ -162,12 +162,16 @@ func (r *VolumeCaptureRequestController) Reconcile(ctx context.Context, req ctrl // - VCR never retries snapshot creation func (r *VolumeCaptureRequestController) processSnapshotMode(ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest) (ctrl.Result, error) { l := log.FromContext(ctx).WithValues("vcr", fmt.Sprintf("%s/%s", vcr.Namespace, vcr.Name), "mode", "Snapshot") - l.Info("Processing VolumeCaptureRequest in Snapshot mode", "targets", len(vcr.Spec.Targets)) + l.Info("Processing VolumeCaptureRequest in Snapshot mode") - if err := validateSnapshotTargets(vcr.Spec.Targets); err != nil { - l.Error(err, "Invalid spec.targets") + target, err := requireCaptureTarget(vcr.Spec) + if err != nil { + l.Error(err, "Invalid spec.target") return r.markFailed(ctx, vcr, storagev1alpha1.ConditionReasonInternalError, err.Error()) } + // spec.target carries no namespace; the captured PVC always lives in the VCR namespace. + // Re-inject it so the per-target capture path and the status binding both see the namespace. + target.Namespace = vcr.Namespace retainerName := objectKeeperNameForVCR(vcr.UID) objectKeeper, result, err := r.ensureObjectKeeper(ctx, retainerName, vcr) @@ -178,64 +182,34 @@ func (r *VolumeCaptureRequestController) processSnapshotMode(ctx context.Context return result, nil } - var bindingUpdates []storagev1alpha1.VolumeDataBinding - var readyCount int - var pending bool - var requeueAfter time.Duration - var firstTerminal *snapshotTargetError - - for _, target := range vcr.Spec.Targets { - tr, targetResult, err := r.processSnapshotTarget(ctx, vcr, objectKeeper, retainerName, target) - if err != nil { - return ctrl.Result{}, err - } - if targetResult.Requeue || targetResult.RequeueAfter > 0 { - pending = true - if targetResult.RequeueAfter > requeueAfter { - requeueAfter = targetResult.RequeueAfter - } - } - if tr.terminal != nil && firstTerminal == nil { - firstTerminal = tr.terminal - } - if tr.binding != nil { - bindingUpdates = append(bindingUpdates, *tr.binding) - } - if tr.ready { - readyCount++ - } - if tr.pending { - pending = true - } + tr, targetResult, err := r.processSnapshotTarget(ctx, vcr, objectKeeper, retainerName, target) + if err != nil { + return ctrl.Result{}, err } - total := len(vcr.Spec.Targets) - if firstTerminal != nil { - l.Error(nil, "Target capture failed", "targetUID", firstTerminal.target.UID, "reason", firstTerminal.reason) - return r.markFailedSnapshotForTarget(ctx, vcr, firstTerminal.target, firstTerminal.vscName, firstTerminal.reason, firstTerminal.message) + if tr.terminal != nil { + l.Error(nil, "Target capture failed", "targetUID", tr.terminal.target.UID, "reason", tr.terminal.reason) + return r.markFailedSnapshotForTarget(ctx, vcr, tr.terminal.target, tr.terminal.vscName, tr.terminal.reason, tr.terminal.message) } - if readyCount == total { - vcr.Status.DataRefs = mergeVolumeDataBindings(vcr.Status.DataRefs, bindingUpdates) - msg := fmt.Sprintf("all %d targets ready", total) - if err := r.finalizeVCR(ctx, vcr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, msg); err != nil { + if tr.ready && tr.binding != nil { + vcr.Status.DataRef = tr.binding + if err := r.finalizeVCR(ctx, vcr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, "target ready"); err != nil { return ctrl.Result{}, err } - l.Info("VolumeCaptureRequest completed", "mode", "Snapshot", "targets", total) + l.Info("VolumeCaptureRequest completed", "mode", "Snapshot") return ctrl.Result{}, nil } - if err := r.patchVCRSnapshotProgress(ctx, vcr, bindingUpdates, readyCount, total); err != nil { + // Still capturing: surface progress and requeue. + if err := r.patchVCRSnapshotPending(ctx, vcr); err != nil { return ctrl.Result{}, err } - if pending { - if requeueAfter == 0 { - requeueAfter = 5 * time.Second - } - return ctrl.Result{RequeueAfter: requeueAfter}, nil + requeueAfter := targetResult.RequeueAfter + if requeueAfter == 0 { + requeueAfter = 5 * time.Second } - - return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + return ctrl.Result{RequeueAfter: requeueAfter}, nil } // processDetachMode handles Detach mode: detaches PV from PVC @@ -244,11 +218,13 @@ func (r *VolumeCaptureRequestController) processSnapshotMode(ctx context.Context func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest) (ctrl.Result, error) { l := log.FromContext(ctx).WithValues("vcr", fmt.Sprintf("%s/%s", vcr.Namespace, vcr.Name), "mode", "Detach") - // 1. Validate spec (Detach: single target only; bulk Detach out of scope) - target, err := detachVolumeCaptureTarget(vcr.Spec) + // 1. Validate spec (single target) + target, err := requireCaptureTarget(vcr.Spec) if err != nil { return r.markFailed(ctx, vcr, storagev1alpha1.ConditionReasonInternalError, err.Error()) } + // spec.target carries no namespace; the captured PVC always lives in the VCR namespace. + target.Namespace = vcr.Namespace // 2. Check RBAC: try to get PVC // NOTE: If PVC is already deleted (from previous reconcile), we need to get PV from annotation or VCR status @@ -274,7 +250,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, var pvName string if pvNameFromAnnotation != "" { pvName = pvNameFromAnnotation - } else if artifact, ok := firstDataArtifactRef(vcr.Status); ok && artifact.Kind == "PersistentVolume" { + } else if artifact, ok := dataArtifactRef(vcr.Status); ok && artifact.Kind == "PersistentVolume" { pvName = artifact.Name } else { // Cannot proceed without PV name @@ -814,40 +790,41 @@ func (r *VolumeCaptureRequestController) scanAndDeleteExpiredVCRs(ctx context.Co // cleanupArtifactsForVCR deletes VCR-created artifacts if they have no ownerRef. // This is a best-effort cleanup used by the TTL scanner to avoid orphaned artifacts. func (r *VolumeCaptureRequestController) cleanupArtifactsForVCR(ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest) error { - for _, binding := range vcr.Status.DataRefs { - artifact := binding.Artifact - if artifact.Name == "" { - continue - } - switch artifact.Kind { - case "VolumeSnapshotContent": - content := &snapshotv1.VolumeSnapshotContent{} - if err := r.Client.Get(ctx, client.ObjectKey{Name: artifact.Name}, content); err != nil { - if apierrors.IsNotFound(err) { - continue - } - return err - } - if hasSnapshotContentOwnerRef(content.OwnerReferences) { - continue - } - if err := r.Client.Delete(ctx, content); err != nil && !apierrors.IsNotFound(err) { - return err - } - case "PersistentVolume": - pv := &corev1.PersistentVolume{} - if err := r.Client.Get(ctx, client.ObjectKey{Name: artifact.Name}, pv); err != nil { - if apierrors.IsNotFound(err) { - continue - } - return err - } - if hasSnapshotContentOwnerRef(pv.OwnerReferences) { - continue + if vcr.Status.DataRef == nil { + return nil + } + artifact := vcr.Status.DataRef.Artifact + if artifact.Name == "" { + return nil + } + switch artifact.Kind { + case "VolumeSnapshotContent": + content := &snapshotv1.VolumeSnapshotContent{} + if err := r.Client.Get(ctx, client.ObjectKey{Name: artifact.Name}, content); err != nil { + if apierrors.IsNotFound(err) { + return nil } - if err := r.Client.Delete(ctx, pv); err != nil && !apierrors.IsNotFound(err) { - return err + return err + } + if hasSnapshotContentOwnerRef(content.OwnerReferences) { + return nil + } + if err := r.Client.Delete(ctx, content); err != nil && !apierrors.IsNotFound(err) { + return err + } + case "PersistentVolume": + pv := &corev1.PersistentVolume{} + if err := r.Client.Get(ctx, client.ObjectKey{Name: artifact.Name}, pv); err != nil { + if apierrors.IsNotFound(err) { + return nil } + return err + } + if hasSnapshotContentOwnerRef(pv.OwnerReferences) { + return nil + } + if err := r.Client.Delete(ctx, pv); err != nil && !apierrors.IsNotFound(err) { + return err } } diff --git a/images/controller/internal/controllers/volumecapturerequest_controller_test.go b/images/controller/internal/controllers/volumecapturerequest_controller_test.go index 15f38d3..f187bb8 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller_test.go @@ -162,7 +162,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { } } - newVCR := func(name, namespace string, mode string, targets []storagev1alpha1.VolumeCaptureTarget) *storagev1alpha1.VolumeCaptureRequest { + newVCR := func(name, namespace string, mode string, target storagev1alpha1.VolumeCaptureTarget) *storagev1alpha1.VolumeCaptureRequest { return &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -170,8 +170,8 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { UID: types.UID(fmt.Sprintf("vcr-uid-%s", name)), }, Spec: storagev1alpha1.VolumeCaptureRequestSpec{ - Mode: mode, - Targets: targets, + Mode: mode, + Target: &target, }, } } @@ -195,24 +195,25 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { ok.UID = types.UID("ok-uid-" + string(vcr.UID)) _ = client.Update(ctx, ok) } - for _, target := range vcr.Spec.Targets { - vscName := snapshotVSCName(vcr.UID, target.UID) - vsc := &snapshotv1.VolumeSnapshotContent{} - if err := client.Get(ctx, types.NamespacedName{Name: vscName}, vsc); err != nil { - continue - } - needsUpdate := false - for i := range vsc.OwnerReferences { - if vsc.OwnerReferences[i].Kind == KindObjectKeeper && vsc.OwnerReferences[i].Name == retainerName { - if vsc.OwnerReferences[i].UID == "" { - vsc.OwnerReferences[i].UID = ok.UID - needsUpdate = true - } + if vcr.Spec.Target == nil { + return + } + vscName := snapshotVSCName(vcr.UID, vcr.Spec.Target.UID) + vsc := &snapshotv1.VolumeSnapshotContent{} + if err := client.Get(ctx, types.NamespacedName{Name: vscName}, vsc); err != nil { + return + } + needsUpdate := false + for i := range vsc.OwnerReferences { + if vsc.OwnerReferences[i].Kind == KindObjectKeeper && vsc.OwnerReferences[i].Name == retainerName { + if vsc.OwnerReferences[i].UID == "" { + vsc.OwnerReferences[i].UID = ok.UID + needsUpdate = true } } - if needsUpdate { - _ = client.Update(ctx, vsc) - } + } + if needsUpdate { + _ = client.Update(ctx, vsc) } } @@ -351,9 +352,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc", "default", "test-sc", "test-pv") Expect(client.Create(ctx, pvc)).To(Succeed()) - vcr := newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc", "uid-test-pvc"), - }) + vcr := newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc", "uid-test-pvc")) Expect(client.Create(ctx, vcr)).To(Succeed()) // When: reconcile until VSC is created (but not terminal yet - waiting for ReadyToUse) @@ -446,9 +445,9 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(readyCondition).ToNot(BeNil()) Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue)) - Expect(updatedVCR.Status.DataRefs).To(HaveLen(1)) - Expect(updatedVCR.Status.DataRefs[0].Artifact.Kind).To(Equal("VolumeSnapshotContent")) - Expect(updatedVCR.Status.DataRefs[0].Artifact.Name).To(Equal(csiVSCName)) + Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) + Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("VolumeSnapshotContent")) + Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal(csiVSCName)) }) }) @@ -467,9 +466,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc", "default", "test-sc", "test-pv") Expect(client.Create(ctx, pvc)).To(Succeed()) - vcr := newVCR("test-vcr-error", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc", "uid-test-pvc"), - }) + vcr := newVCR("test-vcr-error", "default", ModeSnapshot, pvcTarget("default", "test-pvc", "uid-test-pvc")) Expect(client.Create(ctx, vcr)).To(Succeed()) // Create VSC with error (simulating CSI driver failure) @@ -531,9 +528,9 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonSnapshotCreationFailed)) Expect(readyCondition.Message).To(ContainSubstring(errorMsg)) - Expect(updatedVCR.Status.DataRefs).To(HaveLen(1)) - Expect(updatedVCR.Status.DataRefs[0].Artifact.Kind).To(Equal("VolumeSnapshotContent")) - Expect(updatedVCR.Status.DataRefs[0].Artifact.Name).To(Equal(csiVSCName)) + Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) + Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("VolumeSnapshotContent")) + Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal(csiVSCName)) // VSC still exists (not deleted) existingVSC := &snapshotv1.VolumeSnapshotContent{} @@ -567,9 +564,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { }, Entry("PVC not found", func() *storagev1alpha1.VolumeCaptureRequest { - return newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "non-existent-pvc", "uid-non-existent-pvc"), - }) + return newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "non-existent-pvc", "uid-non-existent-pvc")) }, storagev1alpha1.ConditionReasonNotFound, ), @@ -586,9 +581,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { } Expect(client.Create(ctx, pvc)).To(Succeed()) - return newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-unbound", "uid-test-pvc-unbound"), - }) + return newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc-unbound", "uid-test-pvc-unbound")) }, storagev1alpha1.ConditionReasonInternalError, ), @@ -611,9 +604,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc-no-csi", "default", "test-sc", "test-pv-no-csi") Expect(client.Create(ctx, pvc)).To(Succeed()) - return newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-no-csi", "uid-test-pvc-no-csi"), - }) + return newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc-no-csi", "uid-test-pvc-no-csi")) }, storagev1alpha1.ConditionReasonInternalError, ), @@ -633,9 +624,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc-no-annotation", "default", "test-sc-no-annotation", "test-pv-no-annotation") Expect(client.Create(ctx, pvc)).To(Succeed()) - return newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-no-annotation", "uid-test-pvc-no-annotation"), - }) + return newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc-no-annotation", "uid-test-pvc-no-annotation")) }, storagev1alpha1.ConditionReasonNotFound, ), @@ -650,9 +639,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc-bad-vsc", "default", "test-sc-bad-vsc", "test-pv-bad-vsc") Expect(client.Create(ctx, pvc)).To(Succeed()) - return newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-bad-vsc", "uid-test-pvc-bad-vsc"), - }) + return newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc-bad-vsc", "uid-test-pvc-bad-vsc")) }, storagev1alpha1.ConditionReasonNotFound, ), @@ -670,9 +657,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc-mismatch", "default", "test-sc-mismatch", "test-pv-mismatch") Expect(client.Create(ctx, pvc)).To(Succeed()) - return newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-mismatch", "uid-test-pvc-mismatch"), - }) + return newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc-mismatch", "uid-test-pvc-mismatch")) }, storagev1alpha1.ConditionReasonInternalError, ), @@ -695,9 +680,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc-detach", "default", "", "test-pv-detach") Expect(client.Create(ctx, pvc)).To(Succeed()) - vcr := newVCR("test-vcr-detach", "default", ModeDetach, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-detach", "uid-test-pvc-detach"), - }) + vcr := newVCR("test-vcr-detach", "default", ModeDetach, pvcTarget("default", "test-pvc-detach", "uid-test-pvc-detach")) Expect(client.Create(ctx, vcr)).To(Succeed()) // When: reconcile until terminal @@ -735,9 +718,9 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { // VCR status updatedVCR := &storagev1alpha1.VolumeCaptureRequest{} Expect(client.Get(ctx, types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}, updatedVCR)).To(Succeed()) - Expect(updatedVCR.Status.DataRefs).To(HaveLen(1)) - Expect(updatedVCR.Status.DataRefs[0].Artifact.Kind).To(Equal("PersistentVolume")) - Expect(updatedVCR.Status.DataRefs[0].Artifact.Name).To(Equal("test-pv-detach")) + Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) + Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("PersistentVolume")) + Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal("test-pv-detach")) readyCondition := getCondition(updatedVCR.Status.Conditions, storagev1alpha1.ConditionTypeReady) Expect(readyCondition).ToNot(BeNil()) @@ -756,9 +739,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pv.Annotations["storage.deckhouse.io/detached"] = "true" Expect(client.Create(ctx, pv)).To(Succeed()) - vcr := newVCR("test-vcr-idempotent", "default", ModeDetach, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc-idempotent", "uid-test-pvc-idempotent"), - }) + vcr := newVCR("test-vcr-idempotent", "default", ModeDetach, pvcTarget("default", "test-pvc-idempotent", "uid-test-pvc-idempotent")) // Set annotation with PV name (controller sets this during first reconcile) if vcr.Annotations == nil { vcr.Annotations = make(map[string]string) @@ -833,9 +814,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { pvc := newBoundPVC("test-pvc", "default", "test-sc", "test-pv") Expect(client.Create(ctx, pvc)).To(Succeed()) - vcr := newVCR("test-vcr", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc", "uid-test-pvc"), - }) + vcr := newVCR("test-vcr", "default", ModeSnapshot, pvcTarget("default", "test-pvc", "uid-test-pvc")) Expect(client.Create(ctx, vcr)).To(Succeed()) // Reconcile until VSC is created @@ -906,8 +885,12 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { }, Spec: storagev1alpha1.VolumeCaptureRequestSpec{ Mode: ModeSnapshot, - Targets: []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "test-pvc", "uid-test-pvc"), + Target: &storagev1alpha1.VolumeCaptureTarget{ + UID: "uid-test-pvc", + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Namespace: "default", + Name: "test-pvc", }, }, Status: storagev1alpha1.VolumeCaptureRequestStatus{ @@ -934,67 +917,24 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { }) }) - Describe("LEVEL 2: Bulk Snapshot mode (PR-F-2)", func() { - setupTwoTargetFixture := func(vcrName string) (*storagev1alpha1.VolumeCaptureRequest, string, string) { + Describe("LEVEL 2: Single-target Snapshot mode edge cases", func() { + It("should report TargetsPending while the single target VSC is not yet ReadyToUse", func() { storageClass := newStorageClassWithVSC("test-sc", "test-driver", "test-vsc-class") Expect(client.Create(ctx, storageClass)).To(Succeed()) vscClass := newVolumeSnapshotClass("test-vsc-class", "test-driver") Expect(client.Create(ctx, vscClass)).To(Succeed()) + Expect(client.Create(ctx, newCSIPV("test-pv-pending", "test-driver", "handle-pending"))).To(Succeed()) + Expect(client.Create(ctx, newBoundPVC("pvc-pending", "default", "test-sc", "test-pv-pending"))).To(Succeed()) - Expect(client.Create(ctx, newCSIPV("test-pv-a", "test-driver", "handle-a"))).To(Succeed()) - Expect(client.Create(ctx, newCSIPV("test-pv-b", "test-driver", "handle-b"))).To(Succeed()) - Expect(client.Create(ctx, newBoundPVC("pvc-a", "default", "test-sc", "test-pv-a"))).To(Succeed()) - Expect(client.Create(ctx, newBoundPVC("pvc-b", "default", "test-sc", "test-pv-b"))).To(Succeed()) - - targetA := pvcTarget("default", "pvc-a", "uid-pvc-a") - targetB := pvcTarget("default", "pvc-b", "uid-pvc-b") - vcr := newVCR(vcrName, "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{targetA, targetB}) + vcr := newVCR("test-vcr-pending", "default", ModeSnapshot, pvcTarget("default", "pvc-pending", "uid-pvc-pending")) Expect(client.Create(ctx, vcr)).To(Succeed()) - return vcr, snapshotVSCName(vcr.UID, targetA.UID), snapshotVSCName(vcr.UID, targetB.UID) - } - - It("should capture two PVC targets into two VSCs and reach Ready=True", func() { - vcr, vscA, vscB := setupTwoTargetFixture("test-vcr-bulk-2") - for i := 0; i < 8; i++ { - ensureSnapshotObjectKeeperUID(vcr.UID) - _, err := ctrl.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}}) - Expect(err).ToNot(HaveOccurred()) - } - for _, name := range []string{vscA, vscB} { - vsc := &snapshotv1.VolumeSnapshotContent{} - Expect(client.Get(ctx, types.NamespacedName{Name: name}, vsc)).To(Succeed()) - vsc.Status = &snapshotv1.VolumeSnapshotContentStatus{ReadyToUse: pointer.Bool(true)} - Expect(client.Status().Update(ctx, vsc)).To(Succeed()) - } - Expect(reconcileUntilTerminal(vcr, 10)).To(Succeed()) - - updated := &storagev1alpha1.VolumeCaptureRequest{} - Expect(client.Get(ctx, types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}, updated)).To(Succeed()) - ready := getCondition(updated.Status.Conditions, storagev1alpha1.ConditionTypeReady) - Expect(ready).ToNot(BeNil()) - Expect(ready.Status).To(Equal(metav1.ConditionTrue)) - Expect(ready.Reason).To(Equal(storagev1alpha1.ConditionReasonCompleted)) - Expect(updated.Status.DataRefs).To(HaveLen(2)) - - ok := &deckhousev1alpha1.ObjectKeeper{} - Expect(client.Get(ctx, types.NamespacedName{Name: objectKeeperNameForVCR(vcr.UID)}, ok)).To(Succeed()) - }) - It("should report TargetsPending when one target is ready and one is pending", func() { - vcr, vscA, _ := setupTwoTargetFixture("test-vcr-bulk-pending") + // Reconcile several times: the VSC is created but external-snapshotter never sets ReadyToUse. for i := 0; i < 5; i++ { ensureSnapshotObjectKeeperUID(vcr.UID) _, err := ctrl.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}}) Expect(err).ToNot(HaveOccurred()) } - vsc := &snapshotv1.VolumeSnapshotContent{} - Expect(client.Get(ctx, types.NamespacedName{Name: vscA}, vsc)).To(Succeed()) - vsc.Status = &snapshotv1.VolumeSnapshotContentStatus{ReadyToUse: pointer.Bool(true)} - Expect(client.Status().Update(ctx, vsc)).To(Succeed()) - - ensureSnapshotObjectKeeperUID(vcr.UID) - _, err := ctrl.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}}) - Expect(err).ToNot(HaveOccurred()) updated := &storagev1alpha1.VolumeCaptureRequest{} Expect(client.Get(ctx, types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}, updated)).To(Succeed()) @@ -1002,23 +942,8 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(ready).ToNot(BeNil()) Expect(ready.Status).To(Equal(metav1.ConditionFalse)) Expect(ready.Reason).To(Equal(storagev1alpha1.ConditionReasonTargetsPending)) - Expect(updated.Status.DataRefs).To(HaveLen(1)) - }) - - It("should fail the whole VCR when one target VSC has a terminal CSI error", func() { - vcr, vscA, vscB := setupTwoTargetFixture("test-vcr-bulk-fail") - errMsg := "snapshot failed" - for _, name := range []string{vscA, vscB} { - vsc := newReadyVSC(name, false, &errMsg) - Expect(client.Create(ctx, vsc)).To(Succeed()) - } - - Expect(reconcileUntilTerminal(vcr, 10)).To(Succeed()) - updated := &storagev1alpha1.VolumeCaptureRequest{} - Expect(client.Get(ctx, types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}, updated)).To(Succeed()) - ready := getCondition(updated.Status.Conditions, storagev1alpha1.ConditionTypeReady) - Expect(ready.Status).To(Equal(metav1.ConditionFalse)) - Expect(ready.Reason).To(Equal(storagev1alpha1.ConditionReasonSnapshotCreationFailed)) + // dataRef is only set on success. + Expect(updated.Status.DataRef).To(BeNil()) }) It("should requeue without creating VSC when ObjectKeeper UID is empty", func() { @@ -1030,9 +955,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(client.Create(ctx, newBoundPVC("pvc-guard", "default", "test-sc", "test-pv-guard"))).To(Succeed()) targetUID := "uid-pvc-guard" - vcr := newVCR("test-vcr-ok-uid-guard", "default", ModeSnapshot, []storagev1alpha1.VolumeCaptureTarget{ - pvcTarget("default", "pvc-guard", targetUID), - }) + vcr := newVCR("test-vcr-ok-uid-guard", "default", ModeSnapshot, pvcTarget("default", "pvc-guard", targetUID)) Expect(client.Create(ctx, vcr)).To(Succeed()) retainerName := objectKeeperNameForVCR(vcr.UID) @@ -1060,9 +983,19 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(apierrors.IsNotFound(err)).To(BeTrue()) }) - It("should be idempotent across repeated reconciles", func() { - vcr, vscA, vscB := setupTwoTargetFixture("test-vcr-bulk-idempotent") - for i := 0; i < 3; i++ { + It("should be idempotent across repeated reconciles (single VSC)", func() { + storageClass := newStorageClassWithVSC("test-sc", "test-driver", "test-vsc-class") + Expect(client.Create(ctx, storageClass)).To(Succeed()) + vscClass := newVolumeSnapshotClass("test-vsc-class", "test-driver") + Expect(client.Create(ctx, vscClass)).To(Succeed()) + Expect(client.Create(ctx, newCSIPV("test-pv-idem", "test-driver", "handle-idem"))).To(Succeed()) + Expect(client.Create(ctx, newBoundPVC("pvc-idem", "default", "test-sc", "test-pv-idem"))).To(Succeed()) + + vcr := newVCR("test-vcr-idem-single", "default", ModeSnapshot, pvcTarget("default", "pvc-idem", "uid-pvc-idem")) + Expect(client.Create(ctx, vcr)).To(Succeed()) + + vscName := snapshotVSCName(vcr.UID, "uid-pvc-idem") + for i := 0; i < 4; i++ { ensureSnapshotObjectKeeperUID(vcr.UID) _, err := ctrl.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}}) Expect(err).ToNot(HaveOccurred()) @@ -1073,9 +1006,8 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { for _, item := range vscList.Items { names[item.Name] = struct{}{} } - Expect(names).To(HaveKey(vscA)) - Expect(names).To(HaveKey(vscB)) - Expect(len(names)).To(Equal(2)) + Expect(names).To(HaveKey(vscName)) + Expect(len(names)).To(Equal(1)) }) }) }) diff --git a/images/controller/internal/controllers/volumecapturerequest_single_target.go b/images/controller/internal/controllers/volumecapturerequest_single_target.go index e81e86e..a9f40ef 100644 --- a/images/controller/internal/controllers/volumecapturerequest_single_target.go +++ b/images/controller/internal/controllers/volumecapturerequest_single_target.go @@ -22,25 +22,21 @@ import ( storagev1alpha1 "github.com/deckhouse/storage-foundation/api/v1alpha1" ) -// detachVolumeCaptureTarget validates Detach mode still uses exactly one target (bulk Detach out of scope). -func detachVolumeCaptureTarget(spec storagev1alpha1.VolumeCaptureRequestSpec) (storagev1alpha1.VolumeCaptureTarget, error) { - switch len(spec.Targets) { - case 0: - return storagev1alpha1.VolumeCaptureTarget{}, fmt.Errorf("spec.targets is required") - case 1: - return spec.Targets[0], nil - default: - return storagev1alpha1.VolumeCaptureTarget{}, fmt.Errorf("Detach mode supports exactly one target, got %d", len(spec.Targets)) +// requireCaptureTarget returns the single spec.target, erroring when it is absent. +func requireCaptureTarget(spec storagev1alpha1.VolumeCaptureRequestSpec) (storagev1alpha1.VolumeCaptureTarget, error) { + if spec.Target == nil { + return storagev1alpha1.VolumeCaptureTarget{}, fmt.Errorf("spec.target is required") } + return *spec.Target, nil } func setVolumeSnapshotDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, vscName string) { binding := volumeSnapshotBinding(target, vscName) - vcr.Status.DataRefs = upsertVolumeDataBinding(vcr.Status.DataRefs, binding) + vcr.Status.DataRef = &binding } func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, pvName string) { - vcr.Status.DataRefs = upsertVolumeDataBinding(vcr.Status.DataRefs, storagev1alpha1.VolumeDataBinding{ + vcr.Status.DataRef = &storagev1alpha1.VolumeDataBinding{ TargetUID: target.UID, Target: target, Artifact: storagev1alpha1.VolumeDataArtifactRef{ @@ -48,12 +44,12 @@ func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, targe Kind: "PersistentVolume", Name: pvName, }, - }) + } } -func firstDataArtifactRef(status storagev1alpha1.VolumeCaptureRequestStatus) (storagev1alpha1.VolumeDataArtifactRef, bool) { - if len(status.DataRefs) == 0 { +func dataArtifactRef(status storagev1alpha1.VolumeCaptureRequestStatus) (storagev1alpha1.VolumeDataArtifactRef, bool) { + if status.DataRef == nil { return storagev1alpha1.VolumeDataArtifactRef{}, false } - return status.DataRefs[0].Artifact, true + return status.DataRef.Artifact, true } diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot_bulk.go b/images/controller/internal/controllers/volumecapturerequest_snapshot.go similarity index 89% rename from images/controller/internal/controllers/volumecapturerequest_snapshot_bulk.go rename to images/controller/internal/controllers/volumecapturerequest_snapshot.go index 41ef86c..82a5e69 100644 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot_bulk.go +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot.go @@ -68,31 +68,6 @@ func snapshotVSCName(vcrUID types.UID, targetUID string) string { return fmt.Sprintf("snapshot-%s-%s", string(vcrUID), targetUIDHash(targetUID)) } -func mergeVolumeDataBindings(existing, updates []storagev1alpha1.VolumeDataBinding) []storagev1alpha1.VolumeDataBinding { - out := append([]storagev1alpha1.VolumeDataBinding(nil), existing...) - for _, binding := range updates { - out = upsertVolumeDataBinding(out, binding) - } - return out -} - -func validateSnapshotTargets(targets []storagev1alpha1.VolumeCaptureTarget) error { - if len(targets) == 0 { - return fmt.Errorf("spec.targets must not be empty for Snapshot mode") - } - seen := make(map[string]struct{}, len(targets)) - for _, t := range targets { - if t.UID == "" { - return fmt.Errorf("target uid must not be empty") - } - if _, dup := seen[t.UID]; dup { - return fmt.Errorf("duplicate target uid %q", t.UID) - } - seen[t.UID] = struct{}{} - } - return nil -} - func volumeSnapshotBinding(target storagev1alpha1.VolumeCaptureTarget, vscName string) storagev1alpha1.VolumeDataBinding { return storagev1alpha1.VolumeDataBinding{ TargetUID: target.UID, @@ -105,29 +80,18 @@ func volumeSnapshotBinding(target storagev1alpha1.VolumeCaptureTarget, vscName s } } -func upsertVolumeDataBinding(bindings []storagev1alpha1.VolumeDataBinding, binding storagev1alpha1.VolumeDataBinding) []storagev1alpha1.VolumeDataBinding { - for i := range bindings { - if bindings[i].TargetUID == binding.TargetUID { - bindings[i] = binding - return bindings - } - } - return append(bindings, binding) -} - -func (r *VolumeCaptureRequestController) patchVCRSnapshotProgress( +// patchVCRSnapshotPending surfaces a non-terminal "capture in progress" Ready=False/TargetsPending +// condition while the single target is still being captured. It never sets dataRef (only success does). +func (r *VolumeCaptureRequestController) patchVCRSnapshotPending( ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest, - dataRefUpdates []storagev1alpha1.VolumeDataBinding, - readyCount, total int, ) error { - message := fmt.Sprintf("%d of %d targets ready", readyCount, total) now := metav1.Now() pendingCondition := metav1.Condition{ Type: storagev1alpha1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: storagev1alpha1.ConditionReasonTargetsPending, - Message: message, + Message: "target capture in progress", LastTransitionTime: now, } @@ -137,7 +101,6 @@ func (r *VolumeCaptureRequestController) patchVCRSnapshotProgress( return err } base := current.DeepCopy() - current.Status.DataRefs = mergeVolumeDataBindings(current.Status.DataRefs, dataRefUpdates) setSingleCondition(¤t.Status.Conditions, pendingCondition) current.Status.CompletionTimestamp = nil return r.Status().Patch(ctx, current, client.MergeFrom(base)) @@ -350,7 +313,7 @@ func (r *VolumeCaptureRequestController) markFailedSnapshotForTarget( ) (ctrl.Result, error) { if vscName != "" { binding := volumeSnapshotBinding(target, vscName) - vcr.Status.DataRefs = upsertVolumeDataBinding(vcr.Status.DataRefs, binding) + vcr.Status.DataRef = &binding } if err := r.finalizeVCR(ctx, vcr, metav1.ConditionFalse, reason, message); err != nil { return ctrl.Result{}, err diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot_bulk_test.go b/images/controller/internal/controllers/volumecapturerequest_snapshot_bulk_test.go deleted file mode 100644 index 39da343..0000000 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot_bulk_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package controllers - -import ( - "testing" - - storagev1alpha1 "github.com/deckhouse/storage-foundation/api/v1alpha1" - "k8s.io/apimachinery/pkg/types" -) - -func TestSnapshotVSCName(t *testing.T) { - vcrUID := types.UID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - targetUID := "11111111-2222-3333-4444-555555555555" - got := snapshotVSCName(vcrUID, targetUID) - want := "snapshot-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-" + targetUIDHash(targetUID) - if got != want { - t.Fatalf("snapshotVSCName() = %q, want %q", got, want) - } - if len(targetUIDHash(targetUID)) != snapshotTargetHashHexLen { - t.Fatalf("hash len = %d, want %d", len(targetUIDHash(targetUID)), snapshotTargetHashHexLen) - } -} - -func TestTargetUIDHashDeterministic(t *testing.T) { - uid := "11111111-2222-3333-4444-555555555555" - if targetUIDHash(uid) != targetUIDHash(uid) { - t.Fatal("hash must be deterministic") - } - other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - if targetUIDHash(uid) == targetUIDHash(other) { - t.Fatal("expected different hashes for different target UIDs") - } -} - -func TestMergeVolumeDataBindingsPreservesExisting(t *testing.T) { - existingA := volumeSnapshotBinding(storagev1alpha1.VolumeCaptureTarget{UID: "uid-a"}, "vsc-a") - existingB := volumeSnapshotBinding(storagev1alpha1.VolumeCaptureTarget{UID: "uid-b"}, "vsc-b") - merged := mergeVolumeDataBindings([]storagev1alpha1.VolumeDataBinding{existingA}, []storagev1alpha1.VolumeDataBinding{existingB}) - if len(merged) != 2 { - t.Fatalf("len = %d, want 2", len(merged)) - } - updatedA := volumeSnapshotBinding(storagev1alpha1.VolumeCaptureTarget{UID: "uid-a"}, "vsc-a-new") - merged = mergeVolumeDataBindings(merged, []storagev1alpha1.VolumeDataBinding{updatedA}) - if len(merged) != 2 { - t.Fatalf("len after update = %d, want 2", len(merged)) - } - if merged[0].Artifact.Name != "vsc-a-new" || merged[1].Artifact.Name != "vsc-b" { - t.Fatalf("merge lost binding: %#v", merged) - } -} - -func TestValidateSnapshotTargets(t *testing.T) { - if err := validateSnapshotTargets(nil); err == nil { - t.Fatal("expected error for empty targets") - } - targets := []storagev1alpha1.VolumeCaptureTarget{ - {UID: "a", APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "x", Namespace: "ns"}, - {UID: "a", APIVersion: "v1", Kind: "PersistentVolumeClaim", Name: "y", Namespace: "ns"}, - } - if err := validateSnapshotTargets(targets); err == nil { - t.Fatal("expected duplicate uid error") - } -} - -func TestUpsertVolumeDataBinding(t *testing.T) { - b1 := volumeSnapshotBinding(storagev1alpha1.VolumeCaptureTarget{UID: "a"}, "vsc-a") - b2 := volumeSnapshotBinding(storagev1alpha1.VolumeCaptureTarget{UID: "b"}, "vsc-b") - out := upsertVolumeDataBinding([]storagev1alpha1.VolumeDataBinding{b1}, b2) - if len(out) != 2 { - t.Fatalf("len = %d, want 2", len(out)) - } - b1.Artifact.Name = "vsc-a-updated" - out = upsertVolumeDataBinding(out, b1) - if len(out) != 2 || out[0].Artifact.Name != "vsc-a-updated" { - t.Fatalf("upsert failed: %#v", out) - } -} diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go b/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go new file mode 100644 index 0000000..7354771 --- /dev/null +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go @@ -0,0 +1,46 @@ +package controllers + +import ( + "testing" + + storagev1alpha1 "github.com/deckhouse/storage-foundation/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" +) + +func TestSnapshotVSCName(t *testing.T) { + vcrUID := types.UID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + targetUID := "11111111-2222-3333-4444-555555555555" + got := snapshotVSCName(vcrUID, targetUID) + want := "snapshot-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-" + targetUIDHash(targetUID) + if got != want { + t.Fatalf("snapshotVSCName() = %q, want %q", got, want) + } + if len(targetUIDHash(targetUID)) != snapshotTargetHashHexLen { + t.Fatalf("hash len = %d, want %d", len(targetUIDHash(targetUID)), snapshotTargetHashHexLen) + } +} + +func TestTargetUIDHashDeterministic(t *testing.T) { + uid := "11111111-2222-3333-4444-555555555555" + if targetUIDHash(uid) != targetUIDHash(uid) { + t.Fatal("hash must be deterministic") + } + other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + if targetUIDHash(uid) == targetUIDHash(other) { + t.Fatal("expected different hashes for different target UIDs") + } +} + +func TestVolumeSnapshotBindingSetsTargetUID(t *testing.T) { + target := storagev1alpha1.VolumeCaptureTarget{UID: "uid-a", Namespace: "ns", Name: "pvc-a"} + binding := volumeSnapshotBinding(target, "vsc-a") + if binding.TargetUID != "uid-a" { + t.Fatalf("TargetUID = %q, want %q", binding.TargetUID, "uid-a") + } + if binding.Target.Namespace != "ns" { + t.Fatalf("Target.Namespace = %q, want %q", binding.Target.Namespace, "ns") + } + if binding.Artifact.Name != "vsc-a" || binding.Artifact.Kind != "VolumeSnapshotContent" { + t.Fatalf("unexpected artifact: %#v", binding.Artifact) + } +} From 19462a4583245f95729e820da9948c2bdbdf8252 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Tue, 30 Jun 2026 02:26:51 +0300 Subject: [PATCH 02/11] refactor(api): replace VolumeRestoreRequest target fields with targetRef (PVC-only for now) Collapse spec.targetNamespace + spec.targetPVCName into a single spec.targetRef (ObjectReference). Restore is never cross-namespace, so producers do not set targetRef.namespace; the controller derives the target namespace from metadata.namespace. Symmetrically rename status.targetPVCRef -> status.targetRef and populate its namespace so the status stays self-contained. Only kind=PersistentVolumeClaim is supported for now (empty kind defaults to PVC); kind reserves space for future cluster-scoped targets (e.g. PersistentVolume). The controller rejects unsupported target kinds with a terminal Ready=False/UnsupportedTargetKind condition. Updates the in-repo VRR producer (data-export snapshot resolver), the restore controller, tests, and regenerates CRDs + deepcopy. The patched csi-external-provisioner (external-provisioner-fox) consumes these fields and is updated separately during re-vendoring. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/conditions.go | 2 + api/v1alpha1/volumerestorerequest_types.go | 16 ++- api/v1alpha1/zz_generated.deepcopy.go | 5 +- ...ge.deckhouse.io_volumerestorerequests.yaml | 46 +++++-- .../internal/controllers/constants.go | 15 +- .../volumerestorerequest_controller.go | 48 ++++--- .../volumerestorerequest_controller_test.go | 128 ++++++++++++++---- .../data-export/snapshot_resolver.go | 11 +- .../data-export/snapshot_resolver_test.go | 12 +- 9 files changed, 201 insertions(+), 82 deletions(-) diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index 6a5f51f..d5aa9f3 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -40,6 +40,8 @@ const ( ConditionReasonRBACDenied = "RBACDenied" // ConditionReasonInvalidSource indicates invalid source specified ConditionReasonInvalidSource = "InvalidSource" + // ConditionReasonUnsupportedTargetKind indicates the restore target kind is not supported (only PersistentVolumeClaim for now) + ConditionReasonUnsupportedTargetKind = "UnsupportedTargetKind" // ConditionReasonPVBound indicates PV is bound and cannot be detached ConditionReasonPVBound = "PVBound" // ConditionReasonSnapshotCreationFailed indicates CSI snapshot creation failed diff --git a/api/v1alpha1/volumerestorerequest_types.go b/api/v1alpha1/volumerestorerequest_types.go index fd29f6f..3366189 100644 --- a/api/v1alpha1/volumerestorerequest_types.go +++ b/api/v1alpha1/volumerestorerequest_types.go @@ -26,10 +26,13 @@ import ( type VolumeRestoreRequestSpec struct { // SourceRef references the source data to restore from (VolumeSnapshotContent or PersistentVolume) SourceRef ObjectReference `json:"sourceRef"` - // TargetNamespace is the namespace where the restored PVC will be created - TargetNamespace string `json:"targetNamespace"` - // TargetPVCName is the name of the PVC to create - TargetPVCName string `json:"targetPVCName"` + // TargetRef identifies the object to restore into. Only kind=PersistentVolumeClaim is supported for + // now (an empty kind is treated as PersistentVolumeClaim); kind reserves space for future cluster-scoped + // targets such as PersistentVolume. Namespace is intentionally not set in spec: restore is never + // cross-namespace, so the target always lives in the VRR namespace (the controller uses + // metadata.namespace). Name is required. + // +kubebuilder:validation:Required + TargetRef ObjectReference `json:"targetRef"` // StorageClassName is the storage class to use for the restored PVC // +optional StorageClassName string `json:"storageClassName,omitempty"` @@ -57,8 +60,9 @@ type VolumeRestoreRequestStatus struct { CompletionTimestamp *metav1.Time `json:"completionTimestamp,omitempty"` // Conditions represent the latest available observations of the resource's state Conditions []metav1.Condition `json:"conditions,omitempty"` - // TargetPVCRef references the created target PVC - TargetPVCRef *ObjectReference `json:"targetPVCRef,omitempty"` + // TargetRef references the created restore target (currently always a PersistentVolumeClaim). + // Unlike spec.targetRef, the namespace is populated here so the status is self-contained. + TargetRef *ObjectReference `json:"targetRef,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 35b5194..ce7dd61 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -588,6 +588,7 @@ func (in *VolumeRestoreRequestList) DeepCopyObject() runtime.Object { func (in *VolumeRestoreRequestSpec) DeepCopyInto(out *VolumeRestoreRequestSpec) { *out = *in out.SourceRef = in.SourceRef + out.TargetRef = in.TargetRef if in.AccessModes != nil { in, out := &in.AccessModes, &out.AccessModes *out = make([]corev1.PersistentVolumeAccessMode, len(*in)) @@ -619,8 +620,8 @@ func (in *VolumeRestoreRequestStatus) DeepCopyInto(out *VolumeRestoreRequestStat (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.TargetPVCRef != nil { - in, out := &in.TargetPVCRef, &out.TargetPVCRef + if in.TargetRef != nil { + in, out := &in.TargetRef, &out.TargetRef *out = new(ObjectReference) **out = **in } diff --git a/crds/internal/storage.deckhouse.io_volumerestorerequests.yaml b/crds/internal/storage.deckhouse.io_volumerestorerequests.yaml index 473f3bf..50e9df3 100644 --- a/crds/internal/storage.deckhouse.io_volumerestorerequests.yaml +++ b/crds/internal/storage.deckhouse.io_volumerestorerequests.yaml @@ -47,8 +47,8 @@ spec: accessModes: description: |- AccessModes is the list of access modes for the restored PVC - (optional, defaults to ReadWriteOnce). Enables parity with the PV-source - restore path (e.g. RWX). Invalid modes are rejected by admission via the Enum below. + (optional, defaults to ReadWriteOnce). Enables parity with the PV-source restore + path (e.g. RWX). Invalid modes are rejected by admission via the Enum below. items: enum: - ReadWriteOnce @@ -58,8 +58,8 @@ spec: type: string type: array fsType: - description: FsType is the filesystem type for Filesystem volumes (optional, - ignored for Block). + description: FsType is the filesystem type for Filesystem volumes + (optional, ignored for Block). type: string sourceRef: description: SourceRef references the source data to restore from @@ -83,13 +83,28 @@ spec: description: StorageClassName is the storage class to use for the restored PVC type: string - targetNamespace: - description: TargetNamespace is the namespace where the restored PVC - will be created - type: string - targetPVCName: - description: TargetPVCName is the name of the PVC to create - type: string + targetRef: + description: |- + TargetRef identifies the object to restore into. Only kind=PersistentVolumeClaim is supported for + now (an empty kind is treated as PersistentVolumeClaim); kind reserves space for future cluster-scoped + targets such as PersistentVolume. Namespace is intentionally not set in spec: restore is never + cross-namespace, so the target always lives in the VRR namespace (the controller uses + metadata.namespace). Name is required. + properties: + kind: + description: Kind is the kind of the referenced object (e.g., + "VolumeSnapshotContent", "PersistentVolume") + type: string + name: + description: Name is the name of the referenced object + type: string + namespace: + description: Namespace is the namespace of the referenced object + (optional) + type: string + required: + - name + type: object volumeMode: description: |- VolumeMode specifies whether the restored volume is Block or Filesystem. @@ -101,8 +116,7 @@ spec: type: string required: - sourceRef - - targetNamespace - - targetPVCName + - targetRef - volumeMode type: object status: @@ -172,8 +186,10 @@ spec: - type type: object type: array - targetPVCRef: - description: TargetPVCRef references the created target PVC + targetRef: + description: |- + TargetRef references the created restore target (currently always a PersistentVolumeClaim). + Unlike spec.targetRef, the namespace is populated here so the status is self-contained. properties: kind: description: Kind is the kind of the referenced object (e.g., diff --git a/images/controller/internal/controllers/constants.go b/images/controller/internal/controllers/constants.go index 0c3717d..7f10861 100644 --- a/images/controller/internal/controllers/constants.go +++ b/images/controller/internal/controllers/constants.go @@ -66,13 +66,14 @@ const ( // API constants const ( - APIGroupStorageDeckhouse = "storage.deckhouse.io/v1alpha1" - APIGroupSnapshotStorage = "snapshot.storage.k8s.io" - APIGroupDeckhouse = "deckhouse.io/v1alpha1" - KindObjectKeeper = "ObjectKeeper" - KindVolumeCaptureRequest = "VolumeCaptureRequest" - KindVolumeRestoreRequest = "VolumeRestoreRequest" - KindVolumeSnapshot = "VolumeSnapshot" + APIGroupStorageDeckhouse = "storage.deckhouse.io/v1alpha1" + APIGroupSnapshotStorage = "snapshot.storage.k8s.io" + APIGroupDeckhouse = "deckhouse.io/v1alpha1" + KindObjectKeeper = "ObjectKeeper" + KindVolumeCaptureRequest = "VolumeCaptureRequest" + KindVolumeRestoreRequest = "VolumeRestoreRequest" + KindVolumeSnapshot = "VolumeSnapshot" + KindPersistentVolumeClaim = "PersistentVolumeClaim" ) // Annotation key constants diff --git a/images/controller/internal/controllers/volumerestorerequest_controller.go b/images/controller/internal/controllers/volumerestorerequest_controller.go index 061c354..7e301d6 100644 --- a/images/controller/internal/controllers/volumerestorerequest_controller.go +++ b/images/controller/internal/controllers/volumerestorerequest_controller.go @@ -117,6 +117,14 @@ func (r *VolumeRestoreRequestController) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } + // Validate restore target kind. Only PersistentVolumeClaim is supported for now (an empty kind is + // treated as PersistentVolumeClaim); kind reserves space for future cluster-scoped targets such as + // PersistentVolume. Anything else is a terminal error. + if !isSupportedRestoreTargetKind(vrr.Spec.TargetRef.Kind) { + return r.markFailed(ctx, &vrr, storagev1alpha1.ConditionReasonUnsupportedTargetKind, + fmt.Sprintf("Unsupported target kind %q: only %s is supported", vrr.Spec.TargetRef.Kind, KindPersistentVolumeClaim)) + } + // Process based on source type switch vrr.Spec.SourceRef.Kind { case SourceKindVolumeSnapshotContent: @@ -128,6 +136,12 @@ func (r *VolumeRestoreRequestController) Reconcile(ctx context.Context, req ctrl } } +// isSupportedRestoreTargetKind reports whether the VRR target kind is currently supported. +// Only PersistentVolumeClaim is supported; an empty kind defaults to PersistentVolumeClaim. +func isSupportedRestoreTargetKind(kind string) bool { + return kind == "" || kind == KindPersistentVolumeClaim +} + // ensureObjectKeeper creates or gets ObjectKeeper for VRR. // ObjectKeeper follows VRR lifecycle and owns all created artifacts (PVC). // This ensures proper cleanup when VRR is deleted. @@ -280,11 +294,11 @@ func (r *VolumeRestoreRequestController) processVolumeSnapshotContentRestore( // VRR controller MUST NOT create PVC under any circumstances. // VRR controller only observes and finalizes status when PVC is Bound. targetPVC := &corev1.PersistentVolumeClaim{} - if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Spec.TargetNamespace, Name: vrr.Spec.TargetPVCName}, targetPVC); err != nil { + if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Namespace, Name: vrr.Spec.TargetRef.Name}, targetPVC); err != nil { if apierrors.IsNotFound(err) { // Target PVC doesn't exist yet - external-provisioner hasn't created it // Requeue to wait for external-provisioner - l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Spec.TargetNamespace, "name", vrr.Spec.TargetPVCName) + l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } return ctrl.Result{}, fmt.Errorf("failed to check target PVC: %w", err) @@ -293,20 +307,21 @@ func (r *VolumeRestoreRequestController) processVolumeSnapshotContentRestore( // 4. Check if PVC is Bound if targetPVC.Status.Phase != corev1.ClaimBound { // PVC exists but not Bound yet - external-provisioner is still working - l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Spec.TargetNamespace, "name", vrr.Spec.TargetPVCName, "phase", targetPVC.Status.Phase) + l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name, "phase", targetPVC.Status.Phase) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } // 5. Success: PVC is Bound - finalize VRR - vrr.Status.TargetPVCRef = &storagev1alpha1.ObjectReference{ - Name: vrr.Spec.TargetPVCName, - Namespace: vrr.Spec.TargetNamespace, + vrr.Status.TargetRef = &storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: vrr.Spec.TargetRef.Name, + Namespace: vrr.Namespace, } - if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName)); err != nil { + if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully", vrr.Namespace, vrr.Spec.TargetRef.Name)); err != nil { return ctrl.Result{}, err } - l.Info("VolumeRestoreRequest completed", "source", "VolumeSnapshotContent", "pvc", vrr.Spec.TargetPVCName) + l.Info("VolumeRestoreRequest completed", "source", "VolumeSnapshotContent", "pvc", vrr.Spec.TargetRef.Name) return ctrl.Result{}, nil } @@ -358,11 +373,11 @@ func (r *VolumeRestoreRequestController) processPersistentVolumeRestore( // VRR controller MUST NOT create PVC under any circumstances. // VRR controller only observes and finalizes status when PVC is Bound. targetPVC := &corev1.PersistentVolumeClaim{} - if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Spec.TargetNamespace, Name: vrr.Spec.TargetPVCName}, targetPVC); err != nil { + if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Namespace, Name: vrr.Spec.TargetRef.Name}, targetPVC); err != nil { if apierrors.IsNotFound(err) { // Target PVC doesn't exist yet - external-provisioner hasn't created it // Requeue to wait for external-provisioner - l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Spec.TargetNamespace, "name", vrr.Spec.TargetPVCName) + l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } return ctrl.Result{}, fmt.Errorf("failed to check target PVC: %w", err) @@ -371,20 +386,21 @@ func (r *VolumeRestoreRequestController) processPersistentVolumeRestore( // 4. Check if PVC is Bound if targetPVC.Status.Phase != corev1.ClaimBound { // PVC exists but not Bound yet - external-provisioner is still working - l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Spec.TargetNamespace, "name", vrr.Spec.TargetPVCName, "phase", targetPVC.Status.Phase) + l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name, "phase", targetPVC.Status.Phase) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } // 5. Success: PVC is Bound - finalize VRR - vrr.Status.TargetPVCRef = &storagev1alpha1.ObjectReference{ - Name: vrr.Spec.TargetPVCName, - Namespace: vrr.Spec.TargetNamespace, + vrr.Status.TargetRef = &storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: vrr.Spec.TargetRef.Name, + Namespace: vrr.Namespace, } - if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully from PV", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName)); err != nil { + if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully from PV", vrr.Namespace, vrr.Spec.TargetRef.Name)); err != nil { return ctrl.Result{}, err } - l.Info("VolumeRestoreRequest completed", "source", "PersistentVolume", "pvc", vrr.Spec.TargetPVCName) + l.Info("VolumeRestoreRequest completed", "source", "PersistentVolume", "pvc", vrr.Spec.TargetRef.Name) return ctrl.Result{}, nil } diff --git a/images/controller/internal/controllers/volumerestorerequest_controller_test.go b/images/controller/internal/controllers/volumerestorerequest_controller_test.go index e02b530..1e78705 100644 --- a/images/controller/internal/controllers/volumerestorerequest_controller_test.go +++ b/images/controller/internal/controllers/volumerestorerequest_controller_test.go @@ -239,8 +239,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -277,9 +279,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue)) Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonCompleted)) Expect(finalVRR.Status.CompletionTimestamp).ToNot(BeNil()) - Expect(finalVRR.Status.TargetPVCRef).ToNot(BeNil()) - Expect(finalVRR.Status.TargetPVCRef.Name).To(Equal("restored-pvc")) - Expect(finalVRR.Status.TargetPVCRef.Namespace).To(Equal("default")) + Expect(finalVRR.Status.TargetRef).ToNot(BeNil()) + Expect(finalVRR.Status.TargetRef.Kind).To(Equal(KindPersistentVolumeClaim)) + Expect(finalVRR.Status.TargetRef.Name).To(Equal("restored-pvc")) + Expect(finalVRR.Status.TargetRef.Namespace).To(Equal("default")) // Then: No VolumeSnapshot objects created verifyNoVolumeSnapshots() @@ -314,8 +317,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindPersistentVolume, Name: "test-pv", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-pv", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-pv", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -370,8 +375,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-poll", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-poll", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-poll", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -414,8 +421,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-pending", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-pending", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-pending", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -447,8 +456,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "non-existent-vsc", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-not-found", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-not-found", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -494,8 +505,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindPersistentVolume, Name: "non-existent-pv", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-pv-not-found", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-pv-not-found", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -544,8 +557,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-not-ready", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-not-ready", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-not-ready", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -592,8 +607,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-error", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-error", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-error", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -614,6 +631,47 @@ var _ = Describe("VolumeRestoreRequest", func() { Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonInternalError)) Expect(readyCondition.Message).To(ContainSubstring(errorMsg)) }) + + It("should mark VRR failed when target kind is unsupported", func() { + // Given: VRR targets an unsupported kind (only PersistentVolumeClaim is supported for now) + vrr := &storagev1alpha1.VolumeRestoreRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vrr-bad-target-kind", + Namespace: "default", + UID: types.UID("vrr-uid-bad-target-kind"), + }, + Spec: storagev1alpha1.VolumeRestoreRequestSpec{ + SourceRef: storagev1alpha1.ObjectReference{ + Kind: SourceKindVolumeSnapshotContent, + Name: "test-vsc-bad-target-kind", + }, + TargetRef: storagev1alpha1.ObjectReference{ + Kind: SourceKindPersistentVolume, // not a supported restore target yet + Name: "restored-pv-target", + }, + }, + } + Expect(client.Create(ctx, vrr)).To(Succeed()) + + // When: Reconcile called + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-vrr-bad-target-kind", Namespace: "default"}} + result, err := ctrl.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + + // Then: VRR marked as failed with UnsupportedTargetKind, before any ObjectKeeper is created + updatedVRR := &storagev1alpha1.VolumeRestoreRequest{} + Expect(client.Get(ctx, types.NamespacedName{Name: "test-vrr-bad-target-kind", Namespace: "default"}, updatedVRR)).To(Succeed()) + readyCondition := getCondition(updatedVRR.Status.Conditions, storagev1alpha1.ConditionTypeReady) + Expect(readyCondition).ToNot(BeNil()) + Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonUnsupportedTargetKind)) + + retainerName := NamePrefixRetainerPVC + string(vrr.UID) + objectKeeper := &deckhousev1alpha1.ObjectKeeper{} + err = client.Get(ctx, types.NamespacedName{Name: retainerName}, objectKeeper) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) }) Describe("ObjectKeeper Behavior", func() { @@ -633,8 +691,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-ok", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-ok", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-ok", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -683,8 +743,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-uid-mismatch", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-uid-mismatch", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-uid-mismatch", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -739,8 +801,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-no-uid", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-no-uid", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-no-uid", + }, }, } Expect(client.Create(ctx, vrr)).To(Succeed()) @@ -772,8 +836,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-terminal", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-terminal", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-terminal", + }, }, Status: storagev1alpha1.VolumeRestoreRequestStatus{ CompletionTimestamp: &completionTime, @@ -827,8 +893,10 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-spec-change", }, - TargetNamespace: "default", - TargetPVCName: "restored-pvc-spec-change", + TargetRef: storagev1alpha1.ObjectReference{ + Kind: KindPersistentVolumeClaim, + Name: "restored-pvc-spec-change", + }, }, Status: storagev1alpha1.VolumeRestoreRequestStatus{ CompletionTimestamp: &completionTime, @@ -847,7 +915,7 @@ var _ = Describe("VolumeRestoreRequest", func() { // When: Spec changed manually updatedVRR := &storagev1alpha1.VolumeRestoreRequest{} Expect(client.Get(ctx, types.NamespacedName{Name: "test-vrr-spec-change", Namespace: "default"}, updatedVRR)).To(Succeed()) - updatedVRR.Spec.TargetPVCName = "changed-pvc-name" + updatedVRR.Spec.TargetRef.Name = "changed-pvc-name" Expect(client.Update(ctx, updatedVRR)).To(Succeed()) // When: Reconcile called diff --git a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go index 08814a4..9e06df6 100644 --- a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go +++ b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go @@ -275,9 +275,14 @@ func (r *DataexportReconciler) ensureVolumeRestoreRequest(ctx context.Context, d "kind": art.ArtifactKind, "name": art.ArtifactName, }, - "targetNamespace": r.Config.ControllerNamespace, - "targetPVCName": generatedNames.ExportPVCName, - "volumeMode": art.VolumeMode, + // targetRef carries only kind+name: restore is never cross-namespace, so the foundation VRR + // controller derives the target namespace from metadata.namespace (set to ControllerNamespace + // below). Only kind=PersistentVolumeClaim is supported for now. + "targetRef": map[string]interface{}{ + "kind": "PersistentVolumeClaim", + "name": generatedNames.ExportPVCName, + }, + "volumeMode": art.VolumeMode, } if art.StorageClassName != "" { spec["storageClassName"] = art.StorageClassName diff --git a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go index a6c0c7c..20167ee 100644 --- a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go +++ b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go @@ -304,13 +304,19 @@ func TestEnsureVolumeRestoreRequest_CreatesAndIsIdempotent(t *testing.T) { sourceKind, _, _ := unstructured.NestedString(vrr.Object, "spec", "sourceRef", "kind") sourceName, _, _ := unstructured.NestedString(vrr.Object, "spec", "sourceRef", "name") - targetPVC, _, _ := unstructured.NestedString(vrr.Object, "spec", "targetPVCName") - targetNS, _, _ := unstructured.NestedString(vrr.Object, "spec", "targetNamespace") + targetKind, _, _ := unstructured.NestedString(vrr.Object, "spec", "targetRef", "kind") + targetPVC, _, _ := unstructured.NestedString(vrr.Object, "spec", "targetRef", "name") + targetNS, hasTargetNS, _ := unstructured.NestedString(vrr.Object, "spec", "targetRef", "namespace") volumeMode, _, _ := unstructured.NestedString(vrr.Object, "spec", "volumeMode") + metaNS := vrr.GetNamespace() assert.Equal(t, artifactKindVolumeSnapshotContent, sourceKind) assert.Equal(t, "vsc1", sourceName) + assert.Equal(t, "PersistentVolumeClaim", targetKind) assert.Equal(t, names.ExportPVCName, targetPVC) - assert.Equal(t, testControllerNamespace, targetNS) + // Restore is never cross-namespace: targetRef carries no namespace; the target lives in the VRR namespace. + assert.False(t, hasTargetNS, "spec.targetRef.namespace must not be set") + assert.Empty(t, targetNS) + assert.Equal(t, testControllerNamespace, metaNS) assert.Equal(t, "Block", volumeMode) // Second call must be a no-op (Get-before-Create), not an error. From 9436d21f9139b7f693e5677c5aa519cc4edf5478 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Tue, 30 Jun 2026 02:37:26 +0300 Subject: [PATCH 03/11] feat(api): add uid to VolumeDataArtifactRef Add an optional uid field to VolumeDataArtifactRef so the durable artifact reference (e.g. VolumeSnapshotContent / PersistentVolume) is self-contained, symmetric with target.uid. The field is omitempty (no MinLength) because the artifact may be referenced before its UID is known; producers fill it best-effort from the real object. The VCR controller now threads the VSC UID (and PV UID on the detach path) into status.dataRef.artifact.uid. Regenerates the CRD; deepcopy is unchanged (scalar field). Tests assert the uid propagates on the snapshot happy path, the snapshot terminal-failure path, and the detach path. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/volumecapturerequest_schema_test.go | 5 +++++ api/v1alpha1/volumecapturerequest_types.go | 5 +++++ .../storage.deckhouse.io_volumecapturerequests.yaml | 6 ++++++ .../controllers/volumecapturerequest_controller.go | 6 +++--- .../volumecapturerequest_controller_test.go | 12 ++++++++++++ .../volumecapturerequest_single_target.go | 8 +++++--- .../controllers/volumecapturerequest_snapshot.go | 12 ++++++++---- .../volumecapturerequest_snapshot_test.go | 5 ++++- 8 files changed, 48 insertions(+), 11 deletions(-) diff --git a/api/v1alpha1/volumecapturerequest_schema_test.go b/api/v1alpha1/volumecapturerequest_schema_test.go index d5d7276..86a9dd0 100644 --- a/api/v1alpha1/volumecapturerequest_schema_test.go +++ b/api/v1alpha1/volumecapturerequest_schema_test.go @@ -90,6 +90,7 @@ func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { APIVersion: "snapshot.storage.k8s.io/v1", Kind: "VolumeSnapshotContent", Name: "snapcontent-a", + UID: "vsc-uid-a", }, }, }, @@ -111,6 +112,10 @@ func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { if ref.TargetUID != "uid-a" || ref.Artifact.Name != "snapcontent-a" { t.Fatalf("dataRef mismatch: %#v", ref) } + // Artifact UID round-trips so the durable reference is self-contained, symmetric with target.uid. + if ref.Artifact.UID != "vsc-uid-a" { + t.Fatalf("status.dataRef.artifact.uid = %q, want %q", ref.Artifact.UID, "vsc-uid-a") + } // Namespace is preserved in status.dataRef.target so the binding is self-contained. if ref.Target.Namespace != "demo" { t.Fatalf("status.dataRef.target.namespace = %q, want %q", ref.Target.Namespace, "demo") diff --git a/api/v1alpha1/volumecapturerequest_types.go b/api/v1alpha1/volumecapturerequest_types.go index 68a33e2..85f0d03 100644 --- a/api/v1alpha1/volumecapturerequest_types.go +++ b/api/v1alpha1/volumecapturerequest_types.go @@ -63,6 +63,11 @@ type VolumeDataArtifactRef struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 Name string `json:"name"` + // UID is the durable data artifact UID (for example the VolumeSnapshotContent UID). It makes the + // artifact reference self-contained, symmetric with target.uid. Optional: the artifact may be + // referenced before its UID is known, so producers fill it best-effort. + // +optional + UID string `json:"uid,omitempty"` } // +k8s:deepcopy-gen=true diff --git a/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml b/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml index 04f7c06..a5c369e 100644 --- a/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml +++ b/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml @@ -166,6 +166,12 @@ spec: name: minLength: 1 type: string + uid: + description: |- + UID is the durable data artifact UID (for example the VolumeSnapshotContent UID). It makes the + artifact reference self-contained, symmetric with target.uid. Optional: the artifact may be + referenced before its UID is known, so producers fill it best-effort. + type: string required: - apiVersion - kind diff --git a/images/controller/internal/controllers/volumecapturerequest_controller.go b/images/controller/internal/controllers/volumecapturerequest_controller.go index 1341791..2c5ff0e 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller.go @@ -189,7 +189,7 @@ func (r *VolumeCaptureRequestController) processSnapshotMode(ctx context.Context if tr.terminal != nil { l.Error(nil, "Target capture failed", "targetUID", tr.terminal.target.UID, "reason", tr.terminal.reason) - return r.markFailedSnapshotForTarget(ctx, vcr, tr.terminal.target, tr.terminal.vscName, tr.terminal.reason, tr.terminal.message) + return r.markFailedSnapshotForTarget(ctx, vcr, tr.terminal.target, tr.terminal.vscName, tr.terminal.vscUID, tr.terminal.reason, tr.terminal.message) } if tr.ready && tr.binding != nil { @@ -323,7 +323,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, l.Info("PV already detached but not by VCR", "pv", pv.Name) } // Already detached, update status - setPersistentVolumeDataRef(vcr, target, pv.Name) + setPersistentVolumeDataRef(vcr, target, pv.Name, string(pv.UID)) if err := r.finalizeVCR(ctx, vcr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PV %s already detached", pv.Name)); err != nil { return ctrl.Result{}, err } @@ -421,7 +421,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, l.Info("Detached PV from PVC and set ownerRef", "pv", updatedPV.Name) // 10. Update VCR status - setPersistentVolumeDataRef(vcr, target, updatedPV.Name) + setPersistentVolumeDataRef(vcr, target, updatedPV.Name, string(updatedPV.UID)) if err := r.finalizeVCR(ctx, vcr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PV %s detached", updatedPV.Name)); err != nil { return ctrl.Result{}, err } diff --git a/images/controller/internal/controllers/volumecapturerequest_controller_test.go b/images/controller/internal/controllers/volumecapturerequest_controller_test.go index f187bb8..d959d55 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller_test.go @@ -371,6 +371,13 @@ 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 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 == "" { + vsc.UID = types.UID("vsc-uid-happy") + Expect(client.Update(ctx, vsc)).To(Succeed()) + Expect(client.Get(ctx, types.NamespacedName{Name: csiVSCName}, vsc)).To(Succeed()) + } vsc.Status = &snapshotv1.VolumeSnapshotContentStatus{ ReadyToUse: pointer.Bool(true), } @@ -448,6 +455,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("VolumeSnapshotContent")) Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal(csiVSCName)) + Expect(updatedVCR.Status.DataRef.Artifact.UID).To(Equal("vsc-uid-happy")) }) }) @@ -474,6 +482,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { csiVSCName := snapshotVSCName(vcr.UID, targetUID) errorMsg := "provided secret is empty" vsc := newReadyVSC(csiVSCName, false, nil) + vsc.UID = types.UID("vsc-uid-error") retainerName := objectKeeperNameForVCR(vcr.UID) objectKeeper := &deckhousev1alpha1.ObjectKeeper{ @@ -531,6 +540,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("VolumeSnapshotContent")) Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal(csiVSCName)) + Expect(updatedVCR.Status.DataRef.Artifact.UID).To(Equal("vsc-uid-error")) // VSC still exists (not deleted) existingVSC := &snapshotv1.VolumeSnapshotContent{} @@ -670,6 +680,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { It("should detach PV and set correct ownership", func() { // Given pv := newCSIPV("test-pv-detach", "test-driver", "test-volume-handle") + pv.UID = types.UID("pv-uid-detach") pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRetain pv.Spec.ClaimRef = &corev1.ObjectReference{ Namespace: "default", @@ -721,6 +732,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("PersistentVolume")) Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal("test-pv-detach")) + Expect(updatedVCR.Status.DataRef.Artifact.UID).To(Equal("pv-uid-detach")) readyCondition := getCondition(updatedVCR.Status.Conditions, storagev1alpha1.ConditionTypeReady) Expect(readyCondition).ToNot(BeNil()) diff --git a/images/controller/internal/controllers/volumecapturerequest_single_target.go b/images/controller/internal/controllers/volumecapturerequest_single_target.go index a9f40ef..ca7fc58 100644 --- a/images/controller/internal/controllers/volumecapturerequest_single_target.go +++ b/images/controller/internal/controllers/volumecapturerequest_single_target.go @@ -30,12 +30,12 @@ func requireCaptureTarget(spec storagev1alpha1.VolumeCaptureRequestSpec) (storag return *spec.Target, nil } -func setVolumeSnapshotDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, vscName string) { - binding := volumeSnapshotBinding(target, vscName) +func setVolumeSnapshotDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, vscName, vscUID string) { + binding := volumeSnapshotBinding(target, vscName, vscUID) vcr.Status.DataRef = &binding } -func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, pvName string) { +func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, pvName, pvUID string) { vcr.Status.DataRef = &storagev1alpha1.VolumeDataBinding{ TargetUID: target.UID, Target: target, @@ -43,6 +43,8 @@ func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, targe APIVersion: "v1", Kind: "PersistentVolume", Name: pvName, + // UID is best-effort: empty when the PV object is not available. + UID: pvUID, }, } } diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot.go b/images/controller/internal/controllers/volumecapturerequest_snapshot.go index 82a5e69..829e34a 100644 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot.go +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot.go @@ -43,6 +43,7 @@ type snapshotTargetError struct { reason string message string vscName string + vscUID string } type snapshotTargetResult struct { @@ -68,7 +69,7 @@ func snapshotVSCName(vcrUID types.UID, targetUID string) string { return fmt.Sprintf("snapshot-%s-%s", string(vcrUID), targetUIDHash(targetUID)) } -func volumeSnapshotBinding(target storagev1alpha1.VolumeCaptureTarget, vscName string) storagev1alpha1.VolumeDataBinding { +func volumeSnapshotBinding(target storagev1alpha1.VolumeCaptureTarget, vscName, vscUID string) storagev1alpha1.VolumeDataBinding { return storagev1alpha1.VolumeDataBinding{ TargetUID: target.UID, Target: target, @@ -76,6 +77,8 @@ func volumeSnapshotBinding(target storagev1alpha1.VolumeCaptureTarget, vscName s APIVersion: "snapshot.storage.k8s.io/v1", Kind: "VolumeSnapshotContent", Name: vscName, + // UID is best-effort: empty when the artifact is referenced before its object is known. + UID: vscUID, }, } } @@ -290,6 +293,7 @@ func (r *VolumeCaptureRequestController) processSnapshotTarget( target: target, reason: storagev1alpha1.ConditionReasonSnapshotCreationFailed, message: fmt.Sprintf("CSI snapshot creation failed: %s", errorDetails), vscName: csiVSCName, + vscUID: string(csiVSC.UID), }}, ctrl.Result{}, nil } @@ -298,7 +302,7 @@ func (r *VolumeCaptureRequestController) processSnapshotTarget( return snapshotTargetResult{pending: true}, ctrl.Result{RequeueAfter: 5 * time.Second}, nil } - binding := volumeSnapshotBinding(target, csiVSCName) + binding := volumeSnapshotBinding(target, csiVSCName, string(csiVSC.UID)) return snapshotTargetResult{ ready: true, binding: &binding, @@ -309,10 +313,10 @@ func (r *VolumeCaptureRequestController) markFailedSnapshotForTarget( ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, - vscName, reason, message string, + vscName, vscUID, reason, message string, ) (ctrl.Result, error) { if vscName != "" { - binding := volumeSnapshotBinding(target, vscName) + binding := volumeSnapshotBinding(target, vscName, vscUID) vcr.Status.DataRef = &binding } if err := r.finalizeVCR(ctx, vcr, metav1.ConditionFalse, reason, message); err != nil { diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go b/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go index 7354771..dacffc8 100644 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go @@ -33,7 +33,7 @@ func TestTargetUIDHashDeterministic(t *testing.T) { func TestVolumeSnapshotBindingSetsTargetUID(t *testing.T) { target := storagev1alpha1.VolumeCaptureTarget{UID: "uid-a", Namespace: "ns", Name: "pvc-a"} - binding := volumeSnapshotBinding(target, "vsc-a") + binding := volumeSnapshotBinding(target, "vsc-a", "vsc-uid-a") if binding.TargetUID != "uid-a" { t.Fatalf("TargetUID = %q, want %q", binding.TargetUID, "uid-a") } @@ -43,4 +43,7 @@ func TestVolumeSnapshotBindingSetsTargetUID(t *testing.T) { if binding.Artifact.Name != "vsc-a" || binding.Artifact.Kind != "VolumeSnapshotContent" { t.Fatalf("unexpected artifact: %#v", binding.Artifact) } + if binding.Artifact.UID != "vsc-uid-a" { + t.Fatalf("Artifact.UID = %q, want %q", binding.Artifact.UID, "vsc-uid-a") + } } From c995d4de4637049842b2c003611fed654b49b1d8 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Tue, 30 Jun 2026 03:33:24 +0300 Subject: [PATCH 04/11] refactor(api): rename API group storage.deckhouse.io -> storage-foundation.deckhouse.io Move all storage-foundation CRD types (VolumeCaptureRequest, VolumeRestoreRequest, DataExport, DataImport) from the shared storage.deckhouse.io group to the per-module storage-foundation.deckhouse.io group, and regenerate CRDs. - api: register.go APIGroup, doc.go +groupName, GVK/GVR Group fields. - crds: git mv internal VCR/VRR CRD files to _.yaml and regenerate (controller-gen); hand-curated DataExport/DataImport CRDs updated in place (metadata.name/spec.group); generate_code.sh rm-f paths follow the new names. - RBAC/hooks/templates/webhooks/CSI patches/docs/tests: group string updated. - Foundation-private annotation/label/finalizer key domains migrated to storage-foundation.deckhouse.io/. Cross-module boundaries preserved: - SnapshotContent (owned by state-snapshotter) keeps state-snapshotter.deckhouse.io in the data-export resolver GVR, its RBAC, and CRD docs. - The StorageClass annotation storage.deckhouse.io/volumesnapshotclass (written by sds-local-volume) stays on storage.deckhouse.io as an external contract. - snapshot.storage.k8s.io (CSI) and virtualization.deckhouse.io untouched. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/data_consts.go | 18 +++++++-------- api/v1alpha1/doc.go | 4 ++-- api/v1alpha1/register.go | 2 +- .../volumecapturerequest_schema_test.go | 2 +- common/config/config.go | 2 +- crds/dataexports.yaml | 4 ++-- crds/dataimports.yaml | 4 ++-- ...apshot.storage.k8s.io_volumesnapshots.yaml | 4 ++-- ...n.deckhouse.io_volumecapturerequests.yaml} | 4 ++-- ...n.deckhouse.io_volumerestorerequests.yaml} | 4 ++-- ...apshot.storage.k8s.io_volumesnapshots.yaml | 4 ++-- docs/USAGE.md | 4 ++-- docs/USAGE.ru.md | 4 ++-- docs/pr-f-bulk-volumecapturerequest.md | 4 ++-- hack/generate_code.sh | 8 +++---- hack/vrr-rbac-manual.yaml | 2 +- .../vrr-provisioner-rbac.go | 2 +- .../vrr-provisioner-rbac_test.go | 2 +- hooks/go/consts/consts.go | 4 ++-- images/controller/docs/TTL_MECHANISM.md | 4 ++-- .../internal/controllers/constants.go | 16 +++++++------- .../volumecapturerequest_controller.go | 22 +++++++++---------- .../volumecapturerequest_controller_test.go | 10 ++++----- .../volumerestorerequest_controller.go | 8 +++---- images/controller/pkg/internal/const.go | 2 +- .../controller/pkg/snapshotmeta/constants.go | 8 +++---- .../patches/v6.2.0/002-vrr-executor.patch | 8 +++---- .../patches/v6.2.0/README.md | 2 +- .../data-exporter/internal/repository/k8s.go | 2 +- .../data-export/data_export_resource.go | 6 ++--- .../data-export/snapshot_resolver.go | 6 +++-- .../data-export/snapshot_resolver_test.go | 2 +- .../controllers/data-import/volume_capture.go | 2 +- images/populator/cmd/main.go | 4 ++-- .../003-volumesnapshot-dataimport-fork.patch | 12 +++++----- images/webhooks/handlers/deValidator_test.go | 6 ++--- .../handlers/volumeSnapshotMutator.go | 2 +- templates/controller/rbac-for-us.yaml | 8 +++---- templates/data-exporter/rbac-for-us.yaml | 2 +- .../data-manager-controller/rbac-for-us.yaml | 7 +++--- templates/populator/volume-populator.yaml | 4 ++-- templates/rbac-for-us.yaml | 2 +- templates/rbacv2/use/edit.yaml | 2 +- templates/rbacv2/use/view.yaml | 2 +- templates/user-authz-cluster-roles.yaml | 4 ++-- templates/webhooks/dataexport-validation.yaml | 4 ++-- templates/webhooks/webhook.yaml | 2 +- 47 files changed, 122 insertions(+), 119 deletions(-) rename crds/internal/{storage.deckhouse.io_volumecapturerequests.yaml => storage-foundation.deckhouse.io_volumecapturerequests.yaml} (98%) rename crds/internal/{storage.deckhouse.io_volumerestorerequests.yaml => storage-foundation.deckhouse.io_volumerestorerequests.yaml} (98%) diff --git a/api/v1alpha1/data_consts.go b/api/v1alpha1/data_consts.go index b1dc82d..12896b7 100644 --- a/api/v1alpha1/data_consts.go +++ b/api/v1alpha1/data_consts.go @@ -25,10 +25,10 @@ const ( LabelDataImportValue = "data-importer" - AnnotationStorageManagerNamespaceKey = "storage.deckhouse.io/storage-manager-namespace" - AnnotationStorageManagerNameKey = "storage.deckhouse.io/storage-manager-name" - LabelStorageManagerDeploymentNameKey = "storage.deckhouse.io/storage-manager-deployment-name" - StorageManagerFinalizerName = "storage.deckhouse.io/storage-manager-controller" + AnnotationStorageManagerNamespaceKey = "storage-foundation.deckhouse.io/storage-manager-namespace" + AnnotationStorageManagerNameKey = "storage-foundation.deckhouse.io/storage-manager-name" + LabelStorageManagerDeploymentNameKey = "storage-foundation.deckhouse.io/storage-manager-deployment-name" + StorageManagerFinalizerName = "storage-foundation.deckhouse.io/storage-manager-controller" AuthTypeBearer = "Bearer" AuthTypeBasic = "Basic" @@ -49,22 +49,22 @@ const ( // AnnotationUserPVCNamespaceKey is a PV annotation that stores the original user PVC namespace. // Used to restore the PV binding back to user PVC after export cleanup. - AnnotationUserPVCNamespaceKey = "storage.deckhouse.io/original-pvc-namespace" + AnnotationUserPVCNamespaceKey = "storage-foundation.deckhouse.io/original-pvc-namespace" // AnnotationUserPVCNameKey is a PV annotation that stores the original user PVC name. // Used together with AnnotationUserPVCNamespaceKey to restore PV binding after export. - AnnotationUserPVCNameKey = "storage.deckhouse.io/original-pvc-name" + AnnotationUserPVCNameKey = "storage-foundation.deckhouse.io/original-pvc-name" // AnnotationPVTargetKindShortKey is a PV annotation that stores the target kind short name (pvc/vd/vs/vdsnapshot). // Used to reconstruct deployment and exportPVC names during orphan cleanup when DataExport is already deleted. - AnnotationPVTargetKindShortKey = "storage.deckhouse.io/target-ref" + AnnotationPVTargetKindShortKey = "storage-foundation.deckhouse.io/target-ref" // AnnotationPVHashSuffixKey is a PV annotation that stores the hash suffix from generated names. // Used together with AnnotationPVTargetKindShortKey to reconstruct resource names during orphan cleanup. - AnnotationPVHashSuffixKey = "storage.deckhouse.io/generated-name-suffix" + AnnotationPVHashSuffixKey = "storage-foundation.deckhouse.io/generated-name-suffix" // AnnotationOriginalReclaimPolicyKey stores the PV's original reclaim policy before // we change it to Retain during export. This protects data if exportPVC is deleted // accidentally. The original policy is restored during cleanup. - AnnotationOriginalReclaimPolicyKey = "storage.deckhouse.io/original-reclaim-policy" + AnnotationOriginalReclaimPolicyKey = "storage-foundation.deckhouse.io/original-reclaim-policy" // LabelPVDataExporter is added to PVs during export to enable efficient // discovery of orphaned PVs via MatchingLabels in removeOrphanResources. diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go index 1d11ccb..1dbdbf5 100644 --- a/api/v1alpha1/doc.go +++ b/api/v1alpha1/doc.go @@ -14,6 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package v1alpha1 contains API Schema definitions for the storage.deckhouse.io v1alpha1 API group. -// +groupName=storage.deckhouse.io +// Package v1alpha1 contains API Schema definitions for the storage-foundation.deckhouse.io v1alpha1 API group. +// +groupName=storage-foundation.deckhouse.io package v1alpha1 diff --git a/api/v1alpha1/register.go b/api/v1alpha1/register.go index 8eb8702..57f847e 100644 --- a/api/v1alpha1/register.go +++ b/api/v1alpha1/register.go @@ -23,7 +23,7 @@ import ( ) const ( - APIGroup = "storage.deckhouse.io" + APIGroup = "storage-foundation.deckhouse.io" APIVersion = "v1alpha1" ) diff --git a/api/v1alpha1/volumecapturerequest_schema_test.go b/api/v1alpha1/volumecapturerequest_schema_test.go index 86a9dd0..91c38e2 100644 --- a/api/v1alpha1/volumecapturerequest_schema_test.go +++ b/api/v1alpha1/volumecapturerequest_schema_test.go @@ -138,7 +138,7 @@ func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { } func TestVolumeCaptureRequestCRD_SingleTargetSchema(t *testing.T) { - crdPath := filepath.Join("..", "..", "crds", "internal", "storage.deckhouse.io_volumecapturerequests.yaml") + crdPath := filepath.Join("..", "..", "crds", "internal", "storage-foundation.deckhouse.io_volumecapturerequests.yaml") data, err := os.ReadFile(crdPath) if err != nil { t.Fatalf("read CRD: %v", err) diff --git a/common/config/config.go b/common/config/config.go index 0500fbc..09d2047 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -34,7 +34,7 @@ const ( DefaultHealthProbeBindAddress = ":8081" DefaultRequeueStorageClassInterval = 10 HAModeEnvName = "HA_MODE" - LeaderElectionID = "storage-foundation.storage.deckhouse.io" + LeaderElectionID = "storage-foundation.deckhouse.io" OriginIngressNamespaceEnv = "ORIGIN_INGRESS_NAMESPACE" ) diff --git a/crds/dataexports.yaml b/crds/dataexports.yaml index fcfb71b..9b97246 100644 --- a/crds/dataexports.yaml +++ b/crds/dataexports.yaml @@ -2,13 +2,13 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: - name: dataexports.storage.deckhouse.io + name: dataexports.storage-foundation.deckhouse.io labels: heritage: deckhouse module: storage-foundation backup.deckhouse.io/cluster-config: "true" spec: - group: storage.deckhouse.io + group: storage-foundation.deckhouse.io scope: Namespaced names: kind: DataExport diff --git a/crds/dataimports.yaml b/crds/dataimports.yaml index 0f24754..cd85ce4 100644 --- a/crds/dataimports.yaml +++ b/crds/dataimports.yaml @@ -1,13 +1,13 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: - name: dataimports.storage.deckhouse.io + name: dataimports.storage-foundation.deckhouse.io labels: heritage: deckhouse module: storage-foundation backup.deckhouse.io/cluster-config: "true" spec: - group: storage.deckhouse.io + group: storage-foundation.deckhouse.io scope: Namespaced names: kind: DataImport diff --git a/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml b/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml index ceca4bb..bac4d8d 100644 --- a/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml +++ b/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml @@ -87,7 +87,7 @@ spec: properties: boundSnapshotContentName: description: |- - Имя кластерного объекта SnapshotContent (storage.deckhouse.io) state-snapshotter, + Имя кластерного объекта SnapshotContent (state-snapshotter.deckhouse.io) state-snapshotter, который обеспечивает этот VolumeSnapshot как логический узел в дереве снимка. Записывается общим контроллером state-snapshotter в дополнение к boundVolumeSnapshotContentName. @@ -262,7 +262,7 @@ spec: properties: boundSnapshotContentName: description: |- - Имя кластерного объекта SnapshotContent (storage.deckhouse.io) state-snapshotter, + Имя кластерного объекта SnapshotContent (state-snapshotter.deckhouse.io) state-snapshotter, который обеспечивает этот VolumeSnapshot как логический узел в дереве снимка. Записывается общим контроллером state-snapshotter в дополнение к boundVolumeSnapshotContentName. diff --git a/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml b/crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml similarity index 98% rename from crds/internal/storage.deckhouse.io_volumecapturerequests.yaml rename to crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml index a5c369e..9e7de97 100644 --- a/crds/internal/storage.deckhouse.io_volumecapturerequests.yaml +++ b/crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.18.0 - name: volumecapturerequests.storage.deckhouse.io + name: volumecapturerequests.storage-foundation.deckhouse.io spec: - group: storage.deckhouse.io + group: storage-foundation.deckhouse.io names: kind: VolumeCaptureRequest listKind: VolumeCaptureRequestList diff --git a/crds/internal/storage.deckhouse.io_volumerestorerequests.yaml b/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml similarity index 98% rename from crds/internal/storage.deckhouse.io_volumerestorerequests.yaml rename to crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml index 50e9df3..34b5ead 100644 --- a/crds/internal/storage.deckhouse.io_volumerestorerequests.yaml +++ b/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.18.0 - name: volumerestorerequests.storage.deckhouse.io + name: volumerestorerequests.storage-foundation.deckhouse.io spec: - group: storage.deckhouse.io + group: storage-foundation.deckhouse.io names: kind: VolumeRestoreRequest listKind: VolumeRestoreRequestList diff --git a/crds/snapshot.storage.k8s.io_volumesnapshots.yaml b/crds/snapshot.storage.k8s.io_volumesnapshots.yaml index c366697..67640eb 100644 --- a/crds/snapshot.storage.k8s.io_volumesnapshots.yaml +++ b/crds/snapshot.storage.k8s.io_volumesnapshots.yaml @@ -166,7 +166,7 @@ spec: properties: boundSnapshotContentName: description: |- - Name of the cluster-scoped state-snapshotter SnapshotContent (storage.deckhouse.io) + Name of the cluster-scoped state-snapshotter SnapshotContent (state-snapshotter.deckhouse.io) that backs this VolumeSnapshot as a logical node in a snapshot tree. Written by the state-snapshotter common controller in addition to boundVolumeSnapshotContentName. Deckhouse extension. @@ -390,7 +390,7 @@ spec: properties: boundSnapshotContentName: description: |- - Name of the cluster-scoped state-snapshotter SnapshotContent (storage.deckhouse.io) + Name of the cluster-scoped state-snapshotter SnapshotContent (state-snapshotter.deckhouse.io) that backs this VolumeSnapshot as a logical node in a snapshot tree. Written by the state-snapshotter common controller in addition to boundVolumeSnapshotContentName. Deckhouse extension. diff --git a/docs/USAGE.md b/docs/USAGE.md index f235688..d6e3fd6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -147,7 +147,7 @@ export TARGET_NAME="fs-pvc-data-exporter-fs-0" ```bash d8 k apply -f -< 0` | ✅ `+kubebuilder:validation:XValidation` → CRD `x-kubernetes-validations` | | CRD drift: `spec.volumeSnapshotClassName` | ✅ removed from generated CRD (not in Go types; controller uses SC annotation) | -| Codegen | ✅ `zz_generated.deepcopy.go`, `crds/storage.deckhouse.io_volumecapturerequests.yaml` | +| Codegen | ✅ `zz_generated.deepcopy.go`, `crds/storage-foundation.deckhouse.io_volumecapturerequests.yaml` | | API tests | ✅ `volumecapturerequest_prf1_test.go` (JSON round-trip, map-list CRD schema, no singular fields) | | Controller behavior | **unchanged semantics** — `singleVolumeCaptureTarget` shim (exactly one target); compile fixes + `dataRefs[]` status shape only | | Duplicate `targets[].uid` at apiserver | **TODO PR-F-2** — map-list keys; no envtest in `api/` module | @@ -202,7 +202,7 @@ After **PR-F** is deployed: ### 2. CRD / codegen -- Regenerate `crds/storage.deckhouse.io_volumecapturerequests.yaml` + `zz_generated.deepcopy.go`. +- Regenerate `crds/storage-foundation.deckhouse.io_volumecapturerequests.yaml` + `zz_generated.deepcopy.go`. - OpenAPI: required `targets` for Snapshot when product requires ≥1 PVC; reject duplicate map keys; `artifact` minLength on apiVersion/kind/name. - **Breaking CRD change** — bump docs / release note. diff --git a/hack/generate_code.sh b/hack/generate_code.sh index bb8f114..0e949e9 100755 --- a/hack/generate_code.sh +++ b/hack/generate_code.sh @@ -27,18 +27,18 @@ if ! command -v "${CONTROLLER_GEN}" &>/dev/null; then export PATH="$(go env GOPATH)/bin:$PATH" fi -echo "Generating deepcopy code for storage.deckhouse.io/v1alpha1 ..." +echo "Generating deepcopy code for storage-foundation.deckhouse.io/v1alpha1 ..." ${CONTROLLER_GEN} object:headerFile="${HEADER}" paths=./api/v1alpha1 -echo "Generating CRDs for storage.deckhouse.io/v1alpha1 ..." +echo "Generating CRDs for storage-foundation.deckhouse.io/v1alpha1 ..." ${CONTROLLER_GEN} crd:crdVersions=v1 output:crd:dir=./crds/internal paths=./api/v1alpha1 # DataExport/DataImport CRDs are hand-curated in ./crds (they carry a non-standard `download` # subresource and CEL immutability rules that controller-gen markers cannot express). Drop the # auto-generated duplicates so the bundle does not ship two CRDs with the same metadata.name. -rm -f ./crds/internal/storage.deckhouse.io_dataexports.yaml \ - ./crds/internal/storage.deckhouse.io_dataimports.yaml +rm -f ./crds/internal/storage-foundation.deckhouse.io_dataexports.yaml \ + ./crds/internal/storage-foundation.deckhouse.io_dataimports.yaml echo "Done." diff --git a/hack/vrr-rbac-manual.yaml b/hack/vrr-rbac-manual.yaml index 2d5928d..8b9e1a6 100644 --- a/hack/vrr-rbac-manual.yaml +++ b/hack/vrr-rbac-manual.yaml @@ -33,7 +33,7 @@ metadata: rules: # VolumeRestoreRequest - the executor watches VRRs. NO /status: the executor # never writes VRR status (owned by the storage-foundation VRR controller). - - apiGroups: ["storage.deckhouse.io"] + - apiGroups: ["storage-foundation.deckhouse.io"] resources: ["volumerestorerequests"] verbs: ["get", "list", "watch"] diff --git a/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac.go b/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac.go index 69962da..ceb3b12 100644 --- a/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac.go +++ b/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac.go @@ -182,7 +182,7 @@ func desiredClusterRole() *rbacv1.ClusterRole { }, Rules: []rbacv1.PolicyRule{ { - APIGroups: []string{"storage.deckhouse.io"}, + APIGroups: []string{"storage-foundation.deckhouse.io"}, Resources: []string{"volumerestorerequests"}, Verbs: []string{"get", "list", "watch"}, }, diff --git a/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac_test.go b/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac_test.go index 0fe4e90..8bd85eb 100644 --- a/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac_test.go +++ b/hooks/go/040-vrr-provisioner-rbac/vrr-provisioner-rbac_test.go @@ -49,7 +49,7 @@ func TestDesiredClusterRole(t *testing.T) { } vrrRule := cr.Rules[0] - if !reflect.DeepEqual(vrrRule.APIGroups, []string{"storage.deckhouse.io"}) { + if !reflect.DeepEqual(vrrRule.APIGroups, []string{"storage-foundation.deckhouse.io"}) { t.Errorf("vrr apiGroups = %v", vrrRule.APIGroups) } if !reflect.DeepEqual(vrrRule.Resources, []string{"volumerestorerequests"}) { diff --git a/hooks/go/consts/consts.go b/hooks/go/consts/consts.go index 9eb0a91..7ecdbcf 100644 --- a/hooks/go/consts/consts.go +++ b/hooks/go/consts/consts.go @@ -53,8 +53,8 @@ var CRGVKsForFinalizerRemoval = []CRGVK{ {Group: "snapshot.storage.k8s.io", Version: "v1", Kind: "VolumeSnapshot", Namespaced: true}, {Group: "snapshot.storage.k8s.io", Version: "v1", Kind: "VolumeSnapshotContent", Namespaced: false}, {Group: "snapshot.storage.k8s.io", Version: "v1", Kind: "VolumeSnapshotClass", Namespaced: false}, - {Group: "storage.deckhouse.io", Version: "v1alpha1", Kind: "DataExport", Namespaced: true}, - {Group: "storage.deckhouse.io", Version: "v1alpha1", Kind: "DataImport", Namespaced: true}, + {Group: "storage-foundation.deckhouse.io", Version: "v1alpha1", Kind: "DataExport", Namespaced: true}, + {Group: "storage-foundation.deckhouse.io", Version: "v1alpha1", Kind: "DataImport", Namespaced: true}, } type CRGVK struct { diff --git a/images/controller/docs/TTL_MECHANISM.md b/images/controller/docs/TTL_MECHANISM.md index b5c3856..2f34daf 100644 --- a/images/controller/docs/TTL_MECHANISM.md +++ b/images/controller/docs/TTL_MECHANISM.md @@ -13,7 +13,7 @@ TTL is stored as an annotation on the object: ```yaml metadata: annotations: - storage.deckhouse.io/ttl: "10m" + storage-foundation.deckhouse.io/ttl: "10m" ``` ### TTL Value Source @@ -134,7 +134,7 @@ After VCR/VRR deletion, IRetainer continues to live and guard artifacts until it ### TTL Annotation Key ```go -const AnnotationKeyTTL = "storage.deckhouse.io/ttl" +const AnnotationKeyTTL = "storage-foundation.deckhouse.io/ttl" ``` ### Configuration diff --git a/images/controller/internal/controllers/constants.go b/images/controller/internal/controllers/constants.go index 7f10861..0074cda 100644 --- a/images/controller/internal/controllers/constants.go +++ b/images/controller/internal/controllers/constants.go @@ -38,11 +38,11 @@ const ( LabelKeyVCRUID = "vcr-uid" LabelKeyVCRNamespace = "vcr-namespace" LabelKeyVCRName = "vcr-name" - LabelKeyCreatedBy = "storage.deckhouse.io/created-by" - LabelKeyVCRNameFull = "storage.deckhouse.io/vcr-name" - LabelKeyVCRUIDFull = "storage.deckhouse.io/vcr-uid" - LabelKeySourcePVCName = "storage.deckhouse.io/source-pvc-name" - LabelKeySourcePVCNamespace = "storage.deckhouse.io/source-pvc-namespace" + LabelKeyCreatedBy = "storage-foundation.deckhouse.io/created-by" + LabelKeyVCRNameFull = "storage-foundation.deckhouse.io/vcr-name" + LabelKeyVCRUIDFull = "storage-foundation.deckhouse.io/vcr-uid" + LabelKeySourcePVCName = "storage-foundation.deckhouse.io/source-pvc-name" + LabelKeySourcePVCNamespace = "storage-foundation.deckhouse.io/source-pvc-namespace" LabelKeyCSIVSCName = "csi-vsc-name" ) @@ -66,7 +66,7 @@ const ( // API constants const ( - APIGroupStorageDeckhouse = "storage.deckhouse.io/v1alpha1" + APIGroupStorageDeckhouse = "storage-foundation.deckhouse.io/v1alpha1" APIGroupSnapshotStorage = "snapshot.storage.k8s.io" APIGroupDeckhouse = "deckhouse.io/v1alpha1" KindObjectKeeper = "ObjectKeeper" @@ -78,8 +78,8 @@ const ( // Annotation key constants const ( - AnnotationKeyCSIVSName = "storage.deckhouse.io/csi-vs-name" - AnnotationKeyTTL = "storage.deckhouse.io/ttl" // TTL annotation for automatic deletion + AnnotationKeyCSIVSName = "storage-foundation.deckhouse.io/csi-vs-name" + AnnotationKeyTTL = "storage-foundation.deckhouse.io/ttl" // TTL annotation for automatic deletion // AnnotationKeyReadyToStartSnapshot is set on VCR to signal that snapshot-controller should create VSC // This annotation contains VCR UID and is used by snapshot-controller to identify VCR requests AnnotationKeyReadyToStartSnapshot = "volumesnapshot.deckhouse.io/vcr" diff --git a/images/controller/internal/controllers/volumecapturerequest_controller.go b/images/controller/internal/controllers/volumecapturerequest_controller.go index 2c5ff0e..79544e4 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller.go @@ -213,7 +213,7 @@ func (r *VolumeCaptureRequestController) processSnapshotMode(ctx context.Context } // processDetachMode handles Detach mode: detaches PV from PVC -// According to ADR, PV after Detach gets annotation storage.deckhouse.io/detached: "true" +// According to ADR, PV after Detach gets annotation storage-foundation.deckhouse.io/detached: "true" // Provisioner ignores such PV for normal binding (quarantine PV). func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest) (ctrl.Result, error) { l := log.FromContext(ctx).WithValues("vcr", fmt.Sprintf("%s/%s", vcr.Namespace, vcr.Name), "mode", "Detach") @@ -233,7 +233,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, pvcNotFound := false pvNameFromAnnotation := "" if vcr.Annotations != nil { - if pvName, ok := vcr.Annotations["storage.deckhouse.io/detach-pv-name"]; ok { + if pvName, ok := vcr.Annotations["storage-foundation.deckhouse.io/detach-pv-name"]; ok { pvNameFromAnnotation = pvName } } @@ -284,7 +284,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, // Store PV name in annotation for future reconciles (when PVC is deleted) // Use Patch instead of Update to minimize conflicts and reduce churn - if vcr.Annotations == nil || vcr.Annotations["storage.deckhouse.io/detach-pv-name"] != pv.Name { + if vcr.Annotations == nil || vcr.Annotations["storage-foundation.deckhouse.io/detach-pv-name"] != pv.Name { current := &storagev1alpha1.VolumeCaptureRequest{} if err := r.Get(ctx, client.ObjectKeyFromObject(vcr), current); err != nil { return ctrl.Result{}, fmt.Errorf("failed to get VCR for annotation patch: %w", err) @@ -293,7 +293,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, if current.Annotations == nil { current.Annotations = make(map[string]string) } - current.Annotations["storage.deckhouse.io/detach-pv-name"] = pv.Name + current.Annotations["storage-foundation.deckhouse.io/detach-pv-name"] = pv.Name if err := r.Patch(ctx, current, client.MergeFrom(base)); err != nil { return ctrl.Result{}, fmt.Errorf("failed to patch VCR annotation: %w", err) } @@ -314,7 +314,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, // Check if PV has our detached annotation to confirm it was detached by VCR alreadyDetachedByVCR := false if pv.Annotations != nil { - if val, ok := pv.Annotations["storage.deckhouse.io/detached"]; ok && val == "true" { + if val, ok := pv.Annotations["storage-foundation.deckhouse.io/detached"]; ok && val == "true" { alreadyDetachedByVCR = true } } @@ -374,7 +374,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, } // 9. Detach PV from PVC and set ownerRef in a single Patch operation - // According to ADR, PV after Detach gets annotation storage.deckhouse.io/detached: "true" + // According to ADR, PV after Detach gets annotation storage-foundation.deckhouse.io/detached: "true" // NOTE: PV should have ReclaimPolicy=Retain to prevent accidental deletion // Re-read PV to get latest state before patching updatedPV := &corev1.PersistentVolume{} @@ -388,7 +388,7 @@ func (r *VolumeCaptureRequestController) processDetachMode(ctx context.Context, if updatedPV.Annotations == nil { updatedPV.Annotations = make(map[string]string) } - updatedPV.Annotations["storage.deckhouse.io/detached"] = "true" + updatedPV.Annotations["storage-foundation.deckhouse.io/detached"] = "true" // Set ownerRef: ObjectKeeper → PV // INVARIANT: PV can have only one controller owner - ObjectKeeper. @@ -541,7 +541,7 @@ func (r *VolumeCaptureRequestController) ensureObjectKeeper( // This ensures predictable cluster-wide retention policy. // // IMPORTANT: -// TTL annotation (storage.deckhouse.io/ttl) is informational only. +// TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only. // Actual TTL is controlled exclusively by controller configuration. // This ensures predictable cluster-wide retention policy. // @@ -645,7 +645,7 @@ func (r *VolumeCaptureRequestController) SetupWithManager(mgr ctrl.Manager) erro // // 2. TTL source: // - TTL is ALWAYS taken from controller configuration (config.RequestTTL), NOT from VCR annotations -// - TTL annotation (storage.deckhouse.io/ttl) is informational only and does not affect deletion timing +// - TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only and does not affect deletion timing // - This ensures predictable cluster-wide retention policy // // 3. TTL calculation: @@ -689,13 +689,13 @@ func (r *VolumeCaptureRequestController) StartTTLScanner(ctx context.Context, cl // scanAndDeleteExpiredVCRs lists all VCRs and deletes those where completionTimestamp + TTL < now. // // IMPORTANT: -// TTL annotation (storage.deckhouse.io/ttl) is informational only. +// TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only. // Actual TTL is controlled exclusively by controller configuration. // This ensures predictable cluster-wide retention policy. // // TTL SEMANTICS: // - TTL is ALWAYS taken from controller configuration (config.RequestTTL), NOT from VCR annotations. -// - TTL annotation (storage.deckhouse.io/ttl) is informational only and is IGNORED by the scanner. +// - TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only and is IGNORED by the scanner. // - This ensures consistent cleanup behavior: all VCRs use the same TTL policy defined by controller config. // - TTL starts counting from CompletionTimestamp (when VCR reaches Ready=True or Ready=False). func (r *VolumeCaptureRequestController) scanAndDeleteExpiredVCRs(ctx context.Context, client client.Client) { diff --git a/images/controller/internal/controllers/volumecapturerequest_controller_test.go b/images/controller/internal/controllers/volumecapturerequest_controller_test.go index d959d55..2f3d7de 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller_test.go @@ -312,7 +312,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { } // Update PV ownerRef if it has empty UID (PV name is stored in VCR annotation) if currentVCR.Annotations != nil { - if pvName, hasPVName := currentVCR.Annotations["storage.deckhouse.io/detach-pv-name"]; hasPVName { + if pvName, hasPVName := currentVCR.Annotations["storage-foundation.deckhouse.io/detach-pv-name"]; hasPVName { pv := &corev1.PersistentVolume{} if err := client.Get(ctx, types.NamespacedName{Name: pvName}, pv); err == nil { needsUpdate := false @@ -707,8 +707,8 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { updatedPV := &corev1.PersistentVolume{} Expect(client.Get(ctx, types.NamespacedName{Name: "test-pv-detach"}, updatedPV)).To(Succeed()) Expect(updatedPV.Spec.ClaimRef).To(BeNil()) - Expect(updatedPV.Annotations).To(HaveKey("storage.deckhouse.io/detached")) - Expect(updatedPV.Annotations["storage.deckhouse.io/detached"]).To(Equal("true")) + Expect(updatedPV.Annotations).To(HaveKey("storage-foundation.deckhouse.io/detached")) + Expect(updatedPV.Annotations["storage-foundation.deckhouse.io/detached"]).To(Equal("true")) // ObjectKeeper exists retainerName := NamePrefixRetainerPV + string(vcr.UID) @@ -748,7 +748,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { if pv.Annotations == nil { pv.Annotations = make(map[string]string) } - pv.Annotations["storage.deckhouse.io/detached"] = "true" + pv.Annotations["storage-foundation.deckhouse.io/detached"] = "true" Expect(client.Create(ctx, pv)).To(Succeed()) vcr := newVCR("test-vcr-idempotent", "default", ModeDetach, pvcTarget("default", "test-pvc-idempotent", "uid-test-pvc-idempotent")) @@ -756,7 +756,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { if vcr.Annotations == nil { vcr.Annotations = make(map[string]string) } - vcr.Annotations["storage.deckhouse.io/detach-pv-name"] = "test-pv-idempotent" + vcr.Annotations["storage-foundation.deckhouse.io/detach-pv-name"] = "test-pv-idempotent" Expect(client.Create(ctx, vcr)).To(Succeed()) retainerName := NamePrefixRetainerPV + string(vcr.UID) diff --git a/images/controller/internal/controllers/volumerestorerequest_controller.go b/images/controller/internal/controllers/volumerestorerequest_controller.go index 7e301d6..a4c365d 100644 --- a/images/controller/internal/controllers/volumerestorerequest_controller.go +++ b/images/controller/internal/controllers/volumerestorerequest_controller.go @@ -467,7 +467,7 @@ func (r *VolumeRestoreRequestController) finalizeVRR( // setTTLAnnotation sets TTL annotation on the object. // // IMPORTANT TTL SEMANTICS: -// - TTL annotation (storage.deckhouse.io/ttl) is INFORMATIONAL ONLY. +// - TTL annotation (storage-foundation.deckhouse.io/ttl) is INFORMATIONAL ONLY. // - Actual TTL deletion timing is controlled by controller configuration (config.RequestTTL). // - TTL scanner uses config.RequestTTL, NOT the annotation value. // - Annotation is set for observability and post-mortem analysis, but does not affect deletion timing. @@ -518,7 +518,7 @@ func (r *VolumeRestoreRequestController) SetupWithManager(mgr ctrl.Manager) erro // // 2. TTL source: // - TTL is ALWAYS taken from controller configuration (config.RequestTTL), NOT from VRR annotations -// - TTL annotation (storage.deckhouse.io/ttl) is informational only and does not affect deletion timing +// - TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only and does not affect deletion timing // - This ensures predictable cluster-wide retention policy // // 3. TTL calculation: @@ -562,13 +562,13 @@ func (r *VolumeRestoreRequestController) StartTTLScanner(ctx context.Context, cl // scanAndDeleteExpiredVRRs lists all VRRs and deletes those where completionTimestamp + TTL < now. // // IMPORTANT: -// TTL annotation (storage.deckhouse.io/ttl) is informational only. +// TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only. // Actual TTL is controlled exclusively by controller configuration. // This ensures predictable cluster-wide retention policy. // // TTL SEMANTICS: // - TTL is ALWAYS taken from controller configuration (config.RequestTTL), NOT from VRR annotations. -// - TTL annotation (storage.deckhouse.io/ttl) is informational only and is IGNORED by the scanner. +// - TTL annotation (storage-foundation.deckhouse.io/ttl) is informational only and is IGNORED by the scanner. // - This ensures consistent cleanup behavior: all VRRs use the same TTL policy defined by controller config. // - TTL starts counting from CompletionTimestamp (when VRR reaches Ready=True or Ready=False). func (r *VolumeRestoreRequestController) scanAndDeleteExpiredVRRs(ctx context.Context, client client.Client) { diff --git a/images/controller/pkg/internal/const.go b/images/controller/pkg/internal/const.go index aa392dd..673e17a 100644 --- a/images/controller/pkg/internal/const.go +++ b/images/controller/pkg/internal/const.go @@ -22,7 +22,7 @@ const ( storageFoundationCertsSecretName = "storage-foundation-certs" DeleteReconcile = "Delete" UpdateReconcile = "Update" - StorageManagedLabelKey = "storage.deckhouse.io/managed-by" + StorageManagedLabelKey = "storage-foundation.deckhouse.io/managed-by" PhaseFailed = "Failed" PhaseCreated = "Created" diff --git a/images/controller/pkg/snapshotmeta/constants.go b/images/controller/pkg/snapshotmeta/constants.go index f5fe0de..07b54ac 100644 --- a/images/controller/pkg/snapshotmeta/constants.go +++ b/images/controller/pkg/snapshotmeta/constants.go @@ -27,17 +27,17 @@ const ( // AnnDeckhouseManaged marks VolumeSnapshotContent or VolumeSnapshot as managed by Deckhouse // When present, snapshot-controller skips CSI CreateSnapshot/DeleteSnapshot calls // and sets ReadyToUse=true without actual snapshot creation - AnnDeckhouseManaged = "storage.deckhouse.io/managed" + AnnDeckhouseManaged = "storage-foundation.deckhouse.io/managed" // AnnDeckhouseSourceSnapshotContent contains the name of the source SnapshotContent (VCR name) // Used by snapshot-controller to find VSC by SFC name // Value: only name (not namespace/name), as snapshot-controller compares only name - AnnDeckhouseSourceSnapshotContent = "storage.deckhouse.io/source-snapshot-content" + AnnDeckhouseSourceSnapshotContent = "storage-foundation.deckhouse.io/source-snapshot-content" // AnnDeckhouseSourcePVC contains the source PVC reference // Format: "namespace/name" (e.g., "default/my-pvc") // Used by snapshot-controller to get PVC size for RestoreSize - AnnDeckhouseSourcePVC = "storage.deckhouse.io/source-pvc" + AnnDeckhouseSourcePVC = "storage-foundation.deckhouse.io/source-pvc" // AnnDeckhouseVCRUID is set on PVC to signal snapshot-controller to create VSC // Format: VCR UID (string) @@ -49,5 +49,5 @@ const ( const ( // LabelDeckhouseProxy marks VolumeSnapshot as a proxy object // When set to "true", indicates that VS is a proxy for VSC and should not trigger VSC creation - LabelDeckhouseProxy = "storage.deckhouse.io/proxy" + LabelDeckhouseProxy = "storage-foundation.deckhouse.io/proxy" ) diff --git a/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch b/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch index 060e8ef..7d76a32 100644 --- a/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch +++ b/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch @@ -48,7 +48,7 @@ index 9b96ba627..363a43dd8 100644 + // ------------------------------- + // VolumeRestoreRequest (VRR) informer + vrrGVR := schema.GroupVersionResource{ -+ Group: "storage.deckhouse.io", ++ Group: "storage-foundation.deckhouse.io", + Version: "v1alpha1", + Resource: "volumerestorerequests", + } @@ -157,7 +157,7 @@ index 3066c0a78..3a9cd196c 100644 + # PV/PVC -> events); it MUST NOT write VRR status. Status/lifecycle ownership + # belongs to the storage-foundation VRR controller, so no status subresource + # permission is granted here. -+ - apiGroups: ["storage.deckhouse.io"] ++ - apiGroups: ["storage-foundation.deckhouse.io"] + resources: ["volumerestorerequests"] + verbs: ["get", "list", "watch"] + # Secrets - provisioner credentials referenced from StorageClass parameters @@ -253,7 +253,7 @@ index 000000000..4a6c39c28 + +// objectKeeperRefAnnotation carries the ObjectKeeper owner reference set by the +// storage-foundation VRR lifecycle controller. The executor only consumes it. -+const objectKeeperRefAnnotation = "storage.deckhouse.io/object-keeper-ref" ++const objectKeeperRefAnnotation = "storage-foundation.deckhouse.io/object-keeper-ref" + +// VRRExecutor is the low-level executor of VolumeRestoreRequest (VRR) restore operations. +// @@ -292,7 +292,7 @@ index 000000000..4a6c39c28 + +// NewVRRExecutor creates a new VRR executor and registers the enqueue handlers on +// the provided VRR informer. The informer is expected to be a dynamic informer for -+// the storage.deckhouse.io/v1alpha1 volumerestorerequests resource. ++// the storage-foundation.deckhouse.io/v1alpha1 volumerestorerequests resource. +func NewVRRExecutor( + kubeClient kubernetes.Interface, + csiClient csi.ControllerClient, diff --git a/images/csi-external-provisioner/patches/v6.2.0/README.md b/images/csi-external-provisioner/patches/v6.2.0/README.md index 7df8298..60668b9 100644 --- a/images/csi-external-provisioner/patches/v6.2.0/README.md +++ b/images/csi-external-provisioner/patches/v6.2.0/README.md @@ -85,7 +85,7 @@ the VRR lifecycle controller (CR lifecycle, ObjectKeeper, TTL, finalizers, status/conditions) lives in `images/controller` of this module. The executor only: -- watches `storage.deckhouse.io/v1alpha1 VolumeRestoreRequest` and +- watches `storage-foundation.deckhouse.io/v1alpha1 VolumeRestoreRequest` and enqueues work on a rate-limited workqueue; - filters by CSI driver (via the target `StorageClass.provisioner`); - validates the restore source (`VolumeSnapshotContent` ready-to-use, or diff --git a/images/data-exporter/internal/repository/k8s.go b/images/data-exporter/internal/repository/k8s.go index d3937c7..e647b00 100644 --- a/images/data-exporter/internal/repository/k8s.go +++ b/images/data-exporter/internal/repository/k8s.go @@ -174,7 +174,7 @@ func (c *Client) AuthorizeUser(_ context.Context, operation common.Operation, na ResourceAttributes: &authzv1.ResourceAttributes{ Namespace: namespace, Verb: "create", - Group: "storage.deckhouse.io", + Group: "storage-foundation.deckhouse.io", Version: "v1alpha1", Resource: resource, Subresource: "download", diff --git a/images/data-manager-controller/internal/controllers/data-export/data_export_resource.go b/images/data-manager-controller/internal/controllers/data-export/data_export_resource.go index 1460981..059f324 100644 --- a/images/data-manager-controller/internal/controllers/data-export/data_export_resource.go +++ b/images/data-manager-controller/internal/controllers/data-export/data_export_resource.go @@ -70,8 +70,8 @@ type pvRecoveryInfo struct { } const ( - DataExportInProgressKey = "storage.deckhouse.io/data-export-in-progress" - DataExportRequestAnnotationKey = "storage.deckhouse.io/data-export-request" + DataExportInProgressKey = "storage-foundation.deckhouse.io/data-export-in-progress" + DataExportRequestAnnotationKey = "storage-foundation.deckhouse.io/data-export-request" SeverityWarning = "warning" SeverityError = "error" @@ -956,7 +956,7 @@ func (r *DataexportReconciler) validateExportDeploy(ctx context.Context, dataExp // Delete export deployment and export PVC (if exists) // Patch PV for attach it back to user's PVC -// Delete finalizer: storage.deckhouse.io/data-exporter-controller +// Delete finalizer: storage-foundation.deckhouse.io/data-exporter-controller func (r *DataexportReconciler) clearDataExportProviding(ctx context.Context, dataExport *dev1alpha1.DataExport, generatedNames Names) error { log.Printf("Start recovering configuration before Dataexport %s", dataExport.GetName()) diff --git a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go index 9e06df6..a0de82c 100644 --- a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go +++ b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go @@ -68,12 +68,14 @@ const ( var ( snapshotContentGVR = schema.GroupVersionResource{ - Group: "storage.deckhouse.io", + // SnapshotContent is owned by state-snapshotter (cross-module read), so it keeps the + // state-snapshotter API group, not storage-foundation's. + Group: "state-snapshotter.deckhouse.io", Version: "v1alpha1", Resource: "snapshotcontents", } volumeRestoreRequestGVR = schema.GroupVersionResource{ - Group: "storage.deckhouse.io", + Group: "storage-foundation.deckhouse.io", Version: "v1alpha1", Resource: "volumerestorerequests", } diff --git a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go index 20167ee..b8f68d0 100644 --- a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go +++ b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go @@ -146,7 +146,7 @@ func newSnapshotLeaf(namespace, name, boundContentName string) *unstructured.Uns func newSnapshotContent(name, snapshotRefNS, artifactKind, artifactName, volumeMode string) *unstructured.Unstructured { content := &unstructured.Unstructured{} - content.SetGroupVersionKind(schema.GroupVersionKind{Group: "storage.deckhouse.io", Version: "v1alpha1", Kind: "SnapshotContent"}) + content.SetGroupVersionKind(schema.GroupVersionKind{Group: "state-snapshotter.deckhouse.io", Version: "v1alpha1", Kind: "SnapshotContent"}) content.SetName(name) if snapshotRefNS != "" { _ = unstructured.SetNestedField(content.Object, snapshotRefNS, "spec", "snapshotRef", "namespace") diff --git a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go index 2782032..c1e5744 100644 --- a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go +++ b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go @@ -62,7 +62,7 @@ const ( ) var volumeCaptureRequestGVR = schema.GroupVersionResource{ - Group: "storage.deckhouse.io", + Group: "storage-foundation.deckhouse.io", Version: "v1alpha1", Resource: "volumecapturerequests", } diff --git a/images/populator/cmd/main.go b/images/populator/cmd/main.go index d250af0..8832679 100644 --- a/images/populator/cmd/main.go +++ b/images/populator/cmd/main.go @@ -64,7 +64,7 @@ func main() { } groupKind := schema.GroupKind{ - Group: "storage.deckhouse.io", // TODO: move this and other values to constants/config/etc + Group: "storage-foundation.deckhouse.io", // TODO: move this and other values to constants/config/etc Kind: "DataImport", } versionResource := schema.GroupVersionResource{ @@ -79,7 +79,7 @@ func main() { // HttpEndpoint: *httpEndpoint, // ??? // MetricsPath: *metricsPath, // ??? Namespace: cfgParams.ControllerNamespace, - Prefix: "storage.deckhouse.io", + Prefix: "storage-foundation.deckhouse.io", Gk: groupKind, Gvr: versionResource, ProviderFunctionConfig: pfcfg, diff --git a/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch b/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch index ff65c4f..73751da 100644 --- a/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch +++ b/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch @@ -31,7 +31,7 @@ index 36f60dc..e865972 100644 VolumeSnapshotContentName *string `json:"volumeSnapshotContentName,omitempty" protobuf:"bytes,2,opt,name=volumeSnapshotContentName"` + + // import, when present, marks this VolumeSnapshot as an import target: its data artifact is -+ // produced by a DataImport (state-snapshotter.deckhouse.io) in the same namespace, resolved via ++ // produced by a DataImport (storage-foundation.deckhouse.io) in the same namespace, resolved via + // reverse-lookup (DataImport.spec.targetRef), not named here. It is an empty marker object whose + // mere presence selects import mode. When set, the upstream snapshot-controller does NOT reconcile + // this VolumeSnapshot; the state-snapshotter common controller binds it (sets status). @@ -42,7 +42,7 @@ index 36f60dc..e865972 100644 +// VolumeSnapshotImportSource is an empty marker selecting import mode for a VolumeSnapshot +// (spec.source.import: {}). Its mere presence means the data artifact is produced by a DataImport -+// (state-snapshotter.deckhouse.io) and the upstream snapshot-controller does NOT reconcile it. ++// (storage-foundation.deckhouse.io) and the upstream snapshot-controller does NOT reconcile it. +// Deckhouse fork extension. +type VolumeSnapshotImportSource struct{} + @@ -55,7 +55,7 @@ index 36f60dc..e865972 100644 VolumeGroupSnapshotName *string `json:"volumeGroupSnapshotName,omitempty" protobuf:"bytes,6,opt,name=volumeGroupSnapshotName"` + + // boundSnapshotContentName is the name of the cluster-scoped state-snapshotter SnapshotContent -+ // (storage.deckhouse.io) that backs this VolumeSnapshot as a logical node in a snapshot tree. ++ // (state-snapshotter.deckhouse.io) that backs this VolumeSnapshot as a logical node in a snapshot tree. + // It is written by the state-snapshotter common controller (in addition to the legacy + // boundVolumeSnapshotContentName which points at the CSI VolumeSnapshotContent). The upstream + // snapshot-controller preserves this field across status updates (read -> DeepCopy -> UpdateStatus) @@ -197,7 +197,7 @@ index 36f60dc..e865972 100644 VolumeSnapshotContentName *string `json:"volumeSnapshotContentName,omitempty" protobuf:"bytes,2,opt,name=volumeSnapshotContentName"` + + // import, when present, marks this VolumeSnapshot as an import target: its data artifact is -+ // produced by a DataImport (state-snapshotter.deckhouse.io) in the same namespace, resolved via ++ // produced by a DataImport (storage-foundation.deckhouse.io) in the same namespace, resolved via + // reverse-lookup (DataImport.spec.targetRef), not named here. It is an empty marker object whose + // mere presence selects import mode. When set, the upstream snapshot-controller does NOT reconcile + // this VolumeSnapshot; the state-snapshotter common controller binds it (sets status). @@ -208,7 +208,7 @@ index 36f60dc..e865972 100644 +// VolumeSnapshotImportSource is an empty marker selecting import mode for a VolumeSnapshot +// (spec.source.import: {}). Its mere presence means the data artifact is produced by a DataImport -+// (state-snapshotter.deckhouse.io) and the upstream snapshot-controller does NOT reconcile it. ++// (storage-foundation.deckhouse.io) and the upstream snapshot-controller does NOT reconcile it. +// Deckhouse fork extension. +type VolumeSnapshotImportSource struct{} + @@ -221,7 +221,7 @@ index 36f60dc..e865972 100644 VolumeGroupSnapshotName *string `json:"volumeGroupSnapshotName,omitempty" protobuf:"bytes,6,opt,name=volumeGroupSnapshotName"` + + // boundSnapshotContentName is the name of the cluster-scoped state-snapshotter SnapshotContent -+ // (storage.deckhouse.io) that backs this VolumeSnapshot as a logical node in a snapshot tree. ++ // (state-snapshotter.deckhouse.io) that backs this VolumeSnapshot as a logical node in a snapshot tree. + // It is written by the state-snapshotter common controller (in addition to the legacy + // boundVolumeSnapshotContentName which points at the CSI VolumeSnapshotContent). The upstream + // snapshot-controller preserves this field across status updates (read -> DeepCopy -> UpdateStatus) diff --git a/images/webhooks/handlers/deValidator_test.go b/images/webhooks/handlers/deValidator_test.go index 0baa45d..896714d 100644 --- a/images/webhooks/handlers/deValidator_test.go +++ b/images/webhooks/handlers/deValidator_test.go @@ -250,7 +250,7 @@ func TestNewValidatingWebhookHandler_ServeHTTP(t *testing.T) { makeAdmissionReviewBody := func(t *testing.T) []byte { t.Helper() de := makeDataExport("", "PersistentVolumeClaim", "my-pvc") - de.TypeMeta = metav1.TypeMeta{APIVersion: "storage.deckhouse.io/v1alpha1", Kind: "DataExport"} + de.TypeMeta = metav1.TypeMeta{APIVersion: "storage-foundation.deckhouse.io/v1alpha1", Kind: "DataExport"} objJSON, err := json.Marshal(de) if err != nil { t.Fatalf("marshal DataExport: %v", err) @@ -264,12 +264,12 @@ func TestNewValidatingWebhookHandler_ServeHTTP(t *testing.T) { "namespace": "test-ns", "operation": "CREATE", "resource": map[string]interface{}{ - "group": "storage.deckhouse.io", + "group": "storage-foundation.deckhouse.io", "version": "v1alpha1", "resource": "dataexports", }, "kind": map[string]interface{}{ - "group": "storage.deckhouse.io", + "group": "storage-foundation.deckhouse.io", "version": "v1alpha1", "kind": "DataExport", }, diff --git a/images/webhooks/handlers/volumeSnapshotMutator.go b/images/webhooks/handlers/volumeSnapshotMutator.go index 1abc527..a92e8ab 100644 --- a/images/webhooks/handlers/volumeSnapshotMutator.go +++ b/images/webhooks/handlers/volumeSnapshotMutator.go @@ -39,7 +39,7 @@ import ( const ( storageClassVolumeSnapshotAnnotationName = "storage.deckhouse.io/volumesnapshotclass" - storageClassManagedbyLabelName = "storage.deckhouse.io/managed-by" + storageClassManagedbyLabelName = "storage-foundation.deckhouse.io/managed-by" ) func VolumeSnapshotMutate(ctx context.Context, _ *model.AdmissionReview, obj metav1.Object) (*kwhmutating.MutatorResult, error) { diff --git a/templates/controller/rbac-for-us.yaml b/templates/controller/rbac-for-us.yaml index 4092521..9562ada 100644 --- a/templates/controller/rbac-for-us.yaml +++ b/templates/controller/rbac-for-us.yaml @@ -13,22 +13,22 @@ metadata: {{- include "helm_lib_module_labels" (list . (dict "app" "controller")) | nindent 2 }} rules: # VolumeCaptureRequest (namespaced) -- apiGroups: ["storage.deckhouse.io"] +- apiGroups: ["storage-foundation.deckhouse.io"] resources: ["volumecapturerequests"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # VolumeCaptureRequest status -- apiGroups: ["storage.deckhouse.io"] +- apiGroups: ["storage-foundation.deckhouse.io"] resources: ["volumecapturerequests/status"] verbs: ["get", "update", "patch"] # VolumeRestoreRequest (namespaced) -- apiGroups: ["storage.deckhouse.io"] +- apiGroups: ["storage-foundation.deckhouse.io"] resources: ["volumerestorerequests"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # VolumeRestoreRequest status -- apiGroups: ["storage.deckhouse.io"] +- apiGroups: ["storage-foundation.deckhouse.io"] resources: ["volumerestorerequests/status"] verbs: ["get", "update", "patch"] diff --git a/templates/data-exporter/rbac-for-us.yaml b/templates/data-exporter/rbac-for-us.yaml index bb8f56c..5d2e94a 100644 --- a/templates/data-exporter/rbac-for-us.yaml +++ b/templates/data-exporter/rbac-for-us.yaml @@ -14,7 +14,7 @@ metadata: {{- include "helm_lib_module_labels" (list . (dict "app" "data-exporter")) | nindent 2 }} rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - dataexports/status - dataexports diff --git a/templates/data-manager-controller/rbac-for-us.yaml b/templates/data-manager-controller/rbac-for-us.yaml index f61e00e..28eeebc 100644 --- a/templates/data-manager-controller/rbac-for-us.yaml +++ b/templates/data-manager-controller/rbac-for-us.yaml @@ -78,7 +78,7 @@ metadata: {{- include "helm_lib_module_labels" (list . (dict "app" "data-manager-controller")) | nindent 2 }} rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - dataexports - dataexports/status @@ -94,7 +94,7 @@ rules: # DataImport produces durable data artifacts by creating VolumeCaptureRequests (storage-foundation); # DataExport (C6) restores a snapshot's durable artifact into the export PVC via VolumeRestoreRequest. - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - volumecapturerequests - volumerestorerequests @@ -107,8 +107,9 @@ rules: - update # DataExport (C6) resolves the snapshot leaf's bound SnapshotContent (cluster-scoped) to read the # trusted data artifact reference (status.dataRef) for the VolumeRestoreRequest source. Read-only. + # SnapshotContent is owned by state-snapshotter, hence its own API group here. - apiGroups: - - storage.deckhouse.io + - state-snapshotter.deckhouse.io resources: - snapshotcontents verbs: diff --git a/templates/populator/volume-populator.yaml b/templates/populator/volume-populator.yaml index f61fc0d..b51941f 100644 --- a/templates/populator/volume-populator.yaml +++ b/templates/populator/volume-populator.yaml @@ -1,7 +1,7 @@ --- # Registers DataImport as a recognized volume-populator data source. external-provisioner and the # volume-data-source-validator read this cluster-scoped object to allow PVCs whose spec.dataSourceRef -# points at DataImport.storage.deckhouse.io to be provisioned via the populator (instead of being +# points at DataImport.storage-foundation.deckhouse.io to be provisioned via the populator (instead of being # rejected as an unrecognized data source). Must match the GroupKind registered by the populator # (see images/populator/cmd/main.go). apiVersion: populator.storage.k8s.io/v1beta1 @@ -10,5 +10,5 @@ metadata: name: dataimport {{- include "helm_lib_module_labels" (list . (dict "app" "populator")) | nindent 2 }} sourceKind: - group: storage.deckhouse.io + group: storage-foundation.deckhouse.io kind: DataImport diff --git a/templates/rbac-for-us.yaml b/templates/rbac-for-us.yaml index d1ae8e9..b220f0a 100644 --- a/templates/rbac-for-us.yaml +++ b/templates/rbac-for-us.yaml @@ -6,7 +6,7 @@ metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - volumecapturerequests - volumerestorerequests diff --git a/templates/rbacv2/use/edit.yaml b/templates/rbacv2/use/edit.yaml index 7b66c29..dd725a8 100644 --- a/templates/rbacv2/use/edit.yaml +++ b/templates/rbacv2/use/edit.yaml @@ -9,7 +9,7 @@ metadata: name: d8:use:capability:module:storage-foundation:edit rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - volumecapturerequests - volumerestorerequests diff --git a/templates/rbacv2/use/view.yaml b/templates/rbacv2/use/view.yaml index b8883f0..2713049 100644 --- a/templates/rbacv2/use/view.yaml +++ b/templates/rbacv2/use/view.yaml @@ -9,7 +9,7 @@ metadata: name: d8:use:capability:module:storage-foundation:view rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - volumecapturerequests - volumerestorerequests diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 214f35f..bdcd3de 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -8,7 +8,7 @@ metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - volumecapturerequests - volumerestorerequests @@ -38,7 +38,7 @@ metadata: {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: - apiGroups: - - storage.deckhouse.io + - storage-foundation.deckhouse.io resources: - volumecapturerequests - volumerestorerequests diff --git a/templates/webhooks/dataexport-validation.yaml b/templates/webhooks/dataexport-validation.yaml index 2274af0..55de3d4 100644 --- a/templates/webhooks/dataexport-validation.yaml +++ b/templates/webhooks/dataexport-validation.yaml @@ -7,10 +7,10 @@ metadata: heritage: deckhouse module: storage-foundation webhooks: - - name: "d8-{{ .Chart.Name }}-dataexport-validation.storage.deckhouse.io" + - name: "d8-{{ .Chart.Name }}-dataexport-validation.storage-foundation.deckhouse.io" failurePolicy: Fail rules: - - apiGroups: ["storage.deckhouse.io"] + - apiGroups: ["storage-foundation.deckhouse.io"] apiVersions: ["v1alpha1"] operations: ["CREATE"] resources: ["dataexports"] diff --git a/templates/webhooks/webhook.yaml b/templates/webhooks/webhook.yaml index 7670e56..eee68de 100644 --- a/templates/webhooks/webhook.yaml +++ b/templates/webhooks/webhook.yaml @@ -7,7 +7,7 @@ metadata: heritage: deckhouse module: storage-foundation webhooks: - - name: "d8-{{ .Chart.Name }}-volume-snapshot-mutation.storage.deckhouse.io" + - name: "d8-{{ .Chart.Name }}-volume-snapshot-mutation.storage-foundation.deckhouse.io" failurePolicy: Fail rules: - apiGroups: ["snapshot.storage.k8s.io"] From 2f3ce31ab29b5bc20e015891553344f58b3f1f58 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Wed, 1 Jul 2026 02:11:29 +0300 Subject: [PATCH 05/11] feat(api): add uid to DataArtifactReference and read single VCR dataRef Add an optional UID to DataArtifactReference (symmetric with VolumeCaptureRequest's status.dataRef.artifact.uid) and carry it through DataImport. Also fixes a wave1 regression: volumeCaptureArtifact still read the removed plural status.dataRefs slice; switch it to the single status.dataRef map (VCR is one target per request after wave1), so the DataImport artifact resolves again. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/common.go | 5 ++ crds/dataimports.yaml | 5 ++ crds/doc-ru-dataimports.yaml | 3 + .../data-import/data_import_resource.go | 2 +- .../data-import/data_import_unit_test.go | 30 +++++----- .../controllers/data-import/volume_capture.go | 58 +++++++------------ 6 files changed, 48 insertions(+), 55 deletions(-) diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 8f810c1..180daf7 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -41,6 +41,11 @@ type DataArtifactReference struct { APIVersion string `json:"apiVersion"` Kind string `json:"kind"` Name string `json:"name"` + // UID is the durable data artifact UID (for example the VolumeSnapshotContent UID). It makes the + // artifact reference self-contained, symmetric with VolumeCaptureRequest's status.dataRef.artifact.uid. + // Optional: producers fill it best-effort (the artifact may be referenced before its UID is known). + // +optional + UID string `json:"uid,omitempty"` } type Statusable interface { diff --git a/crds/dataimports.yaml b/crds/dataimports.yaml index cd85ce4..ec1555a 100644 --- a/crds/dataimports.yaml +++ b/crds/dataimports.yaml @@ -215,6 +215,11 @@ spec: type: string name: type: string + uid: + type: string + description: | + UID of the durable data artifact (for example the VolumeSnapshotContent UID), + making the reference self-contained. Optional; producers fill it best-effort. conditions: type: array items: diff --git a/crds/doc-ru-dataimports.yaml b/crds/doc-ru-dataimports.yaml index c7387e0..8dbab3a 100644 --- a/crds/doc-ru-dataimports.yaml +++ b/crds/doc-ru-dataimports.yaml @@ -119,6 +119,9 @@ spec: name: description: | Имя артефакта данных. + uid: + description: | + UID артефакта данных (например, UID VolumeSnapshotContent). Необязательное; заполняется по возможности. conditions: type: array items: diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go index b7798a4..a556bb4 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go @@ -572,7 +572,7 @@ func (r *DataImportReconciler) ensureDataArtifact(ctx context.Context, pvc *core return ctrl.Result{RequeueAfter: dataImportRequeueInterval}, nil } - artifact, err := volumeCaptureArtifact(vcr, string(pvc.UID), expectedKind) + artifact, err := volumeCaptureArtifact(vcr, expectedKind) if err != nil { // A produced VCR with a malformed/mismatched dataRef is a contract violation, not transient. return ctrl.Result{}, fmt.Errorf("%w: %w", ErrTargetFailed, err) diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go b/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go index 0166f14..384c626 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go @@ -180,36 +180,32 @@ func TestVolumeCaptureRequestReadyAndFailed(t *testing.T) { func TestVolumeCaptureArtifact(t *testing.T) { vcr := &unstructured.Unstructured{Object: map[string]interface{}{ "status": map[string]interface{}{ - "dataRefs": []interface{}{ - map[string]interface{}{ - "targetUID": "uid-1", - "artifact": map[string]interface{}{ - "apiVersion": "snapshot.storage.k8s.io/v1", - "kind": artifactKindVolumeSnapshotContent, - "name": "snapcontent-x", - }, + "dataRef": map[string]interface{}{ + "targetUID": "uid-1", + "artifact": map[string]interface{}{ + "apiVersion": "snapshot.storage.k8s.io/v1", + "kind": artifactKindVolumeSnapshotContent, + "name": "snapcontent-x", + "uid": "8d7c6b5a-4e3f-4a2b-9c1d-0f1e2d3c4b5a", }, }, }, }} - art, err := volumeCaptureArtifact(vcr, "uid-1", artifactKindVolumeSnapshotContent) + art, err := volumeCaptureArtifact(vcr, artifactKindVolumeSnapshotContent) require.NoError(t, err) assert.Equal(t, "snapcontent-x", art.Name) assert.Equal(t, artifactKindVolumeSnapshotContent, art.Kind) + // The artifact uid is carried through from VCR status.dataRef.artifact.uid. + assert.Equal(t, "8d7c6b5a-4e3f-4a2b-9c1d-0f1e2d3c4b5a", art.UID) // Kind mismatch is rejected. - _, err = volumeCaptureArtifact(vcr, "uid-1", artifactKindPersistentVolume) + _, err = volumeCaptureArtifact(vcr, artifactKindPersistentVolume) assert.Error(t, err) - // Missing targetUID match with a single binding falls back to the only entry. - art, err = volumeCaptureArtifact(vcr, "other-uid", artifactKindVolumeSnapshotContent) - require.NoError(t, err) - assert.Equal(t, "snapcontent-x", art.Name) - - // No dataRefs at all errors. + // No status.dataRef at all errors. empty := &unstructured.Unstructured{Object: map[string]interface{}{"status": map[string]interface{}{}}} - _, err = volumeCaptureArtifact(empty, "uid-1", artifactKindVolumeSnapshotContent) + _, err = volumeCaptureArtifact(empty, artifactKindVolumeSnapshotContent) assert.Error(t, err) } diff --git a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go index c1e5744..4c1c531 100644 --- a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go +++ b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go @@ -209,47 +209,31 @@ func vcrReadyCondition(vcr *unstructured.Unstructured) (status, reason string, o return "", "", false } -// volumeCaptureArtifact extracts the durable artifact reference for the given target PVC UID from -// status.dataRefs (matching by targetUID; falling back to the single entry when present). It validates -// the artifact kind matches what the chosen mode must produce. -func volumeCaptureArtifact(vcr *unstructured.Unstructured, targetUID, expectedKind string) (*dev1alpha1.DataArtifactReference, error) { - dataRefs, found, err := unstructured.NestedSlice(vcr.Object, "status", "dataRefs") - if err != nil || !found || len(dataRefs) == 0 { - return nil, fmt.Errorf("VolumeCaptureRequest has no status.dataRefs") +// volumeCaptureArtifact extracts the durable artifact reference from the VolumeCaptureRequest's single +// status.dataRef. wave1 collapsed the VCR to one target per request (singular status.dataRef), so there +// is no list to match by targetUID. It validates the artifact kind matches what the chosen mode must +// produce and carries the artifact uid through into DataArtifactReference. +func volumeCaptureArtifact(vcr *unstructured.Unstructured, expectedKind string) (*dev1alpha1.DataArtifactReference, error) { + dataRef, found, err := unstructured.NestedMap(vcr.Object, "status", "dataRef") + if err != nil || !found || len(dataRef) == 0 { + return nil, fmt.Errorf("VolumeCaptureRequest has no status.dataRef") } - pick := func(m map[string]interface{}) (*dev1alpha1.DataArtifactReference, error) { - artifact, isMap := m["artifact"].(map[string]interface{}) - if !isMap { - return nil, fmt.Errorf("VolumeCaptureRequest dataRef has no artifact") - } - apiVersion, _, _ := unstructured.NestedString(artifact, "apiVersion") - kind, _, _ := unstructured.NestedString(artifact, "kind") - name, _, _ := unstructured.NestedString(artifact, "name") - if kind != expectedKind { - return nil, fmt.Errorf("VolumeCaptureRequest produced artifact kind %q, expected %q", kind, expectedKind) - } - if name == "" { - return nil, fmt.Errorf("VolumeCaptureRequest artifact has empty name") - } - return &dev1alpha1.DataArtifactReference{APIVersion: apiVersion, Kind: kind, Name: name}, nil + artifact, isMap := dataRef["artifact"].(map[string]interface{}) + if !isMap { + return nil, fmt.Errorf("VolumeCaptureRequest dataRef has no artifact") } - for _, ref := range dataRefs { - m, isMap := ref.(map[string]interface{}) - if !isMap { - continue - } - uid, _, _ := unstructured.NestedString(m, "targetUID") - if uid == targetUID { - return pick(m) - } + apiVersion, _, _ := unstructured.NestedString(artifact, "apiVersion") + kind, _, _ := unstructured.NestedString(artifact, "kind") + name, _, _ := unstructured.NestedString(artifact, "name") + uid, _, _ := unstructured.NestedString(artifact, "uid") + if kind != expectedKind { + return nil, fmt.Errorf("VolumeCaptureRequest produced artifact kind %q, expected %q", kind, expectedKind) } - // Single-target imports: tolerate a missing/empty targetUID by taking the only binding. - if len(dataRefs) == 1 { - if m, isMap := dataRefs[0].(map[string]interface{}); isMap { - return pick(m) - } + if name == "" { + return nil, fmt.Errorf("VolumeCaptureRequest artifact has empty name") } - return nil, fmt.Errorf("VolumeCaptureRequest has no dataRef for target UID %q", targetUID) + + return &dev1alpha1.DataArtifactReference{APIVersion: apiVersion, Kind: kind, Name: name, UID: uid}, nil } From 20c48b5218f82b5ea3e6f20fe3461ab8ea0ebf56 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Mon, 6 Jul 2026 02:29:40 +0300 Subject: [PATCH 06/11] [api] Hard-rename VCR/DataImport status data-role to status.data Wave 5 field rename (no back-compat aliases), symmetric with the state-snapshotter SnapshotContent rename: - VolumeCaptureRequest: status.dataRef -> status.data; VolumeDataBinding is now artifact-only (dropped TargetUID and the duplicated Target - the captured PVC identity lives in the immutable spec.target). - DataImport: status.dataArtifactRef -> status.data.artifact (nested); DataExportImportStatus.DataArtifactRef replaced by Data (*DataExportImportData wrapping Artifact). - Regenerated deepcopy + VCR CRD; hand-updated the curated DataImport CRDs (dataimports.yaml, doc-ru-dataimports.yaml). - Updated all writers/readers (VCR controller + single-target/snapshot helpers, DataImport resource + volume_capture unstructured reader) and all unit tests. api / controller / data-manager-controller: build, vet, and tests green; gofmt clean. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/common.go | 23 ++++-- .../volumecapturerequest_schema_test.go | 80 ++++++++++++------- api/v1alpha1/volumecapturerequest_types.go | 20 ++--- api/v1alpha1/zz_generated.deepcopy.go | 33 ++++++-- crds/dataimports.yaml | 33 +++++--- crds/doc-ru-dataimports.yaml | 30 ++++--- ...on.deckhouse.io_volumecapturerequests.yaml | 46 ++--------- .../volumecapturerequest_cleanup_test.go | 16 +--- .../volumecapturerequest_controller.go | 6 +- .../volumecapturerequest_controller_test.go | 26 +++--- .../volumecapturerequest_single_target.go | 12 ++- .../volumecapturerequest_snapshot.go | 6 +- .../volumecapturerequest_snapshot_test.go | 8 +- .../data-import/data_import_resource.go | 6 +- .../data-import/data_import_unit_test.go | 9 +-- .../controllers/data-import/volume_capture.go | 6 +- 16 files changed, 181 insertions(+), 179 deletions(-) diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 180daf7..7d7b132 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -27,11 +27,22 @@ type DataExportImportStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` VolumeMode string `json:"volumeMode,omitempty"` - // DataArtifactRef references the durable cluster-scoped data artifact produced by a DataImport - // (VolumeSnapshotContent or PersistentVolume). It is written once the backing VolumeCaptureRequest - // completes; the state-snapshotter import orchestrator reads it to populate SnapshotContent.dataRef. - // Empty for DataExport. - DataArtifactRef *DataArtifactReference `json:"dataArtifactRef,omitempty"` + // Data carries the durable cluster-scoped data artifact produced by a DataImport under a nested + // data.artifact (VolumeSnapshotContent or PersistentVolume). It is written once the backing + // VolumeCaptureRequest completes; the state-snapshotter import orchestrator reads data.artifact to + // populate SnapshotContent.status.data.artifact. Empty for DataExport. + // +optional + Data *DataExportImportData `json:"data,omitempty"` +} + +// DataExportImportData is the self-contained captured-data block on a DataImport status. It nests the +// durable artifact under data.artifact (symmetric with SnapshotContent.status.data and +// VolumeCaptureRequest.status.data). +// +k8s:deepcopy-gen=true +type DataExportImportData struct { + // Artifact references the durable cluster-scoped data artifact (VolumeSnapshotContent or PersistentVolume). + // +optional + Artifact *DataArtifactReference `json:"artifact,omitempty"` } // DataArtifactReference references a cluster-scoped durable data artifact (VolumeSnapshotContent or @@ -42,7 +53,7 @@ type DataArtifactReference struct { Kind string `json:"kind"` Name string `json:"name"` // UID is the durable data artifact UID (for example the VolumeSnapshotContent UID). It makes the - // artifact reference self-contained, symmetric with VolumeCaptureRequest's status.dataRef.artifact.uid. + // artifact reference self-contained, symmetric with VolumeCaptureRequest's status.data.artifact.uid. // Optional: producers fill it best-effort (the artifact may be referenced before its UID is known). // +optional UID string `json:"uid,omitempty"` diff --git a/api/v1alpha1/volumecapturerequest_schema_test.go b/api/v1alpha1/volumecapturerequest_schema_test.go index 91c38e2..4585d94 100644 --- a/api/v1alpha1/volumecapturerequest_schema_test.go +++ b/api/v1alpha1/volumecapturerequest_schema_test.go @@ -74,18 +74,10 @@ func TestVolumeCaptureRequestSpec_Target_JSONRoundTrip(t *testing.T) { } } -func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { +func TestVolumeCaptureRequestStatus_Data_JSONRoundTrip(t *testing.T) { vcr := VolumeCaptureRequest{ Status: VolumeCaptureRequestStatus{ - DataRef: &VolumeDataBinding{ - TargetUID: "uid-a", - Target: VolumeCaptureTarget{ - UID: "uid-a", - APIVersion: "v1", - Kind: "PersistentVolumeClaim", - Namespace: "demo", - Name: "data-a", - }, + Data: &VolumeDataBinding{ Artifact: VolumeDataArtifactRef{ APIVersion: "snapshot.storage.k8s.io/v1", Kind: "VolumeSnapshotContent", @@ -105,20 +97,17 @@ func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &out); err != nil { t.Fatalf("unmarshal: %v", err) } - if out.Status.DataRef == nil { - t.Fatal("status.dataRef must round-trip") + if out.Status.Data == nil { + t.Fatal("status.data must round-trip") } - ref := out.Status.DataRef - if ref.TargetUID != "uid-a" || ref.Artifact.Name != "snapcontent-a" { - t.Fatalf("dataRef mismatch: %#v", ref) + ref := out.Status.Data + if ref.Artifact.Name != "snapcontent-a" { + t.Fatalf("data mismatch: %#v", ref) } - // Artifact UID round-trips so the durable reference is self-contained, symmetric with target.uid. + // Artifact UID round-trips so the durable reference is self-contained; the captured PVC identity + // lives in spec.target (immutable), not in status.data. if ref.Artifact.UID != "vsc-uid-a" { - t.Fatalf("status.dataRef.artifact.uid = %q, want %q", ref.Artifact.UID, "vsc-uid-a") - } - // Namespace is preserved in status.dataRef.target so the binding is self-contained. - if ref.Target.Namespace != "demo" { - t.Fatalf("status.dataRef.target.namespace = %q, want %q", ref.Target.Namespace, "demo") + t.Fatalf("status.data.artifact.uid = %q, want %q", ref.Artifact.UID, "vsc-uid-a") } if ref.Artifact.Kind == "VolumeCaptureRequest" { t.Fatal("artifact must not reference an execution request") @@ -129,11 +118,24 @@ func TestVolumeCaptureRequestStatus_DataRef_JSONRoundTrip(t *testing.T) { t.Fatalf("unmarshal raw: %v", err) } status := raw["status"].(map[string]interface{}) + if _, ok := status["dataRef"]; ok { + t.Fatal("legacy status.dataRef must not appear in JSON") + } if _, ok := status["dataRefs"]; ok { t.Fatal("legacy status.dataRefs[] must not appear in JSON") } - if _, ok := status["dataRef"].(map[string]interface{}); !ok { - t.Fatalf("status.dataRef must be a single object, got %#v", status["dataRef"]) + dataObj, ok := status["data"].(map[string]interface{}) + if !ok { + t.Fatalf("status.data must be a single object, got %#v", status["data"]) + } + if _, ok := dataObj["artifact"].(map[string]interface{}); !ok { + t.Fatalf("status.data.artifact must be an object, got %#v", dataObj["artifact"]) + } + if _, ok := dataObj["target"]; ok { + t.Fatal("status.data.target must not appear (identity comes from spec.target)") + } + if _, ok := dataObj["targetUID"]; ok { + t.Fatal("status.data.targetUID must not appear (identity comes from spec.target)") } } @@ -155,13 +157,20 @@ func TestVolumeCaptureRequestCRD_SingleTargetSchema(t *testing.T) { } for _, required := range []string{ "target:", - "dataRef:", - "targetUID:", + "data:", + "artifact:", } { if !strings.Contains(content, required) { t.Fatalf("CRD missing %q", required) } } + for _, forbidden := range []string{ + "targetUID:", + } { + if strings.Contains(content, forbidden) { + t.Fatalf("CRD must not contain %q (status.data is artifact-only)", forbidden) + } + } var doc map[string]interface{} if err := yaml.Unmarshal(data, &doc); err != nil { @@ -182,12 +191,25 @@ func TestVolumeCaptureRequestCRD_SingleTargetSchema(t *testing.T) { } statusProps := schema["properties"].(map[string]interface{})["status"].(map[string]interface{})["properties"].(map[string]interface{}) - dataRef, ok := statusProps["dataRef"].(map[string]interface{}) + dataObj, ok := statusProps["data"].(map[string]interface{}) if !ok { - t.Fatalf("status.dataRef must be an object schema, got %#v", statusProps["dataRef"]) + t.Fatalf("status.data must be an object schema, got %#v", statusProps["data"]) + } + if dataObj["type"] != "object" { + t.Fatalf("status.data type: %#v", dataObj["type"]) + } + dataObjProps, ok := dataObj["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("status.data must have properties, got %#v", dataObj["properties"]) + } + if _, ok := dataObjProps["artifact"].(map[string]interface{}); !ok { + t.Fatalf("status.data.artifact must be an object schema, got %#v", dataObjProps["artifact"]) + } + if _, ok := dataObjProps["target"]; ok { + t.Fatal("status.data.target must not exist in CRD (identity comes from spec.target)") } - if dataRef["type"] != "object" { - t.Fatalf("status.dataRef type: %#v", dataRef["type"]) + if _, ok := statusProps["dataRef"]; ok { + t.Fatal("status.dataRef must not exist in CRD") } if _, ok := statusProps["dataRefs"]; ok { t.Fatal("status.dataRefs[] must not exist in CRD") diff --git a/api/v1alpha1/volumecapturerequest_types.go b/api/v1alpha1/volumecapturerequest_types.go index 85f0d03..717331e 100644 --- a/api/v1alpha1/volumecapturerequest_types.go +++ b/api/v1alpha1/volumecapturerequest_types.go @@ -43,8 +43,7 @@ type VolumeCaptureTarget struct { // +kubebuilder:validation:MinLength=1 Name string `json:"name"` // Namespace is intentionally empty in spec.target (the PVC always lives in the VCR namespace). - // The controller fills it in status.dataRef.target from the VCR namespace so the binding is - // self-contained for downstream data retrieval. + // The captured PVC identity is carried by spec.target (immutable); status.data no longer duplicates it. // +optional Namespace string `json:"namespace,omitempty"` } @@ -71,16 +70,10 @@ type VolumeDataArtifactRef struct { } // +k8s:deepcopy-gen=true -// VolumeDataBinding associates a capture target with its durable data artifact on one VCR. +// VolumeDataBinding carries the durable data artifact produced for the VCR's captured target. +// The captured PVC identity is not duplicated here: it lives in spec.target (immutable), so status.data +// carries only the artifact. type VolumeDataBinding struct { - // TargetUID matches spec.target.uid (the captured PersistentVolumeClaim UID). - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - TargetUID string `json:"targetUID"` - - // Target identifies the PVC target captured in this binding. - Target VolumeCaptureTarget `json:"target"` - // Artifact references the cluster-scoped durable data artifact. Artifact VolumeDataArtifactRef `json:"artifact"` } @@ -106,9 +99,10 @@ type VolumeCaptureRequestStatus struct { // Conditions represent the latest available observations of the resource's state Conditions []metav1.Condition `json:"conditions,omitempty"` - // DataRef is the durable data artifact for the captured target (for example VolumeSnapshotContent). + // Data is the durable data artifact for the captured target (for example VolumeSnapshotContent). + // The captured PVC identity comes from spec.target (immutable); data carries only the artifact. // +optional - DataRef *VolumeDataBinding `json:"dataRef,omitempty"` + Data *VolumeDataBinding `json:"data,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ce7dd61..ff755f8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -68,6 +68,26 @@ func (in *DataExport) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataExportImportData) DeepCopyInto(out *DataExportImportData) { + *out = *in + if in.Artifact != nil { + in, out := &in.Artifact, &out.Artifact + *out = new(DataArtifactReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataExportImportData. +func (in *DataExportImportData) DeepCopy() *DataExportImportData { + if in == nil { + return nil + } + out := new(DataExportImportData) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DataExportImportStatus) DeepCopyInto(out *DataExportImportStatus) { *out = *in @@ -79,10 +99,10 @@ func (in *DataExportImportStatus) DeepCopyInto(out *DataExportImportStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.DataArtifactRef != nil { - in, out := &in.DataArtifactRef, &out.DataArtifactRef - *out = new(DataArtifactReference) - **out = **in + if in.Data != nil { + in, out := &in.Data, &out.Data + *out = new(DataExportImportData) + (*in).DeepCopyInto(*out) } } @@ -439,8 +459,8 @@ func (in *VolumeCaptureRequestStatus) DeepCopyInto(out *VolumeCaptureRequestStat (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.DataRef != nil { - in, out := &in.DataRef, &out.DataRef + if in.Data != nil { + in, out := &in.Data, &out.Data *out = new(VolumeDataBinding) **out = **in } @@ -489,7 +509,6 @@ func (in *VolumeDataArtifactRef) DeepCopy() *VolumeDataArtifactRef { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VolumeDataBinding) DeepCopyInto(out *VolumeDataBinding) { *out = *in - out.Target = in.Target out.Artifact = in.Artifact } diff --git a/crds/dataimports.yaml b/crds/dataimports.yaml index ec1555a..e1442dc 100644 --- a/crds/dataimports.yaml +++ b/crds/dataimports.yaml @@ -203,23 +203,30 @@ spec: enum: ["Block", "Filesystem"] description: | Volume mode of the exported data. - dataArtifactRef: + data: type: object description: | - Reference to the durable cluster-scoped data artifact produced by this import - (a VolumeSnapshotContent). Populated once the backing VolumeCaptureRequest completes. + Captured-data block for this import. Carries the durable cluster-scoped data artifact + under data.artifact (a VolumeSnapshotContent). Populated once the backing + VolumeCaptureRequest completes. properties: - apiVersion: - type: string - kind: - type: string - name: - type: string - uid: - type: string + artifact: + type: object description: | - UID of the durable data artifact (for example the VolumeSnapshotContent UID), - making the reference self-contained. Optional; producers fill it best-effort. + Reference to the durable cluster-scoped data artifact produced by this import + (a VolumeSnapshotContent). + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + uid: + type: string + description: | + UID of the durable data artifact (for example the VolumeSnapshotContent UID), + making the reference self-contained. Optional; producers fill it best-effort. conditions: type: array items: diff --git a/crds/doc-ru-dataimports.yaml b/crds/doc-ru-dataimports.yaml index 8dbab3a..0c21675 100644 --- a/crds/doc-ru-dataimports.yaml +++ b/crds/doc-ru-dataimports.yaml @@ -106,22 +106,26 @@ spec: volumeMode: description: | Режим тома импортированных данных. - dataArtifactRef: + data: description: | - Ссылка на долговечный кластерный артефакт данных (VolumeSnapshotContent), созданный этим импортом. Заполняется после завершения соответствующего VolumeCaptureRequest. + Блок захваченных данных импорта. Содержит долговечный кластерный артефакт данных под data.artifact (VolumeSnapshotContent). Заполняется после завершения соответствующего VolumeCaptureRequest. properties: - apiVersion: + artifact: description: | - Версия API артефакта данных. - kind: - description: | - Тип артефакта данных. - name: - description: | - Имя артефакта данных. - uid: - description: | - UID артефакта данных (например, UID VolumeSnapshotContent). Необязательное; заполняется по возможности. + Ссылка на долговечный кластерный артефакт данных (VolumeSnapshotContent), созданный этим импортом. + properties: + apiVersion: + description: | + Версия API артефакта данных. + kind: + description: | + Тип артефакта данных. + name: + description: | + Имя артефакта данных. + uid: + description: | + UID артефакта данных (например, UID VolumeSnapshotContent). Необязательное; заполняется по возможности. conditions: type: array items: diff --git a/crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml b/crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml index 9e7de97..f65b802 100644 --- a/crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml +++ b/crds/internal/storage-foundation.deckhouse.io_volumecapturerequests.yaml @@ -66,8 +66,7 @@ spec: namespace: description: |- Namespace is intentionally empty in spec.target (the PVC always lives in the VCR namespace). - The controller fills it in status.dataRef.target from the VCR namespace so the binding is - self-contained for downstream data retrieval. + The captured PVC identity is carried by spec.target (immutable); status.data no longer duplicates it. type: string uid: description: UID is the captured PersistentVolumeClaim UID. @@ -149,9 +148,10 @@ spec: - type type: object type: array - dataRef: - description: DataRef is the durable data artifact for the captured - target (for example VolumeSnapshotContent). + data: + description: |- + Data is the durable data artifact for the captured target (for example VolumeSnapshotContent). + The captured PVC identity comes from spec.target (immutable); data carries only the artifact. properties: artifact: description: Artifact references the cluster-scoped durable data @@ -177,44 +177,8 @@ spec: - kind - name type: object - target: - description: Target identifies the PVC target captured in this - binding. - properties: - apiVersion: - minLength: 1 - type: string - kind: - minLength: 1 - type: string - name: - minLength: 1 - type: string - namespace: - description: |- - Namespace is intentionally empty in spec.target (the PVC always lives in the VCR namespace). - The controller fills it in status.dataRef.target from the VCR namespace so the binding is - self-contained for downstream data retrieval. - type: string - uid: - description: UID is the captured PersistentVolumeClaim UID. - minLength: 1 - type: string - required: - - apiVersion - - kind - - name - - uid - type: object - targetUID: - description: TargetUID matches spec.target.uid (the captured PersistentVolumeClaim - UID). - minLength: 1 - type: string required: - artifact - - target - - targetUID type: object type: object type: object diff --git a/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go b/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go index 011345c..6bd30c5 100644 --- a/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_cleanup_test.go @@ -13,16 +13,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -func vcrDataRefBinding(targetUID, kind, name, apiVersion string) *storagev1alpha1.VolumeDataBinding { +func vcrDataBinding(kind, name, apiVersion string) *storagev1alpha1.VolumeDataBinding { return &storagev1alpha1.VolumeDataBinding{ - TargetUID: targetUID, - Target: storagev1alpha1.VolumeCaptureTarget{ - UID: targetUID, - APIVersion: "v1", - Kind: "PersistentVolumeClaim", - Namespace: "default", - Name: "pvc-1", - }, Artifact: storagev1alpha1.VolumeDataArtifactRef{ APIVersion: apiVersion, Kind: kind, @@ -39,7 +31,7 @@ func TestCleanupArtifactsForVCR_DeletesOrphans(t *testing.T) { vcr := &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{Name: "vcr-1", Namespace: "default"}, Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRef: vcrDataRefBinding("uid-1", "VolumeSnapshotContent", "vsc-1", "snapshot.storage.k8s.io/v1"), + Data: vcrDataBinding("VolumeSnapshotContent", "vsc-1", "snapshot.storage.k8s.io/v1"), }, } vsc := &snapshotv1.VolumeSnapshotContent{ @@ -73,7 +65,7 @@ func TestCleanupArtifactsForVCR_SkipsManaged(t *testing.T) { vcr := &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{Name: "vcr-2", Namespace: "default"}, Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRef: vcrDataRefBinding("uid-2", "VolumeSnapshotContent", "vsc-2", "snapshot.storage.k8s.io/v1"), + Data: vcrDataBinding("VolumeSnapshotContent", "vsc-2", "snapshot.storage.k8s.io/v1"), }, } vsc := &snapshotv1.VolumeSnapshotContent{ @@ -100,7 +92,7 @@ func TestCleanupArtifactsForVCR_DeletesPVOrphans(t *testing.T) { vcr := &storagev1alpha1.VolumeCaptureRequest{ ObjectMeta: metav1.ObjectMeta{Name: "vcr-3", Namespace: "default"}, Status: storagev1alpha1.VolumeCaptureRequestStatus{ - DataRef: vcrDataRefBinding("uid-3", "PersistentVolume", "pv-1", "v1"), + Data: vcrDataBinding("PersistentVolume", "pv-1", "v1"), }, } pv := &corev1.PersistentVolume{ diff --git a/images/controller/internal/controllers/volumecapturerequest_controller.go b/images/controller/internal/controllers/volumecapturerequest_controller.go index 79544e4..7aee58d 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller.go @@ -193,7 +193,7 @@ func (r *VolumeCaptureRequestController) processSnapshotMode(ctx context.Context } if tr.ready && tr.binding != nil { - vcr.Status.DataRef = tr.binding + vcr.Status.Data = tr.binding if err := r.finalizeVCR(ctx, vcr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, "target ready"); err != nil { return ctrl.Result{}, err } @@ -790,10 +790,10 @@ func (r *VolumeCaptureRequestController) scanAndDeleteExpiredVCRs(ctx context.Co // cleanupArtifactsForVCR deletes VCR-created artifacts if they have no ownerRef. // This is a best-effort cleanup used by the TTL scanner to avoid orphaned artifacts. func (r *VolumeCaptureRequestController) cleanupArtifactsForVCR(ctx context.Context, vcr *storagev1alpha1.VolumeCaptureRequest) error { - if vcr.Status.DataRef == nil { + if vcr.Status.Data == nil { return nil } - artifact := vcr.Status.DataRef.Artifact + artifact := vcr.Status.Data.Artifact if artifact.Name == "" { return nil } diff --git a/images/controller/internal/controllers/volumecapturerequest_controller_test.go b/images/controller/internal/controllers/volumecapturerequest_controller_test.go index 2f3d7de..e6cb3c6 100644 --- a/images/controller/internal/controllers/volumecapturerequest_controller_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_controller_test.go @@ -452,10 +452,10 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(readyCondition).ToNot(BeNil()) Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue)) - Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) - Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("VolumeSnapshotContent")) - Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal(csiVSCName)) - Expect(updatedVCR.Status.DataRef.Artifact.UID).To(Equal("vsc-uid-happy")) + Expect(updatedVCR.Status.Data).ToNot(BeNil()) + Expect(updatedVCR.Status.Data.Artifact.Kind).To(Equal("VolumeSnapshotContent")) + Expect(updatedVCR.Status.Data.Artifact.Name).To(Equal(csiVSCName)) + Expect(updatedVCR.Status.Data.Artifact.UID).To(Equal("vsc-uid-happy")) }) }) @@ -537,10 +537,10 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonSnapshotCreationFailed)) Expect(readyCondition.Message).To(ContainSubstring(errorMsg)) - Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) - Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("VolumeSnapshotContent")) - Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal(csiVSCName)) - Expect(updatedVCR.Status.DataRef.Artifact.UID).To(Equal("vsc-uid-error")) + Expect(updatedVCR.Status.Data).ToNot(BeNil()) + Expect(updatedVCR.Status.Data.Artifact.Kind).To(Equal("VolumeSnapshotContent")) + Expect(updatedVCR.Status.Data.Artifact.Name).To(Equal(csiVSCName)) + Expect(updatedVCR.Status.Data.Artifact.UID).To(Equal("vsc-uid-error")) // VSC still exists (not deleted) existingVSC := &snapshotv1.VolumeSnapshotContent{} @@ -729,10 +729,10 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { // VCR status updatedVCR := &storagev1alpha1.VolumeCaptureRequest{} Expect(client.Get(ctx, types.NamespacedName{Name: vcr.Name, Namespace: vcr.Namespace}, updatedVCR)).To(Succeed()) - Expect(updatedVCR.Status.DataRef).ToNot(BeNil()) - Expect(updatedVCR.Status.DataRef.Artifact.Kind).To(Equal("PersistentVolume")) - Expect(updatedVCR.Status.DataRef.Artifact.Name).To(Equal("test-pv-detach")) - Expect(updatedVCR.Status.DataRef.Artifact.UID).To(Equal("pv-uid-detach")) + Expect(updatedVCR.Status.Data).ToNot(BeNil()) + Expect(updatedVCR.Status.Data.Artifact.Kind).To(Equal("PersistentVolume")) + Expect(updatedVCR.Status.Data.Artifact.Name).To(Equal("test-pv-detach")) + Expect(updatedVCR.Status.Data.Artifact.UID).To(Equal("pv-uid-detach")) readyCondition := getCondition(updatedVCR.Status.Conditions, storagev1alpha1.ConditionTypeReady) Expect(readyCondition).ToNot(BeNil()) @@ -955,7 +955,7 @@ var _ = Describe("VolumeCaptureRequest Controller", func() { Expect(ready.Status).To(Equal(metav1.ConditionFalse)) Expect(ready.Reason).To(Equal(storagev1alpha1.ConditionReasonTargetsPending)) // dataRef is only set on success. - Expect(updated.Status.DataRef).To(BeNil()) + Expect(updated.Status.Data).To(BeNil()) }) It("should requeue without creating VSC when ObjectKeeper UID is empty", func() { diff --git a/images/controller/internal/controllers/volumecapturerequest_single_target.go b/images/controller/internal/controllers/volumecapturerequest_single_target.go index ca7fc58..ef3496e 100644 --- a/images/controller/internal/controllers/volumecapturerequest_single_target.go +++ b/images/controller/internal/controllers/volumecapturerequest_single_target.go @@ -32,13 +32,11 @@ func requireCaptureTarget(spec storagev1alpha1.VolumeCaptureRequestSpec) (storag func setVolumeSnapshotDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, vscName, vscUID string) { binding := volumeSnapshotBinding(target, vscName, vscUID) - vcr.Status.DataRef = &binding + vcr.Status.Data = &binding } -func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, target storagev1alpha1.VolumeCaptureTarget, pvName, pvUID string) { - vcr.Status.DataRef = &storagev1alpha1.VolumeDataBinding{ - TargetUID: target.UID, - Target: target, +func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, _ storagev1alpha1.VolumeCaptureTarget, pvName, pvUID string) { + vcr.Status.Data = &storagev1alpha1.VolumeDataBinding{ Artifact: storagev1alpha1.VolumeDataArtifactRef{ APIVersion: "v1", Kind: "PersistentVolume", @@ -50,8 +48,8 @@ func setPersistentVolumeDataRef(vcr *storagev1alpha1.VolumeCaptureRequest, targe } func dataArtifactRef(status storagev1alpha1.VolumeCaptureRequestStatus) (storagev1alpha1.VolumeDataArtifactRef, bool) { - if status.DataRef == nil { + if status.Data == nil { return storagev1alpha1.VolumeDataArtifactRef{}, false } - return status.DataRef.Artifact, true + return status.Data.Artifact, true } diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot.go b/images/controller/internal/controllers/volumecapturerequest_snapshot.go index 829e34a..b8f5971 100644 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot.go +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot.go @@ -69,10 +69,8 @@ func snapshotVSCName(vcrUID types.UID, targetUID string) string { return fmt.Sprintf("snapshot-%s-%s", string(vcrUID), targetUIDHash(targetUID)) } -func volumeSnapshotBinding(target storagev1alpha1.VolumeCaptureTarget, vscName, vscUID string) storagev1alpha1.VolumeDataBinding { +func volumeSnapshotBinding(_ storagev1alpha1.VolumeCaptureTarget, vscName, vscUID string) storagev1alpha1.VolumeDataBinding { return storagev1alpha1.VolumeDataBinding{ - TargetUID: target.UID, - Target: target, Artifact: storagev1alpha1.VolumeDataArtifactRef{ APIVersion: "snapshot.storage.k8s.io/v1", Kind: "VolumeSnapshotContent", @@ -317,7 +315,7 @@ func (r *VolumeCaptureRequestController) markFailedSnapshotForTarget( ) (ctrl.Result, error) { if vscName != "" { binding := volumeSnapshotBinding(target, vscName, vscUID) - vcr.Status.DataRef = &binding + vcr.Status.Data = &binding } if err := r.finalizeVCR(ctx, vcr, metav1.ConditionFalse, reason, message); err != nil { return ctrl.Result{}, err diff --git a/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go b/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go index dacffc8..4116f64 100644 --- a/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go +++ b/images/controller/internal/controllers/volumecapturerequest_snapshot_test.go @@ -31,15 +31,9 @@ func TestTargetUIDHashDeterministic(t *testing.T) { } } -func TestVolumeSnapshotBindingSetsTargetUID(t *testing.T) { +func TestVolumeSnapshotBindingSetsArtifact(t *testing.T) { target := storagev1alpha1.VolumeCaptureTarget{UID: "uid-a", Namespace: "ns", Name: "pvc-a"} binding := volumeSnapshotBinding(target, "vsc-a", "vsc-uid-a") - if binding.TargetUID != "uid-a" { - t.Fatalf("TargetUID = %q, want %q", binding.TargetUID, "uid-a") - } - if binding.Target.Namespace != "ns" { - t.Fatalf("Target.Namespace = %q, want %q", binding.Target.Namespace, "ns") - } if binding.Artifact.Name != "vsc-a" || binding.Artifact.Kind != "VolumeSnapshotContent" { t.Fatalf("unexpected artifact: %#v", binding.Artifact) } diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go index a556bb4..52b0205 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go @@ -240,13 +240,13 @@ func (r *DataImportReconciler) ensureSnapshotImportTarget(ctx context.Context) ( // Terminal: once the artifact is produced the import is done. Re-affirm Completed (idempotent) and // stop, so completed DataImports don't re-derive the scratch PVC on every publish/server event until // TTL expiry. - if r.dataImport.Status.DataArtifactRef != nil { + if r.dataImport.Status.Data != nil && r.dataImport.Status.Data.Artifact != nil { meta.SetStatusCondition(&r.dataImport.Status.Conditions, metav1.Condition{ Type: string(common.ConditionCompleted), Status: metav1.ConditionTrue, Reason: string(common.ReasonCompleted), Message: fmt.Sprintf("Data import completed: produced %s %s", - r.dataImport.Status.DataArtifactRef.Kind, r.dataImport.Status.DataArtifactRef.Name), + r.dataImport.Status.Data.Artifact.Kind, r.dataImport.Status.Data.Artifact.Name), ObservedGeneration: r.dataImport.Generation, }) return ctrl.Result{}, nil @@ -584,7 +584,7 @@ func (r *DataImportReconciler) ensureDataArtifact(ctx context.Context, pvc *core return ctrl.Result{}, err } - r.dataImport.Status.DataArtifactRef = artifact + r.dataImport.Status.Data = &dev1alpha1.DataExportImportData{Artifact: artifact} meta.SetStatusCondition(&r.dataImport.Status.Conditions, metav1.Condition{ Type: string(common.ConditionCompleted), Status: metav1.ConditionTrue, diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go b/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go index 384c626..07425c1 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go @@ -180,8 +180,7 @@ func TestVolumeCaptureRequestReadyAndFailed(t *testing.T) { func TestVolumeCaptureArtifact(t *testing.T) { vcr := &unstructured.Unstructured{Object: map[string]interface{}{ "status": map[string]interface{}{ - "dataRef": map[string]interface{}{ - "targetUID": "uid-1", + "data": map[string]interface{}{ "artifact": map[string]interface{}{ "apiVersion": "snapshot.storage.k8s.io/v1", "kind": artifactKindVolumeSnapshotContent, @@ -196,14 +195,14 @@ func TestVolumeCaptureArtifact(t *testing.T) { require.NoError(t, err) assert.Equal(t, "snapcontent-x", art.Name) assert.Equal(t, artifactKindVolumeSnapshotContent, art.Kind) - // The artifact uid is carried through from VCR status.dataRef.artifact.uid. + // The artifact uid is carried through from VCR status.data.artifact.uid. assert.Equal(t, "8d7c6b5a-4e3f-4a2b-9c1d-0f1e2d3c4b5a", art.UID) // Kind mismatch is rejected. _, err = volumeCaptureArtifact(vcr, artifactKindPersistentVolume) assert.Error(t, err) - // No status.dataRef at all errors. + // No status.data at all errors. empty := &unstructured.Unstructured{Object: map[string]interface{}{"status": map[string]interface{}{}}} _, err = volumeCaptureArtifact(empty, artifactKindVolumeSnapshotContent) assert.Error(t, err) @@ -352,7 +351,7 @@ func TestHandlePVCImportStatusCompletesWithoutArtifact(t *testing.T) { assert.Zero(t, res.RequeueAfter) assert.True(t, meta.IsStatusConditionTrue(r.dataImport.Status.Conditions, string(common.ConditionCompleted))) // The defining difference of Mode B: no durable artifact is produced. - assert.Nil(t, r.dataImport.Status.DataArtifactRef) + assert.Nil(t, r.dataImport.Status.Data) assert.Equal(t, "Filesystem", r.dataImport.Status.VolumeMode) } diff --git a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go index 4c1c531..aa5fe22 100644 --- a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go +++ b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go @@ -214,14 +214,14 @@ func vcrReadyCondition(vcr *unstructured.Unstructured) (status, reason string, o // is no list to match by targetUID. It validates the artifact kind matches what the chosen mode must // produce and carries the artifact uid through into DataArtifactReference. func volumeCaptureArtifact(vcr *unstructured.Unstructured, expectedKind string) (*dev1alpha1.DataArtifactReference, error) { - dataRef, found, err := unstructured.NestedMap(vcr.Object, "status", "dataRef") + dataRef, found, err := unstructured.NestedMap(vcr.Object, "status", "data") if err != nil || !found || len(dataRef) == 0 { - return nil, fmt.Errorf("VolumeCaptureRequest has no status.dataRef") + return nil, fmt.Errorf("VolumeCaptureRequest has no status.data") } artifact, isMap := dataRef["artifact"].(map[string]interface{}) if !isMap { - return nil, fmt.Errorf("VolumeCaptureRequest dataRef has no artifact") + return nil, fmt.Errorf("VolumeCaptureRequest data has no artifact") } apiVersion, _, _ := unstructured.NestedString(artifact, "apiVersion") From f855cc08c54a42335d2ca073629e158d39760492 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Mon, 6 Jul 2026 06:20:32 +0300 Subject: [PATCH 07/11] feat(snapshot-controller): reshape forked VolumeSnapshot status into self-contained status.data w5-status-source-descriptor (extended-VS fork). Replace the flat status.storageClassName/size/volumeMode mirror on the Deckhouse-forked extended VolumeSnapshot with a single self-contained status.data (VolumeSnapshotDataBinding: source + artifact + volumeMode/fsType/accessModes/storageClassName/size), whose JSON wire shape is byte-identical to the state-snapshotter SnapshotContent.status.data and to the domain data leaves. This lets d8 resolve an imported leaf's captured-volume descriptor from the namespaced VolumeSnapshot alone. - 003-volumesnapshot-dataimport-fork.patch: drop the three flat status fields, add status.data *VolumeSnapshotDataBinding (+ VolumeSnapshotDataSource / VolumeSnapshotDataArtifact) with hand-written deepcopy, in both the ./client and vendor copies. status.boundSnapshotContentName and the spec.source.import marker are unchanged. - crds/snapshot.storage.k8s.io_volumesnapshots.yaml (+ doc-ru): hand-maintained CRD reshaped for both v1 and v1beta1 (remove flat fields, add status.data object schema). - patches/README.md: document the status.data reshape. The patch applies to the upstream external-snapshotter tree (not vendored here), so it cannot be compiled locally; hunk length-consistency verified via git apply --numstat. The state-snapshotter consumer (volumesnapshotimport mirror) lands on the wave5 branch. Signed-off-by: Aleksandr Zimin --- ...apshot.storage.k8s.io_volumesnapshots.yaml | 70 +++--- ...apshot.storage.k8s.io_volumesnapshots.yaml | 172 +++++++++---- .../003-volumesnapshot-dataimport-fork.patch | 228 +++++++++++++----- images/snapshot-controller/patches/README.md | 14 +- 4 files changed, 343 insertions(+), 141 deletions(-) diff --git a/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml b/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml index bac4d8d..2934aee 100644 --- a/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml +++ b/crds/doc-ru-snapshot.storage.k8s.io_volumesnapshots.yaml @@ -112,6 +112,27 @@ spec: возвращаемым из вызова gRPC `ListSnapshots` CSI, если драйвер поддерживает это. Если не указано, время создания снимка неизвестно. + data: + description: |- + Самодостаточная привязка данных (source + artifact + метаданные тома), зеркалируемая + из привязанного SnapshotContent.status.data state-snapshotter, чтобы d8 определял + дескриптор захваченного тома из этого namespaced VolumeSnapshot. Записывается общим + контроллером state-snapshotter. Расширение Deckhouse. + properties: + accessModes: + description: Режимы доступа исходного PVC (например, ReadWriteOnce). + artifact: + description: Durable-артефакт данных (VolumeSnapshotContent). + fsType: + description: Тип файловой системы источника (только для томов Filesystem). + size: + description: Реальный выделенный размер захваченного тома (например, "10Gi"). + source: + description: Захваченный источник PersistentVolumeClaim (uid — идентичность тома). + storageClassName: + description: StorageClass исходного захваченного тома. + volumeMode: + description: Режим тома источника (Block или Filesystem). error: description: |- Последняя наблюдаемая ошибка при создании снимка, если есть. @@ -157,23 +178,9 @@ spec: быть меньше `restoreSize`, если он указан, иначе восстановление завершится ошибкой. Если не указано, размер неизвестен. - size: - description: |- - Отражает реальный размер тома (например, "10Gi"). Записывается общим контроллером - state-snapshotter (из DataImport.spec.size при импорте или из привязанного content при захвате). - Расширение Deckhouse. - storageClassName: - description: |- - Отражает StorageClass исходного тома для экспорта/потребления в d8. Для импортных - VolumeSnapshot берется из DataImport.spec.storageClassName, при захвате — из привязанного content. - Записывается общим контроллером state-snapshotter. Расширение Deckhouse. volumeGroupSnapshotName: description: |- Имя VolumeGroupSnapshot, частью которого является этот VolumeSnapshot. - volumeMode: - description: |- - Отражает режим тома источника (Filesystem или Block). Записывается общим контроллером - state-snapshotter. Расширение Deckhouse. - name: v1beta1 schema: openAPIV3Schema: @@ -287,6 +294,27 @@ spec: возвращаемым из вызова gRPC `ListSnapshots` CSI, если драйвер поддерживает это. Если не указано, время создания снимка неизвестно. + data: + description: |- + Самодостаточная привязка данных (source + artifact + метаданные тома), зеркалируемая + из привязанного SnapshotContent.status.data state-snapshotter, чтобы d8 определял + дескриптор захваченного тома из этого namespaced VolumeSnapshot. Записывается общим + контроллером state-snapshotter. Расширение Deckhouse. + properties: + accessModes: + description: Режимы доступа исходного PVC (например, ReadWriteOnce). + artifact: + description: Durable-артефакт данных (VolumeSnapshotContent). + fsType: + description: Тип файловой системы источника (только для томов Filesystem). + size: + description: Реальный выделенный размер захваченного тома (например, "10Gi"). + source: + description: Захваченный источник PersistentVolumeClaim (uid — идентичность тома). + storageClassName: + description: StorageClass исходного захваченного тома. + volumeMode: + description: Режим тома источника (Block или Filesystem). error: description: |- Последняя наблюдаемая ошибка при создании снимка, если есть. @@ -332,20 +360,6 @@ spec: быть меньше `restoreSize`, если он указан, иначе восстановление завершится ошибкой. Если не указано, размер неизвестен. - size: - description: |- - Отражает реальный размер тома (например, "10Gi"). Записывается общим контроллером - state-snapshotter (из DataImport.spec.size при импорте или из привязанного content при захвате). - Расширение Deckhouse. - storageClassName: - description: |- - Отражает StorageClass исходного тома для экспорта/потребления в d8. Для импортных - VolumeSnapshot берется из DataImport.spec.storageClassName, при захвате — из привязанного content. - Записывается общим контроллером state-snapshotter. Расширение Deckhouse. volumeGroupSnapshotName: description: |- Имя VolumeGroupSnapshot, частью которого является этот VolumeSnapshot. - volumeMode: - description: |- - Отражает режим тома источника (Filesystem или Block). Записывается общим контроллером - state-snapshotter. Расширение Deckhouse. diff --git a/crds/snapshot.storage.k8s.io_volumesnapshots.yaml b/crds/snapshot.storage.k8s.io_volumesnapshots.yaml index 67640eb..a1a7f67 100644 --- a/crds/snapshot.storage.k8s.io_volumesnapshots.yaml +++ b/crds/snapshot.storage.k8s.io_volumesnapshots.yaml @@ -193,6 +193,71 @@ spec: If not specified, the creation time of the snapshot is unknown. format: date-time type: string + data: + description: |- + data is the self-contained data binding (source + artifact + volume metadata) + mirrored from the backing state-snapshotter SnapshotContent.status.data, so d8 + resolves the captured-volume descriptor from this namespaced VolumeSnapshot + alone. Written by the state-snapshotter common controller. Deckhouse extension. + properties: + accessModes: + description: Source PVC access modes (e.g. ReadWriteOnce). + items: + type: string + type: array + artifact: + description: Durable data artifact (a VolumeSnapshotContent). + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + uid: + type: string + required: + - apiVersion + - kind + - name + type: object + fsType: + description: Source filesystem type (Filesystem volumes only). + type: string + size: + description: Real allocated size of the captured volume (e.g. "10Gi"). + type: string + source: + description: Captured PersistentVolumeClaim source (uid is the volume identity). + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + uid: + type: string + required: + - apiVersion + - kind + - name + type: object + storageClassName: + description: Source StorageClass of the captured volume. + type: string + volumeMode: + description: Source volume mode (Block or Filesystem). + enum: + - Block + - Filesystem + type: string + required: + - artifact + - source + type: object error: description: |- Last observed error during snapshot creation, if any. @@ -240,31 +305,10 @@ spec: If not specified, the size is unknown. pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - size: - description: |- - size mirrors the real volume size (e.g. "10Gi"). Written by the state-snapshotter - common controller (from DataImport.spec.size on import, or the bound content on - capture). Deckhouse extension. - type: string - storageClassName: - description: |- - storageClassName mirrors the source volume StorageClass for d8 export/consumption. - For import VolumeSnapshots it is taken from DataImport.spec.storageClassName; for - capture it mirrors the bound content's storage class. Written by the - state-snapshotter common controller. Deckhouse extension. - type: string volumeGroupSnapshotName: description: |- Name of the VolumeGroupSnapshot of which this VolumeSnapshot is a part. type: string - volumeMode: - description: |- - volumeMode mirrors the source volume mode (Filesystem or Block). Written by the - state-snapshotter common controller. Deckhouse extension. - enum: - - Block - - Filesystem - type: string type: object required: - spec @@ -417,6 +461,71 @@ spec: If not specified, the creation time of the snapshot is unknown. format: date-time type: string + data: + description: |- + data is the self-contained data binding (source + artifact + volume metadata) + mirrored from the backing state-snapshotter SnapshotContent.status.data, so d8 + resolves the captured-volume descriptor from this namespaced VolumeSnapshot + alone. Written by the state-snapshotter common controller. Deckhouse extension. + properties: + accessModes: + description: Source PVC access modes (e.g. ReadWriteOnce). + items: + type: string + type: array + artifact: + description: Durable data artifact (a VolumeSnapshotContent). + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + uid: + type: string + required: + - apiVersion + - kind + - name + type: object + fsType: + description: Source filesystem type (Filesystem volumes only). + type: string + size: + description: Real allocated size of the captured volume (e.g. "10Gi"). + type: string + source: + description: Captured PersistentVolumeClaim source (uid is the volume identity). + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + uid: + type: string + required: + - apiVersion + - kind + - name + type: object + storageClassName: + description: Source StorageClass of the captured volume. + type: string + volumeMode: + description: Source volume mode (Block or Filesystem). + enum: + - Block + - Filesystem + type: string + required: + - artifact + - source + type: object error: description: |- Last observed error during snapshot creation, if any. @@ -464,31 +573,10 @@ spec: If not specified, the size is unknown. pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true - size: - description: |- - size mirrors the real volume size (e.g. "10Gi"). Written by the state-snapshotter - common controller (from DataImport.spec.size on import, or the bound content on - capture). Deckhouse extension. - type: string - storageClassName: - description: |- - storageClassName mirrors the source volume StorageClass for d8 export/consumption. - For import VolumeSnapshots it is taken from DataImport.spec.storageClassName; for - capture it mirrors the bound content's storage class. Written by the - state-snapshotter common controller. Deckhouse extension. - type: string volumeGroupSnapshotName: description: |- Name of the VolumeGroupSnapshot of which this VolumeSnapshot is a part. type: string - volumeMode: - description: |- - volumeMode mirrors the source volume mode (Filesystem or Block). Written by the - state-snapshotter common controller. Deckhouse extension. - enum: - - Block - - Filesystem - type: string type: object required: - spec diff --git a/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch b/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch index 73751da..004e196 100644 --- a/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch +++ b/images/snapshot-controller/patches/003-volumesnapshot-dataimport-fork.patch @@ -25,7 +25,7 @@ index 36f60dc..e865972 100644 type VolumeSnapshotSource struct { // persistentVolumeClaimName specifies the name of the PersistentVolumeClaim // object representing the volume from which a snapshot should be created. -@@ -122,8 +128,23 @@ type VolumeSnapshotSource struct { +@@ -122,8 +128,72 @@ type VolumeSnapshotSource struct { // +optional // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="volumeSnapshotContentName is immutable" VolumeSnapshotContentName *string `json:"volumeSnapshotContentName,omitempty" protobuf:"bytes,2,opt,name=volumeSnapshotContentName"` @@ -45,11 +45,60 @@ index 36f60dc..e865972 100644 +// (storage-foundation.deckhouse.io) and the upstream snapshot-controller does NOT reconcile it. +// Deckhouse fork extension. +type VolumeSnapshotImportSource struct{} ++ ++// VolumeSnapshotDataBinding is the self-contained data binding mirrored onto an import VolumeSnapshot's ++// status.data by the state-snapshotter common controller. Its JSON wire shape is byte-identical to the ++// state-snapshotter SnapshotContent.status.data (source + artifact + volume metadata), so d8 reads the ++// captured-volume descriptor from the namespaced VolumeSnapshot without the cluster-scoped SnapshotContent. ++// Deckhouse fork extension. ++type VolumeSnapshotDataBinding struct { ++ // source identifies the captured PersistentVolumeClaim backing this node's data (uid is the identity). ++ Source VolumeSnapshotDataSource `json:"source" protobuf:"bytes,1,opt,name=source"` ++ // artifact references the cluster-scoped durable data artifact (a VolumeSnapshotContent). ++ Artifact VolumeSnapshotDataArtifact `json:"artifact" protobuf:"bytes,2,opt,name=artifact"` ++ // volumeMode records the source volume mode (Block or Filesystem). ++ // +optional ++ VolumeMode string `json:"volumeMode,omitempty" protobuf:"bytes,3,opt,name=volumeMode"` ++ // fsType records the source filesystem type (Filesystem volumes only). ++ // +optional ++ FsType string `json:"fsType,omitempty" protobuf:"bytes,4,opt,name=fsType"` ++ // accessModes records the source PVC access modes (e.g. ReadWriteOnce). ++ // +optional ++ AccessModes []string `json:"accessModes,omitempty" protobuf:"bytes,5,rep,name=accessModes"` ++ // storageClassName records the source StorageClass of the captured volume. ++ // +optional ++ StorageClassName string `json:"storageClassName,omitempty" protobuf:"bytes,6,opt,name=storageClassName"` ++ // size records the real allocated size of the captured volume (e.g. "10Gi"). ++ // +optional ++ Size string `json:"size,omitempty" protobuf:"bytes,7,opt,name=size"` ++} ++ ++// VolumeSnapshotDataSource identifies the captured PVC source of a VolumeSnapshotDataBinding. ++// Deckhouse fork extension. ++type VolumeSnapshotDataSource struct { ++ APIVersion string `json:"apiVersion" protobuf:"bytes,1,opt,name=apiVersion"` ++ Kind string `json:"kind" protobuf:"bytes,2,opt,name=kind"` ++ Name string `json:"name" protobuf:"bytes,3,opt,name=name"` ++ // +optional ++ Namespace string `json:"namespace,omitempty" protobuf:"bytes,4,opt,name=namespace"` ++ // +optional ++ UID string `json:"uid,omitempty" protobuf:"bytes,5,opt,name=uid"` ++} ++ ++// VolumeSnapshotDataArtifact points to the durable data artifact of a VolumeSnapshotDataBinding ++// (for example a VolumeSnapshotContent). Deckhouse fork extension. ++type VolumeSnapshotDataArtifact struct { ++ APIVersion string `json:"apiVersion" protobuf:"bytes,1,opt,name=apiVersion"` ++ Kind string `json:"kind" protobuf:"bytes,2,opt,name=kind"` ++ Name string `json:"name" protobuf:"bytes,3,opt,name=name"` ++ // +optional ++ UID string `json:"uid,omitempty" protobuf:"bytes,4,opt,name=uid"` ++} + // VolumeSnapshotStatus is the status of the VolumeSnapshot // Note that CreationTime, RestoreSize, ReadyToUse, and Error are in both // VolumeSnapshotStatus and VolumeSnapshotContentStatus. Fields in VolumeSnapshotStatus -@@ -194,6 +215,33 @@ type VolumeSnapshotStatus struct { +@@ -194,6 +264,22 @@ type VolumeSnapshotStatus struct { // VolumeSnapshot is a part of. // +optional VolumeGroupSnapshotName *string `json:"volumeGroupSnapshotName,omitempty" protobuf:"bytes,6,opt,name=volumeGroupSnapshotName"` @@ -63,23 +112,12 @@ index 36f60dc..e865972 100644 + // +optional + BoundSnapshotContentName *string `json:"boundSnapshotContentName,omitempty" protobuf:"bytes,7,opt,name=boundSnapshotContentName"` + -+ // storageClassName mirrors the source volume StorageClass for d8 export/consumption. For import -+ // VolumeSnapshots it is taken from DataImport.spec.storageClassName; for capture it mirrors the -+ // bound content's storage class. Written by the state-snapshotter common controller. -+ // Deckhouse fork extension. -+ // +optional -+ StorageClassName *string `json:"storageClassName,omitempty" protobuf:"bytes,8,opt,name=storageClassName"` -+ -+ // size mirrors the real volume size (e.g. "10Gi"). Written by the state-snapshotter common -+ // controller (from DataImport.spec.size on import, or the bound content on capture). -+ // Deckhouse fork extension. -+ // +optional -+ Size *string `json:"size,omitempty" protobuf:"bytes,9,opt,name=size"` -+ -+ // volumeMode mirrors the source volume mode (Filesystem or Block). Written by the -+ // state-snapshotter common controller. Deckhouse fork extension. ++ // data is the self-contained data binding (source + artifact + volume metadata) mirrored from the ++ // backing state-snapshotter SnapshotContent.status.data. Its wire shape is byte-identical to the ++ // domain data leaves, so d8 resolves the captured-volume descriptor from this namespaced VolumeSnapshot ++ // alone. Written by the state-snapshotter common controller (import flow). Deckhouse fork extension. + // +optional -+ VolumeMode *string `json:"volumeMode,omitempty" protobuf:"bytes,10,opt,name=volumeMode"` ++ Data *VolumeSnapshotDataBinding `json:"data,omitempty" protobuf:"bytes,8,opt,name=data"` } // +genclient @@ -99,7 +137,7 @@ index a590aef..82aeaa3 100644 return } -@@ -427,6 +432,26 @@ func (in *VolumeSnapshotStatus) DeepCopyInto(out *VolumeSnapshotStatus) { +@@ -427,6 +432,36 @@ func (in *VolumeSnapshotStatus) DeepCopyInto(out *VolumeSnapshotStatus) { *out = new(string) **out = **in } @@ -108,23 +146,33 @@ index a590aef..82aeaa3 100644 + *out = new(string) + **out = **in + } -+ if in.StorageClassName != nil { -+ in, out := &in.StorageClassName, &out.StorageClassName -+ *out = new(string) -+ **out = **in -+ } -+ if in.Size != nil { -+ in, out := &in.Size, &out.Size -+ *out = new(string) -+ **out = **in -+ } -+ if in.VolumeMode != nil { -+ in, out := &in.VolumeMode, &out.VolumeMode -+ *out = new(string) -+ **out = **in ++ if in.Data != nil { ++ in, out := &in.Data, &out.Data ++ *out = new(VolumeSnapshotDataBinding) ++ (*in).DeepCopyInto(*out) + } return } ++ ++// DeepCopyInto is a deepcopy function, copying the receiver, writing into out. in must be non-nil. ++func (in *VolumeSnapshotDataBinding) DeepCopyInto(out *VolumeSnapshotDataBinding) { ++ *out = *in ++ if in.AccessModes != nil { ++ in, out := &in.AccessModes, &out.AccessModes ++ *out = make([]string, len(*in)) ++ copy(*out, *in) ++ } ++} ++ ++// DeepCopy is a deepcopy function, copying the receiver, creating a new VolumeSnapshotDataBinding. ++func (in *VolumeSnapshotDataBinding) DeepCopy() *VolumeSnapshotDataBinding { ++ if in == nil { ++ return nil ++ } ++ out := new(VolumeSnapshotDataBinding) ++ in.DeepCopyInto(out) ++ return out ++} diff --git a/pkg/common-controller/snapshot_controller.go b/pkg/common-controller/snapshot_controller.go index 8c1ba60..510f39f 100644 @@ -191,7 +239,7 @@ index 36f60dc..e865972 100644 type VolumeSnapshotSource struct { // persistentVolumeClaimName specifies the name of the PersistentVolumeClaim // object representing the volume from which a snapshot should be created. -@@ -122,8 +128,23 @@ type VolumeSnapshotSource struct { +@@ -122,8 +128,72 @@ type VolumeSnapshotSource struct { // +optional // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="volumeSnapshotContentName is immutable" VolumeSnapshotContentName *string `json:"volumeSnapshotContentName,omitempty" protobuf:"bytes,2,opt,name=volumeSnapshotContentName"` @@ -211,11 +259,60 @@ index 36f60dc..e865972 100644 +// (storage-foundation.deckhouse.io) and the upstream snapshot-controller does NOT reconcile it. +// Deckhouse fork extension. +type VolumeSnapshotImportSource struct{} ++ ++// VolumeSnapshotDataBinding is the self-contained data binding mirrored onto an import VolumeSnapshot's ++// status.data by the state-snapshotter common controller. Its JSON wire shape is byte-identical to the ++// state-snapshotter SnapshotContent.status.data (source + artifact + volume metadata), so d8 reads the ++// captured-volume descriptor from the namespaced VolumeSnapshot without the cluster-scoped SnapshotContent. ++// Deckhouse fork extension. ++type VolumeSnapshotDataBinding struct { ++ // source identifies the captured PersistentVolumeClaim backing this node's data (uid is the identity). ++ Source VolumeSnapshotDataSource `json:"source" protobuf:"bytes,1,opt,name=source"` ++ // artifact references the cluster-scoped durable data artifact (a VolumeSnapshotContent). ++ Artifact VolumeSnapshotDataArtifact `json:"artifact" protobuf:"bytes,2,opt,name=artifact"` ++ // volumeMode records the source volume mode (Block or Filesystem). ++ // +optional ++ VolumeMode string `json:"volumeMode,omitempty" protobuf:"bytes,3,opt,name=volumeMode"` ++ // fsType records the source filesystem type (Filesystem volumes only). ++ // +optional ++ FsType string `json:"fsType,omitempty" protobuf:"bytes,4,opt,name=fsType"` ++ // accessModes records the source PVC access modes (e.g. ReadWriteOnce). ++ // +optional ++ AccessModes []string `json:"accessModes,omitempty" protobuf:"bytes,5,rep,name=accessModes"` ++ // storageClassName records the source StorageClass of the captured volume. ++ // +optional ++ StorageClassName string `json:"storageClassName,omitempty" protobuf:"bytes,6,opt,name=storageClassName"` ++ // size records the real allocated size of the captured volume (e.g. "10Gi"). ++ // +optional ++ Size string `json:"size,omitempty" protobuf:"bytes,7,opt,name=size"` ++} ++ ++// VolumeSnapshotDataSource identifies the captured PVC source of a VolumeSnapshotDataBinding. ++// Deckhouse fork extension. ++type VolumeSnapshotDataSource struct { ++ APIVersion string `json:"apiVersion" protobuf:"bytes,1,opt,name=apiVersion"` ++ Kind string `json:"kind" protobuf:"bytes,2,opt,name=kind"` ++ Name string `json:"name" protobuf:"bytes,3,opt,name=name"` ++ // +optional ++ Namespace string `json:"namespace,omitempty" protobuf:"bytes,4,opt,name=namespace"` ++ // +optional ++ UID string `json:"uid,omitempty" protobuf:"bytes,5,opt,name=uid"` ++} ++ ++// VolumeSnapshotDataArtifact points to the durable data artifact of a VolumeSnapshotDataBinding ++// (for example a VolumeSnapshotContent). Deckhouse fork extension. ++type VolumeSnapshotDataArtifact struct { ++ APIVersion string `json:"apiVersion" protobuf:"bytes,1,opt,name=apiVersion"` ++ Kind string `json:"kind" protobuf:"bytes,2,opt,name=kind"` ++ Name string `json:"name" protobuf:"bytes,3,opt,name=name"` ++ // +optional ++ UID string `json:"uid,omitempty" protobuf:"bytes,4,opt,name=uid"` ++} + // VolumeSnapshotStatus is the status of the VolumeSnapshot // Note that CreationTime, RestoreSize, ReadyToUse, and Error are in both // VolumeSnapshotStatus and VolumeSnapshotContentStatus. Fields in VolumeSnapshotStatus -@@ -194,6 +215,33 @@ type VolumeSnapshotStatus struct { +@@ -194,6 +264,22 @@ type VolumeSnapshotStatus struct { // VolumeSnapshot is a part of. // +optional VolumeGroupSnapshotName *string `json:"volumeGroupSnapshotName,omitempty" protobuf:"bytes,6,opt,name=volumeGroupSnapshotName"` @@ -229,23 +326,12 @@ index 36f60dc..e865972 100644 + // +optional + BoundSnapshotContentName *string `json:"boundSnapshotContentName,omitempty" protobuf:"bytes,7,opt,name=boundSnapshotContentName"` + -+ // storageClassName mirrors the source volume StorageClass for d8 export/consumption. For import -+ // VolumeSnapshots it is taken from DataImport.spec.storageClassName; for capture it mirrors the -+ // bound content's storage class. Written by the state-snapshotter common controller. -+ // Deckhouse fork extension. -+ // +optional -+ StorageClassName *string `json:"storageClassName,omitempty" protobuf:"bytes,8,opt,name=storageClassName"` -+ -+ // size mirrors the real volume size (e.g. "10Gi"). Written by the state-snapshotter common -+ // controller (from DataImport.spec.size on import, or the bound content on capture). -+ // Deckhouse fork extension. -+ // +optional -+ Size *string `json:"size,omitempty" protobuf:"bytes,9,opt,name=size"` -+ -+ // volumeMode mirrors the source volume mode (Filesystem or Block). Written by the -+ // state-snapshotter common controller. Deckhouse fork extension. ++ // data is the self-contained data binding (source + artifact + volume metadata) mirrored from the ++ // backing state-snapshotter SnapshotContent.status.data. Its wire shape is byte-identical to the ++ // domain data leaves, so d8 resolves the captured-volume descriptor from this namespaced VolumeSnapshot ++ // alone. Written by the state-snapshotter common controller (import flow). Deckhouse fork extension. + // +optional -+ VolumeMode *string `json:"volumeMode,omitempty" protobuf:"bytes,10,opt,name=volumeMode"` ++ Data *VolumeSnapshotDataBinding `json:"data,omitempty" protobuf:"bytes,8,opt,name=data"` } // +genclient @@ -265,7 +351,7 @@ index a590aef..82aeaa3 100644 return } -@@ -427,6 +432,26 @@ func (in *VolumeSnapshotStatus) DeepCopyInto(out *VolumeSnapshotStatus) { +@@ -427,6 +432,36 @@ func (in *VolumeSnapshotStatus) DeepCopyInto(out *VolumeSnapshotStatus) { *out = new(string) **out = **in } @@ -274,21 +360,31 @@ index a590aef..82aeaa3 100644 + *out = new(string) + **out = **in + } -+ if in.StorageClassName != nil { -+ in, out := &in.StorageClassName, &out.StorageClassName -+ *out = new(string) -+ **out = **in -+ } -+ if in.Size != nil { -+ in, out := &in.Size, &out.Size -+ *out = new(string) -+ **out = **in -+ } -+ if in.VolumeMode != nil { -+ in, out := &in.VolumeMode, &out.VolumeMode -+ *out = new(string) -+ **out = **in ++ if in.Data != nil { ++ in, out := &in.Data, &out.Data ++ *out = new(VolumeSnapshotDataBinding) ++ (*in).DeepCopyInto(*out) + } return } ++ ++// DeepCopyInto is a deepcopy function, copying the receiver, writing into out. in must be non-nil. ++func (in *VolumeSnapshotDataBinding) DeepCopyInto(out *VolumeSnapshotDataBinding) { ++ *out = *in ++ if in.AccessModes != nil { ++ in, out := &in.AccessModes, &out.AccessModes ++ *out = make([]string, len(*in)) ++ copy(*out, *in) ++ } ++} ++ ++// DeepCopy is a deepcopy function, copying the receiver, creating a new VolumeSnapshotDataBinding. ++func (in *VolumeSnapshotDataBinding) DeepCopy() *VolumeSnapshotDataBinding { ++ if in == nil { ++ return nil ++ } ++ out := new(VolumeSnapshotDataBinding) ++ in.DeepCopyInto(out) ++ return out ++} diff --git a/images/snapshot-controller/patches/README.md b/images/snapshot-controller/patches/README.md index f62827e..bd85ba1 100644 --- a/images/snapshot-controller/patches/README.md +++ b/images/snapshot-controller/patches/README.md @@ -25,11 +25,15 @@ import flow. Must keep applying to the build branch `d8-63742164-vsc-only`. marker used by every state-snapshotter snapshot kind. - Adds `status.boundSnapshotContentName` (points at the cluster-scoped state-snapshotter `SnapshotContent`, alongside legacy - `boundVolumeSnapshotContentName`) plus `status.storageClassName`, - `status.size` and `status.volumeMode` — mirrored volume metadata for d8 - export/consumption. Forking the Go types + deepcopy is enough: - `updateSnapshotStatus` does read -> `DeepCopy()` -> `UpdateStatus`, so the - fields are preserved without controller logic changes. + `boundVolumeSnapshotContentName`) plus `status.data` — a self-contained data + binding (`source` + `artifact` + volume metadata: `volumeMode` / `fsType` / + `accessModes` / `storageClassName` / `size`) whose JSON wire shape is + byte-identical to the state-snapshotter `SnapshotContent.status.data` and to + the domain data leaves, so d8 resolves the captured-volume descriptor from the + namespaced `VolumeSnapshot` alone (no cluster-scoped `SnapshotContent` read). + Forking the Go types + deepcopy is enough: `updateSnapshotStatus` does + read -> `DeepCopy()` -> `UpdateStatus`, so the field is preserved without + controller logic changes. - Behavioral skip: `syncSnapshot` and `syncSnapshotByKey` (before snapshot-class resolution) skip any `VolumeSnapshot` whose `spec.source.import` is set — those objects are owned/bound by the state-snapshotter common controller. From ce314602027fbcc70f2da2477792056cc413dad1 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Mon, 6 Jul 2026 16:01:09 +0300 Subject: [PATCH 08/11] refactor(api): redesign DataImport spec to mode + Template/Ref convention Introduce spec.mode discriminator {PopulateVolume, ProduceArtifact} and drop the overloaded targetRef plus root storageClassName/size/volumeMode. - ProduceArtifact: snapshotRef (Ref to existing snapshot node, set by the external creator) + scratchVolumeTemplate (transient buffer). - PopulateVolume: pvcTemplate (create) or volumeRef+force (overwrite existing, fail-closed stub for now). - ObjectReference: add optional apiVersion (needed for snapshotRef group). - Rename DataImportTargetRefMetaSpec -> PersistentVolumeClaimTemplateMetadata, add ScratchVolumeSpec; regenerate deepcopy + CRDs (controller-gen v0.18.0). - data-manager-controller: branch ensureTarget by spec.mode; read scratchVolumeTemplate/pvcTemplate; volumeRef+force fail-closed. - Hand-curated dataimports.yaml: CEL validation by mode, mode immutability, updated printer columns; doc-ru synced. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/data_import.go | 135 +++++---- api/v1alpha1/objectreference_types.go | 6 +- api/v1alpha1/zz_generated.deepcopy.go | 108 ++++--- crds/dataimports.yaml | 269 ++++++++++-------- crds/doc-ru-dataimports.yaml | 120 +++++--- ...on.deckhouse.io_volumerestorerequests.yaml | 18 ++ .../data-import/data_import_resource.go | 92 +++--- .../data-import/data_import_unit_test.go | 88 +++--- .../controllers/data-import/volume_capture.go | 7 +- 9 files changed, 506 insertions(+), 337 deletions(-) diff --git a/api/v1alpha1/data_import.go b/api/v1alpha1/data_import.go index 417d382..9cf70c2 100644 --- a/api/v1alpha1/data_import.go +++ b/api/v1alpha1/data_import.go @@ -39,78 +39,107 @@ type DataImportList struct { Items []DataImport `json:"items"` } +// DataImportMode is the explicit discriminator that selects what a DataImport does with the imported +// bytes. It replaces the former polymorphic targetRef.kind discrimination. +// +kubebuilder:validation:Enum=PopulateVolume;ProduceArtifact +type DataImportMode string + +const ( + // DataImportModePopulateVolume imports bytes into a volume that is preserved afterwards. The bytes + // land either in a newly created PVC (pvcTemplate) or in an existing volume overwritten in place + // (volumeRef + force). No durable artifact is produced; the volume itself is the product. + DataImportModePopulateVolume DataImportMode = "PopulateVolume" + // DataImportModeProduceArtifact materializes the data leg of an already-existing snapshot node: bytes + // are staged into a transient scratch volume (scratchVolumeTemplate) and captured into a durable + // VolumeSnapshotContent. snapshotRef identifies the owning node for the state-snapshotter + // reverse-lookup; the DataImport controller itself does not read it. + DataImportModeProduceArtifact DataImportMode = "ProduceArtifact" +) + +// DataImportSpec is the desired state of a DataImport. The suffix convention is authoritative: +// ...Template is a spec for an object the import CREATES; ...Ref points at an object that already EXISTS. +// +// spec.mode is the explicit discriminator; the field sets valid on each mode are mutually exclusive and +// enforced by CRD CEL (see crds/dataimports.yaml) with a controller-side fail-closed guard: +// +// - ProduceArtifact: snapshotRef (Ref) + scratchVolumeTemplate (Template) are required; +// pvcTemplate/volumeRef/force are forbidden. +// - PopulateVolume: exactly one of {pvcTemplate (Template), volumeRef (Ref)}; force is allowed only +// together with volumeRef; snapshotRef/scratchVolumeTemplate are forbidden. +// // +k8s:deepcopy-gen=true type DataImportSpec struct { Ttl string `json:"ttl"` Publish bool `json:"publish,omitempty"` WaitForFirstConsumer bool `json:"waitForFirstConsumer"` - // TargetRef is the polymorphic target of the import; its Kind selects the import mode: - // - Mode A (snapshot leaf import): Kind is a snapshot leaf kind (e.g. "VolumeSnapshot"); Group+Name - // identify the leaf and the root StorageClassName/Size/VolumeMode below describe the scratch PVC. - // - Mode B (standalone PVC import): Kind == "PersistentVolumeClaim"; TargetRef.PvcTemplate fully - // specifies the target PVC and the root volume parameters are unused. - TargetRef DataImportTargetRefSpec `json:"targetRef"` - // StorageClassName, Size and VolumeMode describe the scratch PVC the imported bytes are written into - // in Mode A (snapshot leaf import). They are provided directly in the spec (by d8, mirrored from the - // source xxxSnapshot.status) instead of being read from the leaf's captured PVC manifest — the - // snapshot is no longer downloaded on import. The selected StorageClass must be snapshot-capable (its - // driver has a VolumeSnapshotClass); the produced durable artifact is always a VolumeSnapshotContent. - // PersistentVolume/Detach import is not supported in core. Unused in Mode B (the target PVC is fully - // described by targetRef.pvcTemplate). + + // Mode selects what the import does with the bytes. Defaults to PopulateVolume. + // +kubebuilder:default=PopulateVolume // +optional - StorageClassName string `json:"storageClassName,omitempty"` - // Size is the requested scratch-PVC size in Mode A (a Kubernetes quantity, e.g. "10Gi"). Unused in - // Mode B. + Mode DataImportMode `json:"mode,omitempty"` + + // SnapshotRef (ProduceArtifact) references the ALREADY-EXISTING xxxSnapshot node the produced durable + // artifact belongs to (apiVersion/kind/name; namespace implicit = the DataImport namespace). It is set + // by the external creator (d8/user/backup); the DataImport controller does not read it — it exists so + // the state-snapshotter reverse-lookup can match the leaf against spec.snapshotRef. Forbidden in + // PopulateVolume. // +optional - Size string `json:"size,omitempty"` - // VolumeMode is the scratch-PVC volume mode (Block or Filesystem) in Mode A; defaults to Filesystem - // when empty. Unused in Mode B. - // +kubebuilder:validation:Enum=Block;Filesystem + SnapshotRef *ObjectReference `json:"snapshotRef,omitempty"` + // ScratchVolumeTemplate (ProduceArtifact) describes the transient scratch volume the imported bytes are + // staged into before being captured into the durable VolumeSnapshotContent. The scratch volume is + // destroyed after capture. Its StorageClass must be snapshot-capable. Forbidden in PopulateVolume. // +optional - VolumeMode string `json:"volumeMode,omitempty"` -} + ScratchVolumeTemplate *ScratchVolumeSpec `json:"scratchVolumeTemplate,omitempty"` -// DataImportTargetRefSpec is the polymorphic target reference of a DataImport. Its Kind selects the -// import mode: -// -// - Mode A (snapshot leaf import): Kind is the kind of a namespaced snapshot leaf CR (e.g. -// "VolumeSnapshot", "VirtualDiskSnapshot"). Group and Name identify the leaf; the namespace is -// implicit (the DataImport's own namespace). This ref exists so the state-snapshotter common -// controller can reverse-lookup the DataImport from the leaf (matching spec.targetRef); the -// DataImport controller itself does not read the leaf. The produced durable artifact is a -// VolumeSnapshotContent. -// - Mode B (standalone PVC import): Kind == "PersistentVolumeClaim". PvcTemplate fully specifies the -// target PVC the imported bytes are written into; Group/Name are unused. No durable artifact is -// produced and the PVC is preserved after the import. -// -// +k8s:deepcopy-gen=true -type DataImportTargetRefSpec struct { - // Kind selects the import mode: "PersistentVolumeClaim" -> Mode B (standalone PVC import via - // pvcTemplate); any other kind is treated as a snapshot leaf kind -> Mode A. - // +kubebuilder:validation:MinLength=1 - Kind string `json:"kind"` - // Group is the API group of the leaf snapshot resource in Mode A (e.g. "snapshot.storage.k8s.io", - // "virtualization.deckhouse.io"). The served version is resolved dynamically. Unused in Mode B. + // PvcTemplate (PopulateVolume, create) fully specifies a PVC to create and populate; the PVC is + // preserved after the import. Mutually exclusive with volumeRef. Forbidden in ProduceArtifact. // +optional - Group string `json:"group,omitempty"` - // Name is the leaf object name in Mode A. Unused in Mode B (the PVC name comes from - // pvcTemplate.metadata.name). + PvcTemplate *PersistentVolumeClaimTemplateSpec `json:"pvcTemplate,omitempty"` + // VolumeRef (PopulateVolume, overwrite) references an EXISTING volume (kind=PersistentVolumeClaim) to + // overwrite in place; force must be set to acknowledge the destructive overwrite. Mutually exclusive + // with pvcTemplate. Forbidden in ProduceArtifact. // +optional - Name string `json:"name,omitempty"` - // PvcTemplate fully specifies the target PVC in Mode B (Kind == "PersistentVolumeClaim"). The PVC is - // created from it and preserved after the import completes. Forbidden in Mode A. + VolumeRef *ObjectReference `json:"volumeRef,omitempty"` + // Force acknowledges the destructive in-place overwrite of an existing volume; it is only valid + // together with volumeRef. // +optional - PvcTemplate *PersistentVolumeClaimTemplateSpec `json:"pvcTemplate,omitempty"` + Force bool `json:"force,omitempty"` +} + +// EffectiveMode returns the import mode, defaulting an empty value to PopulateVolume (the CRD default) so +// controller logic never has to special-case the unset field. +func (s DataImportSpec) EffectiveMode() DataImportMode { + if s.Mode == "" { + return DataImportModePopulateVolume + } + return s.Mode +} + +// ScratchVolumeSpec parameterizes the transient scratch volume used by a ProduceArtifact import. The bytes +// are staged into a PVC shaped from these parameters and then captured into a durable +// VolumeSnapshotContent; the scratch PVC is destroyed afterwards. StorageClassName and Size are required; +// VolumeMode defaults to Filesystem when empty. +// +k8s:deepcopy-gen=true +type ScratchVolumeSpec struct { + // StorageClassName is the StorageClass of the scratch PVC. It must be snapshot-capable (its driver has + // a VolumeSnapshotClass); the produced durable artifact is always a VolumeSnapshotContent. + StorageClassName string `json:"storageClassName"` + // Size is the requested scratch-PVC size (a Kubernetes quantity, e.g. "10Gi"). + Size string `json:"size"` + // VolumeMode is the scratch-PVC volume mode (Block or Filesystem); defaults to Filesystem when empty. + // +kubebuilder:validation:Enum=Block;Filesystem + // +optional + VolumeMode string `json:"volumeMode,omitempty"` } // +k8s:deepcopy-gen=true type PersistentVolumeClaimTemplateSpec struct { - DataImportTargetRefMetaSpec `json:"metadata,omitempty"` - PersistentVolumeClaimSpec `json:"spec,omitempty"` + PersistentVolumeClaimTemplateMetadata `json:"metadata,omitempty"` + PersistentVolumeClaimSpec `json:"spec,omitempty"` } // +k8s:deepcopy-gen=true -type DataImportTargetRefMetaSpec struct { +type PersistentVolumeClaimTemplateMetadata struct { Name string `json:"name,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Labels map[string]string `json:"labels,omitempty"` diff --git a/api/v1alpha1/objectreference_types.go b/api/v1alpha1/objectreference_types.go index dc4c2d2..3a477a9 100644 --- a/api/v1alpha1/objectreference_types.go +++ b/api/v1alpha1/objectreference_types.go @@ -19,6 +19,11 @@ package v1alpha1 // +k8s:deepcopy-gen=true // ObjectReference references a Kubernetes object type ObjectReference struct { + // APIVersion is the group/version of the referenced object (e.g., "snapshot.storage.k8s.io/v1"). + // Optional: kind-only refs (e.g. core PersistentVolumeClaim) leave it empty. It is required for + // snapshot-leaf refs (spec.snapshotRef) so the state-snapshotter reverse-lookup can derive the group. + // +optional + APIVersion string `json:"apiVersion,omitempty"` // Name is the name of the referenced object Name string `json:"name"` // Namespace is the namespace of the referenced object (optional) @@ -26,4 +31,3 @@ type ObjectReference struct { // Kind is the kind of the referenced object (e.g., "VolumeSnapshotContent", "PersistentVolume") Kind string `json:"kind,omitempty"` } - diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ff755f8..ef98f56 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -241,64 +241,34 @@ func (in *DataImportList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DataImportSpec) DeepCopyInto(out *DataImportSpec) { *out = *in - in.TargetRef.DeepCopyInto(&out.TargetRef) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataImportSpec. -func (in *DataImportSpec) DeepCopy() *DataImportSpec { - if in == nil { - return nil - } - out := new(DataImportSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DataImportTargetRefMetaSpec) DeepCopyInto(out *DataImportTargetRefMetaSpec) { - *out = *in - if in.Annotations != nil { - in, out := &in.Annotations, &out.Annotations - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Labels != nil { - in, out := &in.Labels, &out.Labels - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } + if in.SnapshotRef != nil { + in, out := &in.SnapshotRef, &out.SnapshotRef + *out = new(ObjectReference) + **out = **in } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataImportTargetRefMetaSpec. -func (in *DataImportTargetRefMetaSpec) DeepCopy() *DataImportTargetRefMetaSpec { - if in == nil { - return nil + if in.ScratchVolumeTemplate != nil { + in, out := &in.ScratchVolumeTemplate, &out.ScratchVolumeTemplate + *out = new(ScratchVolumeSpec) + **out = **in } - out := new(DataImportTargetRefMetaSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DataImportTargetRefSpec) DeepCopyInto(out *DataImportTargetRefSpec) { - *out = *in if in.PvcTemplate != nil { in, out := &in.PvcTemplate, &out.PvcTemplate *out = new(PersistentVolumeClaimTemplateSpec) (*in).DeepCopyInto(*out) } + if in.VolumeRef != nil { + in, out := &in.VolumeRef, &out.VolumeRef + *out = new(ObjectReference) + **out = **in + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataImportTargetRefSpec. -func (in *DataImportTargetRefSpec) DeepCopy() *DataImportTargetRefSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataImportSpec. +func (in *DataImportSpec) DeepCopy() *DataImportSpec { if in == nil { return nil } - out := new(DataImportTargetRefSpec) + out := new(DataImportSpec) in.DeepCopyInto(out) return out } @@ -349,10 +319,39 @@ func (in *PersistentVolumeClaimSpec) DeepCopy() *PersistentVolumeClaimSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PersistentVolumeClaimTemplateMetadata) DeepCopyInto(out *PersistentVolumeClaimTemplateMetadata) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PersistentVolumeClaimTemplateMetadata. +func (in *PersistentVolumeClaimTemplateMetadata) DeepCopy() *PersistentVolumeClaimTemplateMetadata { + if in == nil { + return nil + } + out := new(PersistentVolumeClaimTemplateMetadata) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PersistentVolumeClaimTemplateSpec) DeepCopyInto(out *PersistentVolumeClaimTemplateSpec) { *out = *in - in.DataImportTargetRefMetaSpec.DeepCopyInto(&out.DataImportTargetRefMetaSpec) + in.PersistentVolumeClaimTemplateMetadata.DeepCopyInto(&out.PersistentVolumeClaimTemplateMetadata) in.PersistentVolumeClaimSpec.DeepCopyInto(&out.PersistentVolumeClaimSpec) } @@ -366,6 +365,21 @@ func (in *PersistentVolumeClaimTemplateSpec) DeepCopy() *PersistentVolumeClaimTe return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScratchVolumeSpec) DeepCopyInto(out *ScratchVolumeSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScratchVolumeSpec. +func (in *ScratchVolumeSpec) DeepCopy() *ScratchVolumeSpec { + if in == nil { + return nil + } + out := new(ScratchVolumeSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VolumeCaptureRequest) DeepCopyInto(out *VolumeCaptureRequest) { *out = *in diff --git a/crds/dataimports.yaml b/crds/dataimports.yaml index e1442dc..099c8a6 100644 --- a/crds/dataimports.yaml +++ b/crds/dataimports.yaml @@ -24,29 +24,40 @@ spec: openAPIV3Schema: type: object # NOTE: the whole DataImport object is intentionally NOT immutable (unlike DataExport, which uses - # x-kubernetes-immutable): only spec.targetRef is locked after creation, via the CEL transition - # rule (self == oldSelf) on targetRef below. Other spec fields (e.g. ttl) may still change. + # x-kubernetes-immutable): only spec.mode and spec.snapshotRef are locked after creation, via the + # CEL transition rules (self == oldSelf) below. Other spec fields (e.g. ttl) may still change. description: | - Resource for importing data to PV, VolumeSnapshot, VirtualDisk, or VirtualDiskSnapshot. + Resource for importing data into a volume (PopulateVolume) or producing a durable data + artifact for an already-existing snapshot node (ProduceArtifact). properties: spec: type: object required: - ttl - - targetRef x-kubernetes-validations: - # Mode B (targetRef.kind == PersistentVolumeClaim): pvcTemplate with a PVC name is mandatory. - - rule: "self.targetRef.kind != 'PersistentVolumeClaim' || (has(self.targetRef.pvcTemplate) && has(self.targetRef.pvcTemplate.metadata) && has(self.targetRef.pvcTemplate.metadata.name) && size(self.targetRef.pvcTemplate.metadata.name) > 0)" - message: "targetRef.pvcTemplate (with metadata.name) is required when targetRef.kind is PersistentVolumeClaim (Mode B, standalone PVC import)." - # Mode B forbids the Mode A fields (the PVC is fully described by targetRef.pvcTemplate). - - rule: "self.targetRef.kind != 'PersistentVolumeClaim' || (!has(self.targetRef.group) && !has(self.targetRef.name) && !has(self.storageClassName) && !has(self.size) && !has(self.volumeMode))" - message: "when targetRef.kind is PersistentVolumeClaim, targetRef.group/targetRef.name and spec.storageClassName/size/volumeMode must not be set." - # Mode A (snapshot leaf kind) forbids pvcTemplate. - - rule: "self.targetRef.kind == 'PersistentVolumeClaim' || !has(self.targetRef.pvcTemplate)" - message: "targetRef.pvcTemplate is only allowed when targetRef.kind is PersistentVolumeClaim (Mode A imports a snapshot leaf, not a PVC)." - # Mode A requires the snapshot-leaf reference and the scratch-PVC volume parameters. - - rule: "self.targetRef.kind == 'PersistentVolumeClaim' || (has(self.targetRef.group) && has(self.targetRef.name) && has(self.storageClassName) && size(self.storageClassName) > 0 && has(self.size) && size(self.size) > 0)" - message: "when targetRef.kind is a snapshot leaf kind (Mode A), targetRef.group, targetRef.name, spec.storageClassName and spec.size are required." + # ProduceArtifact materializes the data leg of an existing snapshot node: it needs the node + # ref (snapshotRef) and the transient scratch volume (scratchVolumeTemplate), and forbids the + # PopulateVolume fields. + - rule: "self.mode != 'ProduceArtifact' || (has(self.snapshotRef) && has(self.scratchVolumeTemplate))" + message: "mode ProduceArtifact requires snapshotRef and scratchVolumeTemplate." + - rule: "self.mode != 'ProduceArtifact' || (!has(self.pvcTemplate) && !has(self.volumeRef) && !self.force)" + message: "mode ProduceArtifact forbids pvcTemplate, volumeRef and force." + # PopulateVolume writes into a preserved volume: exactly one of pvcTemplate (create) or + # volumeRef (overwrite existing), and none of the ProduceArtifact fields. + - rule: "self.mode != 'PopulateVolume' || (has(self.pvcTemplate) != has(self.volumeRef))" + message: "mode PopulateVolume requires exactly one of pvcTemplate or volumeRef." + - rule: "self.mode != 'PopulateVolume' || (!has(self.snapshotRef) && !has(self.scratchVolumeTemplate))" + message: "mode PopulateVolume forbids snapshotRef and scratchVolumeTemplate." + # force acknowledges the destructive in-place overwrite and is only valid with volumeRef. + - rule: "!self.force || has(self.volumeRef)" + message: "force is only allowed together with volumeRef." + # pvcTemplate must name the PVC it creates. + - rule: "!has(self.pvcTemplate) || (has(self.pvcTemplate.metadata) && has(self.pvcTemplate.metadata.name) && size(self.pvcTemplate.metadata.name) > 0)" + message: "pvcTemplate requires metadata.name." + # mode is set once at creation and never changes (controllers and the state-snapshotter + # reverse-lookup assume a stable mode for the object's lifetime). + - rule: "self.mode == oldSelf.mode" + message: "mode is immutable" properties: ttl: type: string @@ -69,118 +80,152 @@ spec: type: boolean description: If set to `false`, a load pod is created to trigger volume population when the StorageClass has `volumeBindingMode` set to `WaitForFirstConsumer`. default: true - storageClassName: + mode: type: string + enum: ["PopulateVolume", "ProduceArtifact"] + default: PopulateVolume description: | - Mode A only. StorageClass for the scratch PVC the imported bytes are written into. Its - driver must be snapshot-capable (the StorageClass references a VolumeSnapshotClass via the - `storage.deckhouse.io/volumesnapshotclass` annotation); the produced durable artifact is - always a VolumeSnapshotContent. Provided directly by the caller (mirrored from the source - snapshot status), not read from a captured manifest. Must not be set in Mode B. - size: - type: string - description: | - Mode A only. Requested size of the scratch PVC (a Kubernetes quantity, e.g. `10Gi`). - Must not be set in Mode B. - volumeMode: - type: string - enum: ["Block", "Filesystem"] - description: | - Mode A only. Volume mode of the scratch PVC. Defaults to `Filesystem` when omitted. Must - not be set in Mode B. - targetRef: + Discriminator selecting what the import does with the bytes: + + - `PopulateVolume`: write into a preserved volume — a created PVC (`pvcTemplate`) or an + existing volume overwritten in place (`volumeRef` + `force`). No durable artifact. + - `ProduceArtifact`: stage the bytes into a transient scratch volume + (`scratchVolumeTemplate`) and capture them into a durable VolumeSnapshotContent for the + already-existing snapshot node referenced by `snapshotRef`. + snapshotRef: type: object x-kubernetes-validations: - # targetRef selects the import target and its mode; it is set once at creation and must - # never change afterwards (the DataImport controller and the state-snapshotter - # reverse-lookup both assume a stable target for the object's lifetime). Reject any - # mutation after creation. + # snapshotRef binds the produced artifact to an existing snapshot node; the + # state-snapshotter reverse-lookup assumes a stable ref for the object's lifetime. - rule: "self == oldSelf" - message: "targetRef is immutable" + message: "snapshotRef is immutable" description: | - Polymorphic import target. `kind` selects the import mode: - - - Mode A (snapshot leaf import): `kind` is a snapshot leaf kind (e.g. `VolumeSnapshot`, - `VirtualDiskSnapshot`). `group` and `name` identify the namespaced leaf CR (namespace is - implicit = the DataImport's own namespace); the scratch-PVC parameters - (`spec.storageClassName/size/volumeMode`) describe the volume. This ref exists so the - state-snapshotter common controller can reverse-lookup this DataImport from the leaf; the - DataImport controller itself does not read the leaf. The produced durable artifact is a - VolumeSnapshotContent. - - Mode B (standalone PVC import): `kind` is `PersistentVolumeClaim`. `pvcTemplate` fully - describes the target PVC the imported bytes are written into; the PVC is preserved after - the import. No durable artifact is produced. + ProduceArtifact only. Reference to the already-existing xxxSnapshot node the produced + durable artifact belongs to (namespace implicit = the DataImport namespace). Set by the + external creator (d8/user/backup); the DataImport controller does not read it — it exists + so the state-snapshotter reverse-lookup can match the leaf against `spec.snapshotRef`. + required: ["kind", "name"] properties: + apiVersion: + type: string + description: Group/version of the snapshot node (e.g. `snapshot.storage.k8s.io/v1`). kind: type: string minLength: 1 - description: | - Import mode selector. `PersistentVolumeClaim` selects Mode B (standalone PVC import via - `pvcTemplate`); any other value is treated as a snapshot leaf kind and selects Mode A. - group: + description: Kind of the snapshot node (e.g. `VolumeSnapshot`, `VirtualDiskSnapshot`). + name: + type: string + minLength: 1 + description: Name of the snapshot node. + scratchVolumeTemplate: + type: object + description: | + ProduceArtifact only. Transient scratch volume the imported bytes are staged into before + capture; it is destroyed after the durable VolumeSnapshotContent is produced. Its + StorageClass must be snapshot-capable (it references a VolumeSnapshotClass via the + `storage.deckhouse.io/volumesnapshotclass` annotation). + required: ["storageClassName", "size"] + properties: + storageClassName: + type: string + minLength: 1 + description: StorageClass of the scratch PVC (must be snapshot-capable). + size: + type: string + minLength: 1 + description: Requested size of the scratch PVC (a Kubernetes quantity, e.g. `10Gi`). + volumeMode: + type: string + enum: ["Block", "Filesystem"] + description: Volume mode of the scratch PVC. Defaults to `Filesystem` when omitted. + volumeRef: + type: object + description: | + PopulateVolume (overwrite) only. Reference to an existing volume + (`kind: PersistentVolumeClaim`) to overwrite in place; `force` must be set. Mutually + exclusive with `pvcTemplate`. + required: ["kind", "name"] + properties: + apiVersion: type: string - description: Mode A only. API group of the leaf snapshot resource (e.g. `snapshot.storage.k8s.io`). + description: Group/version of the referenced volume (optional for core PersistentVolumeClaim). + kind: + type: string + minLength: 1 + description: Kind of the referenced volume (currently `PersistentVolumeClaim`). name: type: string - description: Mode A only. Leaf object name. - pvcTemplate: + minLength: 1 + description: Name of the referenced volume. + namespace: + type: string + description: Namespace of the referenced volume (optional; defaults to the DataImport namespace). + force: + type: boolean + default: false + description: | + PopulateVolume only. Acknowledges the destructive in-place overwrite of the existing + volume referenced by `volumeRef`. Only valid together with `volumeRef`. + pvcTemplate: + type: object + description: | + PopulateVolume (create) only. PersistentVolumeClaim template fully describing the target + PVC the imported bytes are written into; the PVC is preserved after the import. Mutually + exclusive with `volumeRef`. + properties: + metadata: + type: object + description: PersistentVolumeClaim metadata. + properties: + name: + type: string + description: PersistentVolumeClaim name. + labels: + type: object + description: PersistentVolumeClaim labels. + additionalProperties: + type: string + annotations: + type: object + description: PersistentVolumeClaim annotations. + additionalProperties: + type: string + spec: type: object - description: Mode B only. PersistentVolumeClaim template fully describing the target PVC. + description: PersistentVolumeClaim specification. properties: - metadata: + accessModes: + type: array + description: Desired access modes for the volume. + items: + type: string + enum: + [ + "ReadWriteOnce", + "ReadOnlyMany", + "ReadWriteMany", + "ReadWriteOncePod", + ] + resources: type: object - description: PersistentVolumeClaim metadata. + description: Minimum resource requirements for the volume. properties: - name: - type: string - description: PersistentVolumeClaim name. - labels: - type: object - description: PersistentVolumeClaim labels. - additionalProperties: - type: string - annotations: + requests: type: object - description: PersistentVolumeClaim annotations. + description: Minimum amount of compute resources required. additionalProperties: - type: string - spec: - type: object - description: PersistentVolumeClaim specification. - properties: - accessModes: - type: array - description: Desired access modes for the volume. - items: - type: string - enum: - [ - "ReadWriteOnce", - "ReadOnlyMany", - "ReadWriteMany", - "ReadWriteOncePod", - ] - resources: - type: object - description: Minimum resource requirements for the volume. - properties: - requests: - type: object - description: Minimum amount of compute resources required. - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - storageClassName: - type: string - description: Name of the StorageClass required by the PersistentVolumeClaim. - volumeMode: - type: string - description: Volume mode required by the PersistentVolumeClaim. - enum: ["Block", "Filesystem"] - required: ["kind"] + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + storageClassName: + type: string + description: Name of the StorageClass required by the PersistentVolumeClaim. + volumeMode: + type: string + description: Volume mode required by the PersistentVolumeClaim. + enum: ["Block", "Filesystem"] status: type: object description: DataImport resource status details. @@ -308,13 +353,13 @@ spec: type: string jsonPath: .spec.ttl priority: 1 - - name: Target Kind + - name: Mode type: string - jsonPath: .spec.targetRef.kind + jsonPath: .spec.mode priority: 1 - name: Storage Class type: string - jsonPath: .spec.storageClassName + jsonPath: .spec.scratchVolumeTemplate.storageClassName priority: 1 - name: Wait For Consumer type: boolean diff --git a/crds/doc-ru-dataimports.yaml b/crds/doc-ru-dataimports.yaml index 0c21675..a389e34 100644 --- a/crds/doc-ru-dataimports.yaml +++ b/crds/doc-ru-dataimports.yaml @@ -25,68 +25,94 @@ spec: waitForFirstConsumer: description: | Если параметр установлен в `false`, создаётся load-под для запуска процесса заполнения тома, когда в StorageClass для параметра `volumeBindingMode` задано значение `WaitForFirstConsumer`. - storageClassName: + mode: description: | - Только для режима A. StorageClass для временного (scratch) PersistentVolumeClaim, в который записываются импортируемые данные. Его драйвер должен поддерживать снимки (StorageClass ссылается на VolumeSnapshotClass через аннотацию `storage.deckhouse.io/volumesnapshotclass`); создаваемый артефакт всегда является VolumeSnapshotContent. Передаётся напрямую вызывающей стороной (на основе статуса исходного снимка), а не читается из сохранённого манифеста. Не задаётся в режиме B. - size: + Дискриминатор, определяющий, что импорт делает с данными: + + - `PopulateVolume`: запись в сохраняемый том — в создаваемый PVC (`pvcTemplate`) либо в существующий том с перезаписью на месте (`volumeRef` + `force`). Долговечный артефакт не создаётся. + - `ProduceArtifact`: данные заливаются во временный (scratch) том (`scratchVolumeTemplate`) и захватываются в долговечный VolumeSnapshotContent для уже существующего узла-снимка, указанного в `snapshotRef`. + snapshotRef: description: | - Только для режима A. Запрашиваемый размер временного (scratch) PersistentVolumeClaim (величина Kubernetes, например `10Gi`). Не задаётся в режиме B. - volumeMode: + Только для режима ProduceArtifact. Ссылка на уже существующий узел xxxSnapshot, которому принадлежит создаваемый долговечный артефакт (пространство имён неявно совпадает с пространством имён DataImport). Проставляется внешним создателем (d8/пользователь/система резервного копирования); контроллер DataImport её не читает — она нужна, чтобы обратный поиск state-snapshotter сопоставил лист по `spec.snapshotRef`. + properties: + apiVersion: + description: | + Группа/версия узла-снимка (например, `snapshot.storage.k8s.io/v1`). + kind: + description: | + Вид узла-снимка (например, `VolumeSnapshot`, `VirtualDiskSnapshot`). + name: + description: | + Имя узла-снимка. + scratchVolumeTemplate: description: | - Только для режима A. Режим тома временного (scratch) PersistentVolumeClaim. По умолчанию `Filesystem`. Не задаётся в режиме B. - targetRef: + Только для режима ProduceArtifact. Временный (scratch) том, в который заливаются импортируемые данные до захвата; уничтожается после создания долговечного VolumeSnapshotContent. Его StorageClass должен поддерживать снимки (ссылается на VolumeSnapshotClass через аннотацию `storage.deckhouse.io/volumesnapshotclass`). + properties: + storageClassName: + description: | + StorageClass временного (scratch) PVC (должен поддерживать снимки). + size: + description: | + Запрашиваемый размер временного (scratch) PVC (величина Kubernetes, например `10Gi`). + volumeMode: + description: | + Режим тома временного (scratch) PVC. По умолчанию `Filesystem`. + volumeRef: description: | - Полиморфная цель импорта. Поле `kind` выбирает режим импорта: - - - Режим A (импорт листа-снимка): `kind` — вид листа-снимка (например, `VolumeSnapshot`, `VirtualDiskSnapshot`). `group` и `name` идентифицируют пространственно-зависимый ресурс-лист (пространство имён неявно совпадает с пространством имён самого DataImport); параметры scratch-PVC (`spec.storageClassName/size/volumeMode`) описывают том. Ссылка используется только для обратного поиска DataImport общим контроллером state-snapshotter со стороны листа; сам контроллер DataImport лист не читает. Создаваемый артефакт — VolumeSnapshotContent. - - Режим B (standalone-импорт в PVC): `kind` равно `PersistentVolumeClaim`. `pvcTemplate` полностью описывает целевой PersistentVolumeClaim, в который записываются импортируемые данные; PVC сохраняется после завершения импорта. Долговечный артефакт не создаётся. + Только для режима PopulateVolume (перезапись). Ссылка на существующий том (`kind: PersistentVolumeClaim`) для перезаписи на месте; требуется `force`. Взаимоисключима с `pvcTemplate`. properties: - kind: + apiVersion: description: | - Селектор режима импорта. `PersistentVolumeClaim` выбирает режим B (standalone-импорт в PVC через `pvcTemplate`); любое другое значение трактуется как вид листа-снимка и выбирает режим A. - group: + Группа/версия ссылаемого тома (необязательно для core PersistentVolumeClaim). + kind: description: | - Только для режима A. API-группа ресурса снимка-листа (например, `snapshot.storage.k8s.io`). + Вид ссылаемого тома (сейчас `PersistentVolumeClaim`). name: description: | - Только для режима A. Имя объекта-листа. - pvcTemplate: + Имя ссылаемого тома. + namespace: + description: | + Пространство имён ссылаемого тома (необязательно; по умолчанию — пространство имён DataImport). + force: + description: | + Только для режима PopulateVolume. Подтверждает деструктивную перезапись на месте существующего тома, указанного в `volumeRef`. Допустимо только вместе с `volumeRef`. + pvcTemplate: + description: | + Только для режима PopulateVolume (создание). Шаблон PersistentVolumeClaim, полностью описывающий целевой PVC, в который записываются импортируемые данные; PVC сохраняется после импорта. Взаимоисключим с `volumeRef`. + properties: + metadata: + description: | + Метаданные PersistentVolumeClaim. + properties: + name: + description: | + Имя PersistentVolumeClaim. + labels: + description: | + Метки PersistentVolumeClaim. + annotations: + description: | + Аннотации PersistentVolumeClaim. + spec: description: | - Только для режима B. Шаблон PersistentVolumeClaim, полностью описывающий целевой PVC. + Спецификация PersistentVolumeClaim. properties: - metadata: + accessModes: description: | - Метаданные PersistentVolumeClaim. - properties: - name: - description: | - Имя PersistentVolumeClaim. - labels: - description: | - Метки PersistentVolumeClaim. - annotations: - description: | - Аннотации PersistentVolumeClaim. - spec: + Желаемые режимы доступа к тому. + resources: description: | - Спецификация PersistentVolumeClaim. + Минимальные требования к ресурсам тома. properties: - accessModes: - description: | - Желаемые режимы доступа к тому. - resources: + requests: description: | - Минимальные требования к ресурсам тома. - properties: - requests: - description: | - Минимальный объём требуемых вычислительных ресурсов. - storageClassName: - description: | - Имя StorageClass, требуемого для PersistentVolumeClaim. - volumeMode: - description: | - Режим тома, требуемый для PersistentVolumeClaim. + Минимальный объём требуемых вычислительных ресурсов. + storageClassName: + description: | + Имя StorageClass, требуемого для PersistentVolumeClaim. + volumeMode: + description: | + Режим тома, требуемый для PersistentVolumeClaim. status: description: | Информация о статусе ресурса DataImport. diff --git a/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml b/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml index 34b5ead..0af4ccb 100644 --- a/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml +++ b/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml @@ -65,6 +65,12 @@ spec: description: SourceRef references the source data to restore from (VolumeSnapshotContent or PersistentVolume) properties: + apiVersion: + description: |- + APIVersion is the group/version of the referenced object (e.g., "snapshot.storage.k8s.io/v1"). + Optional: kind-only refs (e.g. core PersistentVolumeClaim) leave it empty. It is required for + snapshot-leaf refs (spec.snapshotRef) so the state-snapshotter reverse-lookup can derive the group. + type: string kind: description: Kind is the kind of the referenced object (e.g., "VolumeSnapshotContent", "PersistentVolume") @@ -91,6 +97,12 @@ spec: cross-namespace, so the target always lives in the VRR namespace (the controller uses metadata.namespace). Name is required. properties: + apiVersion: + description: |- + APIVersion is the group/version of the referenced object (e.g., "snapshot.storage.k8s.io/v1"). + Optional: kind-only refs (e.g. core PersistentVolumeClaim) leave it empty. It is required for + snapshot-leaf refs (spec.snapshotRef) so the state-snapshotter reverse-lookup can derive the group. + type: string kind: description: Kind is the kind of the referenced object (e.g., "VolumeSnapshotContent", "PersistentVolume") @@ -191,6 +203,12 @@ spec: TargetRef references the created restore target (currently always a PersistentVolumeClaim). Unlike spec.targetRef, the namespace is populated here so the status is self-contained. properties: + apiVersion: + description: |- + APIVersion is the group/version of the referenced object (e.g., "snapshot.storage.k8s.io/v1"). + Optional: kind-only refs (e.g. core PersistentVolumeClaim) leave it empty. It is required for + snapshot-leaf refs (spec.snapshotRef) so the state-snapshotter reverse-lookup can derive the group. + type: string kind: description: Kind is the kind of the referenced object (e.g., "VolumeSnapshotContent", "PersistentVolume") diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go index 52b0205..5237f8b 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go @@ -91,7 +91,8 @@ func (r *DataImportReconciler) Reconcile(ctx context.Context, req ctrl.Request) // Server-side resource names (upload deployment, service, ingress, dummy job) are always keyed on // the DataImport identity with the PVC short kind, independent of the import mode and of the actual - // PVC name (Mode A names the scratch PVC after the DataImport; Mode B names it from pvcTemplate). + // PVC name (ProduceArtifact names the scratch PVC after the DataImport; PopulateVolume names it from + // pvcTemplate). r.names = common.NewNames(dev1alpha1.KindPVC, dataImport.Name, dataImport.Namespace, dataImport.Name) r.dataImport = dataImport @@ -211,26 +212,26 @@ func mutateReadyByErr(dataImport *dev1alpha1.DataImport, reconcileErr error) { }) } -// isStandalonePVCImport reports whether this DataImport is a Mode B (standalone PVC import) request: the -// imported bytes are written into a user-described PVC (targetRef.pvcTemplate) that is preserved after -// the import, with no durable artifact produced. The mode is discriminated solely by -// targetRef.kind == PersistentVolumeClaim (pvcTemplate is its mandatory companion, validated by CRD CEL). -func (r *DataImportReconciler) isStandalonePVCImport() bool { - return r.dataImport.Spec.TargetRef.Kind == dev1alpha1.KindPVC +// isPopulateVolumeMode reports whether this DataImport populates a preserved volume (PopulateVolume): the +// imported bytes are written into a created PVC (pvcTemplate) or an overwritten existing volume +// (volumeRef+force), with no durable artifact produced. The mode is the explicit spec.mode discriminator +// (defaulting to PopulateVolume); the alternative is ProduceArtifact. +func (r *DataImportReconciler) isPopulateVolumeMode() bool { + return r.dataImport.Spec.EffectiveMode() == dev1alpha1.DataImportModePopulateVolume } -// ensureTarget dispatches to the import pipeline for the active mode (selected by targetRef.kind): -// Mode B (PersistentVolumeClaim) imports into a user-described PVC; any other (snapshot leaf) kind -// captures the imported bytes into a durable VolumeSnapshotContent. +// ensureTarget dispatches to the import pipeline for the active spec.mode: PopulateVolume imports into a +// preserved volume (created PVC or overwritten existing volume); ProduceArtifact captures the imported +// bytes into a durable VolumeSnapshotContent. func (r *DataImportReconciler) ensureTarget(ctx context.Context) (ctrl.Result, error) { - if r.isStandalonePVCImport() { + if r.isPopulateVolumeMode() { return r.ensurePVCImportTarget(ctx) } return r.ensureSnapshotImportTarget(ctx) } -// ensureSnapshotImportTarget runs the snapshot-leaf import pipeline (Mode A): derive scratch-PVC -// parameters from the spec -> ensure the scratch PVC (the volume the imported bytes land in) -> once it +// ensureSnapshotImportTarget runs the ProduceArtifact pipeline: derive scratch-PVC parameters from +// spec.scratchVolumeTemplate -> ensure the scratch PVC (the volume the imported bytes land in) -> once it // is bound, produce the durable data artifact (VolumeSnapshotContent). It returns a RequeueAfter while // waiting on out-of-band preconditions (PVC bind, VolumeCaptureRequest completion) that the controller // does not watch. @@ -292,23 +293,27 @@ type scratchVolumeParams struct { Size resource.Quantity } -// scratchVolumeParamsFromSpec validates and converts the DataImport spec volume parameters. storageClass -// and size are required (the scratch PVC cannot be built without them); volumeMode is optional and -// defaults to Filesystem downstream. +// scratchVolumeParamsFromSpec validates and converts the ProduceArtifact scratch volume parameters from +// spec.scratchVolumeTemplate. The template, its storageClass and its size are required (the scratch PVC +// cannot be built without them); volumeMode is optional and defaults to Filesystem downstream. func scratchVolumeParamsFromSpec(spec dev1alpha1.DataImportSpec) (scratchVolumeParams, error) { - if spec.StorageClassName == "" { - return scratchVolumeParams{}, fmt.Errorf("spec.storageClassName is required") + tmpl := spec.ScratchVolumeTemplate + if tmpl == nil { + return scratchVolumeParams{}, fmt.Errorf("spec.scratchVolumeTemplate is required for ProduceArtifact") } - if spec.Size == "" { - return scratchVolumeParams{}, fmt.Errorf("spec.size is required") + if tmpl.StorageClassName == "" { + return scratchVolumeParams{}, fmt.Errorf("spec.scratchVolumeTemplate.storageClassName is required") } - size, err := resource.ParseQuantity(spec.Size) + if tmpl.Size == "" { + return scratchVolumeParams{}, fmt.Errorf("spec.scratchVolumeTemplate.size is required") + } + size, err := resource.ParseQuantity(tmpl.Size) if err != nil { - return scratchVolumeParams{}, fmt.Errorf("parse spec.size %q: %w", spec.Size, err) + return scratchVolumeParams{}, fmt.Errorf("parse spec.scratchVolumeTemplate.size %q: %w", tmpl.Size, err) } return scratchVolumeParams{ - StorageClassName: spec.StorageClassName, - VolumeMode: spec.VolumeMode, + StorageClassName: tmpl.StorageClassName, + VolumeMode: tmpl.VolumeMode, Size: size, }, nil } @@ -322,8 +327,8 @@ func (r *DataImportReconciler) ensureScratchPVC(ctx context.Context, params scra // ensureImportPVC creates/updates the PVC the imported bytes land in from the given template, wiring its // DataSourceRef to this DataImport so the volume populator picks it up and brings up the upload endpoint. -// Both modes share it: Mode A passes an internally-derived scratch template (named after the DataImport), -// Mode B passes the user's targetRef.pvcTemplate (named by the user). +// Both modes share it: ProduceArtifact passes an internally-derived scratch template (named after the +// DataImport), PopulateVolume passes the user's spec.pvcTemplate (named by the user). func (r *DataImportReconciler) ensureImportPVC(ctx context.Context, pvcTemplate *dev1alpha1.PersistentVolumeClaimTemplateSpec) error { apiGroup := dev1alpha1.APIGroup dataSourceRef := &corev1.TypedObjectReference{ @@ -344,7 +349,7 @@ func scratchPVCTemplate(name string, params scratchVolumeParams) *dev1alpha1.Per volumeMode = dev1alpha1.PersistentVolumeMode(params.VolumeMode) } return &dev1alpha1.PersistentVolumeClaimTemplateSpec{ - DataImportTargetRefMetaSpec: dev1alpha1.DataImportTargetRefMetaSpec{Name: name}, + PersistentVolumeClaimTemplateMetadata: dev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: name}, PersistentVolumeClaimSpec: dev1alpha1.PersistentVolumeClaimSpec{ AccessModes: []dev1alpha1.PersistentVolumeAccessMode{dev1alpha1.ReadWriteOnce}, Resources: dev1alpha1.VolumeResourceRequirements{Requests: dev1alpha1.ResourceList{dev1alpha1.ResourceStorage: params.Size}}, @@ -425,11 +430,11 @@ func (r *DataImportReconciler) handleTargetStatus(ctx context.Context, pvc *core return ctrl.Result{}, nil } -// ensurePVCImportTarget runs the standalone PVC import pipeline (Mode B): ensure the user-described -// target PVC (targetRef.pvcTemplate) -> once it is bound and the upload has finished, mark the import -// Completed. Unlike the snapshot-leaf path it derives nothing from the root volume params, does not gate -// on snapshot capability, and produces no durable artifact (status.dataArtifactRef stays empty); the PVC -// itself is the product and is preserved on cleanup. +// ensurePVCImportTarget runs the PopulateVolume pipeline: ensure the user-described target PVC +// (spec.pvcTemplate) -> once it is bound and the upload has finished, mark the import Completed. Unlike +// ProduceArtifact it derives nothing from a scratch template, does not gate on snapshot capability, and +// produces no durable artifact (status.data stays empty); the PVC itself is the product and is preserved +// on cleanup. func (r *DataImportReconciler) ensurePVCImportTarget(ctx context.Context) (ctrl.Result, error) { logger := log.FromContext(ctx) @@ -438,10 +443,17 @@ func (r *DataImportReconciler) ensurePVCImportTarget(ctx context.Context) (ctrl. return ctrl.Result{}, nil } - pvcTemplate := r.dataImport.Spec.TargetRef.PvcTemplate + // volumeRef (overwrite an existing volume in place) is accepted by the API/CEL but not yet wired into + // the populator pipeline. Fail closed so the destructive overwrite never runs half-configured; the + // create path (pvcTemplate) is the only implemented PopulateVolume variant for now. + if r.dataImport.Spec.VolumeRef != nil { + return ctrl.Result{}, fmt.Errorf("%w: PopulateVolume via volumeRef+force is not yet implemented", ErrTargetFailed) + } + + pvcTemplate := r.dataImport.Spec.PvcTemplate if pvcTemplate == nil || pvcTemplate.Name == "" { // CRD CEL enforces this; guard anyway since the controller would otherwise build an unnamed PVC. - return ctrl.Result{}, fmt.Errorf("%w: targetRef.pvcTemplate with metadata.name is required for PersistentVolumeClaim import", ErrTargetFailed) + return ctrl.Result{}, fmt.Errorf("%w: pvcTemplate with metadata.name is required for PopulateVolume", ErrTargetFailed) } if err := r.ensureImportPVC(ctx, pvcTemplate); err != nil { @@ -532,7 +544,7 @@ func (r *DataImportReconciler) handlePVCImportStatus(ctx context.Context, pvc *c // ensureDataArtifact captures the bound scratch PVC into a durable cluster-scoped VolumeSnapshotContent // via a VolumeCaptureRequest, pins the artifact's reclaim policy to Retain, anchors it to the import's -// lifetime via the import ObjectKeeper, and records it in status.dataArtifactRef + Completed. The capture +// lifetime via the import ObjectKeeper, and records it in status.data.artifact + Completed. The capture // mode is auto-detected from the spec StorageClass (snapshot-capable -> Snapshot); a non-snapshot-capable // StorageClass fails closed. It requeues while the VolumeCaptureRequest is in progress. DataImport never // becomes the artifact's controller owner (storage-foundation's VCR retainer is); see object_keeper.go / @@ -650,12 +662,12 @@ func (r *DataImportReconciler) cleanupDataImport(ctx context.Context) error { } // Remove the import finalizer from the PVC. RemovePVCFinalizer never deletes the PVC, so the volume - // is preserved in both modes — which is exactly the contract for Mode B (the user's PVC is the - // product). The PVC name differs by mode: Mode A's scratch PVC is named after the DataImport, while - // Mode B's PVC is named by the user's pvcTemplate. + // is preserved in both modes — which is exactly the contract for PopulateVolume (the user's PVC is the + // product). The PVC name differs by mode: ProduceArtifact's scratch PVC is named after the DataImport, + // while PopulateVolume's PVC is named by the user's pvcTemplate. pvcName := r.dataImport.Name - if r.isStandalonePVCImport() { - if tmpl := r.dataImport.Spec.TargetRef.PvcTemplate; tmpl != nil && tmpl.Name != "" { + if r.isPopulateVolumeMode() { + if tmpl := r.dataImport.Spec.PvcTemplate; tmpl != nil && tmpl.Name != "" { pvcName = tmpl.Name } } diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go b/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go index 07425c1..a33f23d 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_unit_test.go @@ -38,27 +38,30 @@ import ( ) func TestScratchVolumeParamsFromSpec(t *testing.T) { - params, err := scratchVolumeParamsFromSpec(dev1alpha1.DataImportSpec{ - StorageClassName: "fast", - Size: "10Gi", - VolumeMode: "Block", - }) + svt := func(sc, size, mode string) dev1alpha1.DataImportSpec { + return dev1alpha1.DataImportSpec{ScratchVolumeTemplate: &dev1alpha1.ScratchVolumeSpec{ + StorageClassName: sc, Size: size, VolumeMode: mode, + }} + } + + params, err := scratchVolumeParamsFromSpec(svt("fast", "10Gi", "Block")) require.NoError(t, err) assert.Equal(t, "fast", params.StorageClassName) assert.Equal(t, "Block", params.VolumeMode) assert.Equal(t, "10Gi", params.Size.String()) // volumeMode is optional. - params, err = scratchVolumeParamsFromSpec(dev1alpha1.DataImportSpec{StorageClassName: "fast", Size: "3Gi"}) + params, err = scratchVolumeParamsFromSpec(svt("fast", "3Gi", "")) require.NoError(t, err) assert.Equal(t, "", params.VolumeMode) assert.Equal(t, "3Gi", params.Size.String()) - // storageClass and size are required, size must parse. + // the scratch template, its storageClass and its size are required, and size must parse. errCases := map[string]dev1alpha1.DataImportSpec{ - "no storageclass": {Size: "3Gi"}, - "no size": {StorageClassName: "fast"}, - "bad size": {StorageClassName: "fast", Size: "not-a-quantity"}, + "no template": {}, + "no storageclass": svt("", "3Gi", ""), + "no size": svt("fast", "", ""), + "bad size": svt("fast", "not-a-quantity", ""), } for name, spec := range errCases { t.Run(name, func(t *testing.T) { @@ -107,13 +110,19 @@ func TestResolveSnapshotCaptureMode(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() return &DataImportReconciler{Client: fakeClient} } + specSC := func(name string) dev1alpha1.DataImportSpec { + return dev1alpha1.DataImportSpec{ + Mode: dev1alpha1.DataImportModeProduceArtifact, + ScratchVolumeTemplate: &dev1alpha1.ScratchVolumeSpec{StorageClassName: name, Size: "1Gi"}, + } + } t.Run("snapshot capable", func(t *testing.T) { r := build( sc("fast", "csi.example.com", map[string]string{storageClassVSCAnnotation: "fast-vsc"}), vsc("fast-vsc", "csi.example.com"), ) - r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{StorageClassName: "fast"}} + r.dataImport = &dev1alpha1.DataImport{Spec: specSC("fast")} mode, kind, err := r.resolveSnapshotCaptureMode(context.Background()) require.NoError(t, err) assert.Equal(t, vcrModeSnapshot, mode) @@ -122,21 +131,21 @@ func TestResolveSnapshotCaptureMode(t *testing.T) { t.Run("storage class not found fails closed", func(t *testing.T) { r := build() - r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{StorageClassName: "missing"}} + r.dataImport = &dev1alpha1.DataImport{Spec: specSC("missing")} _, _, err := r.resolveSnapshotCaptureMode(context.Background()) assert.Error(t, err) }) t.Run("missing annotation fails closed", func(t *testing.T) { r := build(sc("plain", "csi.example.com", nil)) - r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{StorageClassName: "plain"}} + r.dataImport = &dev1alpha1.DataImport{Spec: specSC("plain")} _, _, err := r.resolveSnapshotCaptureMode(context.Background()) assert.Error(t, err) }) t.Run("referenced VolumeSnapshotClass not found fails closed", func(t *testing.T) { r := build(sc("fast", "csi.example.com", map[string]string{storageClassVSCAnnotation: "ghost-vsc"})) - r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{StorageClassName: "fast"}} + r.dataImport = &dev1alpha1.DataImport{Spec: specSC("fast")} _, _, err := r.resolveSnapshotCaptureMode(context.Background()) assert.Error(t, err) }) @@ -146,14 +155,14 @@ func TestResolveSnapshotCaptureMode(t *testing.T) { sc("fast", "csi.example.com", map[string]string{storageClassVSCAnnotation: "other-vsc"}), vsc("other-vsc", "csi.other.com"), ) - r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{StorageClassName: "fast"}} + r.dataImport = &dev1alpha1.DataImport{Spec: specSC("fast")} _, _, err := r.resolveSnapshotCaptureMode(context.Background()) assert.Error(t, err) }) - t.Run("empty storageClassName fails closed", func(t *testing.T) { + t.Run("missing scratchVolumeTemplate fails closed", func(t *testing.T) { r := build() - r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{}} + r.dataImport = &dev1alpha1.DataImport{Spec: dev1alpha1.DataImportSpec{Mode: dev1alpha1.DataImportModeProduceArtifact}} _, _, err := r.resolveSnapshotCaptureMode(context.Background()) assert.Error(t, err) }) @@ -277,22 +286,21 @@ func TestBuildVolumeCaptureRequestTargetsScratchPVC(t *testing.T) { assert.True(t, *owners[0].Controller) } -func TestIsStandalonePVCImport(t *testing.T) { +func TestIsPopulateVolumeMode(t *testing.T) { cases := map[string]struct { - kind string + mode dev1alpha1.DataImportMode want bool }{ - "PersistentVolumeClaim is Mode B": {kind: dev1alpha1.KindPVC, want: true}, - "VolumeSnapshot leaf is Mode A": {kind: dev1alpha1.KindVolumeSnapshot, want: false}, - "VirtualDiskSnapshot leaf is Mode A": {kind: "VirtualDiskSnapshot", want: false}, - "empty kind is not Mode B": {kind: "", want: false}, + "PopulateVolume": {mode: dev1alpha1.DataImportModePopulateVolume, want: true}, + "ProduceArtifact": {mode: dev1alpha1.DataImportModeProduceArtifact, want: false}, + "empty defaults to PopulateVolume": {mode: "", want: true}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { r := &DataImportReconciler{dataImport: &dev1alpha1.DataImport{ - Spec: dev1alpha1.DataImportSpec{TargetRef: dev1alpha1.DataImportTargetRefSpec{Kind: tc.kind}}, + Spec: dev1alpha1.DataImportSpec{Mode: tc.mode}, }} - assert.Equal(t, tc.want, r.isStandalonePVCImport()) + assert.Equal(t, tc.want, r.isPopulateVolumeMode()) }) } } @@ -300,15 +308,29 @@ func TestIsStandalonePVCImport(t *testing.T) { func TestEnsurePVCImportTargetRequiresTemplate(t *testing.T) { r := &DataImportReconciler{dataImport: &dev1alpha1.DataImport{ ObjectMeta: metav1.ObjectMeta{Name: "imp-b", Namespace: "ns"}, - Spec: dev1alpha1.DataImportSpec{TargetRef: dev1alpha1.DataImportTargetRefSpec{Kind: dev1alpha1.KindPVC}}, + Spec: dev1alpha1.DataImportSpec{Mode: dev1alpha1.DataImportModePopulateVolume}, + }} + _, err := r.ensurePVCImportTarget(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTargetFailed) +} + +func TestEnsurePVCImportTargetVolumeRefNotImplemented(t *testing.T) { + r := &DataImportReconciler{dataImport: &dev1alpha1.DataImport{ + ObjectMeta: metav1.ObjectMeta{Name: "imp-b", Namespace: "ns"}, + Spec: dev1alpha1.DataImportSpec{ + Mode: dev1alpha1.DataImportModePopulateVolume, + VolumeRef: &dev1alpha1.ObjectReference{Kind: dev1alpha1.KindPVC, Name: "data"}, + Force: true, + }, }} _, err := r.ensurePVCImportTarget(context.Background()) require.Error(t, err) assert.ErrorIs(t, err, ErrTargetFailed) } -// newModeBReconciler builds a Mode B reconciler whose PVC import target is restored-pvc, with a fake -// client carrying only corev1/storagev1 (the standalone path never touches snapshot machinery). +// newModeBReconciler builds a PopulateVolume reconciler whose PVC import target is restored-pvc, with a +// fake client carrying only corev1/storagev1 (the populate path never touches snapshot machinery). func newModeBReconciler(objs ...runtime.Object) *DataImportReconciler { scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) @@ -319,11 +341,9 @@ func newModeBReconciler(objs ...runtime.Object) *DataImportReconciler { dataImport: &dev1alpha1.DataImport{ ObjectMeta: metav1.ObjectMeta{Name: "imp-b", Namespace: "ns"}, Spec: dev1alpha1.DataImportSpec{ - TargetRef: dev1alpha1.DataImportTargetRefSpec{ - Kind: dev1alpha1.KindPVC, - PvcTemplate: &dev1alpha1.PersistentVolumeClaimTemplateSpec{ - DataImportTargetRefMetaSpec: dev1alpha1.DataImportTargetRefMetaSpec{Name: "restored-pvc"}, - }, + Mode: dev1alpha1.DataImportModePopulateVolume, + PvcTemplate: &dev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: dev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc"}, }, }, }, @@ -373,7 +393,7 @@ func TestEnsureImportPVCWiresDataSourceRefToDataImport(t *testing.T) { sc := "fast" fs := dev1alpha1.PersistentVolumeFilesystem tmpl := &dev1alpha1.PersistentVolumeClaimTemplateSpec{ - DataImportTargetRefMetaSpec: dev1alpha1.DataImportTargetRefMetaSpec{ + PersistentVolumeClaimTemplateMetadata: dev1alpha1.PersistentVolumeClaimTemplateMetadata{ Name: "restored-pvc", Labels: map[string]string{"app": "my-app"}, }, diff --git a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go index aa5fe22..c67f254 100644 --- a/images/data-manager-controller/internal/controllers/data-import/volume_capture.go +++ b/images/data-manager-controller/internal/controllers/data-import/volume_capture.go @@ -77,10 +77,11 @@ var volumeCaptureRequestGVR = schema.GroupVersionResource{ // (no/invalid VolumeSnapshotClass) it fails closed: PersistentVolume/Detach import is intentionally not // supported here. func (r *DataImportReconciler) resolveSnapshotCaptureMode(ctx context.Context) (mode, expectedKind string, err error) { - scName := r.dataImport.Spec.StorageClassName - if scName == "" { - return "", "", fmt.Errorf("spec.storageClassName is required to select a VolumeSnapshotClass") + tmpl := r.dataImport.Spec.ScratchVolumeTemplate + if tmpl == nil || tmpl.StorageClassName == "" { + return "", "", fmt.Errorf("spec.scratchVolumeTemplate.storageClassName is required to select a VolumeSnapshotClass") } + scName := tmpl.StorageClassName sc := &storagev1.StorageClass{} if err := r.Client.Get(ctx, types.NamespacedName{Name: scName}, sc); err != nil { From b52244016685454ea35d554a65d389c1c2c6e770 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Mon, 6 Jul 2026 16:05:42 +0300 Subject: [PATCH 09/11] refactor(api): redesign VolumeRestoreRequest spec to sourceRef + pvcTemplate Adopt the Template/Ref convention for VRR, matching the storage-foundation VCR/VRR ADR: sourceRef (existing VSC/PV) + pvcTemplate (PVC to create). - Drop root targetRef/storageClassName/volumeMode/accessModes; they move into pvcTemplate (reuses PersistentVolumeClaimTemplateSpec). fsType stays at spec root (it is a restore-execution parameter read by external-provisioner, not a PVC field). - status.targetRef -> status.pvcRef; add uid to ObjectReference so the created PVC reference is self-contained (per ADR). - CEL requires pvcTemplate.metadata.name; target-kind gate removed (target is always a PVC now). Regenerate CRD + deepcopy (controller-gen v0.18.0). - VRR controller polls spec.pvcTemplate.name and writes status.pvcRef with the bound PVC uid; tests updated. Signed-off-by: Aleksandr Zimin --- api/v1alpha1/objectreference_types.go | 4 + api/v1alpha1/volumerestorerequest_types.go | 46 +++--- api/v1alpha1/zz_generated.deepcopy.go | 12 +- ...on.deckhouse.io_volumerestorerequests.yaml | 135 ++++++++++-------- .../volumerestorerequest_controller.go | 45 +++--- .../volumerestorerequest_controller_test.go | 116 +++++---------- 6 files changed, 148 insertions(+), 210 deletions(-) diff --git a/api/v1alpha1/objectreference_types.go b/api/v1alpha1/objectreference_types.go index 3a477a9..448eb3b 100644 --- a/api/v1alpha1/objectreference_types.go +++ b/api/v1alpha1/objectreference_types.go @@ -30,4 +30,8 @@ type ObjectReference struct { Namespace string `json:"namespace,omitempty"` // Kind is the kind of the referenced object (e.g., "VolumeSnapshotContent", "PersistentVolume") Kind string `json:"kind,omitempty"` + // UID is the unique identifier of the referenced object. It is populated in status references (e.g. + // VolumeRestoreRequest status.pvcRef) so consumers can detect recreation; spec refs may leave it empty. + // +optional + UID string `json:"uid,omitempty"` } diff --git a/api/v1alpha1/volumerestorerequest_types.go b/api/v1alpha1/volumerestorerequest_types.go index 3366189..9a7f432 100644 --- a/api/v1alpha1/volumerestorerequest_types.go +++ b/api/v1alpha1/volumerestorerequest_types.go @@ -17,40 +17,28 @@ limitations under the License. package v1alpha1 import ( - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // +k8s:deepcopy-gen=true -// VolumeRestoreRequestSpec defines the desired state of VolumeRestoreRequest +// VolumeRestoreRequestSpec defines the desired state of VolumeRestoreRequest. +// +// The suffix convention is authoritative: sourceRef (Ref) points at an object that already EXISTS; +// pvcTemplate (Template) is the spec of the PVC the restore CREATES. The external-provisioner +// provisions the PV from sourceRef and creates the PVC from pvcTemplate (name required), then binds +// them in the VRR namespace (restore is never cross-namespace). +// +kubebuilder:validation:XValidation:rule="size(self.pvcTemplate.metadata.name) > 0",message="pvcTemplate.metadata.name is required" type VolumeRestoreRequestSpec struct { - // SourceRef references the source data to restore from (VolumeSnapshotContent or PersistentVolume) + // SourceRef references the source data to restore from (VolumeSnapshotContent or PersistentVolume). SourceRef ObjectReference `json:"sourceRef"` - // TargetRef identifies the object to restore into. Only kind=PersistentVolumeClaim is supported for - // now (an empty kind is treated as PersistentVolumeClaim); kind reserves space for future cluster-scoped - // targets such as PersistentVolume. Namespace is intentionally not set in spec: restore is never - // cross-namespace, so the target always lives in the VRR namespace (the controller uses - // metadata.namespace). Name is required. - // +kubebuilder:validation:Required - TargetRef ObjectReference `json:"targetRef"` - // StorageClassName is the storage class to use for the restored PVC - // +optional - StorageClassName string `json:"storageClassName,omitempty"` - // VolumeMode specifies whether the restored volume is Block or Filesystem. - // It is required: the executor builds CSI VolumeCapabilities from it and there is - // no implicit default (mismatched mode breaks Block volumes). - // +kubebuilder:validation:Required - // +kubebuilder:validation:Enum=Block;Filesystem - VolumeMode corev1.PersistentVolumeMode `json:"volumeMode"` - // FsType is the filesystem type for Filesystem volumes (optional, ignored for Block). + // PvcTemplate is the spec of the PersistentVolumeClaim the restore creates and binds. It absorbs the + // former root storageClassName/volumeMode/accessModes/size; the PVC name lives in + // pvcTemplate.metadata.name (required). Namespace is implicit = the VRR namespace. + PvcTemplate PersistentVolumeClaimTemplateSpec `json:"pvcTemplate"` + // FsType is the filesystem type for Filesystem volumes (optional, ignored for Block). It is a restore + // execution parameter read by the external-provisioner, not a PVC field, so it stays at spec root. // +optional FsType string `json:"fsType,omitempty"` - // AccessModes is the list of access modes for the restored PVC - // (optional, defaults to ReadWriteOnce). Enables parity with the PV-source restore - // path (e.g. RWX). Invalid modes are rejected by admission via the Enum below. - // +optional - // +kubebuilder:validation:items:Enum=ReadWriteOnce;ReadOnlyMany;ReadWriteMany;ReadWriteOncePod - AccessModes []corev1.PersistentVolumeAccessMode `json:"accessModes,omitempty"` } // +k8s:deepcopy-gen=true @@ -60,9 +48,9 @@ type VolumeRestoreRequestStatus struct { CompletionTimestamp *metav1.Time `json:"completionTimestamp,omitempty"` // Conditions represent the latest available observations of the resource's state Conditions []metav1.Condition `json:"conditions,omitempty"` - // TargetRef references the created restore target (currently always a PersistentVolumeClaim). - // Unlike spec.targetRef, the namespace is populated here so the status is self-contained. - TargetRef *ObjectReference `json:"targetRef,omitempty"` + // PvcRef references the created restore target PersistentVolumeClaim. Unlike spec, the namespace and + // uid are populated here so the status is self-contained and consumers can detect recreation. + PvcRef *ObjectReference `json:"pvcRef,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ef98f56..39dbaa4 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,7 +21,6 @@ limitations under the License. package v1alpha1 import ( - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -621,12 +620,7 @@ func (in *VolumeRestoreRequestList) DeepCopyObject() runtime.Object { func (in *VolumeRestoreRequestSpec) DeepCopyInto(out *VolumeRestoreRequestSpec) { *out = *in out.SourceRef = in.SourceRef - out.TargetRef = in.TargetRef - if in.AccessModes != nil { - in, out := &in.AccessModes, &out.AccessModes - *out = make([]corev1.PersistentVolumeAccessMode, len(*in)) - copy(*out, *in) - } + in.PvcTemplate.DeepCopyInto(&out.PvcTemplate) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeRestoreRequestSpec. @@ -653,8 +647,8 @@ func (in *VolumeRestoreRequestStatus) DeepCopyInto(out *VolumeRestoreRequestStat (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.TargetRef != nil { - in, out := &in.TargetRef, &out.TargetRef + if in.PvcRef != nil { + in, out := &in.PvcRef, &out.PvcRef *out = new(ObjectReference) **out = **in } diff --git a/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml b/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml index 0af4ccb..6fe085b 100644 --- a/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml +++ b/crds/internal/storage-foundation.deckhouse.io_volumerestorerequests.yaml @@ -42,28 +42,70 @@ spec: metadata: type: object spec: - description: VolumeRestoreRequestSpec defines the desired state of VolumeRestoreRequest + description: |- + VolumeRestoreRequestSpec defines the desired state of VolumeRestoreRequest. + + The suffix convention is authoritative: sourceRef (Ref) points at an object that already EXISTS; + pvcTemplate (Template) is the spec of the PVC the restore CREATES. The external-provisioner + provisions the PV from sourceRef and creates the PVC from pvcTemplate (name required), then binds + them in the VRR namespace (restore is never cross-namespace). properties: - accessModes: - description: |- - AccessModes is the list of access modes for the restored PVC - (optional, defaults to ReadWriteOnce). Enables parity with the PV-source restore - path (e.g. RWX). Invalid modes are rejected by admission via the Enum below. - items: - enum: - - ReadWriteOnce - - ReadOnlyMany - - ReadWriteMany - - ReadWriteOncePod - type: string - type: array fsType: - description: FsType is the filesystem type for Filesystem volumes - (optional, ignored for Block). + description: |- + FsType is the filesystem type for Filesystem volumes (optional, ignored for Block). It is a restore + execution parameter read by the external-provisioner, not a PVC field, so it stays at spec root. type: string + pvcTemplate: + description: |- + PvcTemplate is the spec of the PersistentVolumeClaim the restore creates and binds. It absorbs the + former root storageClassName/volumeMode/accessModes/size; the PVC name lives in + pvcTemplate.metadata.name (required). Namespace is implicit = the VRR namespace. + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + name: + type: string + type: object + spec: + properties: + accessModes: + items: + type: string + type: array + resources: + description: VolumeResourceRequirements describes the storage + resource requirements for a volume. + properties: + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: ResourceList is a set of (resource name, + quantity) pairs. + type: object + type: object + storageClassName: + type: string + volumeMode: + description: PersistentVolumeMode describes how a volume is + intended to be consumed, either Block or Filesystem. + type: string + type: object + type: object sourceRef: description: SourceRef references the source data to restore from - (VolumeSnapshotContent or PersistentVolume) + (VolumeSnapshotContent or PersistentVolume). properties: apiVersion: description: |- @@ -82,55 +124,21 @@ spec: description: Namespace is the namespace of the referenced object (optional) type: string - required: - - name - type: object - storageClassName: - description: StorageClassName is the storage class to use for the - restored PVC - type: string - targetRef: - description: |- - TargetRef identifies the object to restore into. Only kind=PersistentVolumeClaim is supported for - now (an empty kind is treated as PersistentVolumeClaim); kind reserves space for future cluster-scoped - targets such as PersistentVolume. Namespace is intentionally not set in spec: restore is never - cross-namespace, so the target always lives in the VRR namespace (the controller uses - metadata.namespace). Name is required. - properties: - apiVersion: + uid: description: |- - APIVersion is the group/version of the referenced object (e.g., "snapshot.storage.k8s.io/v1"). - Optional: kind-only refs (e.g. core PersistentVolumeClaim) leave it empty. It is required for - snapshot-leaf refs (spec.snapshotRef) so the state-snapshotter reverse-lookup can derive the group. - type: string - kind: - description: Kind is the kind of the referenced object (e.g., - "VolumeSnapshotContent", "PersistentVolume") - type: string - name: - description: Name is the name of the referenced object - type: string - namespace: - description: Namespace is the namespace of the referenced object - (optional) + UID is the unique identifier of the referenced object. It is populated in status references (e.g. + VolumeRestoreRequest status.pvcRef) so consumers can detect recreation; spec refs may leave it empty. type: string required: - name type: object - volumeMode: - description: |- - VolumeMode specifies whether the restored volume is Block or Filesystem. - It is required: the executor builds CSI VolumeCapabilities from it and there is - no implicit default (mismatched mode breaks Block volumes). - enum: - - Block - - Filesystem - type: string required: + - pvcTemplate - sourceRef - - targetRef - - volumeMode type: object + x-kubernetes-validations: + - message: pvcTemplate.metadata.name is required + rule: size(self.pvcTemplate.metadata.name) > 0 status: description: VolumeRestoreRequestStatus defines the observed state of VolumeRestoreRequest @@ -198,10 +206,10 @@ spec: - type type: object type: array - targetRef: + pvcRef: description: |- - TargetRef references the created restore target (currently always a PersistentVolumeClaim). - Unlike spec.targetRef, the namespace is populated here so the status is self-contained. + PvcRef references the created restore target PersistentVolumeClaim. Unlike spec, the namespace and + uid are populated here so the status is self-contained and consumers can detect recreation. properties: apiVersion: description: |- @@ -220,6 +228,11 @@ spec: description: Namespace is the namespace of the referenced object (optional) type: string + uid: + description: |- + UID is the unique identifier of the referenced object. It is populated in status references (e.g. + VolumeRestoreRequest status.pvcRef) so consumers can detect recreation; spec refs may leave it empty. + type: string required: - name type: object diff --git a/images/controller/internal/controllers/volumerestorerequest_controller.go b/images/controller/internal/controllers/volumerestorerequest_controller.go index a4c365d..199da0b 100644 --- a/images/controller/internal/controllers/volumerestorerequest_controller.go +++ b/images/controller/internal/controllers/volumerestorerequest_controller.go @@ -117,13 +117,8 @@ func (r *VolumeRestoreRequestController) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - // Validate restore target kind. Only PersistentVolumeClaim is supported for now (an empty kind is - // treated as PersistentVolumeClaim); kind reserves space for future cluster-scoped targets such as - // PersistentVolume. Anything else is a terminal error. - if !isSupportedRestoreTargetKind(vrr.Spec.TargetRef.Kind) { - return r.markFailed(ctx, &vrr, storagev1alpha1.ConditionReasonUnsupportedTargetKind, - fmt.Sprintf("Unsupported target kind %q: only %s is supported", vrr.Spec.TargetRef.Kind, KindPersistentVolumeClaim)) - } + // The restore target is always a PersistentVolumeClaim created from spec.pvcTemplate; there is no + // polymorphic target kind to validate anymore. // Process based on source type switch vrr.Spec.SourceRef.Kind { @@ -136,12 +131,6 @@ func (r *VolumeRestoreRequestController) Reconcile(ctx context.Context, req ctrl } } -// isSupportedRestoreTargetKind reports whether the VRR target kind is currently supported. -// Only PersistentVolumeClaim is supported; an empty kind defaults to PersistentVolumeClaim. -func isSupportedRestoreTargetKind(kind string) bool { - return kind == "" || kind == KindPersistentVolumeClaim -} - // ensureObjectKeeper creates or gets ObjectKeeper for VRR. // ObjectKeeper follows VRR lifecycle and owns all created artifacts (PVC). // This ensures proper cleanup when VRR is deleted. @@ -294,11 +283,11 @@ func (r *VolumeRestoreRequestController) processVolumeSnapshotContentRestore( // VRR controller MUST NOT create PVC under any circumstances. // VRR controller only observes and finalizes status when PVC is Bound. targetPVC := &corev1.PersistentVolumeClaim{} - if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Namespace, Name: vrr.Spec.TargetRef.Name}, targetPVC); err != nil { + if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Namespace, Name: vrr.Spec.PvcTemplate.Name}, targetPVC); err != nil { if apierrors.IsNotFound(err) { // Target PVC doesn't exist yet - external-provisioner hasn't created it // Requeue to wait for external-provisioner - l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name) + l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.PvcTemplate.Name) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } return ctrl.Result{}, fmt.Errorf("failed to check target PVC: %w", err) @@ -307,21 +296,22 @@ func (r *VolumeRestoreRequestController) processVolumeSnapshotContentRestore( // 4. Check if PVC is Bound if targetPVC.Status.Phase != corev1.ClaimBound { // PVC exists but not Bound yet - external-provisioner is still working - l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name, "phase", targetPVC.Status.Phase) + l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.PvcTemplate.Name, "phase", targetPVC.Status.Phase) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } // 5. Success: PVC is Bound - finalize VRR - vrr.Status.TargetRef = &storagev1alpha1.ObjectReference{ + vrr.Status.PvcRef = &storagev1alpha1.ObjectReference{ Kind: KindPersistentVolumeClaim, - Name: vrr.Spec.TargetRef.Name, + Name: vrr.Spec.PvcTemplate.Name, Namespace: vrr.Namespace, + UID: string(targetPVC.UID), } - if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully", vrr.Namespace, vrr.Spec.TargetRef.Name)); err != nil { + if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully", vrr.Namespace, vrr.Spec.PvcTemplate.Name)); err != nil { return ctrl.Result{}, err } - l.Info("VolumeRestoreRequest completed", "source", "VolumeSnapshotContent", "pvc", vrr.Spec.TargetRef.Name) + l.Info("VolumeRestoreRequest completed", "source", "VolumeSnapshotContent", "pvc", vrr.Spec.PvcTemplate.Name) return ctrl.Result{}, nil } @@ -373,11 +363,11 @@ func (r *VolumeRestoreRequestController) processPersistentVolumeRestore( // VRR controller MUST NOT create PVC under any circumstances. // VRR controller only observes and finalizes status when PVC is Bound. targetPVC := &corev1.PersistentVolumeClaim{} - if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Namespace, Name: vrr.Spec.TargetRef.Name}, targetPVC); err != nil { + if err := r.Get(ctx, client.ObjectKey{Namespace: vrr.Namespace, Name: vrr.Spec.PvcTemplate.Name}, targetPVC); err != nil { if apierrors.IsNotFound(err) { // Target PVC doesn't exist yet - external-provisioner hasn't created it // Requeue to wait for external-provisioner - l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name) + l.Info("Target PVC not found yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.PvcTemplate.Name) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } return ctrl.Result{}, fmt.Errorf("failed to check target PVC: %w", err) @@ -386,21 +376,22 @@ func (r *VolumeRestoreRequestController) processPersistentVolumeRestore( // 4. Check if PVC is Bound if targetPVC.Status.Phase != corev1.ClaimBound { // PVC exists but not Bound yet - external-provisioner is still working - l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.TargetRef.Name, "phase", targetPVC.Status.Phase) + l.Info("Target PVC exists but not Bound yet, waiting for external-provisioner", "namespace", vrr.Namespace, "name", vrr.Spec.PvcTemplate.Name, "phase", targetPVC.Status.Phase) return ctrl.Result{RequeueAfter: restorePollInterval}, nil } // 5. Success: PVC is Bound - finalize VRR - vrr.Status.TargetRef = &storagev1alpha1.ObjectReference{ + vrr.Status.PvcRef = &storagev1alpha1.ObjectReference{ Kind: KindPersistentVolumeClaim, - Name: vrr.Spec.TargetRef.Name, + Name: vrr.Spec.PvcTemplate.Name, Namespace: vrr.Namespace, + UID: string(targetPVC.UID), } - if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully from PV", vrr.Namespace, vrr.Spec.TargetRef.Name)); err != nil { + if err := r.finalizeVRR(ctx, vrr, metav1.ConditionTrue, storagev1alpha1.ConditionReasonCompleted, fmt.Sprintf("PVC %s/%s restored successfully from PV", vrr.Namespace, vrr.Spec.PvcTemplate.Name)); err != nil { return ctrl.Result{}, err } - l.Info("VolumeRestoreRequest completed", "source", "PersistentVolume", "pvc", vrr.Spec.TargetRef.Name) + l.Info("VolumeRestoreRequest completed", "source", "PersistentVolume", "pvc", vrr.Spec.PvcTemplate.Name) return ctrl.Result{}, nil } diff --git a/images/controller/internal/controllers/volumerestorerequest_controller_test.go b/images/controller/internal/controllers/volumerestorerequest_controller_test.go index 1e78705..32129f0 100644 --- a/images/controller/internal/controllers/volumerestorerequest_controller_test.go +++ b/images/controller/internal/controllers/volumerestorerequest_controller_test.go @@ -239,9 +239,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc"}, }, }, } @@ -279,10 +278,11 @@ var _ = Describe("VolumeRestoreRequest", func() { Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue)) Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonCompleted)) Expect(finalVRR.Status.CompletionTimestamp).ToNot(BeNil()) - Expect(finalVRR.Status.TargetRef).ToNot(BeNil()) - Expect(finalVRR.Status.TargetRef.Kind).To(Equal(KindPersistentVolumeClaim)) - Expect(finalVRR.Status.TargetRef.Name).To(Equal("restored-pvc")) - Expect(finalVRR.Status.TargetRef.Namespace).To(Equal("default")) + Expect(finalVRR.Status.PvcRef).ToNot(BeNil()) + Expect(finalVRR.Status.PvcRef.Kind).To(Equal(KindPersistentVolumeClaim)) + Expect(finalVRR.Status.PvcRef.Name).To(Equal("restored-pvc")) + Expect(finalVRR.Status.PvcRef.Namespace).To(Equal("default")) + Expect(finalVRR.Status.PvcRef.UID).To(Equal("pvc-uid-restored-pvc")) // Then: No VolumeSnapshot objects created verifyNoVolumeSnapshots() @@ -317,9 +317,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindPersistentVolume, Name: "test-pv", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-pv", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-pv"}, }, }, } @@ -375,9 +374,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-poll", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-poll", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-poll"}, }, }, } @@ -421,9 +419,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-pending", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-pending", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-pending"}, }, }, } @@ -456,9 +453,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "non-existent-vsc", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-not-found", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-not-found"}, }, }, } @@ -505,9 +501,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindPersistentVolume, Name: "non-existent-pv", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-pv-not-found", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-pv-not-found"}, }, }, } @@ -557,9 +552,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-not-ready", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-not-ready", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-not-ready"}, }, }, } @@ -607,9 +601,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-error", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-error", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-error"}, }, }, } @@ -632,46 +625,6 @@ var _ = Describe("VolumeRestoreRequest", func() { Expect(readyCondition.Message).To(ContainSubstring(errorMsg)) }) - It("should mark VRR failed when target kind is unsupported", func() { - // Given: VRR targets an unsupported kind (only PersistentVolumeClaim is supported for now) - vrr := &storagev1alpha1.VolumeRestoreRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-vrr-bad-target-kind", - Namespace: "default", - UID: types.UID("vrr-uid-bad-target-kind"), - }, - Spec: storagev1alpha1.VolumeRestoreRequestSpec{ - SourceRef: storagev1alpha1.ObjectReference{ - Kind: SourceKindVolumeSnapshotContent, - Name: "test-vsc-bad-target-kind", - }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: SourceKindPersistentVolume, // not a supported restore target yet - Name: "restored-pv-target", - }, - }, - } - Expect(client.Create(ctx, vrr)).To(Succeed()) - - // When: Reconcile called - req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-vrr-bad-target-kind", Namespace: "default"}} - result, err := ctrl.Reconcile(ctx, req) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Requeue).To(BeFalse()) - - // Then: VRR marked as failed with UnsupportedTargetKind, before any ObjectKeeper is created - updatedVRR := &storagev1alpha1.VolumeRestoreRequest{} - Expect(client.Get(ctx, types.NamespacedName{Name: "test-vrr-bad-target-kind", Namespace: "default"}, updatedVRR)).To(Succeed()) - readyCondition := getCondition(updatedVRR.Status.Conditions, storagev1alpha1.ConditionTypeReady) - Expect(readyCondition).ToNot(BeNil()) - Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse)) - Expect(readyCondition.Reason).To(Equal(storagev1alpha1.ConditionReasonUnsupportedTargetKind)) - - retainerName := NamePrefixRetainerPVC + string(vrr.UID) - objectKeeper := &deckhousev1alpha1.ObjectKeeper{} - err = client.Get(ctx, types.NamespacedName{Name: retainerName}, objectKeeper) - Expect(apierrors.IsNotFound(err)).To(BeTrue()) - }) }) Describe("ObjectKeeper Behavior", func() { @@ -691,9 +644,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-ok", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-ok", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-ok"}, }, }, } @@ -743,9 +695,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-uid-mismatch", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-uid-mismatch", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-uid-mismatch"}, }, }, } @@ -801,9 +752,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-no-uid", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-no-uid", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-no-uid"}, }, }, } @@ -836,9 +786,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-terminal", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-terminal", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-terminal"}, }, }, Status: storagev1alpha1.VolumeRestoreRequestStatus{ @@ -893,9 +842,8 @@ var _ = Describe("VolumeRestoreRequest", func() { Kind: SourceKindVolumeSnapshotContent, Name: "test-vsc-spec-change", }, - TargetRef: storagev1alpha1.ObjectReference{ - Kind: KindPersistentVolumeClaim, - Name: "restored-pvc-spec-change", + PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ + PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{Name: "restored-pvc-spec-change"}, }, }, Status: storagev1alpha1.VolumeRestoreRequestStatus{ @@ -915,7 +863,7 @@ var _ = Describe("VolumeRestoreRequest", func() { // When: Spec changed manually updatedVRR := &storagev1alpha1.VolumeRestoreRequest{} Expect(client.Get(ctx, types.NamespacedName{Name: "test-vrr-spec-change", Namespace: "default"}, updatedVRR)).To(Succeed()) - updatedVRR.Spec.TargetRef.Name = "changed-pvc-name" + updatedVRR.Spec.PvcTemplate.Name = "changed-pvc-name" Expect(client.Update(ctx, updatedVRR)).To(Succeed()) // When: Reconcile called From 2c525506f13cd2b0ca735931baad740d75532e12 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Mon, 6 Jul 2026 16:47:06 +0300 Subject: [PATCH 10/11] refactor(vrr): follow wave6 pvcTemplate schema in VRR producers and executor - data-export controller (snapshot_resolver): build the VRR with sourceRef + pvcTemplate instead of the removed spec.targetRef (fsType stays at root); test asserts pvcTemplate.metadata.name/spec.volumeMode and no targetRef - csi-external-provisioner 002-vrr-executor.patch: rewrite the VRR executor to read spec.pvcTemplate (+ root fsType) with SF->corev1 mirror-type conversions; target namespace = the VRR namespace; recompute new-file hunk counts (git apply --numstat clean). Pin still predates wave6, so it is not locally verifiable; README carries the publish/regenerate/re-pin blocker - data-import/populator: update stale targetRef comments/log to the mode + snapshotRef/scratchVolumeTemplate/pvcTemplate terminology Signed-off-by: Aleksandr Zimin --- .../patches/v6.2.0/002-vrr-executor.patch | 148 ++++++++++++------ .../patches/v6.2.0/README.md | 64 ++++---- .../data-export/snapshot_resolver.go | 41 ++--- .../data-export/snapshot_resolver_test.go | 16 +- .../data-import/data_import_resource.go | 2 +- .../controllers/data-import/targer_ref.go | 8 +- images/populator/cmd/main.go | 5 +- 7 files changed, 175 insertions(+), 109 deletions(-) diff --git a/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch b/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch index 7d76a32..56c6b30 100644 --- a/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch +++ b/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch @@ -202,7 +202,7 @@ new file mode 100644 index 000000000..4a6c39c28 --- /dev/null +++ b/pkg/controller/vrr_handler.go -@@ -0,0 +1,821 @@ +@@ -0,0 +1,862 @@ +/* +Copyright 2025 Flant JSC + @@ -430,17 +430,17 @@ index 000000000..4a6c39c28 +// storage-foundation controller is responsible for marking such VRRs terminal. +func (e *VRRExecutor) reconcile(ctx context.Context, vrr *storagev1alpha1.VolumeRestoreRequest) error { + // Minimal validation. -+ if vrr.Spec.StorageClassName == "" { ++ if vrrStorageClassName(vrr) == "" { + e.eventRecorder.Eventf(vrr, v1.EventTypeWarning, "VRRInvalidSpec", -+ "StorageClassName is required but not specified") -+ klog.Warningf("VRR %s/%s has empty StorageClassName, ignoring", vrr.Namespace, vrr.Name) ++ "pvcTemplate.spec.storageClassName is required but not specified") ++ klog.Warningf("VRR %s/%s has empty pvcTemplate storageClassName, ignoring", vrr.Namespace, vrr.Name) + return nil + } + + // CRITICAL SECURITY INVARIANT: the driver filter MUST be the first executable + // step after minimal validation. In multi-CSI clusters this prevents this + // provisioner from acting on PVCs/PVs belonging to other CSI drivers. -+ sc, err := e.scLister.Get(vrr.Spec.StorageClassName) ++ sc, err := e.scLister.Get(vrrStorageClassName(vrr)) + if err != nil { + if apierrors.IsNotFound(err) { + // Not our StorageClass (or not present yet): ignore silently. @@ -463,21 +463,21 @@ index 000000000..4a6c39c28 + // there is no implicit default. A wrong/empty mode silently breaks Block volumes, + // so reject it as a permanent invalid spec (event-only; status is the controller's). + // Validated only after the driver filter so foreign-driver VRRs stay untouched. -+ switch vrr.Spec.VolumeMode { ++ switch vrrVolumeMode(vrr) { + case v1.PersistentVolumeBlock, v1.PersistentVolumeFilesystem: + default: + e.eventRecorder.Eventf(vrr, v1.EventTypeWarning, "VRRInvalidSpec", -+ "VolumeMode is required and must be Block or Filesystem, got %q", vrr.Spec.VolumeMode) -+ klog.Warningf("VRR %s/%s has invalid VolumeMode %q, ignoring", vrr.Namespace, vrr.Name, vrr.Spec.VolumeMode) ++ "pvcTemplate.spec.volumeMode is required and must be Block or Filesystem, got %q", vrrVolumeMode(vrr)) ++ klog.Warningf("VRR %s/%s has invalid pvcTemplate volumeMode %q, ignoring", vrr.Namespace, vrr.Name, vrrVolumeMode(vrr)) + return nil + } + + // AccessModes are validated here so an invalid spec fails closed (event-only, + // return nil) symmetrically with VolumeMode, instead of looping forever in the + // retryable capability-conversion error path. Status remains the controller's. -+ if err := validateVRRAccessModes(vrr.Spec.AccessModes); err != nil { ++ if err := validateVRRAccessModes(convertAccessModes(vrr.Spec.PvcTemplate.AccessModes)); err != nil { + e.eventRecorder.Eventf(vrr, v1.EventTypeWarning, "VRRInvalidSpec", "Invalid accessModes: %v", err) -+ klog.Warningf("VRR %s/%s has invalid accessModes %v: %v, ignoring", vrr.Namespace, vrr.Name, vrr.Spec.AccessModes, err) ++ klog.Warningf("VRR %s/%s has invalid accessModes %v: %v, ignoring", vrr.Namespace, vrr.Name, vrr.Spec.PvcTemplate.AccessModes, err) + return nil + } + @@ -488,7 +488,7 @@ index 000000000..4a6c39c28 + } + + // Idempotency / conflict detection on the target PVC. -+ existingPVC, err := e.kubeClient.CoreV1().PersistentVolumeClaims(vrr.Spec.TargetNamespace).Get(ctx, vrr.Spec.TargetPVCName, metav1.GetOptions{}) ++ existingPVC, err := e.kubeClient.CoreV1().PersistentVolumeClaims(vrrTargetNamespace(vrr)).Get(ctx, vrrTargetPVCName(vrr), metav1.GetOptions{}) + switch { + case err == nil: + if existingPVC.Spec.VolumeName != "" && existingPVC.Spec.VolumeName != pvName { @@ -496,7 +496,7 @@ index 000000000..4a6c39c28 + // conflict with another owner. Report and let the controller decide. + e.eventRecorder.Eventf(vrr, v1.EventTypeWarning, "VRRTargetPVCConflict", + "Target PVC %s/%s is bound to volume %q, expected %q", -+ vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName, existingPVC.Spec.VolumeName, pvName) ++ vrrTargetNamespace(vrr), vrrTargetPVCName(vrr), existingPVC.Spec.VolumeName, pvName) + klog.Warningf("VRR %s/%s: target PVC conflict (bound to %q, expected %q)", + vrr.Namespace, vrr.Name, existingPVC.Spec.VolumeName, pvName) + return nil @@ -504,10 +504,10 @@ index 000000000..4a6c39c28 + // Target PVC already created by a previous run: executor is done. The + // controller observes the PVC and finalizes the VRR status. + klog.V(4).Infof("VRR %s/%s: target PVC %s/%s already exists, nothing to do", -+ vrr.Namespace, vrr.Name, vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName) ++ vrr.Namespace, vrr.Name, vrrTargetNamespace(vrr), vrrTargetPVCName(vrr)) + return nil + case !apierrors.IsNotFound(err): -+ return fmt.Errorf("failed to check target PVC %s/%s: %w", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName, err) ++ return fmt.Errorf("failed to check target PVC %s/%s: %w", vrrTargetNamespace(vrr), vrrTargetPVCName(vrr), err) + } + + switch vrr.Spec.SourceRef.Kind { @@ -611,7 +611,7 @@ index 000000000..4a6c39c28 + return nil, fmt.Errorf("failed to convert AccessModes to CSI access mode: %w", err) + } + -+ switch vrr.Spec.VolumeMode { ++ switch vrrVolumeMode(vrr) { + case v1.PersistentVolumeBlock: + return []*csi.VolumeCapability{ + { @@ -631,7 +631,7 @@ index 000000000..4a6c39c28 + }, + }, nil + default: -+ return nil, fmt.Errorf("unsupported volumeMode %q (must be Block or Filesystem)", vrr.Spec.VolumeMode) ++ return nil, fmt.Errorf("unsupported volumeMode %q (must be Block or Filesystem)", vrrVolumeMode(vrr)) + } +} + @@ -644,12 +644,53 @@ index 000000000..4a6c39c28 + return e.defaultFSType +} + -+// effectiveAccessModes returns the VRR-requested access modes, defaulting to -+// ReadWriteOnce when none are specified. The returned slice is what is recorded on -+// the PV/PVC verbatim (no lossy reverse-mapping from the single CSI access mode). ++// convertAccessModes maps storage-foundation pvcTemplate access modes to core v1 access modes. Both api ++// types are string aliases over the same Kubernetes values, so the conversion is value-preserving (an ++// unknown mode round-trips unchanged so validateVRRAccessModes can still reject it). ++func convertAccessModes(in []storagev1alpha1.PersistentVolumeAccessMode) []v1.PersistentVolumeAccessMode { ++ if len(in) == 0 { ++ return nil ++ } ++ out := make([]v1.PersistentVolumeAccessMode, 0, len(in)) ++ for _, m := range in { ++ out = append(out, v1.PersistentVolumeAccessMode(string(m))) ++ } ++ return out ++} ++ ++// vrrStorageClassName returns the target PVC StorageClass from spec.pvcTemplate (empty when unset). ++func vrrStorageClassName(vrr *storagev1alpha1.VolumeRestoreRequest) string { ++ if vrr.Spec.PvcTemplate.StorageClassName != nil { ++ return *vrr.Spec.PvcTemplate.StorageClassName ++ } ++ return "" ++} ++ ++// vrrVolumeMode returns the target PVC volume mode from spec.pvcTemplate as a core v1 value (empty when ++// unset; both api types are string aliases over Block/Filesystem). ++func vrrVolumeMode(vrr *storagev1alpha1.VolumeRestoreRequest) v1.PersistentVolumeMode { ++ if vrr.Spec.PvcTemplate.VolumeMode != nil { ++ return v1.PersistentVolumeMode(string(*vrr.Spec.PvcTemplate.VolumeMode)) ++ } ++ return "" ++} ++ ++// vrrTargetPVCName and vrrTargetNamespace resolve the restore target PVC. Restore is never ++// cross-namespace, so the PVC is spec.pvcTemplate.metadata.name in the VRR's own namespace. ++func vrrTargetPVCName(vrr *storagev1alpha1.VolumeRestoreRequest) string { ++ return vrr.Spec.PvcTemplate.Name ++} ++ ++func vrrTargetNamespace(vrr *storagev1alpha1.VolumeRestoreRequest) string { ++ return vrr.Namespace ++} ++ ++// effectiveAccessModes returns the VRR-requested access modes (from spec.pvcTemplate), defaulting to ++// ReadWriteOnce when none are specified. The returned slice is what is recorded on the PV/PVC verbatim ++// (no lossy reverse-mapping from the single CSI access mode). +func effectiveAccessModes(vrr *storagev1alpha1.VolumeRestoreRequest) []v1.PersistentVolumeAccessMode { -+ if len(vrr.Spec.AccessModes) > 0 { -+ return vrr.Spec.AccessModes ++ if modes := convertAccessModes(vrr.Spec.PvcTemplate.AccessModes); len(modes) > 0 { ++ return modes + } + return []v1.PersistentVolumeAccessMode{v1.ReadWriteOnce} +} @@ -812,11 +853,11 @@ index 000000000..4a6c39c28 +// the storage-foundation controller). +// - AlreadyExists is treated as idempotent success. +func (e *VRRExecutor) createPVCFromVRR(ctx context.Context, vrr *storagev1alpha1.VolumeRestoreRequest, pv *v1.PersistentVolume) error { -+ storageClassName := vrr.Spec.StorageClassName ++ storageClassName := vrrStorageClassName(vrr) + pvc := &v1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ -+ Name: vrr.Spec.TargetPVCName, -+ Namespace: vrr.Spec.TargetNamespace, ++ Name: vrrTargetPVCName(vrr), ++ Namespace: vrrTargetNamespace(vrr), + }, + Spec: v1.PersistentVolumeClaimSpec{ + StorageClassName: &storageClassName, @@ -842,16 +883,16 @@ index 000000000..4a6c39c28 + } + } + -+ if _, err := e.kubeClient.CoreV1().PersistentVolumeClaims(vrr.Spec.TargetNamespace).Create(ctx, pvc, metav1.CreateOptions{}); err != nil { ++ if _, err := e.kubeClient.CoreV1().PersistentVolumeClaims(vrrTargetNamespace(vrr)).Create(ctx, pvc, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { -+ klog.V(2).Infof("createPVCFromVRR: PVC %s/%s already exists, idempotent success", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName) ++ klog.V(2).Infof("createPVCFromVRR: PVC %s/%s already exists, idempotent success", vrrTargetNamespace(vrr), vrrTargetPVCName(vrr)) + return nil + } -+ return fmt.Errorf("failed to create PVC %s/%s: %w", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName, err) ++ return fmt.Errorf("failed to create PVC %s/%s: %w", vrrTargetNamespace(vrr), vrrTargetPVCName(vrr), err) + } + -+ klog.V(2).Infof("createPVCFromVRR: created PVC %s/%s for VRR %s/%s", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName, vrr.Namespace, vrr.Name) -+ e.eventRecorder.Eventf(vrr, v1.EventTypeNormal, "VRRPVCCreated", "Successfully created PVC %s/%s", vrr.Spec.TargetNamespace, vrr.Spec.TargetPVCName) ++ klog.V(2).Infof("createPVCFromVRR: created PVC %s/%s for VRR %s/%s", vrrTargetNamespace(vrr), vrrTargetPVCName(vrr), vrr.Namespace, vrr.Name) ++ e.eventRecorder.Eventf(vrr, v1.EventTypeNormal, "VRRPVCCreated", "Successfully created PVC %s/%s", vrrTargetNamespace(vrr), vrrTargetPVCName(vrr)) + return nil +} + @@ -1029,7 +1070,7 @@ new file mode 100644 index 000000000..794ddee41 --- /dev/null +++ b/pkg/controller/vrr_handler_test.go -@@ -0,0 +1,1073 @@ +@@ -0,0 +1,1084 @@ +/* +Copyright 2025 Flant JSC + @@ -1150,7 +1191,7 @@ index 000000000..794ddee41 + mockSCLister := NewMockStorageClassLister(ctrl) + + vrr := createTestVRR("test-vrr", "test-ns", "VolumeSnapshotContent", "test-vsc") -+ vrr.Spec.StorageClassName = "" ++ vrr.Spec.PvcTemplate.StorageClassName = nil + + kubeClient := fakeclientset.NewSimpleClientset() + eventRecorder := record.NewFakeRecorder(10) @@ -1325,7 +1366,7 @@ index 000000000..794ddee41 + mockSCLister.storageClasses[vrrTestStorageClass] = ourStorageClass() + + vrr := createTestVRR("test-vrr", "test-ns", "VolumeSnapshotContent", "test-vsc") -+ vrr.Spec.VolumeMode = "" // required; executor must reject ++ vrr.Spec.PvcTemplate.VolumeMode = nil // required; executor must reject + + kubeClient := fakeclientset.NewSimpleClientset() + eventRecorder := record.NewFakeRecorder(10) @@ -1352,8 +1393,9 @@ index 000000000..794ddee41 + mockSCLister.storageClasses[vrrTestStorageClass] = ourStorageClass() + + vrr := createTestVRR("test-vrr", "test-ns", "VolumeSnapshotContent", "test-vsc") -+ vrr.Spec.VolumeMode = v1.PersistentVolumeBlock -+ vrr.Spec.AccessModes = []v1.PersistentVolumeAccessMode{v1.ReadWriteMany} ++ blockMode := storagev1alpha1.PersistentVolumeBlock ++ vrr.Spec.PvcTemplate.VolumeMode = &blockMode ++ vrr.Spec.PvcTemplate.AccessModes = []storagev1alpha1.PersistentVolumeAccessMode{storagev1alpha1.ReadWriteMany} + vsc := readyVSC("test-vsc", vrrTestSnapshotHandle) + + kubeClient := fakeclientset.NewSimpleClientset() @@ -1689,9 +1731,9 @@ index 000000000..794ddee41 + + tests := []struct { + name string -+ volumeMode v1.PersistentVolumeMode ++ volumeMode storagev1alpha1.PersistentVolumeMode + fsType string -+ accessModes []v1.PersistentVolumeAccessMode ++ accessModes []storagev1alpha1.PersistentVolumeAccessMode + sc *storagev1.StorageClass + wantBlock bool + wantFSType string @@ -1700,7 +1742,7 @@ index 000000000..794ddee41 + }{ + { + name: "filesystem: default RWO, fsType fallback, SC mount options", -+ volumeMode: v1.PersistentVolumeFilesystem, ++ volumeMode: storagev1alpha1.PersistentVolumeFilesystem, + sc: scWithMount, + wantBlock: false, + wantFSType: vrrTestDefaultFSType, @@ -1709,7 +1751,7 @@ index 000000000..794ddee41 + }, + { + name: "filesystem: explicit fsType, no SC mount options", -+ volumeMode: v1.PersistentVolumeFilesystem, ++ volumeMode: storagev1alpha1.PersistentVolumeFilesystem, + fsType: "xfs", + sc: ourStorageClass(), + wantBlock: false, @@ -1719,8 +1761,8 @@ index 000000000..794ddee41 + }, + { + name: "block: RWX maps to MULTI_NODE_MULTI_WRITER", -+ volumeMode: v1.PersistentVolumeBlock, -+ accessModes: []v1.PersistentVolumeAccessMode{v1.ReadWriteMany}, ++ volumeMode: storagev1alpha1.PersistentVolumeBlock, ++ accessModes: []storagev1alpha1.PersistentVolumeAccessMode{storagev1alpha1.ReadWriteMany}, + sc: ourStorageClass(), + wantBlock: true, + wantAccessMode: csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER, @@ -1731,9 +1773,10 @@ index 000000000..794ddee41 + t.Run(tt.name, func(t *testing.T) { + e := newTestExecutor(&mockControllerClient{}, nil, nil, nil, nil) + vrr := createTestVRR("test-vrr", "test-ns", "VolumeSnapshotContent", "test-vsc") -+ vrr.Spec.VolumeMode = tt.volumeMode ++ vm := tt.volumeMode ++ vrr.Spec.PvcTemplate.VolumeMode = &vm + vrr.Spec.FsType = tt.fsType -+ vrr.Spec.AccessModes = tt.accessModes ++ vrr.Spec.PvcTemplate.AccessModes = tt.accessModes + + caps, err := e.getVolumeCapabilitiesFromVRR(vrr, tt.sc) + if err != nil { @@ -1782,7 +1825,7 @@ index 000000000..794ddee41 + mockSCLister.storageClasses[vrrTestStorageClass] = ourStorageClass() + + vrr := createTestVRR("test-vrr", "test-ns", "VolumeSnapshotContent", "test-vsc") -+ vrr.Spec.AccessModes = []v1.PersistentVolumeAccessMode{"BogusMode"} ++ vrr.Spec.PvcTemplate.AccessModes = []storagev1alpha1.PersistentVolumeAccessMode{"BogusMode"} + + kubeClient := fakeclientset.NewSimpleClientset() + eventRecorder := record.NewFakeRecorder(10) @@ -1983,6 +2026,10 @@ index 000000000..794ddee41 +} + +func createTestVRR(name, namespace, sourceKind, sourceName string) *storagev1alpha1.VolumeRestoreRequest { ++ scName := vrrTestStorageClass ++ fsMode := storagev1alpha1.PersistentVolumeFilesystem ++ // Restore is never cross-namespace: the target PVC (pvcTemplate.metadata.name) lives in the VRR ++ // namespace, so the callers pass namespace == vrrTestTargetNamespace. + return &storagev1alpha1.VolumeRestoreRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, @@ -1990,11 +2037,16 @@ index 000000000..794ddee41 + UID: types.UID("test-uid-" + name), + }, + Spec: storagev1alpha1.VolumeRestoreRequestSpec{ -+ SourceRef: storagev1alpha1.ObjectReference{Kind: sourceKind, Name: sourceName}, -+ TargetNamespace: vrrTestTargetNamespace, -+ TargetPVCName: vrrTestTargetPVCName, -+ StorageClassName: vrrTestStorageClass, -+ VolumeMode: v1.PersistentVolumeFilesystem, ++ SourceRef: storagev1alpha1.ObjectReference{Kind: sourceKind, Name: sourceName}, ++ PvcTemplate: storagev1alpha1.PersistentVolumeClaimTemplateSpec{ ++ PersistentVolumeClaimTemplateMetadata: storagev1alpha1.PersistentVolumeClaimTemplateMetadata{ ++ Name: vrrTestTargetPVCName, ++ }, ++ PersistentVolumeClaimSpec: storagev1alpha1.PersistentVolumeClaimSpec{ ++ StorageClassName: &scName, ++ VolumeMode: &fsMode, ++ }, ++ }, + }, + } +} diff --git a/images/csi-external-provisioner/patches/v6.2.0/README.md b/images/csi-external-provisioner/patches/v6.2.0/README.md index 60668b9..7779131 100644 --- a/images/csi-external-provisioner/patches/v6.2.0/README.md +++ b/images/csi-external-provisioner/patches/v6.2.0/README.md @@ -43,19 +43,22 @@ git -C diff v6.2.0 d8-63742164-vrr \ ``` API dependency: the executor imports -`github.com/deckhouse/storage-foundation/api`. The extended VRR API -(`volumeMode`/`fsType`/`accessModes`, commit `b97b1e1`) is **not yet** -released as a module tag (`api/v0.1.0` predates it), so `002` pins the -module via a Go **pseudo-version** -(`v0.0.0-20260614235316-b97b1e188226`) that resolves to that commit on -the module proxy. The standard pipeline -(`rm -rf vendor && go mod download && go mod vendor`) resolves it -normally — no `-mod=vendor` special case, no shadow API. The patch also -bumps the `go` directive to `1.25.10` (required by the `api` module). - -FOLLOW-UP: publish a real `api/vX.Y.Z` tag at the extended-API commit and -replace the pseudo-version in `002` with the tag (one `go.mod`/`go.sum` -line change). +`github.com/deckhouse/storage-foundation/api`. As of wave6 the executor +reads the **honest-refs** VRR API — `spec.sourceRef` + `spec.pvcTemplate` +(`metadata.name` + `spec.storageClassName`/`volumeMode`/`accessModes`) +with `fsType` at the spec root — and never writes status. The pinned +pseudo-version (`v0.0.0-20260614235316-b97b1e188226`, commit `b97b1e1`) +predates wave6 (it carries the flat `spec.volumeMode`/`fsType`/ +`accessModes` schema), so it will **not** compile against this patch. + +BLOCKER / FOLLOW-UP (wave6): `002` was hand-updated blindly to the +`pvcTemplate`/`pvcRef` schema ahead of the API being published. Before +building you MUST (1) land the wave6 `api/v1alpha1` VRR types +(`PvcTemplate`/`PvcRef`), (2) regenerate the fork branch `d8-63742164-vrr` +from this patch (or re-apply the same edits there and re-diff), and +(3) re-pin the pseudo-version / tag in `002` to the published wave6 API +commit. Until then the patch is unverifiable (no compile path). The patch +also bumps the `go` directive to `1.25.10` (required by the `api` module). Note: the `api` module is a Go submodule (`module .../api` in `api/`), so its version tag is subdirectory-prefixed (`api/vX.Y.Z`); the plain @@ -120,27 +123,32 @@ go build ./cmd/csi-provisioner` (CGO_ENABLED=0, linux/amd64) succeeds. ### API dependency — pinned via Go pseudo-version -The executor needs the **extended** VRR API -(`spec.volumeMode`/`fsType`/`accessModes`, storage-foundation commit -`b97b1e1`). That extension is **not** in the published `api/v0.1.0` tag -(commit `bc90a730`, which predates it), so pinning `api v0.1.0` would not -compile. +As of wave6 the executor needs the **honest-refs** VRR API: +`spec.sourceRef` + `spec.pvcTemplate` (with `metadata.name` and +`spec.storageClassName`/`volumeMode`/`accessModes`), `fsType` at the spec +root, and `status.pvcRef`. `pvcTemplate` uses the storage-foundation +string-alias mirror types (`PersistentVolumeMode`, +`PersistentVolumeAccessMode`), so the executor converts them to core `v1` +before building CSI capabilities / the PV+PVC (see `convertAccessModes`, +`vrrVolumeMode`, `vrrStorageClassName`, `vrrTargetPVCName/Namespace`). -Instead `002` pins the module via a Go **pseudo-version**: +The pinned pseudo-version: ``` require github.com/deckhouse/storage-foundation/api v0.0.0-20260614235316-b97b1e188226 ``` -which the module proxy resolves to commit `b97b1e1`. The standard -pipeline (`rm -rf vendor && go mod download && go mod vendor`) fetches it -over the network — no `-mod=vendor` special case, no shadow API. The -patch also bumps the `go` directive to `1.25.10` because the `api` -module requires it. - -FOLLOW-UP: publish a real `api/vX.Y.Z` tag at (or after) `b97b1e1` and -replace the pseudo-version in `002` with that tag (one `go.mod` line plus -matching `go.sum` lines). +resolves to commit `b97b1e1`, which is the **pre-wave6** flat schema and +will NOT compile against this patch. + +BLOCKER / FOLLOW-UP (wave6): `002` was hand-updated blindly to the +`pvcTemplate`/`pvcRef` schema before the API was published. To build: +(1) land the wave6 VRR API types, (2) regenerate the fork branch +`d8-63742164-vrr` from this patch (or mirror the edits and re-diff), and +(3) re-pin the pseudo-version / tag in `002` to the published wave6 API +commit (one `go.mod` line plus matching `go.sum` lines). The patch also +bumps the `go` directive to `1.25.10` because the `api` module requires +it. ### RBAC for the provisioner ServiceAccount diff --git a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go index a0de82c..7a17e9a 100644 --- a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go +++ b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver.go @@ -271,33 +271,38 @@ func (r *DataexportReconciler) ensureVolumeRestoreRequest(ctx context.Context, d } // art.VolumeMode is guaranteed non-empty (resolveSnapshotDataArtifact rejects an empty one as - // not-ready), so the required VRR volumeMode comes straight from the trusted dataRef. - spec := map[string]interface{}{ - "sourceRef": map[string]interface{}{ - "kind": art.ArtifactKind, - "name": art.ArtifactName, - }, - // targetRef carries only kind+name: restore is never cross-namespace, so the foundation VRR - // controller derives the target namespace from metadata.namespace (set to ControllerNamespace - // below). Only kind=PersistentVolumeClaim is supported for now. - "targetRef": map[string]interface{}{ - "kind": "PersistentVolumeClaim", - "name": generatedNames.ExportPVCName, - }, + // not-ready), so the required pvcTemplate volumeMode comes straight from the trusted dataRef. + // pvcTemplate describes the export PVC the restore creates; its namespace is implicit = the VRR + // namespace (restore is never cross-namespace), so metadata.namespace = ControllerNamespace applies. + pvcSpec := map[string]interface{}{ "volumeMode": art.VolumeMode, } if art.StorageClassName != "" { - spec["storageClassName"] = art.StorageClassName - } - if art.FsType != "" { - spec["fsType"] = art.FsType + pvcSpec["storageClassName"] = art.StorageClassName } if len(art.AccessModes) > 0 { modes := make([]interface{}, 0, len(art.AccessModes)) for _, m := range art.AccessModes { modes = append(modes, m) } - spec["accessModes"] = modes + pvcSpec["accessModes"] = modes + } + spec := map[string]interface{}{ + "sourceRef": map[string]interface{}{ + "kind": art.ArtifactKind, + "name": art.ArtifactName, + }, + "pvcTemplate": map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": generatedNames.ExportPVCName, + }, + "spec": pvcSpec, + }, + } + // fsType is a restore execution parameter read by the external-provisioner, not a PVC field, so it + // stays at spec root (optional, ignored for Block volumes). + if art.FsType != "" { + spec["fsType"] = art.FsType } vrr := &unstructured.Unstructured{Object: map[string]interface{}{ diff --git a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go index b8f68d0..e3bf867 100644 --- a/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go +++ b/images/data-manager-controller/internal/controllers/data-export/snapshot_resolver_test.go @@ -304,18 +304,18 @@ func TestEnsureVolumeRestoreRequest_CreatesAndIsIdempotent(t *testing.T) { sourceKind, _, _ := unstructured.NestedString(vrr.Object, "spec", "sourceRef", "kind") sourceName, _, _ := unstructured.NestedString(vrr.Object, "spec", "sourceRef", "name") - targetKind, _, _ := unstructured.NestedString(vrr.Object, "spec", "targetRef", "kind") - targetPVC, _, _ := unstructured.NestedString(vrr.Object, "spec", "targetRef", "name") - targetNS, hasTargetNS, _ := unstructured.NestedString(vrr.Object, "spec", "targetRef", "namespace") - volumeMode, _, _ := unstructured.NestedString(vrr.Object, "spec", "volumeMode") + targetPVC, _, _ := unstructured.NestedString(vrr.Object, "spec", "pvcTemplate", "metadata", "name") + _, hasTargetNS, _ := unstructured.NestedString(vrr.Object, "spec", "pvcTemplate", "metadata", "namespace") + volumeMode, _, _ := unstructured.NestedString(vrr.Object, "spec", "pvcTemplate", "spec", "volumeMode") + _, hasLegacyTargetRef, _ := unstructured.NestedMap(vrr.Object, "spec", "targetRef") metaNS := vrr.GetNamespace() assert.Equal(t, artifactKindVolumeSnapshotContent, sourceKind) assert.Equal(t, "vsc1", sourceName) - assert.Equal(t, "PersistentVolumeClaim", targetKind) + assert.False(t, hasLegacyTargetRef, "spec.targetRef must not be set (replaced by pvcTemplate)") assert.Equal(t, names.ExportPVCName, targetPVC) - // Restore is never cross-namespace: targetRef carries no namespace; the target lives in the VRR namespace. - assert.False(t, hasTargetNS, "spec.targetRef.namespace must not be set") - assert.Empty(t, targetNS) + // Restore is never cross-namespace: pvcTemplate.metadata carries no namespace; the target lives in + // the VRR namespace. + assert.False(t, hasTargetNS, "spec.pvcTemplate.metadata.namespace must not be set") assert.Equal(t, testControllerNamespace, metaNS) assert.Equal(t, "Block", volumeMode) diff --git a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go index 5237f8b..29a8810 100644 --- a/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go +++ b/images/data-manager-controller/internal/controllers/data-import/data_import_resource.go @@ -679,7 +679,7 @@ func (r *DataImportReconciler) cleanupDataImport(ctx context.Context) error { dev1alpha1.StorageManagerFinalizerName, ) if err != nil { - logger.Error(err, "Failed to remove finalizer from TargetRef") + logger.Error(err, "Failed to remove finalizer from target PVC") return err } diff --git a/images/data-manager-controller/internal/controllers/data-import/targer_ref.go b/images/data-manager-controller/internal/controllers/data-import/targer_ref.go index 131e86b..a47d9fd 100644 --- a/images/data-manager-controller/internal/controllers/data-import/targer_ref.go +++ b/images/data-manager-controller/internal/controllers/data-import/targer_ref.go @@ -35,10 +35,10 @@ const ( TargetStatusFailed // Target failed ) -// GetScratchPVC fetches by name the PVC the imported bytes are written into. In Mode A (snapshot leaf -// import) this is the internal scratch PVC named after the DataImport, with spec derived from the -// DataImport spec parameters (storageClass/size/volumeMode). In Mode B (standalone PVC import) it is the -// user-described PVC named by targetRef.pvcTemplate.metadata.name. +// GetScratchPVC fetches by name the PVC the imported bytes are written into. In mode ProduceArtifact +// (snapshot leaf import) this is the internal scratch PVC named after the DataImport, with spec derived +// from spec.scratchVolumeTemplate (storageClass/size/volumeMode). In mode PopulateVolume (standalone PVC +// import) it is the user-described PVC named by spec.pvcTemplate.metadata.name. func GetScratchPVC(ctx context.Context, c client.Client, namespace, name string) (*corev1.PersistentVolumeClaim, error) { pvc := &corev1.PersistentVolumeClaim{} err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, pvc) diff --git a/images/populator/cmd/main.go b/images/populator/cmd/main.go index 8832679..4dd8912 100644 --- a/images/populator/cmd/main.go +++ b/images/populator/cmd/main.go @@ -240,8 +240,9 @@ func getDataImportAndNames(ctx context.Context, unstructured *unstructured.Unstr return nil, common.Names{}, err } - // The scratch volume the populator fills is always a PVC named after the DataImport (spec.targetRef - // now references the snapshot leaf, not the scratch PVC). + // The scratch volume the populator fills is always a PVC named after the DataImport (in mode + // ProduceArtifact spec.snapshotRef references the snapshot leaf, not the scratch PVC; the scratch + // PVC spec comes from spec.scratchVolumeTemplate). return dataImport, common.NewNames(dev1alpha1.KindPVC, dataImport.Name, dataImport.Namespace, dataImport.Name), nil } From 3d53efc1baa7138ec40875f6bbf5edf4b07efa67 Mon Sep 17 00:00:00 2001 From: Aleksandr Zimin Date: Mon, 6 Jul 2026 18:08:27 +0300 Subject: [PATCH 11/11] build(vrr): pin external-provisioner executor to wave6 api and verify build Re-pin storage-foundation/api b97b1e1 -> 2c52550 (wave6 honest-refs VRR schema: sourceRef + pvcTemplate, fsType at spec root) in 002-vrr-executor.patch (go.mod require + go.sum hashes), and bump the go directive 1.25.10 -> 1.26.4 as required by api/go.mod. Verified end-to-end: 001 + 002 apply cleanly on a pristine v6.2.0 tag, CGO_ENABLED=0 go build ./cmd/csi-provisioner builds, and go test ./pkg/controller (all 25 VRR executor tests) passes against the pinned wave6 api. Update the patch README and oss.yaml: drop the pre-publish BLOCKER and document the patch-on-tag delivery model plus the new pin. Signed-off-by: Aleksandr Zimin --- .../patches/v6.2.0/002-vrr-executor.patch | 8 +-- .../patches/v6.2.0/README.md | 68 +++++++++---------- oss.yaml | 11 +-- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch b/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch index 56c6b30..3ac0e5b 100644 --- a/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch +++ b/images/csi-external-provisioner/patches/v6.2.0/002-vrr-executor.patch @@ -176,11 +176,11 @@ index 9e1264600..d5862ffc3 100644 module github.com/kubernetes-csi/external-provisioner/v5 -go 1.25.7 -+go 1.25.10 ++go 1.26.4 require ( github.com/container-storage-interface/spec v1.12.0 -+ github.com/deckhouse/storage-foundation/api v0.0.0-20260614235316-b97b1e188226 ++ github.com/deckhouse/storage-foundation/api v0.0.0-20260706134706-2c525506f13c github.com/golang/mock v1.6.0 github.com/google/uuid v1.6.0 // indirect github.com/kubernetes-csi/csi-lib-utils v0.23.2 @@ -192,8 +192,8 @@ index fbc500deb..cc4dd56ca 100644 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -+github.com/deckhouse/storage-foundation/api v0.0.0-20260614235316-b97b1e188226 h1:chWiDopdMkx0UsAXstu//YpjUaqKhuP8Ni3m+vABwbI= -+github.com/deckhouse/storage-foundation/api v0.0.0-20260614235316-b97b1e188226/go.mod h1:FRaWxya+74ktZMBDr96eudQs9xPkYmMDFiLpl3K7+O8= ++github.com/deckhouse/storage-foundation/api v0.0.0-20260706134706-2c525506f13c h1:fPX62n3igjkfA6hPYKh1t/h8JhKj6Ev92oycPYhKrhk= ++github.com/deckhouse/storage-foundation/api v0.0.0-20260706134706-2c525506f13c/go.mod h1:EYki1R5o84Rt8WgEwIGj2MUvsGHG6SxwqbeIPmPV1x0= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/images/csi-external-provisioner/patches/v6.2.0/README.md b/images/csi-external-provisioner/patches/v6.2.0/README.md index 7779131..40d3d4b 100644 --- a/images/csi-external-provisioner/patches/v6.2.0/README.md +++ b/images/csi-external-provisioner/patches/v6.2.0/README.md @@ -32,13 +32,14 @@ via `git: add: patches/` with `stageDependencies: install: '**/*'`, so editing any patch file reliably busts the cache. This is the same pattern `csi-external-snapshotter` uses. -Source of truth for the executor remains the Deckhouse fork branch -`d8-63742164-vrr`; `002-vrr-executor.patch` is the diff of that branch -against `v6.2.0` (excluding `vendor/`). To regenerate after a branch -change: +`002-vrr-executor.patch` is the source of truth for the executor; it is a +plain diff against `v6.2.0` (excluding `vendor/`) applied directly to the +tag at build time. There is no long-lived fork branch in the build path — +the patch was developed on the throwaway branch `d8-63742164-vrr`. To +re-derive the diff from such a branch: ``` -git -C diff v6.2.0 d8-63742164-vrr \ +git -C diff v6.2.0 \ -- . ':(exclude)vendor' > 002-vrr-executor.patch ``` @@ -46,19 +47,19 @@ API dependency: the executor imports `github.com/deckhouse/storage-foundation/api`. As of wave6 the executor reads the **honest-refs** VRR API — `spec.sourceRef` + `spec.pvcTemplate` (`metadata.name` + `spec.storageClassName`/`volumeMode`/`accessModes`) -with `fsType` at the spec root — and never writes status. The pinned -pseudo-version (`v0.0.0-20260614235316-b97b1e188226`, commit `b97b1e1`) -predates wave6 (it carries the flat `spec.volumeMode`/`fsType`/ -`accessModes` schema), so it will **not** compile against this patch. - -BLOCKER / FOLLOW-UP (wave6): `002` was hand-updated blindly to the -`pvcTemplate`/`pvcRef` schema ahead of the API being published. Before -building you MUST (1) land the wave6 `api/v1alpha1` VRR types -(`PvcTemplate`/`PvcRef`), (2) regenerate the fork branch `d8-63742164-vrr` -from this patch (or re-apply the same edits there and re-diff), and -(3) re-pin the pseudo-version / tag in `002` to the published wave6 API -commit. Until then the patch is unverifiable (no compile path). The patch -also bumps the `go` directive to `1.25.10` (required by the `api` module). +with `fsType` at the spec root — and never writes status. It is pinned to +the wave6 API pseudo-version `v0.0.0-20260706134706-2c525506f13c` (commit +`2c52550`), which carries the `pvcTemplate`/`pvcRef` schema. The patch +also bumps the `go` directive to `1.26.4` because the `api` module +requires it. + +Verified (wave6): applying `001` + `002` to a clean `v6.2.0` checkout +compiles and the executor test suite passes — +`CGO_ENABLED=0 go build -mod=mod ./cmd/csi-provisioner` and +`go test -mod=mod ./pkg/controller/` (VRR tests) are both green against +the pinned wave6 API. When the API gets a published `api/vX.Y.Z` tag, +replace the pseudo-version with that tag (one `go.mod` line plus matching +`go.sum` lines). Note: the `api` module is a Go submodule (`module .../api` in `api/`), so its version tag is subdirectory-prefixed (`api/vX.Y.Z`); the plain @@ -114,12 +115,13 @@ freshly-started worker never sees an existing StorageClass as NotFound), types) but does **not** vendor it — the dependency is resolved from the module proxy (see below). -Source of truth for the executor code is the development branch -`d8-63742164-vrr` in the `external-provisioner` fork; this patch is the -diff of that branch against upstream v6.2.0 (excluding `vendor/`). -Verified end-to-end on a clean `v6.2.0` checkout with `001` + `002` -applied: `rm -rf vendor && go mod download && go mod vendor && -go build ./cmd/csi-provisioner` (CGO_ENABLED=0, linux/amd64) succeeds. +This patch is the source of truth for the executor code (developed on the +throwaway branch `d8-63742164-vrr`, then diffed against upstream v6.2.0 +excluding `vendor/`). Verified end-to-end on a clean `v6.2.0` checkout +with `001` + `002` applied: `go build ./cmd/csi-provisioner` +(CGO_ENABLED=0) and `go test ./pkg/controller/` (VRR tests) both succeed +against the pinned wave6 API. The build pipeline itself does +`rm -rf vendor && go mod download && go mod vendor` before `make build`. ### API dependency — pinned via Go pseudo-version @@ -135,20 +137,14 @@ before building CSI capabilities / the PV+PVC (see `convertAccessModes`, The pinned pseudo-version: ``` -require github.com/deckhouse/storage-foundation/api v0.0.0-20260614235316-b97b1e188226 +require github.com/deckhouse/storage-foundation/api v0.0.0-20260706134706-2c525506f13c ``` -resolves to commit `b97b1e1`, which is the **pre-wave6** flat schema and -will NOT compile against this patch. - -BLOCKER / FOLLOW-UP (wave6): `002` was hand-updated blindly to the -`pvcTemplate`/`pvcRef` schema before the API was published. To build: -(1) land the wave6 VRR API types, (2) regenerate the fork branch -`d8-63742164-vrr` from this patch (or mirror the edits and re-diff), and -(3) re-pin the pseudo-version / tag in `002` to the published wave6 API -commit (one `go.mod` line plus matching `go.sum` lines). The patch also -bumps the `go` directive to `1.25.10` because the `api` module requires -it. +resolves to commit `2c52550` (wave6 `pvcTemplate`/`pvcRef` schema). The +patch also bumps the `go` directive to `1.26.4` because the `api` module +requires it. When the API gets a published `api/vX.Y.Z` tag, replace the +pseudo-version with that tag (one `go.mod` line plus matching `go.sum` +lines). ### RBAC for the provisioner ServiceAccount diff --git a/oss.yaml b/oss.yaml index 9ec8991..1673801 100644 --- a/oss.yaml +++ b/oss.yaml @@ -21,11 +21,12 @@ # 002-vrr-executor.patch - VolumeRestoreRequest executor with Block/ # Filesystem VolumeMode support (CSI # VolumeCapabilities built from VRR spec). - # Source of truth for the executor is the Deckhouse fork branch - # d8-63742164-vrr; 002 is the diff of that branch against v6.2.0 (excluding - # vendor). The storage-foundation/api dependency is pinned via a Go - # pseudo-version to commit b97b1e1 (extended VRR API: volumeMode/fsType/ - # accessModes); switch to a published api/vX.Y.Z tag later. + # 002 is the source of truth for the executor (a plain diff applied to + # the v6.2.0 tag, excluding vendor; no long-lived fork branch). The + # storage-foundation/api dependency is pinned via a Go pseudo-version to + # commit 2c52550 (wave6 honest-refs VRR API: sourceRef + pvcTemplate, + # fsType at spec root); switch to a published api/vX.Y.Z tag later. The + # patch bumps the go directive to 1.26.4 (required by the api module). - condition: k8s: ["1.20"] version: v6.2.0