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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/v1alpha1/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha1/conditions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions api/v1alpha1/data_consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions api/v1alpha1/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion api/v1alpha1/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
)

const (
APIGroup = "storage.deckhouse.io"
APIGroup = "storage-foundation.deckhouse.io"
APIVersion = "v1alpha1"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
}
Expand All @@ -59,45 +49,48 @@ 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{}
if err := json.Unmarshal(data, &raw); err != nil {
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",
UID: "vsc-uid-a",
},
},
},
Expand All @@ -112,12 +105,20 @@ 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)
}
// 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")
}
if ref.Artifact.Kind == "VolumeCaptureRequest" {
t.Fatal("artifact must not reference an execution request")
Expand All @@ -128,28 +129,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) {
crdPath := filepath.Join("..", "..", "crds", "internal", "storage.deckhouse.io_volumecapturerequests.yaml")
func TestVolumeCaptureRequestCRD_SingleTargetSchema(t *testing.T) {
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)
}
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) {
Expand All @@ -164,23 +170,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")
}
}

Expand All @@ -191,6 +201,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.
31 changes: 17 additions & 14 deletions api/v1alpha1/volumecapturerequest_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand All @@ -61,12 +63,17 @@ 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
// 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"`
Expand All @@ -86,11 +93,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
Expand All @@ -101,16 +106,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 {
Expand Down
Loading
Loading