Test Plan Configuration
tests_params is the catalog of named migration plans used by this repository. Each entry defines which source VMs to use, which MTV plan features to enable, and which extra test behaviors should run before or after migration.
The important thing to know is that the value you write in tests/tests_config/config.py is the raw plan. The test fixtures then turn that raw plan into a runtime prepared_plan before creating the MTV Plan custom resource.
Where Plans Live
The suite loads tests/tests_config/config.py as a Python config file, not as YAML or JSON:
addopts =
-s
-o log_cli=true
-p no:logging
--tc-file=tests/tests_config/config.py
--tc-format=python
Each test class then picks one named entry from py_config["tests_params"]:
@pytest.mark.parametrize(
"class_plan_config",
[
pytest.param(
py_config["tests_params"]["test_sanity_cold_mtv_migration"],
)
],
indirect=True,
ids=["rhel8"],
)
Note:
tests/tests_config/config.pycontains both shared test-suite settings and thetests_paramsdictionary. Only entries insidetests_paramsare individual plan definitions.
Basic Structure
A minimal plan can be very small:
"test_sanity_cold_mtv_migration": {
"virtual_machines": [
{"name": "mtv-tests-rhel8", "guest_agent": True},
],
"warm_migration": False,
},
A more advanced plan adds destination placement, PVC naming, labels, and affinity:
"test_warm_migration_comprehensive": {
"virtual_machines": [
{
"name": "mtv-win2022-ip-3disks",
"source_vm_power": "on",
"guest_agent": True,
},
],
"warm_migration": True,
"target_power_state": "on",
"preserve_static_ips": True,
"vm_target_namespace": "custom-vm-namespace",
"multus_namespace": "default", # Cross-namespace NAD access
"pvc_name_template": '{{ .FileName | trimSuffix ".vmdk" | replace "_" "-" }}-{{.DiskIndex}}',
"pvc_name_template_use_generate_name": True,
"target_labels": {
"mtv-comprehensive-test": None, # None = auto-generate with session_uuid
"static-label": "static-value",
},
"target_affinity": {
"podAffinity": {
"preferredDuringSchedulingIgnoredDuringExecution": [
{
"podAffinityTerm": {
"labelSelector": {"matchLabels": {"app": "comprehensive-test"}},
"topologyKey": "kubernetes.io/hostname",
},
"weight": 75,
}
]
}
},
},
In every entry:
virtual_machinesis the per-VM section.- Everything else is plan-level behavior or test behavior.
Note: This file is plain Python. Use Python values such as
True,False, andNone, not JSON values liketrue,false, ornull.
Per-VM Settings
Each item in virtual_machines describes one source VM or template.
| Key | What it means |
|---|---|
name |
Source VM or template name. During preparation, this may be rewritten to the actual cloned runtime name. |
source_vm_power |
Optional source power state before migration. "on" starts the VM, "off" stops it, and omitting it leaves the current state unchanged. |
guest_agent |
Enables guest-agent-aware validation after migration. |
clone |
Common in copy-offload and mutation-heavy scenarios. In this repository it is mostly a scenario hint, not the only thing that causes runtime cloning. |
clone_name |
Overrides the default clone base name. |
preserve_name_format |
Preserves uppercase letters and underscores in clone_name for name-format tests. |
disk_type |
VMware disk provisioning mode for the cloned VM. |
add_disks |
Adds extra disks to the cloned source VM before migration. |
snapshots |
Number of source snapshots to create before migration in snapshot-focused tests. |
target_datastore_id |
VMware target datastore for the cloned VM. |
add_disks[*].datastore_id |
Optional datastore override for an individual added disk. |
The VMware provider currently supports these disk_type values:
DISK_TYPE_MAP = {
"thin": ("sparse", "Setting disk provisioning to 'thin' (sparse)."),
"thick-lazy": ("flat", "Setting disk provisioning to 'thick-lazy' (flat)."),
"thick-eager": ("eagerZeroedThick", "Setting disk provisioning to 'thick-eager' (eagerZeroedThick)."),
}
A real multi-disk example looks like this:
"test_copyoffload_multi_disk_migration": {
"virtual_machines": [
{
"name": "xcopy-template-test",
"source_vm_power": "off",
"guest_agent": True,
"clone": True,
"add_disks": [
{"size_gb": 30, "disk_mode": "persistent", "provision_type": "thick-lazy"},
],
},
],
"warm_migration": False,
"copyoffload": True,
},
For special naming tests, the config can override the clone name entirely:
"test_copyoffload_nonconforming_name_migration": {
"virtual_machines": [
{
"name": "xcopy-template-test",
"clone_name": "XCopy_Test_VM_CAPS", # Non-conforming name for cloned VM
"preserve_name_format": True, # Don't sanitize the name (keep capitals and underscores)
"source_vm_power": "off",
"guest_agent": True,
"clone": True,
"disk_type": "thin",
},
],
"warm_migration": False,
"copyoffload": True,
},
Snapshot-Driven Tests
snapshots is a good example of a value that affects test preparation, not just MTV plan creation. Snapshot tests create the snapshots first, then store the pre-migration snapshot list back into the prepared plan for later validation:
vm_cfg = prepared_plan["virtual_machines"][0]
provider_vm_api = prepared_plan["source_vms_data"][vm_cfg["name"]]["provider_vm_api"]
# Ensure VM is powered on for snapshot creation
source_provider.start_vm(provider_vm_api)
source_provider.wait_for_vmware_guest_info(provider_vm_api, timeout=60)
snapshots_to_create = int(vm_cfg["snapshots"])
snapshot_prefix = f"{vm_cfg['name']}-{fixture_store['session_uuid']}-snapshot"
for idx in range(1, snapshots_to_create + 1):
source_provider.create_snapshot(
vm=provider_vm_api,
name=f"{snapshot_prefix}-{idx}",
description="mtv-api-tests copy-offload snapshots migration test",
memory=False,
quiesce=False,
wait_timeout=60 * 10,
)
# Refresh and store snapshots list for post-migration snapshot checks
vm_cfg["snapshots_before_migration"] = source_provider.vm_dict(provider_vm_api=provider_vm_api)[
"snapshots_data"
]
Note: In the class-based flow used by this repository, external-provider VMs are resolved with
clone_vm=Trueduring preparation. That means runtime VM names usually become per-test clone names even when the raw plan entry does not setclone: true.
Plan-Level Settings
Migration Behavior
warm_migration: Switches between warm and cold migration behavior.copyoffload: Enables copy-offload-specific plan handling and validation.target_power_state: Expected power state of the migrated VM after completion.preserve_static_ips: Passes the static-IP preservation setting into the MTV plan and enables related validation in supported scenarios.guest_agent_timeout: Overrides how long validation waits for the destination guest agent.
If you omit target_power_state, post-migration validation falls back to the source VM power state:
def check_vms_power_state(
source_vm: dict[str, Any],
destination_vm: dict[str, Any],
source_power_before_migration: str | None,
target_power_state: str | None = None,
) -> None:
# If targetPowerState is specified, check that the destination VM matches it
if target_power_state:
actual_power_state = destination_vm["power_state"]
LOGGER.info(f"Checking target power state: expected={target_power_state}, actual={actual_power_state}")
assert actual_power_state == target_power_state, (
f"VM power state mismatch: expected {target_power_state}, got {actual_power_state}"
)
elif source_power_before_migration:
if source_power_before_migration not in ("on", "off"):
raise ValueError(f"Invalid source_vm_power '{source_power_before_migration}'. Must be 'on' or 'off'")
# Default behavior: destination VM should match source power state before migration
assert destination_vm["power_state"] == source_power_before_migration
Placement, Naming, and Scheduling
vm_target_namespace: Custom namespace for migrated VMs. The preparation flow creates it if needed.multus_namespace: Namespace where NADs are created for additional networks.pvc_name_template: Forklift PVC naming template.pvc_name_template_use_generate_name: WhenTrue, post-checks treat the rendered PVC name as a prefix because Kubernetes adds a generated suffix.target_node_selector: Node label selector used for VM placement tests.target_labels: Labels to apply to migrated VM metadata.target_affinity: Affinity rules passed to the migrated VM template.
None has a special meaning in target_labels and target_node_selector: the fixtures replace it with the current session_uuid so each test run gets a unique value.
Tip:
vm_target_namespaceandmultus_namespacesolve different problems. Usevm_target_namespaceto choose where migrated VMs land, andmultus_namespaceto choose where the test-created NetworkAttachmentDefinitions live.
Hooks and Failure Expectations
The repository also supports hook-driven tests. A simple failure-path example looks like this:
"test_post_hook_retain_failed_vm": {
"virtual_machines": [
{
"name": "mtv-tests-rhel8",
"source_vm_power": "on",
"guest_agent": True,
},
],
"warm_migration": False,
"target_power_state": "off",
"pre_hook": {"expected_result": "succeed"},
"post_hook": {"expected_result": "fail"},
"expected_migration_result": "fail",
},
In hook config:
pre_hookandpost_hookare dictionaries.- The code accepts either
expected_result("succeed"or"fail") orplaybook_base64for a custom encoded Ansible playbook. expected_migration_resulttells the test whether a migration failure is expected.
Copy-Offload-Specific Notes
copyoffload: true is only the plan-side switch. The source provider also needs a copyoffload section in .providers.json.
A real provider-side example from .providers.json.example looks like this:
"copyoffload": {
# Supported storage_vendor_product values:
# - "ontap" (NetApp ONTAP)
# - "vantara" (Hitachi Vantara)
# - "primera3par" (HPE Primera/3PAR)
# - "pureFlashArray" (Pure Storage FlashArray)
# - "powerflex" (Dell PowerFlex)
# - "powermax" (Dell PowerMax)
# - "powerstore" (Dell PowerStore)
# - "infinibox" (Infinidat InfiniBox)
# - "flashsystem" (IBM FlashSystem)
"storage_vendor_product": "ontap",
# Primary datastore for copy-offload operations (required)
# This is the vSphere datastore ID (e.g., "datastore-12345") where VMs reside
# Get via vSphere: Datacenter → Storage → Datastore → Summary → More Objects ID
"datastore_id": "datastore-12345",
# Optional: Secondary datastore for multi-datastore copy-offload tests
# Only needed when testing VMs with disks spanning multiple datastores
# When specified, tests can validate copy-offload with disks on different datastores
"secondary_datastore_id": "datastore-67890",
# Optional: Non-XCOPY datastore for mixed datastore tests
# This should be a datastore that does NOT support XCOPY/VAAI primitives
# Used for testing VMs with disks on both XCOPY and non-XCOPY datastores
"non_xcopy_datastore_id": "datastore-99999",
"default_vm_name": "rhel9-template",
"storage_hostname": "storage.example.com",
"storage_username": "admin",
"storage_password": "your-password-here", # pragma: allowlist secret
Warning: A raw plan with
copyoffload: trueis not enough by itself. The test session validates that the source provider is vSphere and that provider-side copy-offload settings are present, including at leaststorage_vendor_productanddatastore_id.
There is one more important implementation detail: when the helper creates an MTV plan with copyoffload=True, it forces pvc_name_template to "pvc".
# 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"
Warning: Do not expect a custom
pvc_name_templateintests_paramsto survive copy-offload plan creation. The helper currently overwrites it with"pvc".
How Raw Config Becomes prepared_plan
The raw entry from tests_params is not used directly. The repository converts it into a runtime prepared_plan in several steps.
1. The Raw Plan Is Deep-Copied
The fixture starts by copying the selected config and setting up runtime-only storage:
# Deep copy the plan config to avoid mutation
plan: dict[str, Any] = deepcopy(class_plan_config)
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"] = {}
# Handle custom VM target namespace
vm_target_namespace = plan.get("vm_target_namespace")
if vm_target_namespace:
LOGGER.info(f"Using custom VM target namespace: {vm_target_namespace}")
get_or_create_namespace(
fixture_store=fixture_store,
ocp_admin_client=ocp_admin_client,
namespace_name=vm_target_namespace,
)
plan["_vm_target_namespace"] = vm_target_namespace
else:
plan["_vm_target_namespace"] = target_namespace
2. Each VM Is Resolved, Prepared, and Renamed
The fixture then resolves a per-test VM, applies source power-state rules, rewrites the VM name to the actual runtime name, and stores provider details separately:
for vm in virtual_machines:
# Get VM object first (without full vm_dict analysis)
# Add enable_ctk flag for warm migrations
clone_options = {**vm, "enable_ctk": warm_migration}
provider_vm_api = source_provider.get_vm_by_name(
query=vm["name"],
vm_name_suffix=vm_name_suffix,
clone_vm=True,
session_uuid=fixture_store["session_uuid"],
clone_options=clone_options,
)
# Power state control: "on" = start VM, "off" = stop VM, not set = leave unchanged
source_vm_power = vm.get("source_vm_power") # Optional - if not set, VM power state unchanged
if source_vm_power == "on":
source_provider.start_vm(provider_vm_api)
# Wait for guest info to become available (VMware only)
if source_provider.type == Provider.ProviderType.VSPHERE:
source_provider.wait_for_vmware_guest_info(provider_vm_api, timeout=120)
elif source_vm_power == "off":
source_provider.stop_vm(provider_vm_api)
# NOW call vm_dict() with VM in correct power state for guest info
source_vm_details = source_provider.vm_dict(
provider_vm_api=provider_vm_api,
name=vm["name"],
namespace=source_vms_namespace,
clone=False, # Already cloned above
vm_name_suffix=vm_name_suffix,
session_uuid=fixture_store["session_uuid"],
clone_options=vm,
)
vm["name"] = source_vm_details["name"]
# Wait for cloned VM to appear in Forklift inventory before proceeding
# This is needed for external providers that Forklift needs to sync from
# OVA is excluded because it doesn't clone VMs (uses pre-existing files)
if source_provider.type != Provider.ProviderType.OVA:
source_provider_inventory.wait_for_vm(name=vm["name"], timeout=300)
provider_vm_api = source_vm_details["provider_vm_api"]
vm["snapshots_before_migration"] = source_vm_details["snapshots_data"]
# Store complete source VM data separately (keeps virtual_machines clean for Plan CR serialization)
plan["source_vms_data"][vm["name"]] = source_vm_details
# 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)
3. The Test Adds VM IDs and Creates the MTV Plan
Right before creating the MTV Plan, the tests add VM IDs from Forklift inventory and then pass the prepared values into create_plan_resource():
populate_vm_ids(plan=prepared_plan, inventory=source_provider_inventory)
self.__class__.plan_resource = create_plan_resource(
ocp_admin_client=ocp_admin_client,
fixture_store=fixture_store,
source_provider=source_provider,
destination_provider=destination_provider,
storage_map=self.storage_map,
network_map=self.network_map,
virtual_machines_list=prepared_plan["virtual_machines"],
target_power_state=prepared_plan["target_power_state"],
target_namespace=target_namespace,
warm_migration=prepared_plan["warm_migration"],
preserve_static_ips=prepared_plan["preserve_static_ips"],
vm_target_namespace=prepared_plan["vm_target_namespace"],
pvc_name_template=prepared_plan["pvc_name_template"],
pvc_name_template_use_generate_name=prepared_plan["pvc_name_template_use_generate_name"],
target_labels=target_vm_labels["vm_labels"],
target_affinity=prepared_plan["target_affinity"],
)
Runtime Fields Added During Preparation
These fields are derived at runtime. You do not write them yourself in tests_params.
| Field | Added by | Purpose |
|---|---|---|
source_vms_data |
prepared_plan |
Stores rich source VM details for later validation without polluting virtual_machines. |
_vm_target_namespace |
prepared_plan |
Resolved namespace used by post-migration validation. |
_pre_hook_name / _pre_hook_namespace |
Hook creation | Created Hook CR reference for plan creation. |
_post_hook_name / _post_hook_namespace |
Hook creation | Created Hook CR reference for plan creation. |
virtual_machines[*].name |
prepared_plan |
Rewritten to the actual runtime VM name after provider lookup or cloning. |
virtual_machines[*].snapshots_before_migration |
prepared_plan or snapshot test setup |
Snapshot baseline used for post-migration checks. |
virtual_machines[*].id |
populate_vm_ids() |
Forklift VM ID required by plan creation. |
A few raw config fields are also resolved by companion fixtures instead of prepared_plan itself:
multus_namespaceis consumed by the network setup fixture that creates NADs.target_labelsis resolved bytarget_vm_labels.target_node_selectoris resolved bylabeled_worker_node.
Tip: Keep
tests_paramsdeclarative. Do not hand-write runtime fields such asid,source_vms_data,_vm_target_namespace, orsnapshots_before_migration. Let the fixtures derive them.
Practical Authoring Guidelines
- Start with one VM and one or two plan-level flags, then add more only when the scenario really needs them.
- Treat
virtual_machinesas source-side intent andprepared_planas runtime state. They are not the same thing. - Use
Noneintentionally intarget_labelsortarget_node_selectorwhen you want a unique value per run. - Expect VM names in
prepared_plan["virtual_machines"]to differ from the rawnameonce preparation is complete. - For copy-offload scenarios, think in two layers: test plan config in
tests_params, and provider/storage config in.providers.json.