Architecture And Workflow
mtv-api-tests is built around complete migration lifecycles, not isolated unit tests. A typical test run resolves source and
destination providers, waits for Forklift inventory to discover what it should migrate, creates StorageMap and NetworkMap
objects, creates an MTV Plan, runs a Migration, validates the migrated VM or VMs, and then removes both cluster-side and
provider-side leftovers.
Warning: These are live integration tests. The repository's built-in automation only does dry-run checks such as collection and setup planning. A real migration run needs a reachable OpenShift cluster with MTV installed, a valid source provider, credentials, storage, and networking.
Tip: If you want a concrete reference while reading this page, start with
tests/cold/test_mtv_cold_migration.py,tests/cold/test_cold_migration_comprehensive.py, andtests/warm/test_warm_migration_comprehensive.py. Together they show the normal workflow and most optional features.
Runtime Inputs
Two configuration layers drive everything:
-
tests/tests_config/config.pyis loaded automatically bypytest.iniand holds cluster-wide defaults (insecure_verify_skip,source_provider_insecure_skip_verify,number_of_vms,check_vms_signals,target_namespace_prefix,mtv_namespace,vm_name_search_pattern,remote_ocp_cluster,snapshots_interval,mins_before_cutover,plan_wait_timeout) plus per-test plan dictionaries undertests_params. The two valuespytest_sessionstartrequires,storage_classandsource_provider, are not in the file; pass them with--tc storage_class:<name> --tc source_provider:<key>. Not every key in the file is read by the suite today:number_of_vms,check_vms_signals, andvm_name_search_patterncurrently have no reader inconftest.pyorutilities/. -
.providers.jsonselects the actual source provider profile. The repository includes.providers.json.exampleas the field reference, validated againstproviders_schema.json, for source typesvsphere,ovirt,openstack,openshift,ova, andhyperv, plus the vSphere-specificcopyoffload,endpoint_type, andclone_providersettings.
A representative plan config looks like this:
"test_cold_migration_comprehensive": {
"virtual_machines": [
{
"name": "mtv-win2019-3disks",
"source_vm_power": "on", # VM must be on for guest tools to report static IP info
"guest_agent": True,
},
],
"warm_migration": False,
"target_power_state": "on",
"preserve_static_ips": True,
"enable_nested_virtualization": False,
"pvc_name_template": '{{ .VmName | trunc 32 | trimSuffix "-" }}-{{ .VmName | trunc -4 }}-disk-{{.DiskIndex}}',
"pvc_name_template_use_generate_name": False,
"target_node_selector": {
"mtv-comprehensive-node": None, # None = auto-generate with session_uuid
},
"target_labels": {
"mtv-comprehensive-label": None, # None = auto-generate with session_uuid
"test-type": "comprehensive", # Static value
},
"target_affinity": {
"podAffinity": {
"preferredDuringSchedulingIgnoredDuringExecution": [
{
"podAffinityTerm": {
"labelSelector": {"matchLabels": {"app": "test"}},
"topologyKey": "kubernetes.io/hostname",
},
"weight": 50,
}
]
}
},
"vm_target_namespace": f"mtv-vms-cold-comprehensive-{uuid.uuid4().hex[:4]}",
"multus_namespace": "default", # Cross-namespace NAD access
"guest_agent_timeout": 600,
},
That single config entry already tells you a lot about the architecture. The same workflow can change behavior through plan fields
such as warm_migration, preserve_static_ips, pvc_name_template, target_labels, target_affinity, target_node_selector,
vm_target_namespace, enable_nested_virtualization, and guest_agent_timeout. AGENTS.md carries the complete plan-option
table, which also includes copyoffload, xfs_compatibility, migrate_shared_disks, inventory_timeout, clone_to_same_host,
pin_to_non_dedicated_host, disable_drs_for_vms, per_nic_network_map, skip_clone, and rdm_as_lun.
The Standard Test Shape
Most migration tests follow the same five-step pattern: create storage map, create network map, create plan, execute migration,
validate VMs. Condensed from tests/cold/test_mtv_cold_migration.py:19-147:
@pytest.mark.tier0
@pytest.mark.incremental
@pytest.mark.parametrize(
"class_plan_config",
[pytest.param(py_config["tests_params"]["test_sanity_cold_mtv_migration"])],
indirect=True,
ids=["rhel8"],
)
@pytest.mark.usefixtures("cleanup_migrated_vms")
class TestSanityColdMtvMigration:
storage_map: StorageMap
network_map: NetworkMap
plan_resource: Plan
def test_create_storagemap(self, prepared_plan: dict[str, Any], fixture_store: dict[str, Any], ...) -> None:
"""Create StorageMap resource for migration."""
self.__class__.storage_map = get_storage_migration_map(...)
assert self.storage_map, "StorageMap creation failed"
def test_create_networkmap(self, prepared_plan: dict[str, Any], multus_network_name: str, ...) -> None:
"""Create NetworkMap resource for migration."""
self.__class__.network_map = get_network_migration_map(...)
assert self.network_map, "NetworkMap creation failed"
def test_create_plan(
self,
prepared_plan: dict[str, Any],
source_provider_inventory: ForkliftInventory,
...,
) -> None:
"""Create MTV Plan CR resource."""
populate_vm_ids(prepared_plan, source_provider_inventory)
self.__class__.plan_resource = create_plan_resource(...)
assert self.plan_resource, "Plan creation failed"
def test_migrate_vms(self, ocp_admin_client: DynamicClient, target_namespace: str, ...) -> None:
"""Execute migration."""
execute_migration(...)
def test_check_vms(self, prepared_plan: dict[str, Any], vm_ssh_connections: SSHConnectionManager) -> None:
"""Validate migrated VMs."""
check_vms(...)
Every parameter above is a pytest fixture, so the annotations document what each step consumes. The suite's own test methods are currently written without parameter annotations - that is existing practice, not the rule; new framework code and helpers are annotated, and annotating test methods costs nothing.
Note the shape of the two middle steps. test_create_plan calls populate_vm_ids(prepared_plan, source_provider_inventory)
before create_plan_resource(), and test_migrate_vms requests only the fixtures it needs, because the resource it waits on is
already stored on the class.
This class-based layout is intentional. Earlier methods create resources that later methods depend on, and
@pytest.mark.incremental prevents the test from pretending the later stages are meaningful after an earlier failure:
pytest_runtest_makereport records the failure and pytest_runtest_setup turns it into an xfail for the rest of the class.
Extended Step Patterns
Feature suites extend or replace the base steps. AGENTS.md under "Key Patterns" is the authoritative list and names every test
method; the variants actually present in tests/ are:
-
4-step plan-readiness: a leading
test_verify_<feature>step replaces migrate/check, because the feature under test is exercised during provider or Plan creation and Plan readiness proves it works. No migration is executed. Used bytests/cold/test_ca_crt_migration.pyandtests/cold/test_insecure_skip_verify_migration.py. -
6-step plan-archive PVC cleanup:
test_migrate_vmsexpectsMigrationPlanExecError, thentest_archive_and_delete_planandtest_verify_pvc_cleanup. There is notest_check_vms. Lives intests/plan_lifecycle/. -
6-step LUKS:
test_verify_luks_encryptionbeforetest_check_vms. Lives intests/luks/. -
6-step XFS:
test_verify_xfs_versionbeforetest_check_vms. Lives intests/cold/test_cold_migration_xfs.pyandtests/warm/test_warm_migration_xfs.py. -
6-step shared disk (Linux):
test_verify_shared_disk_databeforetest_check_vms. - 7-step shared disk (Windows):
test_label_shared_diskfirst, thentest_verify_shared_disk_databeforetest_check_vms. -
6-step copy-offload:
test_check_xcopy_usedbeforetest_check_vms. Snapshot subclasses addtest_create_snapshots_and_data_markerfirst andtest_check_data_integritylast. -
7/8-step copy-offload throttling:
test_verify_populator_throttlingandtest_verify_vm_inflight_throttlingaftertest_migrate_vms. -
7-step copy-offload dedicated host:
test_verify_dedicated_migration_hostaftertest_migrate_vms. -
Deep Inspection, standalone:
test_create_conversion,test_wait_for_completion,test_verify_di_results. The cancel path addstest_cancel_conversion,test_verify_cancel_cleanup,test_verify_snapshot_cleanup, and a rerun. -
Deep Inspection, plan-driven warm:
test_migrate_vms_failsthentest_verify_di_concerns. - Upgrade:
test_upgrade_mtv,test_verify_post_upgrade, then migrate and check. Maps and Plan come fromtests/upgrade/conftest.pyfixtures rather than test methods.
1. Session Bootstrap And Provider Setup
Before any migration-specific method runs, session fixtures and hooks do the shared setup work:
-
pytest_sessionstart()validates required settings.storage_classandsource_providermust be present, otherwise the session exits. -
autouse_fixtures(session, autouse) pulls insource_provider_data,nfs_storage_profile,base_resource_name,forklift_pods_state, andvirtctl_binaryfor every run. -
target_namespacecreates a unique OpenShift namespace for the run. nfs_storage_profilepatches the NFSStorageProfileaccess modes and volume mode when the configured storage class is NFS.-
forklift_pods_stateverifies the MTV operatorSubscriptionexists and that everyforklift-*pod in the MTV namespace is running or succeeded. -
virtctl_binarydownloads and cachesvirtctlin a cluster-version-scoped shared directory under a file lock. source_provider_dataresolves the selected source provider from.providers.jsonand fails fast if the key is missing.
The source provider setup is centered around utilities/utils.py:create_source_provider(). It picks the provider implementation
from the type field, creates the source Secret, creates the source Provider custom resource, waits until that Provider is
ready in Forklift, and only then opens the matching provider SDK wrapper.
secret_string_data = {
"url": source_provider_data_copy["api_url"],
"insecureSkipVerify": "true" if insecure else "false",
}
provider_args = {
"username": source_provider_data_copy["username"],
"password": source_provider_data_copy["password"],
"fixture_store": fixture_store,
}
metadata_labels = {
"createdForProviderType": source_provider_data_copy["type"],
}
ocp_resource_provider = create_and_store_resource(
fixture_store=fixture_store,
resource=Provider,
client=admin_client,
namespace=namespace,
secret_name=source_provider_secret.name,
secret_namespace=namespace,
url=source_provider_data_copy["api_url"],
provider_type=source_provider_data_copy["type"],
vddk_init_image=source_provider_data_copy.get("vddk_init_image"),
sdk_endpoint=source_provider_data_copy.get("endpoint_type"),
annotations=provider_annotations or None,
)
# OVA (NFS-based) can transiently report ConnectionFailed while NFS initializes;
# allow retry until timeout instead of failing immediately.
stop_status = None if ova_provider(provider_data=source_provider_data_copy) else "ConnectionFailed"
ocp_resource_provider.wait_for_status(Provider.Status.READY, timeout=600, stop_status=stop_status)
# this is for communication with the provider
with source_provider(ocp_resource=ocp_resource_provider, **provider_args) as _source_provider:
if not _source_provider.test:
# The wrappers connect to provider_args["host"] when they have one (Hyper-V and vSphere use the
# fqdn, the rest use the api_url); OpenShift sets no host and connects to the cluster.
unavailable_endpoint = provider_args.get("host") or configured_api_url
raise ConnectionError(f"{_source_provider.type} provider {unavailable_endpoint} is not available.")
yield _source_provider
A few important details come from this design:
-
The tests always create real Forklift
Providerresources first. The provider SDK wrappers are helpers, not the system of record. -
The
sdk_endpointparameter is populated fromsource_provider_data_copy.get("endpoint_type")in the provider config. The name differs becauseendpoint_typeis the user-facing config key ("vcenter"or"esxi"), whilesdk_endpointis the field name in the MTVProviderresource's settings (mapped tosdkEndpoint). This field is vSphere-only. Whenendpoint_typeis missing or null,sdk_endpointis passed asNoneand omitted from the Provider resource, and Forklift's mutating webhook then defaults it to"vcenter". -
vSphere, RHV, OpenStack, OpenShift, Hyper-V, and OVA all share the same outer workflow, even though their provider-specific secrets and connection logic differ. RHV always fetches a CA certificate even when
insecureSkipVerifyis set, because imageio needs it. -
CA certificate placement is a parameter, not a second code path:
create_source_provider(ca_cert_key=...)selects the secret data key, which is howtests/cold/test_ca_crt_migration.pyexercises theca.crtfield. -
Copy-offload vSphere providers get the
forklift.konveyor.io/empty-vddk-init-image: "yes"annotation and pass thecopyoffloadblock into the provider wrapper. -
The
ConnectionErrorraised when_source_provider.testfails reports what the check actually established, which differs by provider. For the providers that set ahostargument - Hyper-V and vSphere connect to the provider'sfqdn, RHV and OpenStack to itsapi_url- the message names the endpoint the wrapper dialed. OVA never reaches this failure path:OVAProvider.connect()does not connect, itstestalways succeeds, and OVA setup waits for the ForkliftProviderresource to report ready before the wrapper opens. OpenShift sets nohost, andOCPProvider.testonly checks that the createdProviderresource exists, so its message names the configured source-provider URL from the providers configuration purely as diagnostic context: not an endpoint the wrapper contacted, and correcting it cannot make this check pass - troubleshoot theProviderresource instead. The URL reported is the original one, not the destination cluster host that the OpenShift branch rewritessource_provider_data_copy["api_url"]to for the secret and theProviderresource. -
Remote OpenShift destination tests reuse the same basic pattern, but switch from
destination_providertodestination_ocp_provider.
2. Forklift Inventory Discovery And Plan Preparation
Once the source Provider exists, source_provider_inventory resolves the matching ForkliftInventory subclass. This is the
layer that queries the forklift-inventory route and turns Forklift's discovered objects into storage and network mappings.
The class-scoped prepared_plan fixture is where the user-facing plan config becomes an actual migration input. It deep-copies
the config, validates and clones source VMs, resolves the PVC name template against the provider type, creates custom namespaces
if requested, adjusts source power state, stores source-side metadata, and creates hooks.
# Deep copy the plan config to avoid mutation
plan: dict[str, Any] = deepcopy(class_plan_config)
if "pvc_name_template" in plan:
plan["pvc_name_template"] = resolve_pvc_name_template(
pvc_name_template=plan["pvc_name_template"],
source_provider_type=source_provider.type,
)
virtual_machines: list[dict[str, Any]] = plan["virtual_machines"]
warm_migration = plan.get("warm_migration", False)
# Initialize separate storage for source VM data (keeps virtual_machines clean for Plan CR serialization)
plan["source_vms_data"] = {}
Cloning runs in two phases. First every VM is cloned, powered to source_vm_power, described through vm_dict(), and recorded
in plan["source_vms_data"]. Then a single blocking wait covers all of them. inventory_timeout is one of the optional plan flags — it defaults to 300 seconds
when a config omits it. Other optional flags in the same fixture, such as warm_migration, are read the same way; only keys a plan must supply are read with direct indexing:
# Phase 2: wait for all cloned VMs in Forklift inventory after every clone completes.
# Sequential per-VM wait during cloning causes inventory sync failures on VM2+.
inventory_timeout = plan.get("inventory_timeout", 300)
wait_for_cloned_vms_in_forklift_inventory(
source_provider=source_provider,
source_provider_inventory=source_provider_inventory,
cloned_vm_names=cloned_vm_names,
inventory_timeout=inventory_timeout,
)
# Create Hooks if configured
create_hook_if_configured(plan, "pre_hook", "pre", fixture_store, ocp_admin_client, target_namespace)
create_hook_if_configured(plan, "post_hook", "post", fixture_store, ocp_admin_client, target_namespace)
This is one of the most important parts of the whole project. The test does not rush from "provider-side clone exists" to "create the Plan." It waits until Forklift inventory has caught up.
Several plan flags shape what prepared_plan does before any MTV resource exists:
-
skip_clone: Trueuses existing VMs instead of clones, for plan-readiness scenarios. It is rejected on providers that do not support it, and it is incompatible withdisable_drs_for_vms,clone_to_same_host,preserve_static_ipsoutside Hyper-V,migrate_shared_disks, andadd_nic. Original power states are captured and restored on teardown because those VMs are shared, real VMs. -
migrate_shared_disks(plan level or per VM) triggersrelink_shared_disks()after cloning, because independent clones otherwise lose the shared-disk relationship. -
add_nicadds a NIC after the clone is synced, which then requireswait_for_added_nics_in_forklift_inventory()to force a refresh and block until the new NIC appears. -
clone_to_same_host,pin_to_non_dedicated_host,disable_drs_for_vms, and the per-VMtarget_esxi_hostcontrol ESXi placement and DRS behavior on vSphere clones. -
clone_providerin.providers.jsonroutes cloning through a vCenter-backedvcenter_clone_providerwhen the source is a bare ESXi host that cannot runCloneVM_Task. -
OpenShift sources skip cloning entirely:
create_source_cnv_vms()creates the source VMs and a dedicated Multus network insource_vms_namespace.
Note: RHV/oVirt is the main exception to the normal "inventory first" story for networks. Those tests clone from templates, so network lookups go through
source_provider.get_vm_or_template_networks()rather than Forklift inventory. OVA is the other exception: OVA VMs are not cloned and only get a uniquetargetName.Note: OpenStack inventory waits are stricter than a simple VM name lookup. The inventory adapter also waits until attached volumes and networks are queryable, because StorageMap and NetworkMap generation depend on that metadata.
3. StorageMap And NetworkMap
After prepared_plan is ready, the suite creates the two map resources that make the rest of the workflow possible.
For StorageMap, the standard path is:
- Ask Forklift inventory which source storages the selected VM or VMs use.
- Map each of those source storages to the configured destination
storage_class.
For NetworkMap, the rule is deterministic and simple:
- The first source network maps to the destination pod network.
- Every additional source network maps to a class-scoped Multus network attachment definition.
- If the plan sets
multus_namespace, those NADs can live outside the main test namespace.
That behavior comes from utilities/utils.py:gen_network_map_list() and the multus_network_name fixture in conftest.py:
network_map_list: list[dict[str, dict[str, str]]] = []
_destination_pod: dict[str, str] = {"type": "pod"}
multus_counter = 1
if per_nic_network_map:
networks = get_per_nic_networks(
source_provider=source_provider, source_provider_inventory=source_provider_inventory, vms=vms
)
else:
networks = source_provider_inventory.vms_networks_mappings(vms=vms)
for index, network in enumerate(networks):
if pod_only or index == 0:
# First network or pod_only mode → pod network
_destination = _destination_pod
multus_network_name sizes the NAD set from the same data, so the count of created NADs matches the count of map entries:
if class_plan_config.get("per_nic_network_map", False):
multus_count = max(
0,
len(
get_per_nic_networks(
source_provider=source_provider, source_provider_inventory=source_provider_inventory, vms=vms
)
)
- 1
+ extra_nics,
)
else:
multus_count = max(0, len(networks) - 1) # First network goes to pod, rest to multus
A subtle but important detail happens right before Plan creation: utilities/utils.py:populate_vm_ids() injects Forklift
inventory IDs into the VM list. The Plan is not built from names alone.
Tip: If a VM has only one NIC, no extra NADs are created. The Multus path only appears for the second and later source networks. Set
per_nic_network_map: Truewhen a VM has two NICs on the same source network and you need both preserved.
4. Plan Creation
utilities/mtv_migration.py:create_plan_resource() is the assembly point where providers, maps, VM IDs, and optional plan
features become an MTV Plan custom resource.
def create_plan_resource(
ocp_admin_client: DynamicClient,
fixture_store: dict[str, Any],
source_provider: BaseProvider,
destination_provider: OCPProvider,
storage_map: StorageMap,
network_map: NetworkMap,
virtual_machines_list: list[dict[str, Any]],
target_namespace: str,
warm_migration: bool = False,
pre_hook_name: str | None = None,
pre_hook_namespace: str | None = None,
after_hook_name: str | None = None,
after_hook_namespace: str | None = None,
test_name: str | None = None,
copyoffload: bool = False,
preserve_static_ips: bool = False,
pvc_name_template: str | None = None,
pvc_name_template_use_generate_name: bool | None = None,
target_node_selector: dict[str, str] | None = None,
target_labels: dict[str, str] | None = None,
target_affinity: dict[str, Any] | None = None,
vm_target_namespace: str | None = None,
migrate_shared_disks: bool | None = None,
target_power_state: str | None = None,
enable_nested_virtualization: bool | None = None,
xfs_compatibility: bool = False,
run_preflight_inspection: bool | None = None,
rdm_as_lun: bool | None = None,
) -> Plan:
The excerpt above shows the signature only. The function itself carries a full docstring documenting every argument, the returned Plan, and the ValueError
cases it raises — read utilities/mtv_migration.py:216-240 for that contract.
The two Any types are deliberate, not laziness:
-
fixture_store: dict[str, Any]— the store is a heterogeneous session registry: tracked resources live underfixture_store["teardown"], keyed by resource kind (Namespace,VirtualMachine,Migration, ...), whose values are resource descriptors built at runtime bycreate_and_store_resource(). One union type per kind would have to be imported by every caller for no gain. -
target_affinity: dict[str, Any]— Kubernetes affinity accepts node selector terms, pod affinity/anti-affinity terms, and their weights, so the value is a nested structure whose leaf types differ per provider. Pinning it to one concrete type would reject valid plans.
Everything the suite itself constructs — virtual_machines_list: list[dict[str, Any]], pvc_name_template, target_labels, target_node_selector — is typed as
narrowly as its content allows.
plan_kwargs: dict[str, Any] = {
"client": ocp_admin_client,
"fixture_store": fixture_store,
"resource": Plan,
"namespace": target_namespace,
"source_provider_name": source_provider.ocp_resource.name,
"source_provider_namespace": source_provider.ocp_resource.namespace,
"destination_provider_name": destination_provider.ocp_resource.name,
"destination_provider_namespace": destination_provider.ocp_resource.namespace,
"storage_map_name": storage_map.name,
"storage_map_namespace": storage_map.namespace,
"network_map_name": network_map.name,
"network_map_namespace": network_map.namespace,
"virtual_machines_list": vms_for_plan,
"target_namespace": vm_target_namespace or target_namespace,
"warm_migration": warm_migration,
"pre_hook_name": pre_hook_name,
"pre_hook_namespace": pre_hook_namespace,
"after_hook_name": after_hook_name,
"after_hook_namespace": after_hook_namespace,
Optional features are only added when requested, so an unset flag never lands on the Plan CR:
if target_node_selector:
plan_kwargs["target_node_selector"] = target_node_selector
if target_labels:
plan_kwargs["target_labels"] = target_labels
if target_affinity:
plan_kwargs["target_affinity"] = target_affinity
if migrate_shared_disks is not None:
plan_kwargs["migrate_shared_disks"] = migrate_shared_disks
if enable_nested_virtualization is not None:
plan_kwargs["enable_nested_virtualization"] = enable_nested_virtualization
if rdm_as_lun is not None:
plan_kwargs["rdm_as_lun"] = rdm_as_lun
# Add copy-offload specific parameters if enabled
if copyoffload:
# Set PVC naming template for copy-offload migrations
# The volume populator framework requires this to generate consistent PVC names
# Note: generateName is enabled by default, so Kubernetes adds random suffix automatically
plan_kwargs["pvc_name_template"] = "pvc"
if xfs_compatibility:
plan_kwargs["xfs_compatibility"] = xfs_compatibility
if run_preflight_inspection is not None:
plan_kwargs["run_preflight_inspection"] = run_preflight_inspection
A few things to notice:
- The
Planresource itself lives in the test namespace, but the VMs can still target a separatevm_target_namespace. -
Hooks, labels, affinity, target power state, PVC naming, static IP preservation, nested virtualization, XFS compatibility, shared-disk migration, RDM-as-LUN, and preflight inspection are all Plan-time features in this suite.
-
Per-VM
migrate_shared_disksin the config is renamed to the camelCasemigrateSharedDisksAPI field here, and test-only VM keys are stripped, so the serialized VM list stays valid for the CR. -
Copy-offload reuses the same overall path, but changes how the storage map is built and forces a fixed
pvc_name_templatebecause the volume populator framework needs consistent PVC names. -
run_preflight_inspectionis the Deep Inspection switch on warm migrations.Noneleaves it at the Forklift default (enabled); several warm configs set it toFalseto skip preflight inspection entirely.
5. Migration Execution
Migration execution is intentionally small in the test code: the heavy lifting is delegated to MTV.
execute_migration() creates a Migration CR that references the prepared Plan, then wait_for_migration_complate() polls the
Plan status until it becomes Succeeded or Failed.
Cold migrations create the Migration immediately. Warm migrations do two extra things:
- They use
precopy_interval_forkliftcontrollerto patch theForkliftControllerprecopy interval fromsnapshots_interval. - They pass
get_cutover_value()when creating theMigration, which schedules cutover usingmins_before_cutoverfromtests/tests_config/config.py.
def get_cutover_value(current_cutover: bool = False) -> datetime:
"""Return the timestamp to set as the warm migration cutover time.
Args:
current_cutover (bool): When True, cut over immediately and return the current
UTC time. When False, return the current UTC time plus
``py_config["mins_before_cutover"]`` minutes.
Returns:
datetime: Timezone-aware UTC cutover timestamp.
Raises:
KeyError: If ``mins_before_cutover`` is missing from the test configuration.
"""
datetime_utc = datetime.now(pytz.utc)
if current_cutover:
return datetime_utc
return datetime_utc + timedelta(minutes=int(py_config["mins_before_cutover"]))
Copy-offload tests use execute_copyoffload_migration() instead, so the populate pod logs are captured while the migration polls.
Throttling tests pair that with execute_migration_monitoring_populator_inflight().
Tip: In this project, warm migration is not just
warm_migration=Trueon the Plan. The test also sets a cutover timestamp on theMigrationresource.
6. Post-Migration Validation
utilities/post_migration.py:check_vms() is the main validator. It re-reads the source VM and the destination VM through the
provider abstraction layer, runs a wide set of checks, collects all failures per VM, and only then fails the test.
Validation depends on the plan and provider, and includes:
- Power state, CPU, CPU features, VBS status, and memory checks.
- Network verification against the created
NetworkMap, plus NIC name preservation for static-IP scenarios. - Storage verification against the created
StorageMap. - PVC naming checks when
pvc_name_templateis set. - VMware snapshot and serial preservation checks.
- Guest agent verification.
- SSH connectivity to the migrated guest through
python-rrmngmnt. - Static IP preservation for VMs migrated from vSphere or Hyper-V with guest tools running.
- Target node placement, labels, and affinity when the plan asks for them.
- LUKS encryption verification (
verify_luks_encryption()) for encrypted guests. - XFS v4 compatibility (
check_vm_command_output()driven by the plan'sxfs_checkconfig). - Shared-disk data verification for Linux and Windows guests.
- Copy-offload data integrity checks.
This is why prepared_plan["source_vms_data"] matters: it preserves source-side facts that are needed later for snapshot,
PVC-name, and static-IP comparisons.
7. Advanced Paths
The normal workflow stays the same, but a few features add important branches.
Warm migration changes execution timing. The Plan is warm, the source-side clone can have Change Block Tracking enabled, the
Forklift precopy interval is patched, and the Migration is created with a cutover timestamp.
Hooks are created during prepared_plan by utilities/hooks.py:create_hook_if_configured(). A plan can define pre_hook or
post_hook with either a predefined success or failure playbook or a custom base64-encoded Ansible playbook.
tests/hooks/test_post_hook_retain_failed_vm.py shows the intended behavior: a pre-hook failure can stop VM validation because the
migration never really happened, while a post-hook failure can still leave migrated VMs behind and therefore still runs
check_vms().
AAP hooks are the external variant. tests/hooks/conftest.py deploys AWX via Helm, creates an OAuth2 API token and job
templates, points MTV at AWX, and the test then uses the resulting AAP Hook CRs.
Plan lifecycle is covered by tests/plan_lifecycle/test_plan_archive_pvc_cleanup.py. It deliberately fails the migration at the
post-hook, confirms at least one PVC or DataVolume still exists in the dedicated vm_target_namespace, archives the Plan and waits
for Archived=True, deletes the Plan and confirms its Migration disappears, then deletes the retained destination VM and polls
for up to 120 seconds until no DataVolumes or PVCs remain.
LUKS encryption is covered by tests/luks/. The luks_vm_specs fixture resolves passphrases (per-VM override, else provider
luks_passphrase), creates the Kubernetes Secrets, and injects them into the plan. tests/luks/test_luks_cold_migration.py also
contains a wrong-passphrase class that expects the migration to fail.
XFS compatibility is covered by tests/cold/test_cold_migration_xfs.py and tests/warm/test_warm_migration_xfs.py. Both set
xfs_compatibility: True on the Plan and then run xfs_info inside the migrated guest to validate the filesystem version.
Shared disks are covered by tests/shared_disk/. The Linux test mounts, writes, and reads a marker from both migrated VMs. The
Windows test first labels the shared NTFS volume on the source VM through the VMware Guest Operations API, then verifies
bidirectional access on the destination.
Copy-offload keeps the same outer sequence but changes the storage side. In that mode:
- The source provider is still vSphere.
- The storage secret comes from the plan's
copyoffloadconfiguration and optional environment-variable overrides. -
get_storage_migration_map()uses datastore IDs andoffloadPluginentries instead of the standard inventory-derived storage list, and can expand to secondary or non-XCOPY datastores for multi-datastore and fallback scenarios. -
rdm_as_lunmaps RDM disks as LUN devices with a SCSI bus instead of the default virtio mapping. -
pin_to_non_dedicated_hostpins every VM to an ESXi host outsidecopyoffload.dedicated_migration_hosts, so dedicated-host verification can never coincide with a VM's own host. -
ForkliftController in-flight limits are patched for the throttling tests, and
verify_populator_throttling()checkssourceHostlabels andPopulatorThrottledevents. -
If the provider requests SSH-based ESXi cloning, the
copyoffload_ssh_keyfixture installs an SSH key before the migration and removes it afterward.
Deep Inspection has two shapes. tests/deep_inspection/test_standalone_di.py creates a Conversion CR directly, waits for
completion, and verifies the inspection results, then exercises cancel, cleanup, and rerun.
tests/deep_inspection/test_plan_driven_di.py runs a warm migration that is expected to fail because Deep Inspection concerns
block it, and then verifies those concerns.
Operator upgrade is covered by tests/upgrade/test_upgrade_migration.py. tests/upgrade/conftest.py creates the StorageMap,
NetworkMap, and Plan before the upgrade; the test then runs the MTV upgrade script, verifies the operator version and that the
Plan survived the upgrade, and only then migrates.
Remote OpenShift destination tests also reuse the same pattern. The difference is mostly in which destination provider fixture
they use, not in the rest of the migration flow. tests/warm/conftest.py adds a ClusterRole-based destination provider plus an SCC
binding for the warm ClusterRole scenarios.
8. Teardown And Failure Handling
Cleanup is just as structured as setup.
Almost every OpenShift-side resource is created through utilities/resources.py:create_and_store_resource(), which deploys the
resource and records it in fixture_store["teardown"]. Class-level cleanup removes migrated VMs early, and session-level cleanup
handles everything else.
def session_teardown(session_store: dict[str, Any]) -> None:
LOGGER.info("Running teardown to delete all created resources")
ocp_client = get_cluster_client()
# When running in parallel (-n auto) `session_store` can be empty.
if session_teardown_resources := session_store.get("teardown"):
for migration_name in session_teardown_resources.get(Migration.kind, []):
migration = Migration(name=migration_name["name"], namespace=migration_name["namespace"], client=ocp_client)
cancel_migration(migration=migration)
for plan_name in session_teardown_resources.get(Plan.kind, []):
plan = Plan(name=plan_name["name"], namespace=plan_name["namespace"], client=ocp_client)
archive_plan(plan=plan)
leftovers = teardown_resources(
session_store=session_store,
ocp_client=ocp_client,
target_namespace=session_store.get("target_namespace"),
)
if leftovers:
raise SessionTeardownError(f"Failed to clean up the following resources: {leftovers}")
teardown_resources() collects leftovers per kind and fails the teardown when anything survives. Besides the resources the tests
created, it also handles the resources the migration created:
migrations = session_teardown_resources.get(Migration.kind, [])
plans = session_teardown_resources.get(Plan.kind, [])
providers = session_teardown_resources.get(Provider.kind, [])
hosts = session_teardown_resources.get(Host.kind, [])
secrets = session_teardown_resources.get(Secret.kind, [])
network_attachment_definitions = session_teardown_resources.get(NetworkAttachmentDefinition.kind, [])
networkmaps = session_teardown_resources.get(NetworkMap.kind, [])
namespaces = session_teardown_resources.get(Namespace.kind, [])
storagemaps = session_teardown_resources.get(StorageMap.kind, [])
conversions = session_teardown_resources.get(Conversion.kind, [])
vmware_cloned_vms = session_teardown_resources.get(Provider.ProviderType.VSPHERE, [])
openstack_cloned_vms = session_teardown_resources.get(Provider.ProviderType.OPENSTACK, [])
rhv_cloned_vms = session_teardown_resources.get(Provider.ProviderType.RHV, [])
hyperv_cloned_vms = session_teardown_resources.get(Provider.ProviderType.HYPERV, [])
openstack_volume_snapshots = session_teardown_resources.get("VolumeSnapshot", [])
# Resources that was created by running migration
pods = session_teardown_resources.get(Pod.kind, [])
virtual_machines = session_teardown_resources.get(VirtualMachine.kind, [])
Session teardown does more than delete a few CRs:
- It cancels still-running migrations.
- It archives Plans before deletion.
-
It deletes tracked
Provider,Host,Secret,NetworkAttachmentDefinition,StorageMap,NetworkMap,Namespace,Migration,Plan,Conversion, andVirtualMachineresources, plus pods andVirtualMachineresources created by the migration. -
It waits for
DataVolume,PVC, andPVcleanup. - It reconnects to source providers to delete cloned source-side VMs, snapshots, and OpenStack volume snapshots that were created for the test.
If --skip-teardown is set, the class and session cleanup paths intentionally leave resources behind.
Note: Failure handling is broader than cleanup. When data collection is enabled, the session writes created resources to
<data-collector-path>/resources.json(default.data-collector), andpytest_exception_interacttriggersoc adm must-gathercollection so you can inspect what MTV and the cluster were doing at the time of failure. Setup/call failures queue one gather per class-plan instance per worker. Thetryfirstpytest_runtest_teardown(item, nextitem)hook collects at a class or class-plan parameter boundary, includingnextitem=Noneon early exit, before default fixture finalization. Directories use percent-encoded class node IDs, class parameter indices and worker IDs. Atryfirstpytest_runtest_protocolwrapper clears staleplan_resourcebefore setup on class-plan transitions, not between consecutive methods of the same plan. The boundary binds the current Plan or explicit None before finalizers and retains it for retries, gathering fully for None. Earlier method teardown failures stay pending until the boundary; failures first reported after boundary cleanup use that context but collect post-finalizer state. Collection attempts at most one successful gather per class-plan-worker; operational failures remain pending and artifacts are not guaranteed if retries fail. A standalone test gathers immediately, once on success. Session-end retries cannot recover a crashed worker's pending state.
Automation And Dry Runs
The repository includes local automation, but it is deliberately conservative.
pytest.ini wires in tests/tests_config/config.py, enables strict markers, produces JUnit XML, and uses loadscope distribution.
tox.toml does not try to run a real migration. Instead, it runs pytest --setup-plan and pytest --collect-only, which are
useful for validating test discovery, parametrization, and fixture wiring without depending on a live MTV environment. Both are
recognized as dry-run modes by utilities/pytest_utils.py:is_dry_run(), which the session and collection hooks use to skip cluster
access and the AI analysis path.
The mtv-api-tests CLI wraps the same workflow from outside pytest:
mtv-api-tests generatewrites.providers.jsonandmtv-api-tests-manifests.yamlthrough an interactive wizard.mtv-api-tests run --mode localshells out touv run pytest;--mode jobapplies the generated Job manifest withoc.
Both accept a category from all, copyoffload, tier0, tier1, warm, and remote.
Optional AI failure analysis (--analyze-with-ai) posts failing test cases to a rootcoz server and enriches junit-report.xml. It
stays disabled unless ROOTCOZ_SERVER_URL is set, and the model defaults come from .rootcoz/settings.json, which rootcoz reads.
Pytest never reads that file itself.
That split is a good way to think about the project as a whole:
- Configuration chooses the source provider and migration shape.
- Fixtures turn that configuration into discoverable Forklift and OpenShift resources.
- Tests create maps, Plans, and Migrations in a fixed order.
- Validation compares source-side and destination-side reality.
- Teardown removes everything the run created, both in the cluster and on the source side when needed.
If you keep that control loop in mind, the rest of the repository becomes much easier to navigate.