@@ -10,6 +10,7 @@ import (
1010 "strings"
1111 "time"
1212
13+ atlaskube "github.com/simplyblock/atlas/kube"
1314 "github.com/simplyblock/atlas/ptr"
1415 vmigration "github.com/simplyblock/simplyblock-operator/internal/volumemigration"
1516 batchv1 "k8s.io/api/batch/v1"
@@ -37,6 +38,7 @@ import (
3738// +kubebuilder:rbac:groups="",resources=pods/log,verbs=get
3839// +kubebuilder:rbac:groups=storage.simplyblock.io,resources=storageclusters,verbs=get;list;watch
3940// +kubebuilder:rbac:groups=storage.simplyblock.io,resources=storageclusters/status,verbs=get;update;patch
41+ // +kubebuilder:rbac:groups=storage.k8s.io,resources=storageclasses,verbs=get
4042
4143// VolumeMigrationReconciler reconciles VolumeMigration resources.
4244type VolumeMigrationReconciler struct {
@@ -50,6 +52,9 @@ type VolumeMigrationReconciler struct {
5052 // otherwise miss a genuinely-running consumer and cause validation to be
5153 // skipped for a live volume, breaking its I/O path after cutover.
5254 apiReader client.Reader
55+ // scResolver reads the StorageClass that provisioned a PV, to tell whether the
56+ // volume is namespaced (shares its NVMe subsystem with siblings).
57+ scResolver atlaskube.Resolver
5358}
5459
5560// errConsumerNotReady indicates that a pod references the volume's PVC but is not
@@ -161,6 +166,9 @@ func (r *VolumeMigrationReconciler) reconcileStart(
161166 vm .Status .ClusterUUID = clusterUUID
162167 vm .Status .VolumeUUID = volumeUUID
163168 vm .Status .PoolUUID = poolUUID
169+ // Resolved here, where the PV (and through it the StorageClass) is at hand, and
170+ // persisted so the later continue step needs no second lookup.
171+ vm .Status .MultiNamespace = r .isMultiNamespaceMigration (ctx , pv )
164172 // SourceNodeUUID and SnapsTotal are populated from GetMigration once status=Running.
165173 vm .Status .Connections = conns
166174 vm .Status .StartedAt = & now
@@ -174,6 +182,39 @@ func (r *VolumeMigrationReconciler) reconcileStart(
174182 return ctrl.Result {Requeue : true }, nil
175183}
176184
185+ // isMultiNamespaceMigration reports whether the migration may affect more than
186+ // one volume, i.e. whether the volume's NVMe subsystem is shared with sibling
187+ // volumes ("namespaced" volumes). The answer comes from the StorageClass that
188+ // provisioned the PV: max_namespace_per_subsys > 1
189+ // (atlaskube.Properties.IsMultiNamespace) makes every volume of the class
190+ // shared, including one that is currently still the first — and so far only —
191+ // namespace in its subsystem. The volume's live NSID would be the host-side
192+ // counterpart, but it is not part of the control plane's volume or migration
193+ // data and the operator has no host sysfs access to Identify it.
194+ //
195+ // A StorageClass that cannot be resolved is treated as single-namespace and
196+ // logged, the same fail-open stance the rebalancer's namespaced-set collection
197+ // takes: one unreadable class must not stall a migration.
198+ func (r * VolumeMigrationReconciler ) isMultiNamespaceMigration (
199+ ctx context.Context ,
200+ pv * corev1.PersistentVolume ,
201+ ) bool {
202+ log := logf .FromContext (ctx )
203+
204+ props , err := atlaskube .ResolvePropertiesForPV (ctx , r .scResolver , pv )
205+ if err != nil {
206+ log .Info ("Cannot resolve StorageClass for volume; assuming a single-namespace migration" ,
207+ "pv" , pv .Name , "error" , err .Error ())
208+ return false
209+ }
210+ if props .IsMultiNamespace () {
211+ log .Info ("StorageClass provisions namespaced volumes; migration is namespaced" ,
212+ "pv" , pv .Name , "maxNamespacePerSubsys" , props .MaxNamespacePerSubsys )
213+ return true
214+ }
215+ return false
216+ }
217+
177218// reconcileValidating creates a Job on the target worker node that:
178219// 1. Runs `nvme connect` for each connection returned by CreateMigration.
179220// 2. Runs `nvme list --verbose` and verifies all new NQNs appear with ANA
@@ -336,7 +377,7 @@ func (r *VolumeMigrationReconciler) performMigration(
336377 log .Info ("Migration already terminal before continue; advancing to Running for classification" ,
337378 "migration" , vm .Status .MigrationUUID , "status" , m .Status )
338379 case m .Phase == webapi .MigrationPhasePreCreated :
339- if err := r .apiClient .ContinueMigration (ctx , vm .Status .ClusterUUID , vm .Status .PoolUUID , vm .Status .VolumeUUID , vm .Status .MigrationUUID ); err != nil {
380+ if err := r .apiClient .ContinueMigration (ctx , vm .Status .ClusterUUID , vm .Status .PoolUUID , vm .Status .VolumeUUID , vm .Status .MigrationUUID , vm . Status . MultiNamespace ); err != nil {
340381 // The continue may have taken effect despite the error. Only a
341382 // migration still stuck in pre_created is a genuine start failure
342383 // worth cancelling; anything else means it already advanced.
@@ -763,6 +804,7 @@ func (r *VolumeMigrationReconciler) SetupWithManager(
763804 return fmt .Errorf ("create k8s client for log collection: %w" , err )
764805 }
765806 r .coreClient = k8s .CoreV1 ()
807+ r .scResolver = atlaskube .NewLiveResolver (k8s )
766808 // Uncached reader for the consumer-detection decision (see resolveConsumerNodeName).
767809 r .apiReader = mgr .GetAPIReader ()
768810 return ctrl .NewControllerManagedBy (mgr ).
0 commit comments