diff --git a/manifests/supervisorcluster/1.33/cns-csi.yaml b/manifests/supervisorcluster/1.33/cns-csi.yaml index ad20a87b27..4d5b074e98 100644 --- a/manifests/supervisorcluster/1.33/cns-csi.yaml +++ b/manifests/supervisorcluster/1.33/cns-csi.yaml @@ -132,6 +132,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes/status"] verbs: ["patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -791,6 +797,9 @@ rules: - apiGroups: ["vmoperator.vmware.com"] resources: ["virtualmachines"] verbs: ["get", "list"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/manifests/supervisorcluster/1.34/cns-csi.yaml b/manifests/supervisorcluster/1.34/cns-csi.yaml index ad20a87b27..4d5b074e98 100644 --- a/manifests/supervisorcluster/1.34/cns-csi.yaml +++ b/manifests/supervisorcluster/1.34/cns-csi.yaml @@ -132,6 +132,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes/status"] verbs: ["patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -791,6 +797,9 @@ rules: - apiGroups: ["vmoperator.vmware.com"] resources: ["virtualmachines"] verbs: ["get", "list"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/manifests/supervisorcluster/1.35/cns-csi.yaml b/manifests/supervisorcluster/1.35/cns-csi.yaml index ad20a87b27..4d5b074e98 100644 --- a/manifests/supervisorcluster/1.35/cns-csi.yaml +++ b/manifests/supervisorcluster/1.35/cns-csi.yaml @@ -132,6 +132,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes/status"] verbs: ["patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -791,6 +797,9 @@ rules: - apiGroups: ["vmoperator.vmware.com"] resources: ["virtualmachines"] verbs: ["get", "list"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/pkg/apis/cnsoperator/cnsnodevmbatchattachment/v1alpha1/cnsnodebatchvmattachment_types.go b/pkg/apis/cnsoperator/cnsnodevmbatchattachment/v1alpha1/cnsnodebatchvmattachment_types.go index 533dec7c2d..afff96620d 100644 --- a/pkg/apis/cnsoperator/cnsnodevmbatchattachment/v1alpha1/cnsnodebatchvmattachment_types.go +++ b/pkg/apis/cnsoperator/cnsnodevmbatchattachment/v1alpha1/cnsnodebatchvmattachment_types.go @@ -54,6 +54,13 @@ const ( ReasonDetachFailed = "DetachFailed" // ReasonFailed reflects that the CR instance is not yet ready. ReasonFailed = "Failed" + // ReasonDroppedBySnapshotRevert reflects that the volume was removed from the VM + // as part of a snapshot revert operation. vSphere has already removed the disk; + // no ReconfigVM call is needed. + ReasonDroppedBySnapshotRevert = "DroppedBySnapshotRevert" + // ReasonDetachBlocked reflects that a ReconfigVM remove was blocked, typically + // because a vSphere snapshot still retains the disk. + ReasonDetachBlocked = "DetachBlocked" ) // SharingMode is the sharing mode of the virtual disk. diff --git a/pkg/apis/cnsoperator/csivolumeinfo/config/cns.vmware.com_csivolumeinfos.yaml b/pkg/apis/cnsoperator/csivolumeinfo/config/cns.vmware.com_csivolumeinfos.yaml new file mode 100644 index 0000000000..77edbdc36c --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/config/cns.vmware.com_csivolumeinfos.yaml @@ -0,0 +1,176 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + creationTimestamp: null + name: csivolumeinfos.cns.vmware.com +spec: + group: cns.vmware.com + names: + kind: CsiVolumeInfo + listKind: CsiVolumeInfoList + plural: csivolumeinfos + shortNames: + - cvi + singular: csivolumeinfo + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.ownershipState + name: OwnershipState + type: string + - jsonPath: .status.vmName + name: VMName + type: string + - jsonPath: .status.diskUUID + name: diskUUID + type: string + - jsonPath: .status.diskPath + name: diskPath + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: CsiVolumeInfo tracks the per-volume ownership lifecycle for + the VM-owned volume attach/detach model. One CR exists per PVC (in the + PVC's namespace) from the moment it is provisioned (while VMOwnedVolumes + FSS is enabled) until its owning PV is deleted. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: CsiVolumeInfoSpec defines the desired (immutable) state + of CsiVolumeInfo. All fields are set at creation time and must not change + except pvcName on Retain-reclaim rebind. + properties: + pvName: + description: PVName is the bound PV name. The CVI carries a PV ownerReference + so PV deletion cascades CVI deletion via K8s GC. + type: string + pvcName: + description: PVCName is the bound PVC name at CVI creation (or last + bind update on Retain-reclaim rebind). Together with metadata.namespace, + uniquely identifies the PVC. + type: string + volumeID: + description: VolumeID is the CNS volume ID. Immutable after creation. + Matches PV.spec.csi.volumeHandle. + minLength: 1 + type: string + required: + - pvName + - pvcName + - volumeID + type: object + status: + description: CsiVolumeInfoStatus defines the observed state of CsiVolumeInfo. + All writes go through the /status subresource endpoint. + properties: + conditions: + description: Conditions is a standard K8s condition array for extensible + status. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource." + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + diskPath: + description: DiskPath is the datastore path to the VMDK file. An + informational JIT cache — may be stale at rest. Refreshed at each + consumption point (attach, detach, snapshot-delete, revert). + type: string + diskUUID: + description: DiskUUID is the stable identifier for the virtual disk + (VirtualDisk.Backing.Uuid). Populated at CVI creation from the FCD's + backing VMDK. Immutable after creation. + type: string + ownershipState: + description: OwnershipState is the current ownership lifecycle state + of the volume. One of CSI_MANAGED, TRANSFERRING_TO_VM, VM_MANAGED, + TRANSFERRING_TO_CSI. + enum: + - CSI_MANAGED + - TRANSFERRING_TO_VM + - VM_MANAGED + - TRANSFERRING_TO_CSI + type: string + vmInstanceUUID: + description: VMInstanceUUID is the instance UUID of the VM identified + by VMName. Empty when CSI-owned or snapshot-retained. + type: string + vmName: + description: VMName is the name of the VirtualMachine CR this volume + is attached to. Empty when CSI-owned or snapshot-retained. + type: string + required: + - diskUUID + - ownershipState + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/config/config.go b/pkg/apis/cnsoperator/csivolumeinfo/config/config.go new file mode 100644 index 0000000000..813a140c19 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/config/config.go @@ -0,0 +1,24 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import "embed" + +//go:embed cns.vmware.com_csivolumeinfos.yaml +var EmbedCsiVolumeInfoFile embed.FS + +const EmbedCsiVolumeInfoFileName = "cns.vmware.com_csivolumeinfos.yaml" diff --git a/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice.go b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice.go new file mode 100644 index 0000000000..7c77c00a91 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice.go @@ -0,0 +1,371 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package csivolumeinfo provides the service layer for CsiVolumeInfo CRs. +// CsiVolumeInfo is a namespaced CR that tracks the per-volume ownership +// lifecycle for the VM-owned volume attach/detach model. +package csivolumeinfo + +import ( + "context" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + k8s "sigs.k8s.io/vsphere-csi-driver/v3/pkg/kubernetes" +) + +const ( + // cviNamePrefix is the prefix used for CsiVolumeInfo CR names. + // CR name = cviNamePrefix + volumeID. + cviNamePrefix = "csi-volume-info-" + + // allowedRetries is the default number of patch retries on conflict. + allowedRetries = 5 +) + +// CsiVolumeInfoService exposes CRUD operations on CsiVolumeInfo CRs. +// CRs are namespaced (they live in the PVC's namespace) and span the entire +// supervisor cluster. The service uses a typed controller-runtime client for +// type safety; callers provide the namespace on each operation. +type CsiVolumeInfoService interface { + // CreateCsiVolumeInfo creates a new CsiVolumeInfo CR. + // Returns nil if a CR with the same name already exists (idempotent). + CreateCsiVolumeInfo(ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error + + // GetCsiVolumeInfo fetches a CsiVolumeInfo by deterministic name derived + // from volumeID (csi-volume-info-) in the given namespace. + GetCsiVolumeInfo(ctx context.Context, namespace, volumeID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) + + // GetCsiVolumeInfoByDiskUUID fetches a CsiVolumeInfo by the + // cns.vmware.com/disk-uuid label in the given namespace. Uses an + // API-server-indexed label selector for O(1) lookup. + // Returns nil and no error if not found. + GetCsiVolumeInfoByDiskUUID(ctx context.Context, namespace, diskUUID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) //nolint:lll + + // UpdateCsiVolumeInfoStatus replaces the status subresource of the given + // CsiVolumeInfo object. The object must have been fetched from the API + // server (its ResourceVersion is used for optimistic concurrency). + UpdateCsiVolumeInfoStatus(ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error + + // PatchCsiVolumeInfo applies a JSON merge-patch to the spec and metadata + // (not status) of the CsiVolumeInfo identified by volumeID / namespace. + // Retries up to allowedRetries times on conflict. + PatchCsiVolumeInfo(ctx context.Context, namespace, volumeID string, patchBytes []byte) error + + // DeleteCsiVolumeInfo deletes the CsiVolumeInfo for the given volumeID in + // the given namespace. Returns nil if the CR is already gone (idempotent). + DeleteCsiVolumeInfo(ctx context.Context, namespace, volumeID string) error + + // CsiVolumeInfoExists reports whether a CsiVolumeInfo CR exists for the + // given volumeID in the given namespace. + CsiVolumeInfoExists(ctx context.Context, namespace, volumeID string) (bool, error) + + // GetCsiVolumeInfoByPVCName returns the CsiVolumeInfo whose spec.pvcName + // matches the given PVC name in the given namespace. Returns nil and no + // error when no matching CR is found. + GetCsiVolumeInfoByPVCName(ctx context.Context, namespace, pvcName string) ( + *csivolumeinfov1alpha1.CsiVolumeInfo, error) +} + +// csiVolumeInfoSvc is the concrete singleton implementing CsiVolumeInfoService. +type csiVolumeInfoSvc struct { + k8sClient client.Client +} + +var ( + // serviceInstance is the package-level singleton. + serviceInstance *csiVolumeInfoSvc +) + +// InitCsiVolumeInfoService initialises (idempotent) the CsiVolumeInfo service +// by building a controller-runtime client for the cns.vmware.com group. +// +// The CsiVolumeInfo CRD must already exist before calling this function. +// CRD creation is handled by InitCnsOperator (in init.go) which is +// gated behind both the Workload cluster flavor check and the +// VMOwnedVolumes FSS — ensuring this service is only initialised on +// supervisor clusters with the feature enabled. +// +// Callers must hold no lock; internal state is initialised once and is +// safe for concurrent reads afterward. +func InitCsiVolumeInfoService(ctx context.Context) (CsiVolumeInfoService, error) { + log := logger.GetLogger(ctx) + if serviceInstance != nil { + return serviceInstance, nil + } + + log.Info("Initializing CsiVolumeInfo service...") + + config, err := k8s.GetKubeConfig(ctx) + if err != nil { + return nil, logger.LogNewErrorf(log, + "failed to get kubeconfig for CsiVolumeInfo service. err: %v", err) + } + + k8sClient, err := k8s.NewClientForGroup(ctx, config, + csivolumeinfov1alpha1.GroupName) + if err != nil { + return nil, logger.LogNewErrorf(log, + "failed to create k8s client for CsiVolumeInfo service. err: %v", err) + } + + serviceInstance = &csiVolumeInfoSvc{k8sClient: k8sClient} + log.Info("CsiVolumeInfo service initialized") + return serviceInstance, nil +} + +// GetCsiVolumeInfoCRName returns the deterministic CR name for a volumeID. +// Name format: csi-volume-info-. +func GetCsiVolumeInfoCRName(volumeID string) string { + return cviNamePrefix + volumeID +} + +// BuildCsiVolumeInfo constructs a CsiVolumeInfo object ready for creation. +// When pvUID is empty, no ownerReference is set; the caller is expected to +// patch it once the PersistentVolume is available. +// No cvi-protection finalizer is set because the volume starts in the +// CSI_MANAGED steady state where none is required. +func BuildCsiVolumeInfo( + volumeID, pvcName, pvcNamespace, pvName, pvUID, diskUUID, diskPath string, +) *csivolumeinfov1alpha1.CsiVolumeInfo { + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetCsiVolumeInfoCRName(volumeID), + Namespace: pvcNamespace, + Labels: map[string]string{ + csivolumeinfov1alpha1.LabelDiskUUID: diskUUID, + }, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + PVCName: pvcName, + PVName: pvName, + }, + Status: csivolumeinfov1alpha1.CsiVolumeInfoStatus{ + OwnershipState: csivolumeinfov1alpha1.OwnershipStateCSIManaged, + DiskUUID: diskUUID, + DiskPath: diskPath, + }, + } + if pvUID != "" { + controller := true + blockOwnerDeletion := true + cvi.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "PersistentVolume", + Name: pvName, + UID: k8stypes.UID(pvUID), + Controller: &controller, + BlockOwnerDeletion: &blockOwnerDeletion, + }, + } + } + return cvi +} + +// CreateCsiVolumeInfo creates a new CsiVolumeInfo CR. +// AlreadyExists errors are treated as success (idempotent). +func (s *csiVolumeInfoSvc) CreateCsiVolumeInfo( + ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx) + log.Infof("Creating CsiVolumeInfo %s/%s", cvi.Namespace, cvi.Name) + + if err := s.k8sClient.Create(ctx, cvi); err != nil { + if apierrors.IsAlreadyExists(err) { + log.Infof("CsiVolumeInfo %s/%s already exists", cvi.Namespace, cvi.Name) + return nil + } + return logger.LogNewErrorf(log, + "failed to create CsiVolumeInfo %s/%s: %v", cvi.Namespace, cvi.Name, err) + } + log.Infof("Successfully created CsiVolumeInfo %s/%s", cvi.Namespace, cvi.Name) + return nil +} + +// GetCsiVolumeInfo fetches a CsiVolumeInfo by volumeID in the given namespace. +func (s *csiVolumeInfoSvc) GetCsiVolumeInfo( + ctx context.Context, namespace, volumeID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{} + if err := s.k8sClient.Get(ctx, k8stypes.NamespacedName{ + Namespace: namespace, + Name: name, + }, cvi); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, logger.LogNewErrorf(log, + "failed to get CsiVolumeInfo %s/%s: %v", namespace, name, err) + } + return cvi, nil +} + +// GetCsiVolumeInfoByDiskUUID fetches a CsiVolumeInfo by the disk-uuid label in +// the given namespace. Returns nil if no CR is found. +// If multiple CRs carry the same label (should not happen by construction), +// the first result is returned and an error is logged. +func (s *csiVolumeInfoSvc) GetCsiVolumeInfoByDiskUUID( + ctx context.Context, namespace, diskUUID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) { + log := logger.GetLogger(ctx) + list := &csivolumeinfov1alpha1.CsiVolumeInfoList{} + labelSelector := labels.SelectorFromSet(labels.Set{ + csivolumeinfov1alpha1.LabelDiskUUID: diskUUID, + }) + if err := s.k8sClient.List(ctx, list, + &client.ListOptions{ + Namespace: namespace, + LabelSelector: labelSelector, + }); err != nil { + return nil, logger.LogNewErrorf(log, + "failed to list CsiVolumeInfo by diskUUID %q in namespace %q: %v", + diskUUID, namespace, err) + } + if len(list.Items) == 0 { + return nil, nil + } + if len(list.Items) > 1 { + // Defensive: disk-uuid must be unique — log and use the first match. + log.Errorf("found %d CsiVolumeInfo CRs with disk-uuid=%q in namespace %q; "+ + "using first match. This is a data integrity issue.", + len(list.Items), diskUUID, namespace) + } + return &list.Items[0], nil +} + +// UpdateCsiVolumeInfoStatus replaces the status subresource. +func (s *csiVolumeInfoSvc) UpdateCsiVolumeInfoStatus( + ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx) + if err := s.k8sClient.Status().Update(ctx, cvi); err != nil { + return logger.LogNewErrorf(log, + "failed to update status of CsiVolumeInfo %s/%s: %v", + cvi.Namespace, cvi.Name, err) + } + log.Infof("Successfully updated status of CsiVolumeInfo %s/%s (ownershipState=%s)", + cvi.Namespace, cvi.Name, cvi.Status.OwnershipState) + return nil +} + +// PatchCsiVolumeInfo applies a JSON merge-patch to the CsiVolumeInfo for the +// given volumeID/namespace. Retries on conflict up to allowedRetries times. +func (s *csiVolumeInfoSvc) PatchCsiVolumeInfo( + ctx context.Context, namespace, volumeID string, patchBytes []byte) error { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + } + + var lastErr error + for attempt := 1; attempt <= allowedRetries; attempt++ { + err := s.k8sClient.Patch(ctx, cvi, + client.RawPatch(k8stypes.MergePatchType, patchBytes)) + if err == nil { + log.Infof("attempt %d: successfully patched CsiVolumeInfo %s/%s", + attempt, namespace, name) + return nil + } + lastErr = err + log.Warnf("attempt %d: failed to patch CsiVolumeInfo %s/%s: %v", + attempt, namespace, name, err) + time.Sleep(100 * time.Millisecond) + } + return logger.LogNewErrorf(log, + "failed to patch CsiVolumeInfo %s/%s after %d retries: %v", + namespace, name, allowedRetries, lastErr) +} + +// DeleteCsiVolumeInfo deletes the CsiVolumeInfo for the given volumeID. +// NotFound errors are treated as success (idempotent). +func (s *csiVolumeInfoSvc) DeleteCsiVolumeInfo( + ctx context.Context, namespace, volumeID string) error { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + } + if err := s.k8sClient.Delete(ctx, cvi); err != nil { + if apierrors.IsNotFound(err) { + log.Infof("CsiVolumeInfo %s/%s already deleted", namespace, name) + return nil + } + return logger.LogNewErrorf(log, + "failed to delete CsiVolumeInfo %s/%s: %v", namespace, name, err) + } + log.Infof("Successfully deleted CsiVolumeInfo %s/%s", namespace, name) + return nil +} + +// CsiVolumeInfoExists reports whether a CsiVolumeInfo CR exists for volumeID. +func (s *csiVolumeInfoSvc) CsiVolumeInfoExists( + ctx context.Context, namespace, volumeID string) (bool, error) { + cvi, err := s.GetCsiVolumeInfo(ctx, namespace, volumeID) + if err != nil { + return false, fmt.Errorf("CsiVolumeInfoExists: %w", err) + } + return cvi != nil, nil +} + +// GetCsiVolumeInfoByPVCName lists all CsiVolumeInfo CRs in the given namespace +// and returns the one whose spec.pvcName matches pvcName. Returns nil and no +// error when no matching CR is found. If multiple CRs match (data integrity +// anomaly), the first is returned and the discrepancy is logged. +func (s *csiVolumeInfoSvc) GetCsiVolumeInfoByPVCName( + ctx context.Context, namespace, pvcName string) ( + *csivolumeinfov1alpha1.CsiVolumeInfo, error) { + log := logger.GetLogger(ctx) + + list := &csivolumeinfov1alpha1.CsiVolumeInfoList{} + if err := s.k8sClient.List(ctx, list, &client.ListOptions{Namespace: namespace}); err != nil { + return nil, logger.LogNewErrorf(log, + "failed to list CsiVolumeInfo in namespace %q: %v", namespace, err) + } + + var matches []csivolumeinfov1alpha1.CsiVolumeInfo + for i := range list.Items { + if list.Items[i].Spec.PVCName == pvcName { + matches = append(matches, list.Items[i]) + } + } + + if len(matches) == 0 { + return nil, nil + } + if len(matches) > 1 { + log.Errorf("found %d CsiVolumeInfo CRs with pvcName=%q in namespace %q; "+ + "using first match. This is a data integrity issue.", + len(matches), pvcName, namespace) + } + return &matches[0], nil +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice_test.go b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice_test.go new file mode 100644 index 0000000000..1553071d79 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice_test.go @@ -0,0 +1,494 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package csivolumeinfo + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" +) + +// newTestScheme builds a runtime.Scheme with CsiVolumeInfo types registered. +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := csivolumeinfov1alpha1.AddToScheme(s); err != nil { + t.Fatalf("failed to add CsiVolumeInfo to scheme: %v", err) + } + return s +} + +// newTestService creates a CsiVolumeInfoService backed by a fake client. +func newTestService(t *testing.T, objs ...runtime.Object) *csiVolumeInfoSvc { + t.Helper() + s := newTestScheme(t) + b := fake.NewClientBuilder().WithScheme(s).WithStatusSubresource(&csivolumeinfov1alpha1.CsiVolumeInfo{}) + if len(objs) > 0 { + b = b.WithRuntimeObjects(objs...) + } + return &csiVolumeInfoSvc{k8sClient: b.Build()} +} + +// buildCVI is a helper that constructs a minimal CsiVolumeInfo for tests. +func buildCVI(namespace, volumeID, diskUUID, pvcName, pvName string) *csivolumeinfov1alpha1.CsiVolumeInfo { + return &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetCsiVolumeInfoCRName(volumeID), + Namespace: namespace, + Labels: map[string]string{ + csivolumeinfov1alpha1.LabelDiskUUID: diskUUID, + }, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + PVCName: pvcName, + PVName: pvName, + }, + Status: csivolumeinfov1alpha1.CsiVolumeInfoStatus{ + OwnershipState: csivolumeinfov1alpha1.OwnershipStateCSIManaged, + DiskUUID: diskUUID, + }, + } +} + +// TestCsiVolumeInfoCRName verifies the deterministic naming convention. +func TestCsiVolumeInfoCRName(t *testing.T) { + cases := []struct { + volumeID string + want string + }{ + {"abc-d3", "csi-volume-info-abc-d3"}, + {"some-long-volume-id-1234", "csi-volume-info-some-long-volume-id-1234"}, + {"", "csi-volume-info-"}, + } + for _, tc := range cases { + got := GetCsiVolumeInfoCRName(tc.volumeID) + if got != tc.want { + t.Errorf("GetCsiVolumeInfoCRName(%q) = %q, want %q", tc.volumeID, got, tc.want) + } + } +} + +// TestOwnershipStateEnum verifies all four OwnershipState constants are non-empty. +func TestOwnershipStateEnum(t *testing.T) { + states := []csivolumeinfov1alpha1.OwnershipState{ + csivolumeinfov1alpha1.OwnershipStateCSIManaged, + csivolumeinfov1alpha1.OwnershipStateTransferringToVM, + csivolumeinfov1alpha1.OwnershipStateVMManaged, + csivolumeinfov1alpha1.OwnershipStateTransferringToCSI, + } + for _, s := range states { + if string(s) == "" { + t.Errorf("OwnershipState constant is empty string") + } + } +} + +// TestCreateCsiVolumeInfo creates a CVI and verifies it is retrievable. +func TestCreateCsiVolumeInfo(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + cvi := buildCVI("testns", "vol-1", "disk-uuid-1", "pvc-1", "pv-1") + + if err := svc.CreateCsiVolumeInfo(ctx, cvi); err != nil { + t.Fatalf("CreateCsiVolumeInfo: %v", err) + } + + got, err := svc.GetCsiVolumeInfo(ctx, "testns", "vol-1") + if err != nil { + t.Fatalf("GetCsiVolumeInfo: %v", err) + } + if got == nil { + t.Fatal("expected CVI to exist, got nil") + } + if got.Spec.VolumeID != "vol-1" { + t.Errorf("Spec.VolumeID = %q, want %q", got.Spec.VolumeID, "vol-1") + } + if got.Spec.PVCName != "pvc-1" { + t.Errorf("Spec.PVCName = %q, want %q", got.Spec.PVCName, "pvc-1") + } +} + +// TestCreateCsiVolumeInfo_Idempotent verifies that creating a CVI that already +// exists returns no error (AlreadyExists is silently swallowed). +func TestCreateCsiVolumeInfo_Idempotent(t *testing.T) { + ctx := context.Background() + // Pre-populate the fake store with an existing CVI. + existing := buildCVI("testns", "vol-2", "disk-uuid-2", "pvc-2", "pv-2") + svc := newTestService(t, existing) + + // Build a fresh object (no ResourceVersion) that represents a second create + // attempt for the same volume. + duplicate := buildCVI("testns", "vol-2", "disk-uuid-2", "pvc-2", "pv-2") + if err := svc.CreateCsiVolumeInfo(ctx, duplicate); err != nil { + t.Errorf("second CreateCsiVolumeInfo should be a no-op, got: %v", err) + } +} + +// TestGetCsiVolumeInfo_NotFound verifies nil is returned for a missing CVI. +func TestGetCsiVolumeInfo_NotFound(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + got, err := svc.GetCsiVolumeInfo(ctx, "testns", "nonexistent") + if err != nil { + t.Fatalf("GetCsiVolumeInfo for missing CVI returned error: %v", err) + } + if got != nil { + t.Errorf("expected nil for missing CVI, got %+v", got) + } +} + +// TestGetCsiVolumeInfoByDiskUUID locates a CVI by disk-uuid label. +func TestGetCsiVolumeInfoByDiskUUID(t *testing.T) { + ctx := context.Background() + cvi := buildCVI("testns", "vol-3", "6000C29-d3", "pvc-3", "pv-3") + svc := newTestService(t, cvi) + + got, err := svc.GetCsiVolumeInfoByDiskUUID(ctx, "testns", "6000C29-d3") + if err != nil { + t.Fatalf("GetCsiVolumeInfoByDiskUUID: %v", err) + } + if got == nil { + t.Fatal("expected CVI, got nil") + } + if got.Spec.VolumeID != "vol-3" { + t.Errorf("VolumeID = %q, want %q", got.Spec.VolumeID, "vol-3") + } +} + +// TestGetCsiVolumeInfoByDiskUUID_NotFound returns nil without error. +func TestGetCsiVolumeInfoByDiskUUID_NotFound(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + got, err := svc.GetCsiVolumeInfoByDiskUUID(ctx, "testns", "no-such-uuid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("expected nil for missing diskUUID, got %+v", got) + } +} + +// TestUpdateCsiVolumeInfoStatus transitions ownershipState and verifies spec +// is unchanged. +func TestUpdateCsiVolumeInfoStatus(t *testing.T) { + ctx := context.Background() + cvi := buildCVI("testns", "vol-4", "disk-uuid-4", "pvc-4", "pv-4") + svc := newTestService(t, cvi) + + // Fetch fresh copy (needed for ResourceVersion). + fresh, err := svc.GetCsiVolumeInfo(ctx, "testns", "vol-4") + if err != nil || fresh == nil { + t.Fatalf("GetCsiVolumeInfo: %v, got %v", err, fresh) + } + + fresh.Status.OwnershipState = csivolumeinfov1alpha1.OwnershipStateTransferringToVM + fresh.Status.VMName = "my-vm" + fresh.Status.VMInstanceUUID = "502e71fa" + + if err := svc.UpdateCsiVolumeInfoStatus(ctx, fresh); err != nil { + t.Fatalf("UpdateCsiVolumeInfoStatus: %v", err) + } + + updated, _ := svc.GetCsiVolumeInfo(ctx, "testns", "vol-4") + if updated.Status.OwnershipState != csivolumeinfov1alpha1.OwnershipStateTransferringToVM { + t.Errorf("OwnershipState = %q, want TRANSFERRING_TO_VM", updated.Status.OwnershipState) + } + if updated.Status.VMName != "my-vm" { + t.Errorf("VMName = %q, want my-vm", updated.Status.VMName) + } + // Spec must not have changed. + if updated.Spec.VolumeID != "vol-4" { + t.Errorf("Spec.VolumeID changed: %q", updated.Spec.VolumeID) + } +} + +// TestDeleteCsiVolumeInfo creates, deletes, and verifies the CVI is gone. +func TestDeleteCsiVolumeInfo(t *testing.T) { + ctx := context.Background() + cvi := buildCVI("testns", "vol-5", "disk-uuid-5", "pvc-5", "pv-5") + svc := newTestService(t, cvi) + + if err := svc.DeleteCsiVolumeInfo(ctx, "testns", "vol-5"); err != nil { + t.Fatalf("DeleteCsiVolumeInfo: %v", err) + } + + got, _ := svc.GetCsiVolumeInfo(ctx, "testns", "vol-5") + if got != nil { + t.Error("expected CVI to be deleted, but still found") + } +} + +// TestDeleteCsiVolumeInfo_Idempotent verifies that deleting a non-existent +// CVI returns no error. +func TestDeleteCsiVolumeInfo_Idempotent(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + if err := svc.DeleteCsiVolumeInfo(ctx, "testns", "nonexistent"); err != nil { + t.Errorf("DeleteCsiVolumeInfo on missing CVI should be no-op, got: %v", err) + } +} + +// TestCsiVolumeInfoExists verifies exists/not-exists semantics. +func TestCsiVolumeInfoExists(t *testing.T) { + ctx := context.Background() + cvi := buildCVI("testns", "vol-6", "disk-uuid-6", "pvc-6", "pv-6") + svc := newTestService(t, cvi) + + exists, err := svc.CsiVolumeInfoExists(ctx, "testns", "vol-6") + if err != nil { + t.Fatalf("CsiVolumeInfoExists: %v", err) + } + if !exists { + t.Error("expected CVI to exist") + } + + if err := svc.DeleteCsiVolumeInfo(ctx, "testns", "vol-6"); err != nil { + t.Fatalf("DeleteCsiVolumeInfo: %v", err) + } + exists, err = svc.CsiVolumeInfoExists(ctx, "testns", "vol-6") + if err != nil { + t.Fatalf("CsiVolumeInfoExists after delete: %v", err) + } + if exists { + t.Error("expected CVI to be gone after delete") + } +} + +// TestCsiVolumeInfoDeepCopy verifies that DeepCopy produces an independent +// copy (no shared slice pointers). +func TestCsiVolumeInfoDeepCopy(t *testing.T) { + cvi := buildCVI("testns", "vol-7", "disk-uuid-7", "pvc-7", "pv-7") + cvi.Status.Conditions = []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "OK", + Message: "all good", + LastTransitionTime: metav1.Now(), + }, + } + + copy := cvi.DeepCopy() + + // Mutating the copy's Conditions must not affect the original. + copy.Status.Conditions[0].Message = "mutated" + if cvi.Status.Conditions[0].Message == "mutated" { + t.Error("DeepCopy shares Conditions slice with original") + } + + // Mutating the copy's spec must not affect original. + copy.Spec.VolumeID = "different" + if cvi.Spec.VolumeID == "different" { + t.Error("DeepCopy shares Spec with original") + } +} + +// TestCsiVolumeInfoLabels verifies that the disk-uuid label is set correctly +// and the volume-ownership PVC label constants are defined. +func TestCsiVolumeInfoLabels(t *testing.T) { + cvi := buildCVI("testns", "vol-8", "6000C29-abc", "pvc-8", "pv-8") + if got := cvi.Labels[csivolumeinfov1alpha1.LabelDiskUUID]; got != "6000C29-abc" { + t.Errorf("LabelDiskUUID = %q, want %q", got, "6000C29-abc") + } + + ownershipLabels := []string{ + csivolumeinfov1alpha1.OwnershipLabelVMOwned, + csivolumeinfov1alpha1.OwnershipLabelCSIOwned, + csivolumeinfov1alpha1.OwnershipLabelRetainedBySnapshot, + } + for _, l := range ownershipLabels { + if l == "" { + t.Errorf("ownership label constant is empty string") + } + } +} + +// TestCsiVolumeInfoOwnerReference verifies that the PV ownerReference fields +// can be set correctly on a CVI. +func TestCsiVolumeInfoOwnerReference(t *testing.T) { + cvi := buildCVI("testns", "vol-9", "disk-uuid-9", "pvc-9", "pv-9") + controller := true + blockOwnerDeletion := true + cvi.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "PersistentVolume", + Name: "pv-9", + UID: "some-uid", + Controller: &controller, + BlockOwnerDeletion: &blockOwnerDeletion, + }, + } + + if len(cvi.OwnerReferences) != 1 { + t.Fatalf("expected 1 ownerRef, got %d", len(cvi.OwnerReferences)) + } + ref := cvi.OwnerReferences[0] + if ref.Kind != "PersistentVolume" { + t.Errorf("Kind = %q, want PersistentVolume", ref.Kind) + } + if ref.Controller == nil || !*ref.Controller { + t.Error("Controller must be true") + } + if ref.BlockOwnerDeletion == nil || !*ref.BlockOwnerDeletion { + t.Error("BlockOwnerDeletion must be true") + } +} + +// TestCsiVolumeInfoFinalizer verifies that the CVIProtectionFinalizer can be +// added to and removed from a CVI. +func TestCsiVolumeInfoFinalizer(t *testing.T) { + cvi := buildCVI("testns", "vol-10", "disk-uuid-10", "pvc-10", "pv-10") + + // Add finalizer. + cvi.Finalizers = append(cvi.Finalizers, csivolumeinfov1alpha1.CVIProtectionFinalizer) + found := false + for _, f := range cvi.Finalizers { + if f == csivolumeinfov1alpha1.CVIProtectionFinalizer { + found = true + break + } + } + if !found { + t.Error("CVIProtectionFinalizer not found after add") + } + + // Remove finalizer. + updated := make([]string, 0, len(cvi.Finalizers)) + for _, f := range cvi.Finalizers { + if f != csivolumeinfov1alpha1.CVIProtectionFinalizer { + updated = append(updated, f) + } + } + cvi.Finalizers = updated + for _, f := range cvi.Finalizers { + if f == csivolumeinfov1alpha1.CVIProtectionFinalizer { + t.Error("CVIProtectionFinalizer still present after remove") + } + } +} + +// TestCsiVolumeInfoSpec_Immutability documents that the spec fields are treated +// as immutable by convention (enforcement is in the admission webhook, Phase 5). +func TestCsiVolumeInfoSpec_Immutability(t *testing.T) { + cvi := buildCVI("testns", "vol-11", "disk-uuid-11", "pvc-11", "pv-11") + originalVolumeID := cvi.Spec.VolumeID + + // Ensure DeepCopy preserves spec fields correctly. + copy := cvi.DeepCopy() + if copy.Spec.VolumeID != originalVolumeID { + t.Errorf("DeepCopy changed VolumeID: got %q want %q", + copy.Spec.VolumeID, originalVolumeID) + } + if copy.Spec.PVCName != cvi.Spec.PVCName { + t.Errorf("DeepCopy changed PVCName") + } + if copy.Spec.PVName != cvi.Spec.PVName { + t.Errorf("DeepCopy changed PVName") + } +} + +// TestBuildCsiVolumeInfo verifies that BuildCsiVolumeInfo produces a correctly +// populated CsiVolumeInfo object. +func TestBuildCsiVolumeInfo(t *testing.T) { + cvi := BuildCsiVolumeInfo("vol-1", "pvc-1", "ns-1", "pv-1", "uid-123", "disk-uuid-1", "[ds] path/disk.vmdk") + + if cvi.Name != GetCsiVolumeInfoCRName("vol-1") { + t.Errorf("expected name %q, got %q", GetCsiVolumeInfoCRName("vol-1"), cvi.Name) + } + if cvi.Namespace != "ns-1" { + t.Errorf("expected namespace ns-1, got %q", cvi.Namespace) + } + if cvi.Labels[csivolumeinfov1alpha1.LabelDiskUUID] != "disk-uuid-1" { + t.Errorf("expected disk-uuid label disk-uuid-1, got %q", + cvi.Labels[csivolumeinfov1alpha1.LabelDiskUUID]) + } + if cvi.Status.OwnershipState != csivolumeinfov1alpha1.OwnershipStateCSIManaged { + t.Errorf("expected ownershipState CSI_MANAGED, got %q", cvi.Status.OwnershipState) + } + if cvi.Status.DiskUUID != "disk-uuid-1" { + t.Errorf("expected diskUUID disk-uuid-1, got %q", cvi.Status.DiskUUID) + } + if cvi.Status.DiskPath != "[ds] path/disk.vmdk" { + t.Errorf("expected diskPath '[ds] path/disk.vmdk', got %q", cvi.Status.DiskPath) + } + if cvi.Status.VMName != "" { + t.Errorf("expected empty vmName, got %q", cvi.Status.VMName) + } + if len(cvi.Finalizers) != 0 { + t.Errorf("expected no finalizers, got %v", cvi.Finalizers) + } + if len(cvi.OwnerReferences) != 1 { + t.Fatalf("expected 1 ownerReference, got %d", len(cvi.OwnerReferences)) + } + ref := cvi.OwnerReferences[0] + if ref.Kind != "PersistentVolume" || ref.Name != "pv-1" || string(ref.UID) != "uid-123" { + t.Errorf("unexpected ownerReference: %+v", ref) + } +} + +// TestBuildCsiVolumeInfo_NoOwnerRef verifies that BuildCsiVolumeInfo omits +// the ownerReference when pvUID is empty. +func TestBuildCsiVolumeInfo_NoOwnerRef(t *testing.T) { + cvi := BuildCsiVolumeInfo("vol-2", "pvc-2", "ns-2", "pv-2", "", "disk-uuid-2", "") + if len(cvi.OwnerReferences) != 0 { + t.Errorf("expected no ownerReferences when pvUID is empty, got %v", cvi.OwnerReferences) + } +} + +// TestGetCsiVolumeInfoByPVCName_Found verifies the lookup by pvcName returns +// the correct CsiVolumeInfo. +func TestGetCsiVolumeInfoByPVCName_Found(t *testing.T) { + ctx := context.Background() + existing := buildCVI("ns-3", "vol-3", "disk-3", "pvc-3", "pv-3") + svc := newTestService(t, existing) + + result, err := svc.GetCsiVolumeInfoByPVCName(ctx, "ns-3", "pvc-3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected a result, got nil") + } + if result.Spec.VolumeID != "vol-3" { + t.Errorf("expected volumeID vol-3, got %q", result.Spec.VolumeID) + } +} + +// TestGetCsiVolumeInfoByPVCName_NotFound verifies that a missing PVC returns +// nil without error. +func TestGetCsiVolumeInfoByPVCName_NotFound(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + result, err := svc.GetCsiVolumeInfoByPVCName(ctx, "ns-4", "pvc-missing") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Errorf("expected nil, got %+v", result) + } +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/csivolumeinfo_types.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/csivolumeinfo_types.go new file mode 100644 index 0000000000..c6aaf3f980 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/csivolumeinfo_types.go @@ -0,0 +1,189 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // CRDSingular represents the singular name of CsiVolumeInfo CRD. + CRDSingular = "csivolumeinfo" + // CRDPlural represents the plural name of CsiVolumeInfo CRD. + CRDPlural = "csivolumeinfos" + + // LabelDiskUUID is the label key set on CsiVolumeInfo CRs to the + // VirtualDisk.Backing.Uuid value. The label enables O(1) API-server-indexed + // lookup of a CVI by diskUUID. + LabelDiskUUID = "cns.vmware.com/disk-uuid" + + // LabelVolumeOwnership is the label key set on PVC objects to surface the + // human-friendly ownership state. + LabelVolumeOwnership = "cns.vmware.com/volume-ownership" + + // OwnershipLabelVMOwned is the PVC label value when the volume is attached + // to a greenfield VM (CVI ownershipState=VM_MANAGED, vmName set). + OwnershipLabelVMOwned = "vm-owned" + + // OwnershipLabelCSIOwned is the PVC label value when the volume is + // detached and registered as an FCD (CVI ownershipState=CSI_MANAGED). + OwnershipLabelCSIOwned = "csi-owned" + + // OwnershipLabelRetainedBySnapshot is the PVC label value when the volume + // is snapshot-retained (CVI ownershipState=VM_MANAGED, vmName=""). + OwnershipLabelRetainedBySnapshot = "retained-by-snapshot" + + // CVIProtectionFinalizer is added to a CsiVolumeInfo CR when the volume + // leaves the CSI_MANAGED steady state. It prevents premature GC while the + // volume is in-flight, attached to a VM, or pinned by a snapshot. + CVIProtectionFinalizer = "csi.vsphere.vmware.com/cvi-protection" + + // SnapshotFinalizer is added by CSI to VirtualMachineSnapshot CRs to gate + // phase-2 PVC re-evaluation during snapshot deletion. + SnapshotFinalizer = "csi.vsphere.vmware.com/snapshot" +) + +// OwnershipState represents the ownership lifecycle state of a volume. +// +// +kubebuilder:validation:Enum=CSI_MANAGED;TRANSFERRING_TO_VM;VM_MANAGED;TRANSFERRING_TO_CSI +type OwnershipState string + +const ( + // OwnershipStateCSIManaged is the steady state when the volume is a + // registered FCD managed by CSI. vmName is empty. No cvi-protection + // finalizer is held in this state. + OwnershipStateCSIManaged OwnershipState = "CSI_MANAGED" + + // OwnershipStateTransferringToVM is the transient state between + // CnsUnregisterVolume (A.3) and vm-operator confirming the disk is on the + // VM (A.6). vmName is set to the target VM. + OwnershipStateTransferringToVM OwnershipState = "TRANSFERRING_TO_VM" + + // OwnershipStateVMManaged is the steady state when the disk is a plain + // VMDK on a greenfield VM (vmName set), or snapshot-retained (vmName=""). + OwnershipStateVMManaged OwnershipState = "VM_MANAGED" + + // OwnershipStateTransferringToCSI is the transient state after + // vm-operator removes the disk from the VM (C.3) and before CSI + // re-registers the FCD or marks the volume snapshot-retained. vmName is + // set to the source VM. + OwnershipStateTransferringToCSI OwnershipState = "TRANSFERRING_TO_CSI" +) + +// CsiVolumeInfoSpec defines the desired (immutable) state of CsiVolumeInfo. +// All fields are set at creation time and must not change except pvcName on +// Retain-reclaim rebind. +type CsiVolumeInfoSpec struct { + // VolumeID is the CNS volume ID. Immutable after creation. + // Matches PV.spec.csi.volumeHandle. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VolumeID string `json:"volumeID"` + + // PVCName is the bound PVC name at CVI creation (or last bind update on + // Retain-reclaim rebind). Together with metadata.namespace, uniquely + // identifies the PVC. + // +kubebuilder:validation:Required + PVCName string `json:"pvcName"` + + // PVName is the bound PV name. The CVI carries a PV ownerReference so + // PV deletion cascades CVI deletion via K8s GC. + // +kubebuilder:validation:Required + PVName string `json:"pvName"` +} + +// CsiVolumeInfoStatus defines the observed state of CsiVolumeInfo. +// All writes go through the /status subresource endpoint. +type CsiVolumeInfoStatus struct { + // OwnershipState is the current ownership lifecycle state of the volume. + // One of CSI_MANAGED, TRANSFERRING_TO_VM, VM_MANAGED, TRANSFERRING_TO_CSI. + // +kubebuilder:validation:Required + OwnershipState OwnershipState `json:"ownershipState"` + + // VMName is the name of the VirtualMachine CR this volume is attached to + // (or being attached to / detached from). Empty when CSI-owned or + // snapshot-retained (ownershipState=VM_MANAGED, vmName=""). + // +optional + VMName string `json:"vmName,omitempty"` + + // VMInstanceUUID is the instance UUID of the VM identified by VMName. + // Empty when CSI-owned or snapshot-retained. + // +optional + VMInstanceUUID string `json:"vmInstanceUUID,omitempty"` + + // DiskUUID is the stable identifier for the virtual disk + // (VirtualDisk.Backing.Uuid). Populated at CVI creation from the FCD's + // backing VMDK. Immutable after creation; also surfaced as the + // cns.vmware.com/disk-uuid label on this CR. + // +kubebuilder:validation:Required + DiskUUID string `json:"diskUUID"` + + // DiskPath is the datastore path to the VMDK file. An informational JIT + // cache — may be stale at rest. Refreshed at each consumption point + // (attach, detach, snapshot-delete, revert). + // +optional + DiskPath string `json:"diskPath,omitempty"` + + // Conditions is a standard K8s condition array for extensible status. + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=8 + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=cvi,scope=Namespaced +// +kubebuilder:printcolumn:name="OwnershipState",type=string,JSONPath=`.status.ownershipState` +// +kubebuilder:printcolumn:name="VMName",type=string,JSONPath=`.status.vmName` +// +kubebuilder:printcolumn:name="diskUUID",type=string,JSONPath=`.status.diskUUID` +// +kubebuilder:printcolumn:name="diskPath",type=string,JSONPath=`.status.diskPath` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// CsiVolumeInfo tracks the per-volume ownership lifecycle for the VM-owned +// volume attach/detach model. One CR exists per PVC (in the PVC's namespace) +// from the moment it is provisioned (while VMOwnedVolumes FSS is enabled) +// until its owning PV is deleted. +type CsiVolumeInfo struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CsiVolumeInfoSpec `json:"spec"` + Status CsiVolumeInfoStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// CsiVolumeInfoList contains a list of CsiVolumeInfo. +type CsiVolumeInfoList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CsiVolumeInfo `json:"items"` +} + +// GetConditions returns the condition slice for CsiVolumeInfo. +// Implements the conditions.Getter interface. +func (in *CsiVolumeInfo) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the condition slice for CsiVolumeInfo. +// Implements the conditions.Setter interface. +func (in *CsiVolumeInfo) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/doc.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/doc.go new file mode 100644 index 0000000000..cbeb1f96c7 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package +// +k8s:defaulter-gen=TypeMeta +// +groupName=cns.vmware.com + +package v1alpha1 diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/register.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/register.go new file mode 100644 index 0000000000..2e3b884b3d --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/register.go @@ -0,0 +1,72 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName represents the group for csivolumeinfo APIs. +const GroupName = "cns.vmware.com" + +// Version represents the version for csivolumeinfo APIs. +const Version = "v1alpha1" + +// CsiVolumeInfoPlural is the plural name of CsiVolumeInfo. +const CsiVolumeInfoPlural = "csivolumeinfos" + +// CsiVolumeInfoSingular is the singular name of CsiVolumeInfo. +const CsiVolumeInfoSingular = "csivolumeinfo" + +// SchemeGroupVersion defines the schema Group and Version for csivolumeinfo. +var SchemeGroupVersion = schema.GroupVersion{ + Group: GroupName, + Version: Version, +} + +var ( + schemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &schemeBuilder + // AddToScheme adds all csivolumeinfo types to the given scheme. + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Resource takes an unqualified resource and returns a Group-qualified GroupResource. +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +// addKnownTypes adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes( + SchemeGroupVersion, + &CsiVolumeInfo{}, + &CsiVolumeInfoList{}, + ) + scheme.AddKnownTypes(SchemeGroupVersion, &metav1.Status{}) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..7e0bdab467 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,123 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CsiVolumeInfo) DeepCopyInto(out *CsiVolumeInfo) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CsiVolumeInfo. +func (in *CsiVolumeInfo) DeepCopy() *CsiVolumeInfo { + if in == nil { + return nil + } + out := new(CsiVolumeInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CsiVolumeInfo) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CsiVolumeInfoList) DeepCopyInto(out *CsiVolumeInfoList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CsiVolumeInfo, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CsiVolumeInfoList. +func (in *CsiVolumeInfoList) DeepCopy() *CsiVolumeInfoList { + if in == nil { + return nil + } + out := new(CsiVolumeInfoList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CsiVolumeInfoList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CsiVolumeInfoSpec) DeepCopyInto(out *CsiVolumeInfoSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CsiVolumeInfoSpec. +func (in *CsiVolumeInfoSpec) DeepCopy() *CsiVolumeInfoSpec { + if in == nil { + return nil + } + out := new(CsiVolumeInfoSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CsiVolumeInfoStatus) DeepCopyInto(out *CsiVolumeInfoStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CsiVolumeInfoStatus. +func (in *CsiVolumeInfoStatus) DeepCopy() *CsiVolumeInfoStatus { + if in == nil { + return nil + } + out := new(CsiVolumeInfoStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/cnsoperator/register.go b/pkg/apis/cnsoperator/register.go index e58a211ce4..9a846fde4e 100644 --- a/pkg/apis/cnsoperator/register.go +++ b/pkg/apis/cnsoperator/register.go @@ -32,6 +32,7 @@ import ( cnsregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1" cnsunregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsunregistervolume/v1alpha1" cnsvolumemetadatav1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsvolumemetadata/v1alpha1" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" infrastoragepolicyinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/infrastoragepolicyinfo/v1alpha1" storagepolicyv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha1" storagepolicyv1alpha2 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha2" @@ -208,6 +209,12 @@ func addKnownTypes(scheme *runtime.Scheme) error { &infrastoragepolicyinfov1alpha1.InfraStoragePolicyInfoList{}, ) + scheme.AddKnownTypes( + csivolumeinfov1alpha1.SchemeGroupVersion, + &csivolumeinfov1alpha1.CsiVolumeInfo{}, + &csivolumeinfov1alpha1.CsiVolumeInfoList{}, + ) + scheme.AddKnownTypes( SchemeGroupVersion, &metav1.Status{}, diff --git a/pkg/common/unittestcommon/fss_test.go b/pkg/common/unittestcommon/fss_test.go new file mode 100644 index 0000000000..a545ecef69 --- /dev/null +++ b/pkg/common/unittestcommon/fss_test.go @@ -0,0 +1,74 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package unittestcommon + +import ( + "context" + "testing" + + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" +) + +// TestVMOwnedVolumesFSSDefaultDisabledInFake verifies the FakeK8SOrchestrator +// defaults VMOwnedVolumes to false, ensuring existing tests are unaffected. +func TestVMOwnedVolumesFSSDefaultDisabledInFake(t *testing.T) { + ctx := context.Background() + co, err := GetFakeContainerOrchestratorInterface(common.Kubernetes) + if err != nil { + t.Fatalf("failed to get fake CO interface: %v", err) + } + if co.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + t.Errorf("VMOwnedVolumes should default to false in FakeK8SOrchestrator") + } +} + +// TestVMOwnedVolumesFSSEnableInFake verifies EnableFSS correctly enables +// VMOwnedVolumes in the FakeK8SOrchestrator. +func TestVMOwnedVolumesFSSEnableInFake(t *testing.T) { + ctx := context.Background() + co, err := GetFakeContainerOrchestratorInterface(common.Kubernetes) + if err != nil { + t.Fatalf("failed to get fake CO interface: %v", err) + } + + if err := co.EnableFSS(ctx, common.VMOwnedVolumes); err != nil { + t.Fatalf("EnableFSS returned unexpected error: %v", err) + } + if !co.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + t.Error("IsFSSEnabled should return true after EnableFSS") + } +} + +// TestVMOwnedVolumesFSSDisableInFake verifies DisableFSS correctly disables +// VMOwnedVolumes in the FakeK8SOrchestrator after it has been enabled. +func TestVMOwnedVolumesFSSDisableInFake(t *testing.T) { + ctx := context.Background() + co, err := GetFakeContainerOrchestratorInterface(common.Kubernetes) + if err != nil { + t.Fatalf("failed to get fake CO interface: %v", err) + } + + if err := co.EnableFSS(ctx, common.VMOwnedVolumes); err != nil { + t.Fatalf("EnableFSS returned unexpected error: %v", err) + } + if err := co.DisableFSS(ctx, common.VMOwnedVolumes); err != nil { + t.Fatalf("DisableFSS returned unexpected error: %v", err) + } + if co.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + t.Error("IsFSSEnabled should return false after DisableFSS") + } +} diff --git a/pkg/common/unittestcommon/utils.go b/pkg/common/unittestcommon/utils.go index 7d1a6283bb..ce47d8a949 100644 --- a/pkg/common/unittestcommon/utils.go +++ b/pkg/common/unittestcommon/utils.go @@ -79,6 +79,7 @@ func GetFakeContainerOrchestratorInterface(orchestratorType int) (commonco.COCom // From `wcp-cluster-capabilities` configmap in supervisor "Workload_Domain_Isolation_Supported": "false", "supports_CSI_Backup_API": "false", + "supports_vm_owned_volumes": "false", } fakeCO := &FakeK8SOrchestrator{ diff --git a/pkg/csi/service/common/constants.go b/pkg/csi/service/common/constants.go index 4b63bb5ea5..b065ec3101 100644 --- a/pkg/csi/service/common/constants.go +++ b/pkg/csi/service/common/constants.go @@ -620,6 +620,12 @@ const ( // on the supervisor. VMPVCStoragePolicyMutabilityFSS = "VM_PVC_STORAGE_POLICY_MUTABILITY" + // VMOwnedVolumes is the WCP capability that gates the VM-owned volume + // attach/detach path. When enabled, the CSI driver creates CsiVolumeInfo + // CRs for new PVCs and uses FCD unregister/re-register instead of + // CnsAttachVolume/CnsDetachVolume for greenfield VMs. + VMOwnedVolumes = "supports_vm_owned_volumes" + // AnnMigrationCRKind is the SV PVC annotation set by the Mobility Operator that // names the kind of the migration CR it created (one of MigrationCRKindVMInfra // or MigrationCRKindVolume). The CSI Syncer uses this annotation, together with @@ -664,6 +670,7 @@ var WCPFeatureStates = map[string]struct{}{ SupportsExposingStoragePolicyAttributes: {}, SupportsPerNamespaceNetworkProviders: {}, VMPVCStoragePolicyMutability: {}, + VMOwnedVolumes: {}, } // WCPFeatureStatesSupportsLateEnablement contains capabilities that can be enabled later @@ -683,6 +690,7 @@ var WCPFeatureStatesSupportsLateEnablement = map[string]struct{}{ SupportsExposingStoragePolicyAttributes: {}, SupportsPerNamespaceNetworkProviders: {}, VMPVCStoragePolicyMutability: {}, + VMOwnedVolumes: {}, } // WCPFeatureAssociatedWithPVCSI contains FSS name used in PVCSI and associated WCP Capability name on a diff --git a/pkg/csi/service/common/constants_test.go b/pkg/csi/service/common/constants_test.go new file mode 100644 index 0000000000..fa52e8baf9 --- /dev/null +++ b/pkg/csi/service/common/constants_test.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "testing" +) + +// TestVMOwnedVolumesFSSInWCPFeatureStates verifies VMOwnedVolumes is registered +// as a WCP capability so IsFSSEnabled reads it from the Capabilities CR. +func TestVMOwnedVolumesFSSInWCPFeatureStates(t *testing.T) { + if _, ok := WCPFeatureStates[VMOwnedVolumes]; !ok { + t.Errorf("VMOwnedVolumes (%q) is missing from WCPFeatureStates map", VMOwnedVolumes) + } +} + +// TestVMOwnedVolumesFSSInLateEnablement verifies VMOwnedVolumes is registered +// for late enablement so the driver can detect it being turned on post-startup. +func TestVMOwnedVolumesFSSInLateEnablement(t *testing.T) { + if _, ok := WCPFeatureStatesSupportsLateEnablement[VMOwnedVolumes]; !ok { + t.Errorf("VMOwnedVolumes (%q) is missing from WCPFeatureStatesSupportsLateEnablement map", VMOwnedVolumes) + } +} + +// TestVMOwnedVolumesFSSNotInPVCSIAssociation verifies VMOwnedVolumes is NOT in +// the pvCSI association map since this is a supervisor-only feature. +func TestVMOwnedVolumesFSSNotInPVCSIAssociation(t *testing.T) { + for pvcsiFSS, wcpCap := range WCPFeatureStateAssociatedWithPVCSI { + if wcpCap == VMOwnedVolumes { + t.Errorf("VMOwnedVolumes should not appear in WCPFeatureStateAssociatedWithPVCSI "+ + "(found as value for pvCSI FSS %q)", pvcsiFSS) + } + } +} diff --git a/pkg/csi/service/common/vsphereutil.go b/pkg/csi/service/common/vsphereutil.go index 24b5a98908..86eeef2a29 100644 --- a/pkg/csi/service/common/vsphereutil.go +++ b/pkg/csi/service/common/vsphereutil.go @@ -1359,6 +1359,35 @@ func QueryVolumeCryptoKeyByID( return diskFileBackingInfo.KeyId, nil } +// QueryFCDBackingInfo retrieves the diskUUID and datastore path for a block volume +// by querying its CNS backing metadata. +func QueryFCDBackingInfo( + ctx context.Context, + volumeManager cnsvolume.Manager, + volumeID string) (diskUUID string, diskPath string, err error) { + + queryFilter := cnstypes.CnsQueryFilter{ + VolumeIds: []cnstypes.CnsVolumeId{{Id: volumeID}}, + } + querySelection := cnstypes.CnsQuerySelection{ + Names: []string{string(cnstypes.QuerySelectionNameTypeBackingObjectDetails)}, + } + queryResult, err := utils.QueryVolumeUtil(ctx, volumeManager, queryFilter, &querySelection) + if err != nil { + return "", "", err + } + if queryResult == nil || len(queryResult.Volumes) == 0 { + return "", "", fmt.Errorf("volume %q not found in CNS", volumeID) + } + + blockBacking, ok := queryResult.Volumes[0].BackingObjectDetails.(*cnstypes.CnsBlockBackingDetails) + if !ok { + return "", "", fmt.Errorf("volume %q is not a block volume", volumeID) + } + + return blockBacking.BackingDiskObjectId, blockBacking.BackingDiskPath, nil +} + // createCryptoSpec creates a crypto spec based on the requested encryption operation. // // - Encrypt: Source is not encrypted, target is encrypted. diff --git a/pkg/csi/service/common/vsphereutil_test.go b/pkg/csi/service/common/vsphereutil_test.go index d79af62855..a2ac160122 100644 --- a/pkg/csi/service/common/vsphereutil_test.go +++ b/pkg/csi/service/common/vsphereutil_test.go @@ -438,3 +438,102 @@ func TestCreateBlockVolumeFromSnapshotTargetDatastore(t *testing.T) { }) } } + +// mockVolumeManagerWithQuery extends mockVolumeManager to provide a +// configurable QueryVolumeAsync response for QueryFCDBackingInfo tests. +type mockVolumeManagerWithQuery struct { + mockVolumeManager + queryAsyncFunc func(ctx context.Context, queryFilter cnstypes.CnsQueryFilter, + querySelection *cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) + queryFunc func(ctx context.Context, + queryFilter cnstypes.CnsQueryFilter) (*cnstypes.CnsQueryResult, error) +} + +func (m *mockVolumeManagerWithQuery) QueryVolumeAsync(ctx context.Context, + queryFilter cnstypes.CnsQueryFilter, + querySelection *cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) { + if m.queryAsyncFunc != nil { + return m.queryAsyncFunc(ctx, queryFilter, querySelection) + } + return nil, nil +} + +func (m *mockVolumeManagerWithQuery) QueryVolume(ctx context.Context, + queryFilter cnstypes.CnsQueryFilter) (*cnstypes.CnsQueryResult, error) { + if m.queryFunc != nil { + return m.queryFunc(ctx, queryFilter) + } + return nil, nil +} + +// TestQueryFCDBackingInfo_Success verifies that diskUUID and diskPath are +// extracted correctly from a CNS query result. +func TestQueryFCDBackingInfo_Success(t *testing.T) { + ctx := context.Background() + mgr := &mockVolumeManagerWithQuery{ + queryAsyncFunc: func(_ context.Context, _ cnstypes.CnsQueryFilter, + _ *cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) { + return &cnstypes.CnsQueryResult{ + Volumes: []cnstypes.CnsVolume{ + { + VolumeId: cnstypes.CnsVolumeId{Id: "vol-1"}, + BackingObjectDetails: &cnstypes.CnsBlockBackingDetails{ + BackingDiskObjectId: "disk-uuid-abc", + BackingDiskPath: "[ds1] vms/disk.vmdk", + }, + }, + }, + }, nil + }, + } + + diskUUID, diskPath, err := QueryFCDBackingInfo(ctx, mgr, "vol-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diskUUID != "disk-uuid-abc" { + t.Errorf("expected diskUUID disk-uuid-abc, got %q", diskUUID) + } + if diskPath != "[ds1] vms/disk.vmdk" { + t.Errorf("expected diskPath '[ds1] vms/disk.vmdk', got %q", diskPath) + } +} + +// TestQueryFCDBackingInfo_NotFound verifies that a missing volume returns an +// error. +func TestQueryFCDBackingInfo_NotFound(t *testing.T) { + ctx := context.Background() + mgr := &mockVolumeManagerWithQuery{ + queryAsyncFunc: func(_ context.Context, _ cnstypes.CnsQueryFilter, + _ *cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) { + return &cnstypes.CnsQueryResult{Volumes: nil}, nil + }, + } + _, _, err := QueryFCDBackingInfo(ctx, mgr, "vol-missing") + if err == nil { + t.Error("expected error for missing volume, got nil") + } +} + +// TestQueryFCDBackingInfo_NotBlockVolume verifies that a file volume returns +// an error because its backing is not CnsBlockBackingDetails. +func TestQueryFCDBackingInfo_NotBlockVolume(t *testing.T) { + ctx := context.Background() + mgr := &mockVolumeManagerWithQuery{ + queryAsyncFunc: func(_ context.Context, _ cnstypes.CnsQueryFilter, + _ *cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) { + return &cnstypes.CnsQueryResult{ + Volumes: []cnstypes.CnsVolume{ + { + VolumeId: cnstypes.CnsVolumeId{Id: "vol-file"}, + BackingObjectDetails: &cnstypes.CnsVsanFileShareBackingDetails{}, + }, + }, + }, nil + }, + } + _, _, err := QueryFCDBackingInfo(ctx, mgr, "vol-file") + if err == nil { + t.Error("expected error for non-block volume, got nil") + } +} diff --git a/pkg/csi/service/wcp/controller.go b/pkg/csi/service/wcp/controller.go index 0697e7beab..c5ce252620 100644 --- a/pkg/csi/service/wcp/controller.go +++ b/pkg/csi/service/wcp/controller.go @@ -51,6 +51,8 @@ import ( ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ctrlconfig "sigs.k8s.io/controller-runtime/pkg/client/config" cbtconfigv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cbtconfig/v1alpha1" + csivolumeinfo "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" fvsapis "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/filevolume" "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/crypto" cnsvolume "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" @@ -112,6 +114,10 @@ var ( // capability is enabled on the supervisor; when true, FVS routing reads the namespace-scoped // NetworkSettings CR per CreateVolume call instead of consulting the cached global provider. isPerNamespaceNetworkProvidersFSSEnabled bool + // isVMOwnedVolumesFSSEnabled is true when the supports_VM_owned_volumes supervisor capability + // is enabled. When true, the CSI driver creates CsiVolumeInfo CRs at provisioning time and + // routes greenfield VM attach/detach through the FCD unregister/re-register path. + isVMOwnedVolumesFSSEnabled bool // cachedGlobalNetworkProvider holds the value of wcp-network-config.network_provider resolved // once during controller.Init when isPerNamespaceNetworkProvidersFSSEnabled is false. It is // only consulted on the FVS routing path for the reserved vsan-file-service-policy / @@ -160,6 +166,7 @@ type controller struct { fileVolumeClient ctrlclient.Client manager *common.Manager snapshotLockMgr *snapshotLockManager + cviService csivolumeinfo.CsiVolumeInfoService } // New creates a CNS controller. @@ -272,6 +279,18 @@ func (c *controller) Init(config *cnsconfig.Config, version string) error { go commonco.ContainerOrchestratorUtility.HandleLateEnablementOfCapability(ctx, cnstypes.CnsClusterFlavorWorkload, common.VMPVCStoragePolicyMutability, "", "") } + isVMOwnedVolumesFSSEnabled = commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) + if !isVMOwnedVolumesFSSEnabled { + go commonco.ContainerOrchestratorUtility.HandleLateEnablementOfCapability(ctx, cnstypes.CnsClusterFlavorWorkload, + common.VMOwnedVolumes, "", "") + } else { + cviSvc, cviErr := csivolumeinfo.InitCsiVolumeInfoService(ctx) + if cviErr != nil { + log.Errorf("failed to initialize CsiVolumeInfo service: %v", cviErr) + } else { + c.cviService = cviSvc + } + } if idempotencyHandlingEnabled { log.Info("CSI Volume manager idempotency handling feature flag is enabled.") operationStore, err = cnsvolumeoperationrequest.InitVolumeOperationRequestInterface(ctx, @@ -1262,6 +1281,23 @@ func (c *controller) createBlockVolume(ctx context.Context, req *csi.CreateVolum } } + if isVMOwnedVolumesFSSEnabled && c.cviService != nil && pvcNamespace != "" && pvcName != "" { + diskUUID, diskPath, fcdErr := common.QueryFCDBackingInfo(ctx, c.manager.VolumeManager, volumeInfo.VolumeID.Id) + if fcdErr != nil { + log.Warnf("failed to query FCD backing info for volume %q, CsiVolumeInfo will be "+ + "created on PV bind: %v", volumeInfo.VolumeID.Id, fcdErr) + } else { + cvi := csivolumeinfo.BuildCsiVolumeInfo( + volumeInfo.VolumeID.Id, pvcName, pvcNamespace, + req.Name, "", diskUUID, diskPath, + ) + if cviErr := c.cviService.CreateCsiVolumeInfo(ctx, cvi); cviErr != nil { + log.Warnf("failed to create CsiVolumeInfo for volume %q, will be created on PV bind: %v", + volumeInfo.VolumeID.Id, cviErr) + } + } + } + if isCSIBackupAPIEnabled { // It's the best effort scenario to enable the CBT before create volume. // If any error occurs during CBT enablement, it may be deferred to attachment or @@ -1999,6 +2035,23 @@ func (c *controller) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequ "please delete snapshots before deleting the volume", req.VolumeId, snapshots) } } + if isVMOwnedVolumesFSSEnabled && c.cviService != nil { + _, pvcNS, ok := commonco.ContainerOrchestratorUtility.GetPVCNameFromCSIVolumeID(req.VolumeId) + if ok && pvcNS != "" { + cvi, cviErr := c.cviService.GetCsiVolumeInfo(ctx, pvcNS, req.VolumeId) + if cviErr != nil { + log.Warnf("failed to check CsiVolumeInfo for volume %q: %v", + req.VolumeId, cviErr) + } else if cvi != nil && + cvi.Status.OwnershipState != csivolumeinfov1alpha1.OwnershipStateCSIManaged { + return nil, csifault.CSIInternalFault, + logger.LogNewErrorCodef(log, codes.FailedPrecondition, + "cannot delete volume %q: volume ownership state is %q, "+ + "detach the volume or delete retaining snapshots first", + req.VolumeId, cvi.Status.OwnershipState) + } + } + } faultType, err := common.DeleteVolumeUtil(ctx, c.manager.VolumeManager, req.VolumeId, true) if err != nil { log.Debugf("DeleteVolumeUtil returns fault %s:", faultType) diff --git a/pkg/syncer/admissionhandler/validatepvc.go b/pkg/syncer/admissionhandler/validatepvc.go index 85ec01f270..6f97c0b96d 100644 --- a/pkg/syncer/admissionhandler/validatepvc.go +++ b/pkg/syncer/admissionhandler/validatepvc.go @@ -19,6 +19,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientset "k8s.io/client-go/kubernetes" + csivolumeinfo "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" k8s "sigs.k8s.io/vsphere-csi-driver/v3/pkg/kubernetes" ) @@ -128,6 +131,20 @@ func validatePVC(ctx context.Context, req *admissionv1.AdmissionRequest) *admiss } } + // Block deletion of snapshot-retained PVCs tracked by CsiVolumeInfo. + if req.Operation == admissionv1.Delete && + commonco.ContainerOrchestratorUtility != nil && + commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + if cviDenied, cviMsg := checkCVISnapshotRetained(ctx, oldPVC.Namespace, oldPVC.Name); cviDenied { + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: cviMsg, + }, + } + } + } + snapshots, err := getSnapshotsForPVC(ctx, oldPVC.Namespace, oldPVC.Name) if err != nil { log.Warnf("error getting snapshots for pvc: %v. skipping validation.", err) @@ -659,3 +676,33 @@ func validateGuestPVCOperation(ctx context.Context, req *admissionv1.AdmissionRe Allowed: true, } } + +// checkCVISnapshotRetained returns (true, errorMessage) when the PVC is +// tracked by a CsiVolumeInfo CR that is in the snapshot-retained state +// (VM_MANAGED with vmName empty). It returns (false, "") otherwise, including +// when the feature gate is off, the CVI lookup fails, or no CVI exists. +func checkCVISnapshotRetained(ctx context.Context, namespace, pvcName string) (bool, string) { + log := logger.GetLogger(ctx) + + cviSvc, err := csivolumeinfo.InitCsiVolumeInfoService(ctx) + if err != nil { + log.Warnf("checkCVISnapshotRetained: failed to get CsiVolumeInfo service: %v", err) + return false, "" + } + + cvi, err := cviSvc.GetCsiVolumeInfoByPVCName(ctx, namespace, pvcName) + if err != nil { + log.Warnf("checkCVISnapshotRetained: failed to look up CsiVolumeInfo for PVC %s/%s: %v", + namespace, pvcName, err) + return false, "" + } + if cvi == nil { + return false, "" + } + if cvi.Status.OwnershipState == csivolumeinfov1alpha1.OwnershipStateVMManaged && + cvi.Status.VMName == "" { + return true, fmt.Sprintf("cannot delete PVC %s/%s: retained by VirtualMachineSnapshot(s); "+ + "delete the retaining snapshot(s) first", namespace, pvcName) + } + return false, "" +} diff --git a/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cnsnodevmbatchattachment_controller.go b/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cnsnodevmbatchattachment_controller.go index c2741a780b..4d5d492ba1 100644 --- a/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cnsnodevmbatchattachment_controller.go +++ b/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cnsnodevmbatchattachment_controller.go @@ -37,6 +37,7 @@ import ( cnstypes "github.com/vmware/govmomi/cns/types" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" @@ -495,6 +496,11 @@ func (r *Reconciler) detachVolumes(ctx context.Context, // Remove finalizer from the PVC as the detach was successful. volumesThatFailedToDetach = removeFinalizerAndStatusEntry(ctx, r.client, k8sClient, r.cnsOperatorClient, instance, pvc, volumesThatFailedToDetach) + // Attempt lazy CVI creation for brownfield volumes when preconditions are met. + go MaybeLazilyCreateCVI(ctx, vm, r.volumeManager, + instance.Namespace, + volumeId, pvc, "", "", + getVolumeStatusConditions(instance, pvc)) } log.Infof("Detach call ended for PVC %s in namespace %s for instance %s", pvc, instance.Namespace, instance.Name) @@ -504,6 +510,18 @@ func (r *Reconciler) detachVolumes(ctx context.Context, return volumesThatFailedToDetach } +// getVolumeStatusConditions returns the conditions for the given PVC from the +// instance status. Returns nil if no matching status entry is found. +func getVolumeStatusConditions( + instance *v1alpha1.CnsNodeVMBatchAttachment, pvcName string) []metav1.Condition { + for _, vs := range instance.Status.VolumeStatus { + if vs.PersistentVolumeClaim.ClaimName == pvcName { + return vs.PersistentVolumeClaim.Conditions + } + } + return nil +} + // removeFinalizerAndStatusEntry removes finalizer from the given PVC and // removes its entry from the instance status if it is successful. // If removing the finalizer fails, it adds the volume to volumesThatFailedToDetach list. diff --git a/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cvi_lazy_create.go b/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cvi_lazy_create.go new file mode 100644 index 0000000000..13f8770fcc --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cvi_lazy_create.go @@ -0,0 +1,130 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cnsnodevmbatchattachment + +import ( + "context" + + "github.com/vmware/govmomi/vim25/mo" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + bav1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsnodevmbatchattachment/v1alpha1" + csivolumeinfo "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + cnsvsphere "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/vsphere" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" +) + +// VMHasVCenterSnapshots queries vCenter for the given VM and reports whether +// its snapshot tree is non-empty. This uses the VirtualMachine managed object +// property rather than listing K8s VirtualMachineSnapshot CRs. +func VMHasVCenterSnapshots(ctx context.Context, + vm *cnsvsphere.VirtualMachine) (bool, error) { + var vmMo mo.VirtualMachine + err := vm.VirtualMachine.Properties(ctx, + vm.VirtualMachine.Reference(), []string{"snapshot"}, &vmMo) + if err != nil { + return false, err + } + return vmMo.Snapshot != nil, nil +} + +// IsSnapshotRevertInducedDetach reports whether the per-volume status +// conditions indicate that the disk was dropped by a snapshot revert rather +// than by an explicit user request. It looks for a VolumeDetached=True +// condition with the DroppedBySnapshotRevert reason. +func IsSnapshotRevertInducedDetach(conditions []metav1.Condition) bool { + for _, c := range conditions { + if c.Type == bav1alpha1.ConditionDetached && + c.Status == "True" && + c.Reason == bav1alpha1.ReasonDroppedBySnapshotRevert { + return true + } + } + return false +} + +// MaybeLazilyCreateCVI creates a CsiVolumeInfo CR for the volume after a +// successful legacy detach — but only when both preconditions are met: +// 1. The detach was user-initiated, not caused by a snapshot revert. +// 2. The VM's vCenter snapshot tree is empty (no vSphere snapshots exist +// that could trigger a future revert requiring legacy semantics). +// +// A failure to create the CVI is logged but not propagated; the CVI can +// be created later during PV bind reconciliation. +func MaybeLazilyCreateCVI(ctx context.Context, + vm *cnsvsphere.VirtualMachine, + volumeManager volumes.Manager, + namespace string, + volumeID, pvcName, pvName, pvUID string, + detachConditions []metav1.Condition, +) { + log := logger.GetLogger(ctx) + + if !commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + return + } + if IsSnapshotRevertInducedDetach(detachConditions) { + log.Debugf("MaybeLazilyCreateCVI: skipping volume %q — detach was revert-induced", + volumeID) + return + } + + hasSnaps, err := VMHasVCenterSnapshots(ctx, vm) + if err != nil { + log.Warnf("MaybeLazilyCreateCVI: failed to check vCenter snapshot tree "+ + "for VM %v: %v", vm.VirtualMachine.Reference(), err) + return + } + if hasSnaps { + log.Debugf("MaybeLazilyCreateCVI: skipping volume %q — VM %v still has "+ + "vCenter snapshots", volumeID, vm.VirtualMachine.Reference()) + return + } + + cviSvc, err := csivolumeinfo.InitCsiVolumeInfoService(ctx) + if err != nil { + log.Errorf("MaybeLazilyCreateCVI: failed to get CsiVolumeInfo service: %v", err) + return + } + + exists, err := cviSvc.CsiVolumeInfoExists(ctx, namespace, volumeID) + if err != nil { + log.Warnf("MaybeLazilyCreateCVI: failed to check CsiVolumeInfo for %q: %v", + volumeID, err) + return + } + if exists { + return + } + + diskUUID, diskPath, fcdErr := common.QueryFCDBackingInfo(ctx, volumeManager, volumeID) + if fcdErr != nil { + log.Errorf("MaybeLazilyCreateCVI: failed to query FCD backing for %q: %v", + volumeID, fcdErr) + return + } + + cvi := csivolumeinfo.BuildCsiVolumeInfo( + volumeID, pvcName, namespace, pvName, pvUID, diskUUID, diskPath) + if createErr := cviSvc.CreateCsiVolumeInfo(ctx, cvi); createErr != nil { + log.Errorf("MaybeLazilyCreateCVI: failed to create CsiVolumeInfo for %q: %v", + volumeID, createErr) + } +} diff --git a/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cvi_lazy_create_test.go b/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cvi_lazy_create_test.go new file mode 100644 index 0000000000..186b03fa19 --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/cnsnodevmbatchattachment/cvi_lazy_create_test.go @@ -0,0 +1,63 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cnsnodevmbatchattachment + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + bav1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsnodevmbatchattachment/v1alpha1" +) + +// TestIsSnapshotRevertInducedDetach_True confirms that the +// DroppedBySnapshotRevert reason triggers a true result. +func TestIsSnapshotRevertInducedDetach_True(t *testing.T) { + conditions := []metav1.Condition{ + { + Type: bav1alpha1.ConditionDetached, + Status: "True", + Reason: bav1alpha1.ReasonDroppedBySnapshotRevert, + }, + } + if !IsSnapshotRevertInducedDetach(conditions) { + t.Error("expected true for DroppedBySnapshotRevert condition") + } +} + +// TestIsSnapshotRevertInducedDetach_False confirms that other detach reasons +// return false. +func TestIsSnapshotRevertInducedDetach_False(t *testing.T) { + conditions := []metav1.Condition{ + { + Type: bav1alpha1.ConditionDetached, + Status: "True", + Reason: bav1alpha1.ReasonDetachFailed, + }, + } + if IsSnapshotRevertInducedDetach(conditions) { + t.Error("expected false for DetachFailed condition") + } +} + +// TestIsSnapshotRevertInducedDetach_Empty returns false for an empty +// condition slice. +func TestIsSnapshotRevertInducedDetach_Empty(t *testing.T) { + if IsSnapshotRevertInducedDetach(nil) { + t.Error("expected false for nil conditions") + } +} diff --git a/pkg/syncer/cnsoperator/manager/init.go b/pkg/syncer/cnsoperator/manager/init.go index c9522f3904..4db94b62eb 100644 --- a/pkg/syncer/cnsoperator/manager/init.go +++ b/pkg/syncer/cnsoperator/manager/init.go @@ -38,6 +38,7 @@ import ( cnsoperatorv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator" cnsvolumemetadatav1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsvolumemetadata/v1alpha1" cnsoperatorconfig "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/config" + csivolumeinfocfg "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/config" wcpcapapis "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/wcpcapabilities" volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" cnsvsphere "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/vsphere" @@ -230,6 +231,17 @@ func InitCnsOperator(ctx context.Context, clusterFlavor cnstypes.CnsClusterFlavo }() } + if cnsOperator.coCommonInterface.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + // Create CsiVolumeInfo CRD for the VM-owned volume lifecycle model. + if err = k8s.CreateCustomResourceDefinitionFromManifest(ctx, + csivolumeinfocfg.EmbedCsiVolumeInfoFile, + csivolumeinfocfg.EmbedCsiVolumeInfoFileName); err != nil { + log.Errorf("failed to create CsiVolumeInfo CRD. Err: %+v", err) + return err + } + log.Info("CsiVolumeInfo CRD created (VMOwnedVolumes FSS enabled)") + } + // Create CnsVolumeMetadata CRD err = k8s.CreateCustomResourceDefinitionFromManifest(ctx, cnsoperatorconfig.EmbedCnsVolumeMetadataCRFile, cnsoperatorconfig.EmbedCnsVolumeMetadataCRFileName) diff --git a/pkg/syncer/cvi_pv_bind.go b/pkg/syncer/cvi_pv_bind.go new file mode 100644 index 0000000000..530f4a08f9 --- /dev/null +++ b/pkg/syncer/cvi_pv_bind.go @@ -0,0 +1,158 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package syncer + +import ( + "context" + "encoding/json" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + + csivolumeinfo "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + csitypes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/types" +) + +// reconcileCVIOnPVBind is called when a PersistentVolume transitions to Bound. +// It ensures the corresponding CsiVolumeInfo CR exists with the PV ownerReference +// set, creating or patching it as needed. It also handles same-namespace and +// cross-namespace rebind under the Retain reclaim policy. +func reconcileCVIOnPVBind(ctx context.Context, oldPV, newPV *v1.PersistentVolume, + volumeManager volumes.Manager) { + log := logger.GetLogger(ctx) + + if !commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + return + } + if newPV.Spec.CSI == nil || newPV.Spec.CSI.Driver != csitypes.Name { + return + } + if newPV.Spec.ClaimRef == nil { + return + } + if newPV.Status.Phase != v1.VolumeBound { + return + } + + volumeID := newPV.Spec.CSI.VolumeHandle + pvName := newPV.Name + pvUID := string(newPV.UID) + newNS := newPV.Spec.ClaimRef.Namespace + newPVCName := newPV.Spec.ClaimRef.Name + + cviSvc, err := csivolumeinfo.InitCsiVolumeInfoService(ctx) + if err != nil { + log.Errorf("reconcileCVIOnPVBind: failed to get CsiVolumeInfo service: %v", err) + return + } + + // Detect namespace change for cross-namespace Retain rebind. + oldNS := "" + if oldPV != nil && oldPV.Spec.ClaimRef != nil { + oldNS = oldPV.Spec.ClaimRef.Namespace + } + if oldNS != "" && oldNS != newNS { + // Cross-namespace rebind: remove the stale CVI from the old namespace. + if delErr := cviSvc.DeleteCsiVolumeInfo(ctx, oldNS, volumeID); delErr != nil { + log.Warnf("reconcileCVIOnPVBind: failed to delete stale CsiVolumeInfo in "+ + "old namespace %q for volume %q: %v", oldNS, volumeID, delErr) + } + } + + existing, err := cviSvc.GetCsiVolumeInfo(ctx, newNS, volumeID) + if err != nil { + log.Errorf("reconcileCVIOnPVBind: failed to look up CsiVolumeInfo for volume %q "+ + "in namespace %q: %v", volumeID, newNS, err) + return + } + + ownerRef := metav1.OwnerReference{ + APIVersion: "v1", + Kind: "PersistentVolume", + Name: pvName, + UID: k8stypes.UID(pvUID), + Controller: boolPtr(true), + BlockOwnerDeletion: boolPtr(true), + } + + if existing == nil { + // CVI was not created at provisioning time (e.g., crash recovery). + // Create it now with the ownerReference already included. + diskUUID, diskPath, fcdErr := common.QueryFCDBackingInfo(ctx, volumeManager, volumeID) + if fcdErr != nil { + log.Errorf("reconcileCVIOnPVBind: failed to query FCD backing info for volume %q: %v", + volumeID, fcdErr) + return + } + cvi := csivolumeinfo.BuildCsiVolumeInfo( + volumeID, newPVCName, newNS, pvName, pvUID, diskUUID, diskPath) + if createErr := cviSvc.CreateCsiVolumeInfo(ctx, cvi); createErr != nil { + log.Errorf("reconcileCVIOnPVBind: failed to create CsiVolumeInfo for volume %q: %v", + volumeID, createErr) + } + return + } + + // CVI exists — check whether ownerReference and pvcName are up to date. + needsOwnerRef := !hasPVOwnerReference(existing, pvUID) + needsPVCName := existing.Spec.PVCName != newPVCName + + if !needsOwnerRef && !needsPVCName { + return + } + + patch := map[string]interface{}{} + if needsOwnerRef { + patch["metadata"] = map[string]interface{}{ + "ownerReferences": []metav1.OwnerReference{ownerRef}, + } + } + if needsPVCName { + patch["spec"] = map[string]interface{}{ + "pvcName": newPVCName, + } + } + patchBytes, err := json.Marshal(patch) + if err != nil { + log.Errorf("reconcileCVIOnPVBind: failed to marshal patch for CsiVolumeInfo %q/%q: %v", + newNS, existing.Name, err) + return + } + if patchErr := cviSvc.PatchCsiVolumeInfo(ctx, newNS, volumeID, patchBytes); patchErr != nil { + log.Errorf("reconcileCVIOnPVBind: failed to patch CsiVolumeInfo for volume %q: %v", + volumeID, patchErr) + } +} + +// hasPVOwnerReference reports whether the given CVI already carries an +// ownerReference for a PersistentVolume with the supplied UID. +func hasPVOwnerReference(cvi *csivolumeinfov1alpha1.CsiVolumeInfo, pvUID string) bool { + for _, ref := range cvi.OwnerReferences { + if ref.Kind == "PersistentVolume" && string(ref.UID) == pvUID { + return true + } + } + return false +} + +func boolPtr(b bool) *bool { return &b } diff --git a/pkg/syncer/metadatasyncer.go b/pkg/syncer/metadatasyncer.go index 5ac39d59e7..27b3950ce3 100644 --- a/pkg/syncer/metadatasyncer.go +++ b/pkg/syncer/metadatasyncer.go @@ -2870,6 +2870,13 @@ func pvUpdated(oldObj, newObj interface{}, metadataSyncer *metadataSyncInformer) } log.Debugf("PVUpdated: PV Updated from %+v to %+v", oldPv, newPv) + // Reconcile CsiVolumeInfo ownerReference and pvcName whenever a vSphere CSI + // PV transitions to Bound. This is a no-op when the FSS is disabled. + if newPv.Spec.CSI != nil && newPv.Spec.CSI.Driver == csitypes.Name && + newPv.Status.Phase == v1.VolumeBound { + go reconcileCVIOnPVBind(ctx, oldPv, newPv, metadataSyncer.volumeManager) + } + // Return if new PV status is Pending or Failed. if newPv.Status.Phase == v1.VolumePending || newPv.Status.Phase == v1.VolumeFailed { log.Debugf("PVUpdated: PV %s metadata is not updated since updated PV is in phase %s",