diff --git a/packages/system/linstor/images/linstor-csi/patches/001-relocate-after-clone-restore.diff b/packages/system/linstor/images/linstor-csi/patches/001-relocate-after-clone-restore.diff new file mode 100644 index 000000000..b9730ade1 --- /dev/null +++ b/packages/system/linstor/images/linstor-csi/patches/001-relocate-after-clone-restore.diff @@ -0,0 +1,355 @@ +diff --git a/pkg/client/linstor.go b/pkg/client/linstor.go +index f544493..ebaa3d7 100644 +--- a/pkg/client/linstor.go ++++ b/pkg/client/linstor.go +@@ -181,7 +181,7 @@ func LogLevel(s string) func(*Linstor) error { + func (s *Linstor) ListAllWithStatus(ctx context.Context) ([]volume.VolumeStatus, error) { + var vols []volume.VolumeStatus + +- resourcesByName := make(map[string][]lapi.Resource) ++ resourcesByName := make(map[string][]lapi.ResourceWithVolumes) + + resDefs, err := s.client.ResourceDefinitions.GetAll(ctx, lapi.RDGetAllRequest{WithVolumeDefinitions: true}) + if err != nil { +@@ -194,7 +194,7 @@ func (s *Linstor) ListAllWithStatus(ctx context.Context) ([]volume.VolumeStatus, + } + + for i := range allResources { +- resourcesByName[allResources[i].Name] = append(resourcesByName[allResources[i].Name], allResources[i].Resource) ++ resourcesByName[allResources[i].Name] = append(resourcesByName[allResources[i].Name], allResources[i]) + } + + for _, rd := range resDefs { +@@ -462,6 +462,14 @@ func (s *Linstor) Clone(ctx context.Context, vol, src *volume.Info, params *volu + return err + } + ++ if params.RelocateAfterClone { ++ logger.Debug("relocate resources to optimal nodes") ++ ++ if err := s.relocateResources(ctx, vol.ID, rGroup.Name); err != nil { ++ logger.WithError(err).Warn("resource relocation failed, volume is still usable") ++ } ++ } ++ + logger.Debug("reconcile extra properties") + + err = s.client.ResourceDefinitions.Modify(ctx, vol.ID, lapi.GenericPropsModify{OverrideProps: vol.Properties}) +@@ -1280,6 +1288,14 @@ func (s *Linstor) VolFromSnap(ctx context.Context, snap *volume.Snapshot, vol *v + return err + } + ++ if params.RelocateAfterSnapshotRestore { ++ logger.Debug("relocate resources to optimal nodes") ++ ++ if err := s.relocateResources(ctx, vol.ID, rGroup.Name); err != nil { ++ logger.WithError(err).Warn("resource relocation failed, volume is still usable") ++ } ++ } ++ + logger.Debug("reconcile extra properties") + + err = s.client.ResourceDefinitions.Modify(ctx, vol.ID, lapi.GenericPropsModify{OverrideProps: vol.Properties}) +@@ -1470,9 +1486,8 @@ func (s *Linstor) reconcileSnapshotResources(ctx context.Context, snapshot *volu + return fmt.Errorf("snapshot '%s' not deployed on any node", snap.Name) + } + +- // Optimize the node we use to restore. It should be one of the preferred nodes, or just the first with a snapshot +- // if no preferred nodes match. +- var selectedNode string ++ // Collect available snapshot nodes, preferring those matching the topology hints. ++ var preferred, available []string + for _, snapNode := range snap.Nodes { + if err := s.NodeAvailable(ctx, snapNode); err != nil { + logger.WithField("selected node candidate", snapNode).WithError(err).Debug("node is not available") +@@ -1480,13 +1495,23 @@ func (s *Linstor) reconcileSnapshotResources(ctx context.Context, snapshot *volu + } + + if slices.Contains(preferredNodes, snapNode) { +- // We found a perfect candidate. +- selectedNode = snapNode +- break +- } else if selectedNode == "" { +- // Set a fallback if we have no candidate yet. +- selectedNode = snapNode ++ preferred = append(preferred, snapNode) + } ++ ++ available = append(available, snapNode) ++ } ++ ++ // Pick a random node from preferred candidates, falling back to any available node. ++ // Randomization distributes restore load across snapshot nodes, preventing all ++ // clones of the same source from concentrating on a single node. ++ candidates := preferred ++ if len(candidates) == 0 { ++ candidates = available ++ } ++ ++ var selectedNode string ++ if len(candidates) > 0 { ++ selectedNode = candidates[rand.Intn(len(candidates))] + } + + if selectedNode == "" { +@@ -1679,6 +1704,114 @@ func (s *Linstor) reconcileResourcePlacement(ctx context.Context, vol *volume.In + return nil + } + ++const propertyRelocationTriggered = "Aux/csi-relocation-triggered" ++ ++// relocateResources migrates replicas from their current nodes to the nodes that ++// LINSTOR's autoplacer considers optimal. This is a best-effort, fire-and-forget ++// operation: migrate-disk API is asynchronous and the volume remains usable on its ++// current nodes even if relocation fails. ++func (s *Linstor) relocateResources(ctx context.Context, volID, rgName string) error { ++ logger := s.log.WithFields(logrus.Fields{ ++ "volume": volID, ++ "resourceGroup": rgName, ++ }) ++ ++ // Check if relocation was already triggered (idempotency guard). ++ rd, err := s.client.ResourceDefinitions.Get(ctx, volID) ++ if err != nil { ++ return fmt.Errorf("failed to get resource definition: %w", err) ++ } ++ ++ if rd.Props[propertyRelocationTriggered] != "" { ++ logger.Debug("relocation already triggered, skipping") ++ return nil ++ } ++ ++ // Get current diskful nodes. ++ resources, err := s.client.Resources.GetAll(ctx, volID) ++ if err != nil { ++ return fmt.Errorf("failed to list resources: %w", err) ++ } ++ ++ currentNodes := util.DeployedDiskfullyNodes(resources) ++ ++ // Query optimal placement from LINSTOR autoplacer. ++ sizeInfo, err := s.client.ResourceGroups.QuerySizeInfo(ctx, rgName, lapi.QuerySizeInfoRequest{}) ++ if err != nil { ++ return fmt.Errorf("failed to query size info: %w", err) ++ } ++ ++ if sizeInfo.SpaceInfo == nil || len(sizeInfo.SpaceInfo.NextSpawnResult) == 0 { ++ logger.Debug("no spawn result from query-size-info, skipping relocation") ++ return nil ++ } ++ ++ // Build a map of optimal node -> storage pool. ++ optimalPools := make(map[string]string, len(sizeInfo.SpaceInfo.NextSpawnResult)) ++ for _, r := range sizeInfo.SpaceInfo.NextSpawnResult { ++ optimalPools[r.NodeName] = r.StorPoolName ++ } ++ ++ // Compute nodes to remove (current but not optimal) and nodes to add (optimal but not current). ++ var nodesToRemove, nodesToAdd []string ++ ++ for _, node := range currentNodes { ++ if _, ok := optimalPools[node]; !ok { ++ nodesToRemove = append(nodesToRemove, node) ++ } ++ } ++ ++ for _, r := range sizeInfo.SpaceInfo.NextSpawnResult { ++ if !slices.Contains(currentNodes, r.NodeName) { ++ nodesToAdd = append(nodesToAdd, r.NodeName) ++ } ++ } ++ ++ // Only migrate min(remove, add) pairs. ++ pairs := min(len(nodesToRemove), len(nodesToAdd)) ++ if pairs == 0 { ++ logger.Info("no relocation needed, placement is already optimal") ++ return nil ++ } ++ ++ logger.Infof("relocating %d replica(s): %v -> %v", pairs, nodesToRemove[:pairs], nodesToAdd[:pairs]) ++ ++ // Mark relocation as triggered before starting migrations. ++ err = s.client.ResourceDefinitions.Modify(ctx, volID, lapi.GenericPropsModify{ ++ OverrideProps: map[string]string{propertyRelocationTriggered: "true"}, ++ }) ++ if err != nil { ++ return fmt.Errorf("failed to set relocation property: %w", err) ++ } ++ ++ // Migrate each pair sequentially (LINSTOR serializes at the RD level anyway). ++ for i := range pairs { ++ fromNode := nodesToRemove[i] ++ toNode := nodesToAdd[i] ++ storPool := optimalPools[toNode] ++ ++ logger := logger.WithFields(logrus.Fields{"from": fromNode, "to": toNode, "storagePool": storPool}) ++ ++ logger.Info("creating diskless resource on target node") ++ ++ err := s.client.Resources.MakeAvailable(ctx, volID, toNode, lapi.ResourceMakeAvailable{}) ++ if err != nil { ++ logger.WithError(err).Warn("failed to create diskless on target, skipping this pair") ++ continue ++ } ++ ++ logger.Info("initiating migrate-disk") ++ ++ err = s.client.Resources.Migrate(ctx, volID, fromNode, toNode, storPool) ++ if err != nil { ++ logger.WithError(err).Warn("migrate-disk failed, skipping this pair") ++ continue ++ } ++ } ++ ++ return nil ++} ++ + // FindSnapsByID searches the snapshot in the backend + func (s *Linstor) FindSnapsByID(ctx context.Context, id string) ([]*volume.Snapshot, error) { + snapshotId, err := volume.ParseSnapshotId(id) +@@ -2173,7 +2306,7 @@ func (s *Linstor) Status(ctx context.Context, volId string) ([]string, *csi.Volu + "volume": volId, + }).Debug("getting assignments") + +- ress, err := s.client.Resources.GetAll(ctx, volId) ++ ress, err := s.client.Resources.GetResourceView(ctx, &lapi.ListOpts{Resource: []string{volId}}) + if err != nil { + return nil, nil, fmt.Errorf("failed to list resources for '%s': %w", volId, err) + } +@@ -2261,13 +2394,20 @@ func GetSnapshotRemoteAndReadiness(snap *lapi.Snapshot) (string, bool, error) { + return "", slices.Contains(snap.Flags, lapiconsts.FlagSuccessful), nil + } + +-func NodesAndConditionFromResources(ress []lapi.Resource) ([]string, *csi.VolumeCondition) { ++func NodesAndConditionFromResources(ress []lapi.ResourceWithVolumes) ([]string, *csi.VolumeCondition) { + var allNodes, abnormalNodes []string + + for i := range ress { + res := &ress[i] + +- allNodes = append(allNodes, res.NodeName) ++ // A resource is a CSI publish target if any of its volumes were created ++ // by ControllerPublishVolume, identified by the temporary-diskless-attach property. ++ if slices.ContainsFunc(res.Volumes, func(v lapi.Volume) bool { ++ createdFor, ok := v.Props[linstor.PropertyCreatedFor] ++ return ok && createdFor == linstor.CreatedForTemporaryDisklessAttach ++ }) { ++ allNodes = append(allNodes, res.NodeName) ++ } + + if res.State == nil { + abnormalNodes = append(abnormalNodes, res.NodeName) +diff --git a/pkg/volume/parameter.go b/pkg/volume/parameter.go +index 39acd95..110c597 100644 +--- a/pkg/volume/parameter.go ++++ b/pkg/volume/parameter.go +@@ -50,6 +50,8 @@ const ( + nfsservicename + nfssquash + nfsrecoveryvolumesize ++ relocateafterclone ++ relocateaftersnapshotrestore + ) + + // Parameters configuration for linstor volumes. +@@ -118,6 +120,10 @@ type Parameters struct { + // NfsRecoveryVolumeSize sets the volume size (in bytes) of the recovery volume used by the NFS server. + // Defaults to 300MiB. + NfsRecoveryVolumeBytes int64 ++ // RelocateAfterClone triggers asynchronous relocation of replicas to optimal nodes after cloning. ++ RelocateAfterClone bool ++ // RelocateAfterSnapshotRestore triggers asynchronous relocation of replicas to optimal nodes after snapshot restore. ++ RelocateAfterSnapshotRestore bool + } + + const DefaultDisklessStoragePoolName = "DfltDisklessStorPool" +@@ -136,10 +142,12 @@ func NewParameters(params map[string]string, topologyPrefix string) (Parameters, + Encryption: false, + PlacementPolicy: topology.AutoPlaceTopology, + Properties: make(map[string]string), +- NfsConfigTemplatePath: "/etc/nfs-helper/default-config.tmpl", +- NfsServiceName: "linstor-csi-nfs", +- NfsSquash: "no_root_squash", +- NfsRecoveryVolumeBytes: 300 * 1024 * 1024, ++ NfsConfigTemplatePath: "/etc/nfs-helper/default-config.tmpl", ++ NfsServiceName: "linstor-csi-nfs", ++ NfsSquash: "no_root_squash", ++ NfsRecoveryVolumeBytes: 300 * 1024 * 1024, ++ RelocateAfterClone: true, ++ RelocateAfterSnapshotRestore: true, + } + + for k, v := range params { +@@ -287,6 +295,20 @@ func NewParameters(params map[string]string, topologyPrefix string) (Parameters, + } + + p.NfsRecoveryVolumeBytes = s.Value() ++ case relocateafterclone: ++ val, err := strconv.ParseBool(v) ++ if err != nil { ++ return p, err ++ } ++ ++ p.RelocateAfterClone = val ++ case relocateaftersnapshotrestore: ++ val, err := strconv.ParseBool(v) ++ if err != nil { ++ return p, err ++ } ++ ++ p.RelocateAfterSnapshotRestore = val + } + } + +diff --git a/pkg/volume/paramkey_enumer.go b/pkg/volume/paramkey_enumer.go +index 5474963..1c2fc87 100644 +--- a/pkg/volume/paramkey_enumer.go ++++ b/pkg/volume/paramkey_enumer.go +@@ -7,11 +7,11 @@ import ( + "strings" + ) + +-const _paramKeyName = "allowremotevolumeaccessautoplaceclientlistdisklessonremainingdisklessstoragepooldonotplacewithregexencryptionfsoptslayerlistmountoptsnodelistplacementcountplacementpolicyreplicasondifferentreplicasonsamesizekibstoragepoolpostmountxfsoptsresourcegroupusepvcnameoverprovisionxreplicasondifferentnfsconfigtemplatenfsservicenamenfssquashnfsrecoveryvolumesize" ++const _paramKeyName = "allowremotevolumeaccessautoplaceclientlistdisklessonremainingdisklessstoragepooldonotplacewithregexencryptionfsoptslayerlistmountoptsnodelistplacementcountplacementpolicyreplicasondifferentreplicasonsamesizekibstoragepoolpostmountxfsoptsresourcegroupusepvcnameoverprovisionxreplicasondifferentnfsconfigtemplatenfsservicenamenfssquashnfsrecoveryvolumesizerelocateafterclonerelocateaftersnapshotrestore" + +-var _paramKeyIndex = [...]uint16{0, 23, 32, 42, 61, 80, 99, 109, 115, 124, 133, 141, 155, 170, 189, 203, 210, 221, 237, 250, 260, 273, 293, 310, 324, 333, 354} ++var _paramKeyIndex = [...]uint16{0, 23, 32, 42, 61, 80, 99, 109, 115, 124, 133, 141, 155, 170, 189, 203, 210, 221, 237, 250, 260, 273, 293, 310, 324, 333, 354, 372, 400} + +-const _paramKeyLowerName = "allowremotevolumeaccessautoplaceclientlistdisklessonremainingdisklessstoragepooldonotplacewithregexencryptionfsoptslayerlistmountoptsnodelistplacementcountplacementpolicyreplicasondifferentreplicasonsamesizekibstoragepoolpostmountxfsoptsresourcegroupusepvcnameoverprovisionxreplicasondifferentnfsconfigtemplatenfsservicenamenfssquashnfsrecoveryvolumesize" ++const _paramKeyLowerName = "allowremotevolumeaccessautoplaceclientlistdisklessonremainingdisklessstoragepooldonotplacewithregexencryptionfsoptslayerlistmountoptsnodelistplacementcountplacementpolicyreplicasondifferentreplicasonsamesizekibstoragepoolpostmountxfsoptsresourcegroupusepvcnameoverprovisionxreplicasondifferentnfsconfigtemplatenfsservicenamenfssquashnfsrecoveryvolumesizerelocateafterclonerelocateaftersnapshotrestore" + + func (i paramKey) String() string { + if i < 0 || i >= paramKey(len(_paramKeyIndex)-1) { +@@ -50,9 +50,11 @@ func _paramKeyNoOp() { + _ = x[nfsservicename-(23)] + _ = x[nfssquash-(24)] + _ = x[nfsrecoveryvolumesize-(25)] ++ _ = x[relocateafterclone-(26)] ++ _ = x[relocateaftersnapshotrestore-(27)] + } + +-var _paramKeyValues = []paramKey{allowremotevolumeaccess, autoplace, clientlist, disklessonremaining, disklessstoragepool, donotplacewithregex, encryption, fsopts, layerlist, mountopts, nodelist, placementcount, placementpolicy, replicasondifferent, replicasonsame, sizekib, storagepool, postmountxfsopts, resourcegroup, usepvcname, overprovision, xreplicasondifferent, nfsconfigtemplate, nfsservicename, nfssquash, nfsrecoveryvolumesize} ++var _paramKeyValues = []paramKey{allowremotevolumeaccess, autoplace, clientlist, disklessonremaining, disklessstoragepool, donotplacewithregex, encryption, fsopts, layerlist, mountopts, nodelist, placementcount, placementpolicy, replicasondifferent, replicasonsame, sizekib, storagepool, postmountxfsopts, resourcegroup, usepvcname, overprovision, xreplicasondifferent, nfsconfigtemplate, nfsservicename, nfssquash, nfsrecoveryvolumesize, relocateafterclone, relocateaftersnapshotrestore} + + var _paramKeyNameToValueMap = map[string]paramKey{ + _paramKeyName[0:23]: allowremotevolumeaccess, +@@ -107,6 +109,10 @@ var _paramKeyNameToValueMap = map[string]paramKey{ + _paramKeyLowerName[324:333]: nfssquash, + _paramKeyName[333:354]: nfsrecoveryvolumesize, + _paramKeyLowerName[333:354]: nfsrecoveryvolumesize, ++ _paramKeyName[354:372]: relocateafterclone, ++ _paramKeyLowerName[354:372]: relocateafterclone, ++ _paramKeyName[372:400]: relocateaftersnapshotrestore, ++ _paramKeyLowerName[372:400]: relocateaftersnapshotrestore, + } + + var _paramKeyNames = []string{ +@@ -136,6 +142,8 @@ var _paramKeyNames = []string{ + _paramKeyName[310:324], + _paramKeyName[324:333], + _paramKeyName[333:354], ++ _paramKeyName[354:372], ++ _paramKeyName[372:400], + } + + // paramKeyString retrieves an enum value from the enum constants string name. diff --git a/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff b/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff deleted file mode 100644 index 7da0f1373..000000000 --- a/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff +++ /dev/null @@ -1,718 +0,0 @@ -diff --git a/cmd/linstor-csi/linstor-csi.go b/cmd/linstor-csi/linstor-csi.go -index 143f6cee..bd28e06e 100644 ---- a/cmd/linstor-csi/linstor-csi.go -+++ b/cmd/linstor-csi/linstor-csi.go -@@ -41,22 +41,23 @@ import ( - - func main() { - var ( -- lsEndpoint = flag.String("linstor-endpoint", "", "Controller API endpoint for LINSTOR") -- lsSkipTLSVerification = flag.Bool("linstor-skip-tls-verification", false, "If true, do not verify tls") -- csiEndpoint = flag.String("csi-endpoint", "unix:///var/lib/kubelet/plugins/linstor.csi.linbit.com/csi.sock", "CSI endpoint") -- node = flag.String("node", "", "Node ID to pass to node service") -- logLevel = flag.String("log-level", "info", "Enable debug log output. Choose from: panic, fatal, error, warn, info, debug") -- rps = flag.Float64("linstor-api-requests-per-second", 0, "Maximum allowed number of LINSTOR API requests per second. Default: Unlimited") -- burst = flag.Int("linstor-api-burst", 1, "Maximum number of API requests allowed before being limited by requests-per-second. Default: 1 (no bursting)") -- bearerTokenFile = flag.String("bearer-token", "", "Read the bearer token from the given file and use it for authentication.") -- propNs = flag.String("property-namespace", linstor.NamespcAuxiliary, "Limit the reported topology keys to properties from the given namespace.") -- labelBySP = flag.Bool("label-by-storage-pool", true, "Set to false to disable labeling of nodes based on their configured storage pools.") -- nodeCacheTimeout = flag.Duration("node-cache-timeout", 1*time.Minute, "Duration for which the results of node and storage pool related API responses should be cached.") -- resourceCacheTimeout = flag.Duration("resource-cache-timeout", 30*time.Second, "Duration for which the results of resource related API responses should be cached.") -- resyncAfter = flag.Duration("resync-after", 5*time.Minute, "Duration after which reconciliations (such as for VolumeSnapshotClasses) should be rerun. Set to 0 to disable.") -- enableRWX = flag.Bool("enable-rwx", false, "Enable RWX support via NFS (requires running in Kubernetes).") -- namespace = flag.String("nfs-service-namespace", "", "The namespace the NFS service is running in.") -- reactorConfigMapName = flag.String("nfs-reactor-config-map-name", "linstor-csi-nfs-reactor-config", "Name of the config map used to store promoter configuration") -+ lsEndpoint = flag.String("linstor-endpoint", "", "Controller API endpoint for LINSTOR") -+ lsSkipTLSVerification = flag.Bool("linstor-skip-tls-verification", false, "If true, do not verify tls") -+ csiEndpoint = flag.String("csi-endpoint", "unix:///var/lib/kubelet/plugins/linstor.csi.linbit.com/csi.sock", "CSI endpoint") -+ node = flag.String("node", "", "Node ID to pass to node service") -+ logLevel = flag.String("log-level", "info", "Enable debug log output. Choose from: panic, fatal, error, warn, info, debug") -+ rps = flag.Float64("linstor-api-requests-per-second", 0, "Maximum allowed number of LINSTOR API requests per second. Default: Unlimited") -+ burst = flag.Int("linstor-api-burst", 1, "Maximum number of API requests allowed before being limited by requests-per-second. Default: 1 (no bursting)") -+ bearerTokenFile = flag.String("bearer-token", "", "Read the bearer token from the given file and use it for authentication.") -+ propNs = flag.String("property-namespace", linstor.NamespcAuxiliary, "Limit the reported topology keys to properties from the given namespace.") -+ labelBySP = flag.Bool("label-by-storage-pool", true, "Set to false to disable labeling of nodes based on their configured storage pools.") -+ nodeCacheTimeout = flag.Duration("node-cache-timeout", 1*time.Minute, "Duration for which the results of node and storage pool related API responses should be cached.") -+ resourceCacheTimeout = flag.Duration("resource-cache-timeout", 30*time.Second, "Duration for which the results of resource related API responses should be cached.") -+ resyncAfter = flag.Duration("resync-after", 5*time.Minute, "Duration after which reconciliations (such as for VolumeSnapshotClasses) should be rerun. Set to 0 to disable.") -+ enableRWX = flag.Bool("enable-rwx", false, "Enable RWX support via NFS (requires running in Kubernetes).") -+ namespace = flag.String("nfs-service-namespace", "", "The namespace the NFS service is running in.") -+ reactorConfigMapName = flag.String("nfs-reactor-config-map-name", "linstor-csi-nfs-reactor-config", "Name of the config map used to store promoter configuration") -+ disableRWXBlockValidation = flag.Bool("disable-rwx-block-validation", false, "Disable KubeVirt VM ownership validation for RWX block volumes.") - ) - - flag.Var(&volume.DefaultRemoteAccessPolicy, "default-remote-access-policy", "") -@@ -169,6 +170,10 @@ func main() { - opts = append(opts, driver.ConfigureRWX(*namespace, *reactorConfigMapName)) - } - -+ if *disableRWXBlockValidation { -+ opts = append(opts, driver.DisableRWXBlockValidation()) -+ } -+ - drv, err := driver.NewDriver(opts...) - if err != nil { - log.Fatal(err) -diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go -index bea69a8b..a39674b6 100644 ---- a/pkg/driver/driver.go -+++ b/pkg/driver/driver.go -@@ -83,6 +83,8 @@ type Driver struct { - topologyPrefix string - // resyncAfter is the interval after which reconciliations should be retried - resyncAfter time.Duration -+ // disableRWXBlockValidation disables KubeVirt VM ownership validation for RWX block volumes -+ disableRWXBlockValidation bool - - // Embed for forward compatibility. - csi.UnimplementedIdentityServer -@@ -300,6 +302,17 @@ func ResyncAfter(resyncAfter time.Duration) func(*Driver) error { - } - } - -+// DisableRWXBlockValidation disables the KubeVirt VM ownership validation for RWX block volumes. -+// When disabled, the driver will not check if multiple pods using the same RWX block volume -+// belong to the same VM. This may be needed in environments where the validation causes issues -+// or when using RWX block volumes outside of KubeVirt. -+func DisableRWXBlockValidation() func(*Driver) error { -+ return func(d *Driver) error { -+ d.disableRWXBlockValidation = true -+ return nil -+ } -+} -+ - // GetPluginInfo https://github.com/container-storage-interface/spec/blob/v1.9.0/spec.md#getplugininfo - func (d Driver) GetPluginInfo(ctx context.Context, req *csi.GetPluginInfoRequest) (*csi.GetPluginInfoResponse, error) { - return &csi.GetPluginInfoResponse{ -@@ -751,6 +764,14 @@ func (d Driver) ControllerPublishVolume(ctx context.Context, req *csi.Controller - // ReadWriteMany block volume - rwxBlock := req.VolumeCapability.AccessMode.GetMode() == csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER && req.VolumeCapability.GetBlock() != nil - -+ // Validate RWX block attachment to prevent misuse of allow-two-primaries -+ if rwxBlock && !d.disableRWXBlockValidation { -+ if _, err := utils.ValidateRWXBlockAttachment(ctx, d.kubeClient, d.log, req.GetVolumeId()); err != nil { -+ return nil, status.Errorf(codes.FailedPrecondition, -+ "ControllerPublishVolume failed for %s: %v", req.GetVolumeId(), err) -+ } -+ } -+ - devPath, err := d.Assignments.Attach(ctx, req.GetVolumeId(), req.GetNodeId(), rwxBlock) - if err != nil { - return nil, status.Errorf(codes.Internal, -diff --git a/pkg/utils/rwx_validation.go b/pkg/utils/rwx_validation.go -new file mode 100644 -index 00000000..9fe82768 ---- /dev/null -+++ b/pkg/utils/rwx_validation.go -@@ -0,0 +1,263 @@ -+/* -+CSI Driver for Linstor -+Copyright © 2018 LINBIT USA, LLC -+ -+This program is free software; you can redistribute it and/or modify -+it under the terms of the GNU General Public License as published by -+the Free Software Foundation; either version 2 of the License, or -+(at your option) any later version. -+ -+This program is distributed in the hope that it will be useful, -+but WITHOUT ANY WARRANTY; without even the implied warranty of -+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+GNU General Public License for more details. -+ -+You should have received a copy of the GNU General Public License -+along with this program; if not, see . -+*/ -+ -+package utils -+ -+import ( -+ "context" -+ "fmt" -+ -+ "github.com/sirupsen/logrus" -+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -+ "k8s.io/apimachinery/pkg/runtime/schema" -+ "k8s.io/client-go/dynamic" -+) -+ -+// KubeVirtVMLabel is the label that KubeVirt adds to pods to identify the VM they belong to. -+const KubeVirtVMLabel = "vm.kubevirt.io/name" -+ -+// KubeVirtHotplugDiskLabel is the label that KubeVirt adds to hotplug disk pods. -+const KubeVirtHotplugDiskLabel = "kubevirt.io" -+ -+// PodGVR is the GroupVersionResource for pods. -+var PodGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} -+ -+// PVGVR is the GroupVersionResource for persistent volumes. -+var PVGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumes"} -+ -+// ValidateRWXBlockAttachment checks that RWX block volumes are only used by pods belonging to the same VM. -+// This prevents misuse of allow-two-primaries while still permitting live migration. -+// Returns the VM name if validation passes, or an error if: -+// - Multiple pods from different VMs are trying to use the same volume -+// - A pod without the KubeVirt VM label is trying to use a volume already attached elsewhere (strict mode) -+// Returns empty string for VM name when no pods are using the volume or validation is skipped. -+func ValidateRWXBlockAttachment(ctx context.Context, kubeClient dynamic.Interface, log *logrus.Entry, volumeID string) (string, error) { -+ log.WithField("volumeID", volumeID).Info("validateRWXBlockAttachment called") -+ -+ if kubeClient == nil { -+ // Not running in Kubernetes, skip validation -+ log.Warn("validateRWXBlockAttachment: kubeClient is nil, skipping validation") -+ return "", nil -+ } -+ -+ // Get PV to find PVC reference -+ pv, err := kubeClient.Resource(PVGVR).Get(ctx, volumeID, metav1.GetOptions{}) -+ if err != nil { -+ log.WithError(err).Warn("cannot validate RWX attachment: failed to get PV") -+ return "", nil -+ } -+ -+ // Verify that PV's volumeHandle matches the volumeID -+ volumeHandle, found, err := unstructured.NestedString(pv.Object, "spec", "csi", "volumeHandle") -+ if err != nil { -+ log.WithError(err).Warnf("cannot validate RWX attachment: failed to read volumeHandle for PV %s", volumeID) -+ -+ return "", nil -+ } -+ -+ if !found { -+ log.Warnf("cannot validate RWX attachment: volumeHandle not found for PV %s", volumeID) -+ -+ return "", nil -+ } -+ -+ if volumeHandle != volumeID { -+ log.WithFields(logrus.Fields{ -+ "volumeID": volumeID, -+ "volumeHandle": volumeHandle, -+ }).Warn("cannot validate RWX attachment: PV volumeHandle does not match volumeID") -+ -+ return "", nil -+ } -+ -+ // Extract claimRef from PV -+ claimRef, found, _ := unstructured.NestedMap(pv.Object, "spec", "claimRef") -+ if !found { -+ log.Warn("cannot validate RWX attachment: PV has no claimRef") -+ return "", nil -+ } -+ -+ pvcName, _, _ := unstructured.NestedString(claimRef, "name") -+ pvcNamespace, _, _ := unstructured.NestedString(claimRef, "namespace") -+ -+ if pvcNamespace == "" || pvcName == "" { -+ log.Warn("cannot validate RWX attachment: PVC name or namespace is empty in claimRef") -+ return "", nil -+ } -+ -+ // List all pods in the namespace -+ podList, err := kubeClient.Resource(PodGVR).Namespace(pvcNamespace).List(ctx, metav1.ListOptions{}) -+ if err != nil { -+ return "", fmt.Errorf("failed to list pods in namespace %s: %w", pvcNamespace, err) -+ } -+ -+ // Filter pods that use this PVC and are in a running/pending state -+ type podInfo struct { -+ name string -+ vmName string -+ } -+ -+ var podsUsingPVC []podInfo -+ -+ for _, item := range podList.Items { -+ // Get pod phase from status -+ phase, _, _ := unstructured.NestedString(item.Object, "status", "phase") -+ if phase == "Succeeded" || phase == "Failed" { -+ continue -+ } -+ -+ // Check if pod uses the PVC -+ volumes, found, _ := unstructured.NestedSlice(item.Object, "spec", "volumes") -+ if !found { -+ continue -+ } -+ -+ for _, vol := range volumes { -+ volMap, ok := vol.(map[string]interface{}) -+ if !ok { -+ continue -+ } -+ -+ claimName, found, _ := unstructured.NestedString(volMap, "persistentVolumeClaim", "claimName") -+ if !found || claimName != pvcName { -+ continue -+ } -+ -+ // Extract VM name, handling both regular and hotplug disk pods -+ vmName, err := GetVMNameFromPod(ctx, kubeClient, log, &item) -+ if err != nil { -+ log.WithError(err).WithField("pod", item.GetName()).Warn("failed to get VM name from pod") -+ // Continue with empty vmName - will be caught by strict mode check -+ vmName = "" -+ } -+ -+ podsUsingPVC = append(podsUsingPVC, podInfo{ -+ name: item.GetName(), -+ vmName: vmName, -+ }) -+ -+ break -+ } -+ } -+ -+ // If 0 or 1 pod uses the PVC, no conflict possible -+ if len(podsUsingPVC) <= 1 { -+ // Return VM name if there's exactly one pod -+ if len(podsUsingPVC) == 1 { -+ log.WithFields(logrus.Fields{ -+ "volumeID": volumeID, -+ "vmName": podsUsingPVC[0].vmName, -+ "podCount": 1, -+ "pvcNamespace": pvcNamespace, -+ "pvcName": pvcName, -+ }).Info("validateRWXBlockAttachment: single pod found, returning VM name") -+ -+ return podsUsingPVC[0].vmName, nil -+ } -+ -+ log.WithFields(logrus.Fields{ -+ "volumeID": volumeID, -+ "pvcNamespace": pvcNamespace, -+ "pvcName": pvcName, -+ }).Info("validateRWXBlockAttachment: no pods found using PVC") -+ -+ return "", nil -+ } -+ -+ // Check that all pods belong to the same VM -+ var vmName string -+ for _, pod := range podsUsingPVC { -+ if pod.vmName == "" { -+ // Strict mode: if any pod doesn't have the KubeVirt label and there are multiple pods, -+ // deny the attachment -+ return "", fmt.Errorf("RWX block volume %s/%s is used by multiple pods but pod %s does not have the %s label; "+ -+ "RWX block volumes with allow-two-primaries are only supported for KubeVirt live migration", -+ pvcNamespace, pvcName, pod.name, KubeVirtVMLabel) -+ } -+ -+ if vmName == "" { -+ vmName = pod.vmName -+ } else if vmName != pod.vmName { -+ // Different VMs are trying to use the same volume -+ return "", fmt.Errorf("RWX block volume %s/%s is being used by pods from different VMs (%s and %s); "+ -+ "this is not supported - RWX block volumes with allow-two-primaries are only for live migration of a single VM", -+ pvcNamespace, pvcName, vmName, pod.vmName) -+ } -+ } -+ -+ log.WithFields(logrus.Fields{ -+ "pvcNamespace": pvcNamespace, -+ "pvcName": pvcName, -+ "vmName": vmName, -+ "podCount": len(podsUsingPVC), -+ }).Debug("RWX block attachment validated: all pods belong to the same VM (likely live migration)") -+ -+ return vmName, nil -+} -+ -+// GetVMNameFromPod extracts the VM name from a pod, handling both regular virt-launcher pods -+// and hotplug disk pods (which reference the virt-launcher pod via ownerReferences). -+func GetVMNameFromPod(ctx context.Context, kubeClient dynamic.Interface, log *logrus.Entry, pod *unstructured.Unstructured) (string, error) { -+ labels := pod.GetLabels() -+ if labels == nil { -+ return "", nil -+ } -+ -+ // Direct case: pod has vm.kubevirt.io/name label (virt-launcher pod) -+ if vmName, ok := labels[KubeVirtVMLabel]; ok && vmName != "" { -+ return vmName, nil -+ } -+ -+ // Hotplug disk case: pod has kubevirt.io: hotplug-disk label -+ // Follow ownerReferences to find the virt-launcher pod -+ if hotplugValue, ok := labels[KubeVirtHotplugDiskLabel]; ok && hotplugValue == "hotplug-disk" { -+ ownerRefs := pod.GetOwnerReferences() -+ for _, owner := range ownerRefs { -+ if owner.Kind != "Pod" || owner.Controller == nil || !*owner.Controller { -+ continue -+ } -+ -+ // Get the owner pod (virt-launcher) -+ ownerPod, err := kubeClient.Resource(PodGVR).Namespace(pod.GetNamespace()).Get(ctx, owner.Name, metav1.GetOptions{}) -+ if err != nil { -+ return "", fmt.Errorf("failed to get owner pod %s: %w", owner.Name, err) -+ } -+ -+ // Extract VM name from owner pod -+ ownerLabels := ownerPod.GetLabels() -+ if ownerLabels != nil { -+ if vmName, ok := ownerLabels[KubeVirtVMLabel]; ok && vmName != "" { -+ log.WithFields(logrus.Fields{ -+ "hotplugPod": pod.GetName(), -+ "virtLauncher": owner.Name, -+ "vmName": vmName, -+ }).Debug("resolved VM name from hotplug disk pod via owner reference") -+ -+ return vmName, nil -+ } -+ } -+ -+ return "", fmt.Errorf("owner pod %s does not have %s label", owner.Name, KubeVirtVMLabel) -+ } -+ -+ return "", fmt.Errorf("hotplug disk pod %s has no controller owner reference", pod.GetName()) -+ } -+ -+ return "", nil -+} -diff --git a/pkg/utils/rwx_validation_test.go b/pkg/utils/rwx_validation_test.go -new file mode 100644 -index 00000000..d75690f9 ---- /dev/null -+++ b/pkg/utils/rwx_validation_test.go -@@ -0,0 +1,342 @@ -+/* -+CSI Driver for Linstor -+Copyright © 2018 LINBIT USA, LLC -+ -+This program is free software; you can redistribute it and/or modify -+it under the terms of the GNU General Public License as published by -+the Free Software Foundation; either version 2 of the License, or -+(at your option) any later version. -+ -+This program is distributed in the hope that it will be useful, -+but WITHOUT ANY WARRANTY; without even the implied warranty of -+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+GNU General Public License for more details. -+ -+You should have received a copy of the GNU General Public License -+along with this program; if not, see . -+*/ -+ -+package utils -+ -+import ( -+ "context" -+ "testing" -+ -+ "github.com/sirupsen/logrus" -+ "github.com/stretchr/testify/assert" -+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -+ "k8s.io/apimachinery/pkg/runtime" -+ "k8s.io/apimachinery/pkg/runtime/schema" -+ dynamicfake "k8s.io/client-go/dynamic/fake" -+) -+ -+func TestValidateRWXBlockAttachment(t *testing.T) { -+ testCases := []struct { -+ name string -+ pods []*unstructured.Unstructured -+ pvcName string -+ namespace string -+ expectError bool -+ errorMsg string -+ }{ -+ { -+ name: "no pods using PVC", -+ pods: []*unstructured.Unstructured{}, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "single pod using PVC", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("pod1", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "two pods same VM (live migration)", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("virt-launcher-vm1-abc", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("virt-launcher-vm1-xyz", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "two pods different VMs (should fail)", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("virt-launcher-vm1-abc", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("virt-launcher-vm2-xyz", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm2"}, "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: true, -+ errorMsg: "different VMs", -+ }, -+ { -+ name: "pod without KubeVirt label when multiple pods exist (strict mode)", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("pod1", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("pod2", "default", "test-pvc", map[string]string{}, "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: true, -+ errorMsg: "does not have the vm.kubevirt.io/name label", -+ }, -+ { -+ name: "completed pods should be ignored", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("pod1", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("pod2", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm2"}, "Succeeded"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "failed pods should be ignored", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("pod1", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("pod2", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm2"}, "Failed"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "pods in different namespace should not conflict", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("pod1", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("pod2", "other", "test-pvc", map[string]string{KubeVirtVMLabel: "vm2"}, "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "pods using different PVCs should not conflict", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("pod1", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("pod2", "default", "other-pvc", map[string]string{KubeVirtVMLabel: "vm2"}, "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "three pods from same VM (multi-node live migration scenario)", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("virt-launcher-vm1-a", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("virt-launcher-vm1-b", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createUnstructuredPod("virt-launcher-vm1-c", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Pending"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "hotplug disk pod with virt-launcher (should succeed)", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("virt-launcher-vm1-abc", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createHotplugDiskPod("hp-volume-xyz", "default", "test-pvc", "virt-launcher-vm1-abc", "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: false, -+ }, -+ { -+ name: "hotplug disks from different VMs (should fail)", -+ pods: []*unstructured.Unstructured{ -+ createUnstructuredPod("virt-launcher-vm1-abc", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm1"}, "Running"), -+ createHotplugDiskPod("hp-volume-vm1", "default", "test-pvc", "virt-launcher-vm1-abc", "Running"), -+ createUnstructuredPod("virt-launcher-vm2-xyz", "default", "test-pvc", map[string]string{KubeVirtVMLabel: "vm2"}, "Running"), -+ createHotplugDiskPod("hp-volume-vm2", "default", "test-pvc", "virt-launcher-vm2-xyz", "Running"), -+ }, -+ pvcName: "test-pvc", -+ namespace: "default", -+ expectError: true, -+ errorMsg: "different VMs", -+ }, -+ } -+ -+ for _, tc := range testCases { -+ t.Run(tc.name, func(t *testing.T) { -+ // Create fake dynamic client with test pods and PV -+ scheme := runtime.NewScheme() -+ -+ // Create PV object that references the PVC -+ pv := createUnstructuredPV("test-volume-id", tc.namespace, tc.pvcName) -+ -+ objects := make([]runtime.Object, 0, len(tc.pods)+1) -+ objects = append(objects, pv) -+ -+ for _, pod := range tc.pods { -+ objects = append(objects, pod) -+ } -+ -+ gvrToListKind := map[schema.GroupVersionResource]string{ -+ PodGVR: "PodList", -+ PVGVR: "PersistentVolumeList", -+ } -+ client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind, objects...) -+ -+ // Create logger -+ logger := logrus.NewEntry(logrus.New()) -+ logger.Logger.SetLevel(logrus.DebugLevel) -+ -+ // Run validation -+ vmName, err := ValidateRWXBlockAttachment(context.Background(), client, logger, "test-volume-id") -+ -+ if tc.expectError { -+ assert.Error(t, err) -+ -+ if tc.errorMsg != "" { -+ assert.Contains(t, err.Error(), tc.errorMsg) -+ } -+ } else { -+ assert.NoError(t, err) -+ // VM name is returned when there are pods using the volume -+ if len(tc.pods) > 0 { -+ assert.NotEmpty(t, vmName) -+ } -+ } -+ }) -+ } -+} -+ -+func TestValidateRWXBlockAttachmentNoKubeClient(t *testing.T) { -+ // When not running in Kubernetes (no client), validation should be skipped -+ logger := logrus.NewEntry(logrus.New()) -+ -+ vmName, err := ValidateRWXBlockAttachment(context.Background(), nil, logger, "test-volume-id") -+ assert.NoError(t, err) -+ assert.Empty(t, vmName) -+} -+ -+func TestValidateRWXBlockAttachmentPVNotFound(t *testing.T) { -+ // When PV is not found, validation should be skipped with warning -+ scheme := runtime.NewScheme() -+ -+ gvrToListKind := map[schema.GroupVersionResource]string{ -+ PodGVR: "PodList", -+ PVGVR: "PersistentVolumeList", -+ } -+ client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind) -+ -+ logger := logrus.NewEntry(logrus.New()) -+ logger.Logger.SetLevel(logrus.DebugLevel) -+ -+ vmName, err := ValidateRWXBlockAttachment(context.Background(), client, logger, "non-existent-pv") -+ assert.NoError(t, err) -+ assert.Empty(t, vmName) -+} -+ -+// createUnstructuredPod creates an unstructured pod object for testing. -+func createUnstructuredPod(name, namespace, pvcName string, labels map[string]string, phase string) *unstructured.Unstructured { -+ pod := &unstructured.Unstructured{ -+ Object: map[string]interface{}{ -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": map[string]interface{}{ -+ "name": name, -+ "namespace": namespace, -+ "labels": toStringInterfaceMap(labels), -+ }, -+ "spec": map[string]interface{}{ -+ "volumes": []interface{}{ -+ map[string]interface{}{ -+ "name": "data", -+ "persistentVolumeClaim": map[string]interface{}{ -+ "claimName": pvcName, -+ }, -+ }, -+ }, -+ }, -+ "status": map[string]interface{}{ -+ "phase": phase, -+ }, -+ }, -+ } -+ -+ return pod -+} -+ -+// createUnstructuredPV creates an unstructured PersistentVolume object for testing. -+func createUnstructuredPV(name, pvcNamespace, pvcName string) *unstructured.Unstructured { -+ pv := &unstructured.Unstructured{ -+ Object: map[string]interface{}{ -+ "apiVersion": "v1", -+ "kind": "PersistentVolume", -+ "metadata": map[string]interface{}{ -+ "name": name, -+ }, -+ "spec": map[string]interface{}{ -+ "claimRef": map[string]interface{}{ -+ "name": pvcName, -+ "namespace": pvcNamespace, -+ }, -+ "csi": map[string]interface{}{ -+ "volumeHandle": name, -+ }, -+ }, -+ }, -+ } -+ -+ return pv -+} -+ -+// toStringInterfaceMap converts map[string]string to map[string]interface{}. -+func toStringInterfaceMap(m map[string]string) map[string]interface{} { -+ result := make(map[string]interface{}) -+ -+ for k, v := range m { -+ result[k] = v -+ } -+ -+ return result -+} -+ -+// createHotplugDiskPod creates a hotplug disk pod that references a virt-launcher pod via ownerReferences. -+func createHotplugDiskPod(name, namespace, pvcName, ownerPodName, phase string) *unstructured.Unstructured { -+ pod := &unstructured.Unstructured{ -+ Object: map[string]interface{}{ -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "metadata": map[string]interface{}{ -+ "name": name, -+ "namespace": namespace, -+ "labels": map[string]interface{}{ -+ "kubevirt.io": "hotplug-disk", -+ }, -+ "ownerReferences": []interface{}{ -+ map[string]interface{}{ -+ "apiVersion": "v1", -+ "kind": "Pod", -+ "name": ownerPodName, -+ "controller": true, -+ "blockOwnerDeletion": true, -+ }, -+ }, -+ }, -+ "spec": map[string]interface{}{ -+ "volumes": []interface{}{ -+ map[string]interface{}{ -+ "name": "data", -+ "persistentVolumeClaim": map[string]interface{}{ -+ "claimName": pvcName, -+ }, -+ }, -+ }, -+ }, -+ "status": map[string]interface{}{ -+ "phase": phase, -+ }, -+ }, -+ } -+ -+ return pod -+}