From 3f51f37e83f25fe21d3b160e7916798079f05fff Mon Sep 17 00:00:00 2001 From: valentin-pf9 Date: Mon, 18 May 2026 08:44:32 +0000 Subject: [PATCH] feat(migration): add option to leave target VM powered off after migration Implements #1867. By default the migrated VM boots powered-on in the target cloud (Nova starts it on creation). Operators who need to run post-migration tasks before first boot -- e.g. assigning security groups via script -- had no way to keep it off. Adds a powerOffTargetVm option that, when set, stops the target VM immediately after it is created and verified, and skips post-migration health checks (which require a running VM). The flag mirrors the existing disconnectSourceNetwork plumbing: - migrationplan_types.go: add PowerOffTargetVM to MigrationStrategy. - migration_types.go: add PowerOffTargetVM to MigrationSpec. - config/crd/bases: regenerated via 'make manifests'. - migrationplan_controller.go: copy the strategy flag onto each Migration spec and write POWER_OFF_TARGET_VM into the v2v-helper configmap. - v2v-helper/pkg/utils/vcenterutils.go: read POWER_OFF_TARGET_VM into MigrationParams. - v2v-helper/main.go: populate Migrate.PowerOffTargetVM and log it. - v2v-helper/migrate/migrate.go: new PowerOffTargetVM field; in CreateTargetInstance, after the VM is ACTIVE, stop it and skip health checks when the flag is set. New OpenStack operation: - openstack.OpenstackOperations gains StopServer, which issues servers.Stop and polls until the server reaches SHUTOFF (reusing the VMActiveWait retry settings). Impl in openstackopsutils.go, mock in openstackops_mock.go. Tests: - TestCreateTargetInstance_PowerOffTargetVM asserts StopServer is called exactly once when PowerOffTargetVM is set. deploy/installer.yaml + deploy/00crds.yaml regenerated by the pre-commit make build-installer hook. Backend only -- a UI checkbox ('Keep Target VM Powered Off') will follow in a separate PR. The capability is usable now via the MigrationPlan CRD. Test plan: - go build ./... (k8s/migration): OK. - go test ./pkg/utils/... (k8s/migration): OK. - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build ./... (v2v-helper): OK. - v2v-helper migrate tests not runnable locally (blocked by the pre-existing CreateVM mock build break on main, addressed by #1945); TestCreateTargetInstance_PowerOffTargetVM runs in CI. --- deploy/00crds.yaml | 25 +++++++-- deploy/installer.yaml | 25 +++++++-- k8s/migration/api/v1alpha1/migration_types.go | 6 +++ .../api/v1alpha1/migrationplan_types.go | 4 ++ .../vjailbreak.k8s.pf9.io_migrationplans.yaml | 6 +++ .../vjailbreak.k8s.pf9.io_migrations.yaml | 6 +++ ...reak.k8s.pf9.io_rollingmigrationplans.yaml | 6 +++ .../controller/migrationplan_controller.go | 2 + v2v-helper/main.go | 3 ++ v2v-helper/migrate/migrate.go | 10 ++++ v2v-helper/migrate/migrate_test.go | 53 +++++++++++++++++++ v2v-helper/openstack/openstackops.go | 1 + v2v-helper/openstack/openstackops_mock.go | 14 +++++ v2v-helper/pkg/utils/openstackopsutils.go | 26 +++++++++ v2v-helper/pkg/utils/vcenterutils.go | 2 + 15 files changed, 183 insertions(+), 6 deletions(-) diff --git a/deploy/00crds.yaml b/deploy/00crds.yaml index 89869faf2..77518e7f0 100644 --- a/deploy/00crds.yaml +++ b/deploy/00crds.yaml @@ -1007,6 +1007,12 @@ spec: performHealthChecks: default: false type: boolean + powerOffTargetVm: + default: false + description: |- + PowerOffTargetVM, when true, leaves the migrated VM powered off in the + target cloud instead of starting it after creation. + type: boolean type: enum: - hot @@ -1202,6 +1208,12 @@ spec: podRef: description: PodRef is the name of the pod type: string + powerOffTargetVm: + description: |- + PowerOffTargetVM specifies whether to leave the migrated VM powered off + in the target cloud instead of starting it. Useful when post-migration + tasks must run before the VM's first boot. Defaults to false. + type: boolean vmName: description: VMName is the name of the VM getting migrated from VMWare to Openstack @@ -1379,8 +1391,10 @@ spec: - openstackRef type: object networkMapping: - description: NetworkMapping is the reference to the NetworkMapping - resource that defines source to destination network mappings + description: |- + NetworkMapping is the reference to the NetworkMapping resource that defines source to destination network mappings. + Optional: when the selected VMs have no attached VMware networks, no NetworkMapping is required and this field + may be empty. The controller will skip network reconciliation in that case. type: string osFamily: description: OSFamily is the OS type of the virtual machine @@ -1430,7 +1444,6 @@ spec: type: string required: - destination - - networkMapping - source type: object type: object @@ -2383,6 +2396,12 @@ spec: performHealthChecks: default: false type: boolean + powerOffTargetVm: + default: false + description: |- + PowerOffTargetVM, when true, leaves the migrated VM powered off in the + target cloud instead of starting it after creation. + type: boolean type: enum: - hot diff --git a/deploy/installer.yaml b/deploy/installer.yaml index 4d9f5e109..712c910b1 100644 --- a/deploy/installer.yaml +++ b/deploy/installer.yaml @@ -1007,6 +1007,12 @@ spec: performHealthChecks: default: false type: boolean + powerOffTargetVm: + default: false + description: |- + PowerOffTargetVM, when true, leaves the migrated VM powered off in the + target cloud instead of starting it after creation. + type: boolean type: enum: - hot @@ -1202,6 +1208,12 @@ spec: podRef: description: PodRef is the name of the pod type: string + powerOffTargetVm: + description: |- + PowerOffTargetVM specifies whether to leave the migrated VM powered off + in the target cloud instead of starting it. Useful when post-migration + tasks must run before the VM's first boot. Defaults to false. + type: boolean vmName: description: VMName is the name of the VM getting migrated from VMWare to Openstack @@ -1379,8 +1391,10 @@ spec: - openstackRef type: object networkMapping: - description: NetworkMapping is the reference to the NetworkMapping - resource that defines source to destination network mappings + description: |- + NetworkMapping is the reference to the NetworkMapping resource that defines source to destination network mappings. + Optional: when the selected VMs have no attached VMware networks, no NetworkMapping is required and this field + may be empty. The controller will skip network reconciliation in that case. type: string osFamily: description: OSFamily is the OS type of the virtual machine @@ -1430,7 +1444,6 @@ spec: type: string required: - destination - - networkMapping - source type: object type: object @@ -2383,6 +2396,12 @@ spec: performHealthChecks: default: false type: boolean + powerOffTargetVm: + default: false + description: |- + PowerOffTargetVM, when true, leaves the migrated VM powered off in the + target cloud instead of starting it after creation. + type: boolean type: enum: - hot diff --git a/k8s/migration/api/v1alpha1/migration_types.go b/k8s/migration/api/v1alpha1/migration_types.go index b7fcfe15b..a3e4b0fed 100644 --- a/k8s/migration/api/v1alpha1/migration_types.go +++ b/k8s/migration/api/v1alpha1/migration_types.go @@ -94,6 +94,12 @@ type MigrationSpec struct { // +optional DisconnectSourceNetwork bool `json:"disconnectSourceNetwork,omitempty"` + // PowerOffTargetVM specifies whether to leave the migrated VM powered off + // in the target cloud instead of starting it. Useful when post-migration + // tasks must run before the VM's first boot. Defaults to false. + // +optional + PowerOffTargetVM bool `json:"powerOffTargetVm,omitempty"` + // NetworkOverrides is the JSON-serialized per-NIC overrides for IP and MAC preservation // +optional NetworkOverrides string `json:"networkOverrides,omitempty"` diff --git a/k8s/migration/api/v1alpha1/migrationplan_types.go b/k8s/migration/api/v1alpha1/migrationplan_types.go index 3e2fe8abe..b5a3932f2 100644 --- a/k8s/migration/api/v1alpha1/migrationplan_types.go +++ b/k8s/migration/api/v1alpha1/migrationplan_types.go @@ -45,6 +45,10 @@ type MigrationPlanStrategy struct { DisconnectSourceNetwork bool `json:"disconnectSourceNetwork,omitempty"` // +kubebuilder:default:=false ArrayOffload bool `json:"arrayOffload,omitempty"` + // PowerOffTargetVM, when true, leaves the migrated VM powered off in the + // target cloud instead of starting it after creation. + // +kubebuilder:default:=false + PowerOffTargetVM bool `json:"powerOffTargetVm,omitempty"` } // AdvancedOptions defines advanced configuration options for the migration process diff --git a/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrationplans.yaml b/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrationplans.yaml index a7d6ad528..3d3743308 100644 --- a/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrationplans.yaml +++ b/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrationplans.yaml @@ -130,6 +130,12 @@ spec: performHealthChecks: default: false type: boolean + powerOffTargetVm: + default: false + description: |- + PowerOffTargetVM, when true, leaves the migrated VM powered off in the + target cloud instead of starting it after creation. + type: boolean type: enum: - hot diff --git a/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrations.yaml b/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrations.yaml index 569f33bf0..cb52243ce 100644 --- a/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrations.yaml +++ b/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_migrations.yaml @@ -91,6 +91,12 @@ spec: podRef: description: PodRef is the name of the pod type: string + powerOffTargetVm: + description: |- + PowerOffTargetVM specifies whether to leave the migrated VM powered off + in the target cloud instead of starting it. Useful when post-migration + tasks must run before the VM's first boot. Defaults to false. + type: boolean vmName: description: VMName is the name of the VM getting migrated from VMWare to Openstack diff --git a/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_rollingmigrationplans.yaml b/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_rollingmigrationplans.yaml index a47278503..05265fa4a 100644 --- a/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_rollingmigrationplans.yaml +++ b/k8s/migration/config/crd/bases/vjailbreak.k8s.pf9.io_rollingmigrationplans.yaml @@ -211,6 +211,12 @@ spec: performHealthChecks: default: false type: boolean + powerOffTargetVm: + default: false + description: |- + PowerOffTargetVM, when true, leaves the migrated VM powered off in the + target cloud instead of starting it after creation. + type: boolean type: enum: - hot diff --git a/k8s/migration/internal/controller/migrationplan_controller.go b/k8s/migration/internal/controller/migrationplan_controller.go index b7e38993b..5eec259a4 100644 --- a/k8s/migration/internal/controller/migrationplan_controller.go +++ b/k8s/migration/internal/controller/migrationplan_controller.go @@ -969,6 +969,7 @@ func (r *MigrationPlanReconciler) CreateMigration(ctx context.Context, // PodRef will be set in the migration controller InitiateCutover: migrationplan.Spec.MigrationStrategy.AdminInitiatedCutOver, DisconnectSourceNetwork: migrationplan.Spec.MigrationStrategy.DisconnectSourceNetwork, + PowerOffTargetVM: migrationplan.Spec.MigrationStrategy.PowerOffTargetVM, NetworkOverrides: networkOverrides, MigrationType: migrationplan.Spec.MigrationStrategy.Type, }, @@ -1519,6 +1520,7 @@ func (r *MigrationPlanReconciler) buildBaseConfigMapData( "REMOVE_VMWARE_TOOLS": strconv.FormatBool(migrationplan.Spec.AdvancedOptions.RemoveVMwareTools), "ACKNOWLEDGE_NETWORK_CONFLICT_RISK": strconv.FormatBool(migrationplan.Spec.AdvancedOptions.AcknowledgeNetworkConflictRisk), "DISCONNECT_SOURCE_NETWORK": strconv.FormatBool(migrationobj.Spec.DisconnectSourceNetwork), + "POWER_OFF_TARGET_VM": strconv.FormatBool(migrationobj.Spec.PowerOffTargetVM), } } diff --git a/v2v-helper/main.go b/v2v-helper/main.go index 54c0e6bcf..c993c2726 100644 --- a/v2v-helper/main.go +++ b/v2v-helper/main.go @@ -140,6 +140,7 @@ func main() { Thumbprint: thumbprint, Convert: migrationparams.OpenstackConvert, DisconnectSourceNetwork: migrationparams.DisconnectSourceNetwork, + PowerOffTargetVM: migrationparams.PowerOffTargetVM, Openstackclients: openstackclients, Vcclient: vcclient, VMops: vmops, @@ -221,6 +222,7 @@ TYPE=%s TARGET_FLAVOR_ID=%s TARGET_AVAILABILITY_ZONE=%s DISCONNECT_SOURCE_NETWORK=%s +POWER_OFF_TARGET_VM=%v SECURITY_GROUPS=%s SERVER_GROUP=%s RDM_DISKS=%s @@ -239,6 +241,7 @@ ACKNOWLEDGE_NETWORK_CONFLICT_RISK=%s`, migrationparams.TARGET_FLAVOR_ID, migrationparams.TargetAvailabilityZone, migrationparams.DisconnectSourceNetwork, + migrationparams.PowerOffTargetVM, migrationparams.SecurityGroups, migrationparams.ServerGroup, migrationparams.RDMDisks, diff --git a/v2v-helper/migrate/migrate.go b/v2v-helper/migrate/migrate.go index 2bdf6d269..344ffa8ef 100644 --- a/v2v-helper/migrate/migrate.go +++ b/v2v-helper/migrate/migrate.go @@ -50,6 +50,7 @@ type Migrate struct { Thumbprint string Convert bool DisconnectSourceNetwork bool + PowerOffTargetVM bool Openstackclients openstack.OpenstackOperations Vcclient vcenter.VCenterOperations VMops vm.VMOperations @@ -1637,6 +1638,15 @@ func (migobj *Migrate) CreateTargetInstance(ctx context.Context, vminfo vm.VMInf migobj.logMessage(fmt.Sprintf("VM created successfully: ID: %s", newVM.ID)) + if migobj.PowerOffTargetVM { + migobj.logMessage("PowerOffTargetVM is set: powering off the target VM and skipping health checks") + if err := openstackops.StopServer(ctx, newVM.ID, *vjailbreakSettings); err != nil { + return errors.Wrap(err, "failed to power off target VM") + } + migobj.logMessage("Target VM powered off successfully") + return nil + } + if migobj.PerformHealthChecks { err = migobj.HealthCheck(vminfo, ipaddresses) if err != nil { diff --git a/v2v-helper/migrate/migrate_test.go b/v2v-helper/migrate/migrate_test.go index 186728c04..bc08ccff6 100644 --- a/v2v-helper/migrate/migrate_test.go +++ b/v2v-helper/migrate/migrate_test.go @@ -553,6 +553,59 @@ func TestCreateTargetInstance(t *testing.T) { assert.NoError(t, err) } +func TestCreateTargetInstance_PowerOffTargetVM(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := context.Background() + + mockOpenStackOps := openstack.NewMockOpenstackOperations(ctrl) + mockOpenStackOps.EXPECT().GetClosestFlavour(gomock.Any(), gomock.Any(), gomock.Any()).Return(&flavors.Flavor{ + VCPUs: 2, + RAM: 2048, + }, nil).AnyTimes() + mockOpenStackOps.EXPECT().GetNetwork(gomock.Any(), gomock.Any()).Return(&networks.Network{}, nil).AnyTimes() + mockOpenStackOps.EXPECT().CreatePort(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&ports.Port{ + MACAddress: "mac-address", + }, nil).AnyTimes() + mockOpenStackOps.EXPECT().CreateVM(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&servers.Server{}, nil).AnyTimes() + mockOpenStackOps.EXPECT().WaitUntilVMActive(gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() + mockOpenStackOps.EXPECT().GetFlavor(gomock.Any(), "flavor-id").Return(&flavors.Flavor{ + VCPUs: 2, + RAM: 2048, + }, nil).AnyTimes() + mockOpenStackOps.EXPECT().GetSecurityGroupIDs(gomock.Any(), gomock.Any(), gomock.Any()).Return([]string{}, nil).AnyTimes() + // With PowerOffTargetVM set, CreateTargetInstance must call StopServer exactly once. + mockOpenStackOps.EXPECT().StopServer(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1) + + inputvminfo := vm.VMInfo{ + Name: "test-vm", + OSType: "linux", + Mac: []string{ + "mac-address-1", + "mac-address-2", + }, + } + + fakeCtrlClient := ctrlfake.NewClientBuilder().WithObjects(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VjailbreakSettingsConfigMapName, + Namespace: constants.NamespaceMigrationSystem, + }, + Data: map[string]string{}, + }).Build() + + migobj := Migrate{ + Openstackclients: mockOpenStackOps, + Networknames: []string{"network-name-1", "network-name-2"}, + InPod: false, + TargetFlavorId: "flavor-id", + K8sClient: fakeCtrlClient, + PowerOffTargetVM: true, + } + err := migobj.CreateTargetInstance(ctx, inputvminfo, []string{"network-id-1", "network-id-2"}, []string{"port-id-1", "port-id-2"}, []string{"ip-address-1", "ip-address-2"}, -1) + assert.NoError(t, err) +} + func TestCreateTargetInstance_AdvancedMapping_Ports(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/v2v-helper/openstack/openstackops.go b/v2v-helper/openstack/openstackops.go index 7aef7a621..c6367164c 100644 --- a/v2v-helper/openstack/openstackops.go +++ b/v2v-helper/openstack/openstackops.go @@ -54,6 +54,7 @@ type OpenstackOperations interface { FindDevice(volumeID string) (string, error) ManageExistingVolume(name string, ref map[string]interface{}, host string, volumeType string) (*volumes.Volume, error) WaitUntilVMActive(ctx context.Context, vmID string) (bool, error) + StopServer(ctx context.Context, serverID string, vjailbreakSettings k8sutils.VjailbreakSettings) error GetIsSimpleNetwork(ctx context.Context, networkID string) (bool, error) // GetCinderVolumeServices returns Cinder volume services (Host, Status, State) // Returns a slice of structs with these fields - defined in implementation package to avoid import cycles diff --git a/v2v-helper/openstack/openstackops_mock.go b/v2v-helper/openstack/openstackops_mock.go index f1c6f54bc..9dcfa710a 100644 --- a/v2v-helper/openstack/openstackops_mock.go +++ b/v2v-helper/openstack/openstackops_mock.go @@ -436,3 +436,17 @@ func (mr *MockOpenstackOperationsMockRecorder) WaitUntilVMActive(ctx, vmID inter mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WaitUntilVMActive", reflect.TypeOf((*MockOpenstackOperations)(nil).WaitUntilVMActive), ctx, vmID) } + +// StopServer mocks base method. +func (m *MockOpenstackOperations) StopServer(ctx context.Context, serverID string, vjailbreakSettings k8sutils.VjailbreakSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopServer", ctx, serverID, vjailbreakSettings) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopServer indicates an expected call of StopServer. +func (mr *MockOpenstackOperationsMockRecorder) StopServer(ctx, serverID, vjailbreakSettings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopServer", reflect.TypeOf((*MockOpenstackOperations)(nil).StopServer), ctx, serverID, vjailbreakSettings) +} diff --git a/v2v-helper/pkg/utils/openstackopsutils.go b/v2v-helper/pkg/utils/openstackopsutils.go index 1ae0d8d05..7c1afaa89 100644 --- a/v2v-helper/pkg/utils/openstackopsutils.go +++ b/v2v-helper/pkg/utils/openstackopsutils.go @@ -974,6 +974,32 @@ func (osclient *OpenStackClients) WaitUntilVMActive(ctx context.Context, vmID st return true, nil } +// StopServer issues a power-off (stop) request for an OpenStack server and +// waits until it reaches the SHUTOFF state. Used when the migration is +// configured to leave the target VM powered off (see MigrationStrategy +// .powerOffTargetVm). +func (osclient *OpenStackClients) StopServer(ctx context.Context, serverID string, vjailbreakSettings k8sutils.VjailbreakSettings) error { + PrintLog(fmt.Sprintf("OPENSTACK API: Stopping server %s", serverID)) + if err := servers.Stop(ctx, osclient.ComputeClient, serverID).ExtractErr(); err != nil { + return fmt.Errorf("failed to issue stop for server %s: %s", serverID, err) + } + for i := 0; i < vjailbreakSettings.VMActiveWaitRetryLimit; i++ { + result, err := servers.Get(ctx, osclient.ComputeClient, serverID).Extract() + if err != nil { + return fmt.Errorf("failed to get server status while waiting for shutoff: %s", err) + } + if result.Status == "SHUTOFF" { + PrintLog(fmt.Sprintf("Server %s is now powered off", serverID)) + return nil + } + if result.Status == "ERROR" { + return fmt.Errorf("server %s went into ERROR state while stopping", serverID) + } + time.Sleep(time.Duration(vjailbreakSettings.VMActiveWaitIntervalSeconds) * time.Second) + } + return fmt.Errorf("server %s did not reach SHUTOFF after %d retries", serverID, vjailbreakSettings.VMActiveWaitRetryLimit) +} + // ManageExistingVolume manages an existing volume on the storage backend into Cinder // Uses the manageable_volumes endpoint which is the standard Cinder manage API func (osclient *OpenStackClients) ManageExistingVolume(name string, ref map[string]interface{}, host string, volumeType string) (*volumes.Volume, error) { diff --git a/v2v-helper/pkg/utils/vcenterutils.go b/v2v-helper/pkg/utils/vcenterutils.go index 585fb6a0a..c67ff3749 100644 --- a/v2v-helper/pkg/utils/vcenterutils.go +++ b/v2v-helper/pkg/utils/vcenterutils.go @@ -37,6 +37,7 @@ type MigrationParams struct { TargetAvailabilityZone string VMwareMachineName string DisconnectSourceNetwork bool + PowerOffTargetVM bool SecurityGroups string ServerGroup string RDMDisks string @@ -101,6 +102,7 @@ func GetMigrationParams(ctx context.Context, client client.Client) (*MigrationPa TargetAvailabilityZone: string(configMap.Data["TARGET_AVAILABILITY_ZONE"]), VMwareMachineName: string(configMap.Data["VMWARE_MACHINE_OBJECT_NAME"]), DisconnectSourceNetwork: string(configMap.Data["DISCONNECT_SOURCE_NETWORK"]) == constants.TrueString, + PowerOffTargetVM: string(configMap.Data["POWER_OFF_TARGET_VM"]) == constants.TrueString, SecurityGroups: string(configMap.Data["SECURITY_GROUPS"]), ServerGroup: string(configMap.Data["SERVER_GROUP"]), RDMDisks: string(configMap.Data["RDM_DISK_NAMES"]),