Hooks And Expected Failures
Hooks let you run Ansible logic before or after an MTV migration. In this repository, you do not hand-craft Hook resources yourself. You describe the hook in the plan
configuration, and the test suite creates the Hook resource, attaches it to the Plan, and validates the outcome.
The project supports three hook modes, and each pre_hook or post_hook block uses exactly one of them:
- predefined playbooks selected with
expected_result - custom playbooks supplied through
playbook_base64 - AAP (Ansible Automation Platform) job templates selected with
aap_job_template_id
When you intentionally test a failure, the suite validates more than “migration failed.” It checks whether the failure happened in PreHook or PostHook, and it changes later VM
validation based on that result.
Hook Test Classes
| Test class | Where | Markers | What it covers |
|---|---|---|---|
TestPostHookRetainFailedVm |
tests/hooks/test_post_hook_retain_failed_vm.py |
tier0, incremental |
Predefined hooks, post hook fails on purpose, VMs retained |
TestAapHookMigration |
tests/hooks/test_aap_hook_migration.py |
vsphere, tier1, aap, incremental |
AAP hooks: AWX job templates run as PreHook and PostHook |
TestPostHookRetainFailedVm also carries vsphere, rhv, openstack, and openshift, because it
is not tied to one source type.
The aap marker is the one to reach for when you only want the AAP integration test:
uv run pytest -m aap -v --tc=source_provider:vsphere-8.0.3.00400
Where Hook Configuration Lives
Hook settings live in tests/tests_config/config.py. The repository’s end-to-end hook 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",
},
This config uses two different “expected” keys:
| Key | What it controls |
|---|---|
pre_hook.expected_result / post_hook.expected_result |
Chooses a built-in hook playbook: succeed or fail. |
expected_migration_result |
Tells the test whether the overall migration should raise MigrationPlanExecError. |
Use pre_hook when you want to affect the migration before VM work begins. Use post_hook when you want the migration to reach the end of VM processing and then test what happens
after that.
One other plan config in the repository also sets a failing post hook: test_plan_archive_pvc_cleanup.
That test does not use expected_migration_result at all. It wraps execute_migration() in
pytest.raises(MigrationPlanExecError) directly, then archives and deletes the Plan to verify the
leftover DataVolume and PVC cleanup.
Note:
expected_resultandexpected_migration_resultare not interchangeable. The first controls hook behavior. The second controls the expected result of the entire migration test.
How Hooks Get Attached To A Plan
During prepared_plan, the suite checks the plan for pre_hook and post_hook. If either exists, it creates the corresponding Hook resource and stores the generated name and
namespace back into the plan.
def create_hook_if_configured(
plan: dict[str, Any],
hook_key: str,
hook_type: str,
fixture_store: dict[str, Any],
ocp_admin_client: "DynamicClient",
target_namespace: str,
) -> None:
"""Create hook if configured in plan and store references.
...
"""
hook_config = plan.get(hook_key)
if hook_config:
hook_name, hook_namespace = create_hook_for_plan(
hook_config=hook_config,
hook_type=hook_type,
fixture_store=fixture_store,
ocp_admin_client=ocp_admin_client,
target_namespace=target_namespace,
)
plan[f"_{hook_type}_hook_name"] = hook_name
plan[f"_{hook_type}_hook_namespace"] = hook_namespace
The prepared_plan fixture calls this once for pre_hook and once for post_hook. When the suite
creates the MTV Plan, it passes those hook references into the Plan helper. Pre-hooks use
pre_hook_name and pre_hook_namespace. Post-hooks are passed through the helper as
after_hook_name and after_hook_namespace.
for vm in vms_for_plan:
if "migrate_shared_disks" in vm:
vm["migrateSharedDisks"] = vm.pop("migrate_shared_disks")
for key in _VM_DICT_TEST_ONLY_KEYS:
vm.pop(key, None)
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,
"preserve_static_ips": preserve_static_ips,
"pvc_name_template": pvc_name_template,
"pvc_name_template_use_generate_name": pvc_name_template_use_generate_name,
"target_power_state": target_power_state,
}
Both hook test classes then read the same four keys back out of prepared_plan when they build their Plan.
Note: You do not configure hook resource names manually in the test config. The suite generates them and stores them as
_pre_hook_name,_pre_hook_namespace,_post_hook_name, and_post_hook_namespace.Note: Hook resources are created in the migration namespace passed as
target_namespace. If you also usevm_target_namespace, that changes where migrated VMs land, not where the hook CR itself is created.
Predefined Playbooks
If you set expected_result, the suite chooses one of two built-in Ansible playbooks stored as base64 strings in utilities/hooks.py. The file includes their decoded content in
comments:
# Predefined hook playbooks for testing (base64 encoded Ansible playbooks)
#
# HOOK_PLAYBOOK_SUCCESS decodes to:
# - name: Successful-hook
# hosts: localhost
# tasks:
# - name: Success task
# debug:
# msg: "Hook executed successfully"
#
# HOOK_PLAYBOOK_FAIL decodes to:
# - name: Failing-post-migration
# hosts: localhost
# tasks:
# - name: Task that will fail
# fail:
# msg: "This hook is designed to fail for testing purposes"
In other words:
expected_result: succeeduses a simple playbook that logs a debug message.expected_result: failuses a playbook that fails on purpose.
This makes predefined mode the easiest way to test hook behavior without having to build and base64-encode your own playbook.
Tip: If your goal is to verify that a migration fails specifically in
PreHookorPostHook, predefined playbooks are the clearest option because the test can compare the actual failed step against a declared expectation.
Custom Playbooks
If the built-in success and failure playbooks are not enough, you can provide your own playbook in playbook_base64.
Before the suite creates the hook, it enforces several validation rules:
expected_result = hook_config.get("expected_result")
custom_playbook = hook_config.get("playbook_base64")
aap_job_template_id = hook_config.get("aap_job_template_id")
modes_specified = sum(x is not None for x in (expected_result, custom_playbook, aap_job_template_id))
if modes_specified > 1:
raise ValueError(
f"Invalid {hook_type} hook config: 'expected_result', 'playbook_base64', and "
f"'aap_job_template_id' are mutually exclusive. Specify exactly one."
)
if modes_specified == 0:
raise ValueError(
f"Invalid {hook_type} hook config: must specify exactly one of 'expected_result', "
f"'playbook_base64', or 'aap_job_template_id'."
)
if aap_job_template_id is not None:
if (
isinstance(aap_job_template_id, bool)
or not isinstance(aap_job_template_id, int)
or aap_job_template_id <= 0
):
raise ValueError(
f"Invalid {hook_type} hook config: 'aap_job_template_id' must be a positive integer, "
f"got: {aap_job_template_id!r}"
)
# Reject empty strings for both expected_result and custom_playbook
if isinstance(expected_result, str) and expected_result.strip() == "":
raise ValueError(f"Invalid {hook_type} hook config: 'expected_result' cannot be empty or whitespace-only.")
if isinstance(custom_playbook, str) and custom_playbook.strip() == "":
raise ValueError(f"Invalid {hook_type} hook config: 'playbook_base64' cannot be empty or whitespace-only.")
A custom hook payload must therefore be:
- valid base64
- valid UTF-8 after decoding
- valid YAML
- a non-empty list of plays
Warning:
expected_result,playbook_base64, andaap_job_template_idare mutually exclusive. You must supply exactly one of them for each hook.Warning: Passing validation only proves the payload is structurally valid. It does not guarantee the playbook will succeed at runtime.
Note: This repository has an end-to-end example for predefined hooks and one for AAP hooks, but it does not currently include a test case whose plan config uses
playbook_base64.
AAP Hooks
The third mode runs the hook as an AWX job template instead of embedding a playbook in the Hook CR.
Instead of image and playbook, the suite creates the Hook with an aap block:
hook = create_and_store_resource(
client=ocp_admin_client,
fixture_store=fixture_store,
resource=Hook,
namespace=target_namespace,
aap={"jobTemplateId": aap_job_template_id},
)
TestAapHookMigration never writes aap_job_template_id into tests/tests_config/config.py.
Its plan config is a plain cold migration. The fixtures in tests/hooks/conftest.py build everything
else at runtime:
-
awx_deploymentinstalls the AWX operator via Helm, creates an AWX instance backed by CephFS storage classes, waits for all pods, and returns the AWX route URL. If AWX was already present, the fixture leaves it in place at teardown instead of deleting it. -
awx_api_tokencreates an AWX OAuth2 API token for the admin user. -
awx_job_templatescreates a project from themtv-aap-test-playbooksgit repository, waits for SCM sync, creates an inventory, then creates themtv-pre-hookandmtv-post-hookjob templates and returns their IDs. -
aap_mtv_settingscreates a session-unique tokenSecretin the MTV namespace and patches theForkliftControllerwith three fields —spec.aap_url,spec.aap_token_secret_name, andspec.aap_insecure_skip_verify: -
aap_hook_refscreates oneHookper type withspec.aap.jobTemplateIdpointing at the matching template ID, then writes_pre_hook_name,_pre_hook_namespace,_post_hook_name, and_post_hook_namespaceintoprepared_plan— the same four keys the predefined mode writes.
editor = ResourceEditor(
patches={
forklift_controller: {
"spec": {
"aap_url": awx_deployment,
"aap_token_secret_name": token_secret_name,
"aap_insecure_skip_verify": "true",
}
}
}
)
editor.update(backup_resources=True)
The patch is restored during pytest session teardown, not when the test class finishes: aap_mtv_settings is session-scoped
(tests/hooks/conftest.py:163) and calls editor.restore() in its fixture teardown, so MTV keeps its AAP configuration for the
rest of the run.
The migration step of TestAapHookMigration asserts success, not failure. Because the hooks run in
pipeline order, a passing migration is what proves Forklift launched the AWX jobs and waited for
them: PreHook runs before disk transfer, PostHook after VM creation.
Warning: The AAP path mutates cluster-wide MTV settings and installs an AWX instance in the
awxnamespace. It needsocs-storagecluster-cephfsto exist as a storage class for the AWX project and PostgreSQL volumes.Tip: If AWX is already deployed in the cluster, the fixture reuses it and skips teardown, so a shared AWX is not torn down when the test class ends.
How Expected Failures Are Validated
A correct hook-failure scenario does not show up as a broken test in this suite. The migration itself fails, but the pytest test passes because that failure was expected and was validated.
The expected-failure test does that explicitly. If expected_migration_result is fail, it expects
execute_migration() to raise MigrationPlanExecError, and then it asks the hook utility whether VM
checks should still run.
expected_result = prepared_plan["expected_migration_result"]
if expected_result == "fail":
with pytest.raises(MigrationPlanExecError) as exc_info:
execute_migration(
ocp_admin_client=ocp_admin_client,
fixture_store=fixture_store,
plan=self.plan_resource,
target_namespace=target_namespace,
)
try:
self.__class__.should_check_vms = validate_hook_failure_and_check_vms(self.plan_resource, prepared_plan)
except Exception as e:
# Chain with original migration error so the root cause is visible in traceback
e.__cause__ = exc_info.value
raise
else:
execute_migration(
ocp_admin_client=ocp_admin_client,
fixture_store=fixture_store,
plan=self.plan_resource,
target_namespace=target_namespace,
)
self.__class__.should_check_vms = True
And the VM step turns that boolean into a skip:
# Runtime skip needed - decision based on previous test's migration execution result
if not self.__class__.should_check_vms:
pytest.skip("Skipping VM checks - hook failed before VM migration")
The suite then looks past the high-level migration outcome. It reads each VM's pipeline from the
Plan CR status and returns the first step that carries an error. The Plan CR is the authoritative
source on purpose: the forklift controller writes it before the Migration CR syncs, so a migration
that just failed cannot race into a "no error found" result.
vms_status = plan.instance.status.migration.vms
for vm_status in vms_status:
vm_id = getattr(vm_status, "id", "")
vm_status_name = getattr(vm_status, "name", "")
if vm_name not in (vm_id, vm_status_name):
continue
pipeline = getattr(vm_status, "pipeline", None)
if not pipeline:
raise VmPipelineError(vm_name=vm_name)
for step in pipeline:
step_error = getattr(step, "error", None)
if step_error:
step_name = step.name
LOGGER.info(f"VM {vm_name} failed at step '{step_name}': {step_error}")
return step_name
raise VmPipelineError(vm_name=vm_name)
raise VmNotFoundError(f"VM '{vm_name}' not found in Plan '{plan.name}' migration status")
A VM that is missing from the plan status raises VmNotFoundError; a VM with no pipeline or no failed step raises VmPipelineError.
Once it knows the actual failed step, the hook utility validates it against the configured hook and decides what to do next:
def validate_expected_hook_failure(
actual_failed_step: str,
plan_config: dict[str, Any],
) -> None:
"""
Validate the actual failed step matches expected (predefined mode only).
For custom playbook mode (no expected_result set), this is a no-op.
"""
# Extract hook configs with type validation
pre_hook_config = plan_config.get("pre_hook")
if pre_hook_config is not None and not isinstance(pre_hook_config, dict):
raise TypeError(f"pre_hook must be a dict, got {type(pre_hook_config).__name__}")
pre_hook_expected = pre_hook_config.get("expected_result") if pre_hook_config else None
post_hook_config = plan_config.get("post_hook")
if post_hook_config is not None and not isinstance(post_hook_config, dict):
raise TypeError(f"post_hook must be a dict, got {type(post_hook_config).__name__}")
post_hook_expected = post_hook_config.get("expected_result") if post_hook_config else None
# PreHook runs before PostHook, so check PreHook first
if pre_hook_expected == "fail":
expected_step = "PreHook"
elif post_hook_expected == "fail":
expected_step = "PostHook"
else:
LOGGER.info("No expected_result specified - skipping step validation")
return
if actual_failed_step != expected_step:
raise AssertionError(
f"Migration failed at step '{actual_failed_step}' but expected to fail at '{expected_step}'"
)
LOGGER.info("Migration correctly failed at expected step '%s'", expected_step)
if actual_failed_step == "PostHook":
return True
elif actual_failed_step == "PreHook":
return False
else:
raise ValueError(f"Unexpected failure step: {actual_failed_step}")
That leads to a simple rule set:
- If the actual failed step matches the expected hook, the expected-failure test passes.
- If the actual failed step is wrong, the test fails.
- If the failure happened in
PreHook, VM checks are skipped because migration stopped too early. - If the failure happened in
PostHook, VM checks still run because the VMs should already exist.
Warning: For multi-VM plans, the suite expects all VMs to fail in the same step.
validate_all_vms_same_step()collects one failed step per VM name and raisesVmMigrationStepMismatchErrorwhen the values differ, or when no VM produced a step at all.Note: In custom-playbook mode, the suite still determines whether the failure happened in
PreHookorPostHook. What it skips is the comparison against a declared expected step, because custom mode does not useexpected_result.
Pytest And Reporting Behavior
Both hook test classes use the repository's standard incremental class pattern. If an earlier step in
the class fails unexpectedly, later steps are marked xfail instead of running anyway.
# Incremental test support - track failures for class-based tests
if "incremental" in item.keywords and rep.when == "call" and rep.failed:
item.parent._previousfailed = item
def pytest_runtest_setup(item):
# Incremental test support - xfail if previous test in class failed
if "incremental" in item.keywords:
previousfailed = getattr(item.parent, "_previousfailed", None)
if previousfailed is not None:
pytest.xfail(f"previous test failed ({previousfailed.name})")
This is different from hook expected-failure validation:
pytest.xfail()is used here to stop later class steps after an unexpected earlier failure.- Hook expected failures are validated explicitly with
pytest.raises(MigrationPlanExecError)plus step checking.
Tip: If a hook failure is part of the test’s intended behavior, model it with
expected_migration_result: failand hook-step validation, not withpytest.xfail().
The test runner is also configured to write junit-report.xml, so correctly validated hook-failure scenarios appear as normal passed or skipped test steps in standard reporting.
The migration failed, but the test did exactly what it was supposed to do.