From b6e08f37ab819969718e7357b8189cef376da98b Mon Sep 17 00:00:00 2001 From: Vinny Valdez Date: Sat, 17 Oct 2026 13:15:01 -0500 Subject: [PATCH 1/2] feat(validate): add migration validation role validate_migration validates outcomes after vmf_migrate: migration status, VM presence and running state, target list, guest agent, IP addresses, disk topology, and the per-VM fail-closed source power-off (docs/adr/0001). The source power-off uses certified vmware.vmware.vm_powerstate; gathering stays on SOAP-only community.vmware.vmware_guest_info (documented tier-3 exception). Adds utility_v2v_nofstrim_shim.yml for MTV 2.11/2.12 on pre-x86-64-v3 CPUs. Validated live on pool1 across failed, stopped, and running states. Assisted-by: opencode:enmaas/rits/zai-org/glm-5-3 --- .github/workflows/pre-commit.yml | 8 + .pre-commit-config.yaml | 14 + README.md | 1 + .../fragments/108-validate-migration-role.yml | 4 + ...alidate-migration-fail-closed-power-off.md | 38 ++ .../validate-migration-vmware-powerstate.md | 328 ++++++++++ galaxy.yml | 1 + playbooks/utility_v2v_nofstrim_shim.yml | 339 ++++++++++ playbooks/vmf_validate_migration.yml | 12 + requirements-dev.yml | 2 + roles/validate_migration/README.md | 591 ++++++++++++++++++ roles/validate_migration/defaults/main.yml | 163 +++++ .../meta/argument_specs.yml | 175 ++++++ roles/validate_migration/meta/main.yml | 20 + roles/validate_migration/tasks/main.yml | 116 ++++ roles/validate_migration/tasks/power_off.yml | 109 ++++ .../validate_migration/tasks/power_report.yml | 63 ++ .../validate_migration/tasks/pre_validate.yml | 51 ++ roles/validate_migration/tasks/report.yml | 88 +++ .../tasks/validate_agent.yml | 60 ++ .../tasks/validate_disks.yml | 129 ++++ .../validate_migration/tasks/validate_ip.yml | 127 ++++ .../tasks/validate_list.yml | 52 ++ .../tasks/validate_plan.yml | 89 +++ .../validate_migration/tasks/validate_vms.yml | 68 ++ roles/validate_migration/vars/main.yml | 6 + scripts/check-validate-migration-vmware.py | 95 +++ .../validate_migration_report/tasks/main.yml | 215 +++++++ tests/unit/test_validate_migration_power.py | 258 ++++++++ 29 files changed, 3222 insertions(+) create mode 100644 changelogs/fragments/108-validate-migration-role.yml create mode 100644 docs/adr/0001-validate-migration-fail-closed-power-off.md create mode 100644 docs/research/validate-migration-vmware-powerstate.md create mode 100644 playbooks/utility_v2v_nofstrim_shim.yml create mode 100644 playbooks/vmf_validate_migration.yml create mode 100644 roles/validate_migration/README.md create mode 100644 roles/validate_migration/defaults/main.yml create mode 100644 roles/validate_migration/meta/argument_specs.yml create mode 100644 roles/validate_migration/meta/main.yml create mode 100644 roles/validate_migration/tasks/main.yml create mode 100644 roles/validate_migration/tasks/power_off.yml create mode 100644 roles/validate_migration/tasks/power_report.yml create mode 100644 roles/validate_migration/tasks/pre_validate.yml create mode 100644 roles/validate_migration/tasks/report.yml create mode 100644 roles/validate_migration/tasks/validate_agent.yml create mode 100644 roles/validate_migration/tasks/validate_disks.yml create mode 100644 roles/validate_migration/tasks/validate_ip.yml create mode 100644 roles/validate_migration/tasks/validate_list.yml create mode 100644 roles/validate_migration/tasks/validate_plan.yml create mode 100644 roles/validate_migration/tasks/validate_vms.yml create mode 100644 roles/validate_migration/vars/main.yml create mode 100644 scripts/check-validate-migration-vmware.py create mode 100644 tests/integration/targets/validate_migration_report/tasks/main.yml create mode 100644 tests/unit/test_validate_migration_power.py diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 67c3385..14b7003 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -78,6 +78,14 @@ jobs: run: | pip install -r requirements-ci.txt + - name: Install collections for the VMware module-contract guard + # The validate_migration-vmware-contract hook verifies the REAL installed + # module contracts (vmware.vmware.vm_powerstate and + # community.vmware.vmware_guest_info); .ansible/ is gitignored, so + # CI must install them or the guard fails closed on every PR. + run: | + ansible-galaxy collection install vmware.vmware community.vmware + - name: Cache pre-commit environments uses: actions/cache@v4 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5354eec..b32ba4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,20 @@ repos: # Ensures generated documentation (README.md and role READMEs) are up-to-date - repo: local hooks: + - id: validate_migration-vmware-contract + name: Verify installed VMware power-state module contract + entry: python3 scripts/check-validate-migration-vmware.py + language: system + pass_filenames: false + files: ^(roles/validate_migration/|galaxy.yml|requirements-dev.yml|scripts/check-validate-migration-vmware.py) + + - id: validate_migration-power-offline + name: Test validate_migration power-off with offline module doubles + entry: python3 -m unittest discover --start-directory tests/unit --pattern test_validate_migration_power.py + language: system + pass_filenames: false + files: ^(roles/validate_migration/|tests/unit/test_validate_migration_power.py) + - id: update-documentation name: Generate documentation entry: bash scripts/update-documentation.sh diff --git a/README.md b/README.md index 0af0c62..0f51c2c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ This collection includes the following roles for managing OpenShift Virtualizati * [utility_aap_deploy](roles/utility_aap_deploy/README.md) - Deploys an instance of Ansible Automation Platform. * [utility_openshift_target_credential](roles/utility_openshift_target_credential/README.md) - A utility role to manage OpenShift target cluster credentials for migration * [utility_registry_credentials](roles/utility_registry_credentials/README.md) - Manages registry credentials in an OpenShift namespace. +* [validate_migration](roles/validate_migration/README.md) - Validate migration outcomes after vmf_migrate completes — Migration status, migrated VM presence and state, target list match, guest agent, IP addresses, disk topology, and the fail-closed source power-off. ## Requirements diff --git a/changelogs/fragments/108-validate-migration-role.yml b/changelogs/fragments/108-validate-migration-role.yml new file mode 100644 index 0000000..5dcd151 --- /dev/null +++ b/changelogs/fragments/108-validate-migration-role.yml @@ -0,0 +1,4 @@ +--- +minor_changes: + - Add validate_migration role to validate migration outcomes after vmf_migrate completes — migration status, migrated VM presence and running state, target list match, guest agent, IP addresses, disk topology, and the per-VM fail-closed source power-off (see docs/adr/0001-validate-migration-fail-closed-power-off.md; playbook vmf_validate_migration.yml). +... diff --git a/docs/adr/0001-validate-migration-fail-closed-power-off.md b/docs/adr/0001-validate-migration-fail-closed-power-off.md new file mode 100644 index 0000000..7ac5c66 --- /dev/null +++ b/docs/adr/0001-validate-migration-fail-closed-power-off.md @@ -0,0 +1,38 @@ +# ADR-0001: Migration validation source power-off is per-VM and fail-closed + +Date: 2026-10-16 +Status: Accepted + +## Context + +Migration validation (the post-migration validation pass) powers off source VMs by +default after a migration. A power-off is destructive: if the migration +failed or a migrated VM is unhealthy, powering off its source removes the +operator's fallback. + +## Decision + +The source power-off is per-VM and fail-closed: + +- A source VM is powered off only after ITS migrated counterpart passes + every validate_migration check. +- A failed check leaves that source VM running; the role reports which + sources stayed on and why. +- The toggle (`validate_migration_power_off_source`, default on) switches the + action off entirely; in that mode the role reports source power state as + a read-only check and never mutates. + +The power-off is a hard stop via the vCenter API — not a graceful guest +shutdown; the migrated counterpart is already validated at that point. It +is the only sanctioned mutation in validate_migration; every other check is a +read-only probe. + +## Consequences + +- Partial migrations are safe: the sources of failed VMs stay available + for retry or rollback. +- The default-on toggle is safe only because of the fail-closed gate — + the two decisions are paired and must not be separated. +- Operators running with the toggle on must treat a "sources still + running" summary as the signal to investigate, not as a role failure to + force through. diff --git a/docs/research/validate-migration-vmware-powerstate.md b/docs/research/validate-migration-vmware-powerstate.md new file mode 100644 index 0000000..fd4d676 --- /dev/null +++ b/docs/research/validate-migration-vmware-powerstate.md @@ -0,0 +1,328 @@ +# Migration validation VMware power-state replacement + +Research date: 2026-10-16. Research only; no implementation changes or live VM operations. + +## Recommendation + +Replace `community.vmware.vmware_guest_powerstate` with +`vmware.vmware.vm_powerstate`. Community 7.0.0 **tombstones** the old name; +its runtime routing recommends the replacement but does not redirect calls. +The release changelog confirms removal. Pinning community below 7 only defers +the migration. [1] + +Keep `moid` and `state: powered-off`. Add a real, nonempty `datacenter`, +preserve the per-VM validation gates, and fix error accounting before claiming +successful power-off. Do not claim verified vCenter 6.x support. [2][3] + +## Exact contract + +| Concern | Verified replacement behavior | +| --- | --- | +| VM identity | `moid` remains `moid`, not `vm`, `vm_id`, or `managed_object_id`. Exactly one of `name`, `uuid`, `moid` is required; they are mutually exclusive. MoIDs are unique only within the connected vCenter. Keep the same host/credential context as the source lookup. [2] | +| State spelling | `powered-off` is valid; `powered_off` is not an argument-spec choice. Internal normalization does not make an invalid input choice valid. [2] | +| Hard stop | `powered-off` calls `self.vm.PowerOff()`. `shutdown-guest` instead calls `ShutdownGuest()`, requires running VMware Tools, and polls for shutdown. These are different operations. Do not substitute guest shutdown for the role's hard-stop contract. [2] | +| Datacenter | A required string even with `moid`. Current validate_migration omits it on mutation and defaults its inventory value to empty. Require/resolve an authentic datacenter before mutation, not `omit`, an empty string, or a fabricated default. In the inspected lookup helper, datacenter formats folder paths; MoID-only lookup does **not** independently validate datacenter membership. [2][3] | +| Credentials | `hostname`, `username`, `password` retain their names; environment fallbacks are `VMWARE_HOST`, `VMWARE_USER`, `VMWARE_PASSWORD`. Username aliases: `user`, `admin`; password aliases: `pass`, `pwd`. Empty connection credentials fail in the client. Explicit role values take precedence over module environment fallback. [4][5] | +| TLS/port | `validate_certs` is a boolean, default `true`; fallback `VMWARE_VALIDATE_CERTS`. `port` defaults to 443, fallback `VMWARE_PORT`. Retain the role's certificate setting and secure-logging variable. Trust the vCenter CA in the executing environment rather than disabling verification. No module-specific CA-file parameter exists in this schema. [4][5] | +| Force/timeout | `force` defaults to `false`; non-on/non-off current states require it. A hard stop does **not** require `force: true`. `timeout` defaults to 3600 seconds and bounds task monitoring. Do not add scheduling or question-answer parameters. [2][6] | + +### Idempotency and check mode + +For immediate `powered-off`, a VM already off exits with `changed: false`; +an on VM invokes `PowerOff()` and waits for its task. Check mode still connects +and resolves the VM, then predicts `changed: true` without configuring its power +state. `supports_check_mode=True` appears in both the introducing 1.11.0 module +and 2.12.0. It is not an offline connectivity test. Pending VM questions can fail +before the normal state/check-mode exit. [2][7] + +Keep role reporting check-mode-aware: a predicted change is not evidence that a +VM powered off. For strongest operational confirmation, read back the actual +power state after a successful mutation; the replacement does not return it. [2] + +### Return and error handling + +- Success: `changed` plus `vm: {name: ..., moid: ...}`. There is no returned + `instance.hw_power_status`; keep using the separate guest-info lookup for its + existing `instance` facts, not the power module's output. [2] +- Explicit module failures use `fail_json(msg=...)`, for example missing VM, + disallowed source state, pending question, VMware operation exception, or task + timeout. Those branches do not pass the success `vm` result through. Client + initialization can instead raise `ApiAccessError`/`MissingLibError`, producing + executor-level failure/exception output. Do not require one universal error + dictionary, a VMware error-code field, or a success-shaped `vm` on failure. + A timeout may follow an issued mutation; failure does not prove nothing + changed. [2][3][5][6] +- **Existing reporting bug:** `roles/validate_migration/tasks/power_off.yml` sets + `failed_when: false` on lookup and mutation, then tests `results[*].failed`. + Ansible overwrites `failed` with the failed-when expression result, including + false. The summary also classifies successful power-off from pre-mutation + facts/gates rather than the mutation result, so it can report an attempted, + failed operation as powered off. This is not fixed by changing the FQCN. [8] +- Proposed approach: remove `failed_when: false`, retain `ignore_errors: true` + where graceful collection is intended, and classify every registered loop + result explicitly: skipped, failed, successful, or predicted-only check mode. + Preserve and test missing/invalid response handling. Require valid lookup + identity before calling the mutator; match mutation results by source identity. + Do not log raw failure payloads containing credentials. `ignore_errors` does + not universally catch undefined-variable, syntax, unreachable, or plugin-loading + errors; module resolution belongs in a separate deterministic guard. [8] + +## Versions, Python, and execution environment + +| Version | Verified minimums | +| --- | --- | +| `vmware.vmware` 1.11.0 | First release containing `vm_powerstate`; runtime requires ansible-core >=2.15.0; module test configuration declares Python >=3.9; collection requirements specify pyVmomi >=6.7.1. [7][9] | +| `vmware.vmware` 2.0.0 through inspected 2.12.0 | Core >=2.16.0; module Python >=3.9; pyVmomi >=8.0.3.0.1. Core 2.16 itself requires controller Python >=3.10. Thus a localhost/EE running core 2.16 needs >=3.10, not merely the module's 3.9. [9][10] | +| Local CI | `requirements-ci.txt` pins core 2.20.1; `requirements-dev.txt` requests >=2.20. Core 2.20 requires controller Python >=3.12. [10] | + +Local inspection: + +- `galaxy.yml` and `requirements-dev.yml` declare community >=5.5.0 and + vmware_rest >=4.6.0, but no direct `vmware.vmware` dependency, despite existing + uses in `roles/pre_migration_vm/tasks/enable_cbt.yml`. +- `meta/runtime.yml` advertises core >=2.16.0. This replacement needs no increase + for the 2.x collection. There is already a broader dependency mismatch: + community 5.5.0 requires core >=2.17.0, while installed community 7.0.0 requires + >=2.21.0. Unbounded community resolution cannot validate the advertised core + floor or the pinned 2.20 CI environment. Resolve that separately, not by + asserting that the replacement requires core 2.21. [1][11] +- No checked-in `execution-environment.yml` or `extensions/ee/` exists. + `docs/architecture-v2.md` proposes an EE with unbounded `pyVmomi`, but it is a + design example, not a build definition. `test-requirements.txt` does not install + pyVmomi; `tests/integration/requirements.txt` is a placeholder. The devfile's + moving container tag does not establish installed VMware SDK versions. + +**Minimal justified bounds:** add direct `vmware.vmware: ">=1.11.0"` to the +collection and developer requirements; this is the actual module introduction, +not 2.4.0 or 2.12.0. No upper bound is technically established by this research. +If project policy excludes untested majors, an upper bound is a policy decision, +not a demonstrated incompatibility. Lock and test an exact EE combination +(2.12.0 is locally installed and catalog-certified), with pyVmomi +>=8.0.3.0.1 for 2.x. Do not raise this role's collection floor to 2.4 solely for +the shutdown-guest timeout fix: that is not the proposed operation. [9][12] + +The exact collection version's full Python requirements must be installed by +the EE build, not by Galaxy alone. At 2.12.0 the collection lists `pyVmomi` and +`vmware-vcenter`; the SOAP client also imports `requests`. A module-specific +minimal environment must ensure both pyVmomi and requests; a full collection EE +must retain the full dependency closure. Do not install the old 1.11 SDK git +requirement blindly into a modern production EE. [5][9] + +## vCenter 6.x and certification: evidence boundaries + +- The replacement uses pyVmomi `SmartConnect`, not the vmware_rest API. That + establishes a SOAP implementation, **not official support for every SOAP-era + vCenter version**. The inspected collection source/docs do not establish a + vCenter 6.0/6.5/6.7 support matrix. [2][5] +- pyVmomi 8.0.3.0.1 documents backward compatibility with the previous four + vSphere releases and warns that older versions may work without active + support. This SDK policy is not a collection certification or an exact + vCenter 6.x matrix, and unbounded SDK upgrades can change that baseline. [13] +- Upstream has real-vCenter integration tasks for MoID power-off and separate + unit mocks. The inspected integration workflow targets an environment named + `eco-vcenter`, without a published vCenter version matrix. Its suite also + uses REST modules. This proves test coverage exists, not that 6.x was tested + or officially supported. No live compatibility was verified here. [14] +- Local ansibleize policy ranks `vmware.vmware` as preferred certified content + above `community.vmware` (reference file, lines 15-53). Independently, Red + Hat's public Ecosystem Catalog labels `vmware.vmware` **Certified**, provided + by VMware, and lists certified version **2.12.0**. Upstream's README also + describes Red Hat Ansible Certified Content support. The Automation Hub URL + returned only the console shell in this session, so no authenticated Hub + artifact or historical 1.11.0 certification was verified. Certification does + not independently establish support for an EOL vCenter. [15] + +**Uncertainty to retain:** actual connectivity/behavior on each required 6.x +minor release and patch level, its supported SDK combination, and contractual +Red Hat/Broadcom support remain unverified. Replace existing blanket “covers +vCenter 6.x+” comments with this limitation; obtain vendor confirmation and an +authorized disposable-lab test before making a support claim. + +## Minimal proposed mutation task + +### Gathering is not a drop-in replacement + +The operator requires retaining vCenter 6.x, with the exact minor releases +currently unknown. Do not interpret approval of a full swap as permission to +drop that requirement. + +In inspected `vmware.vmware` 2.12.0, `vm_info` inherits `ModuleRestBase`. +Its constructor connects the REST client before creating its SOAP helper; +the REST client calls `create_vsphere_client`. Selecting `schema: vsphere` +does not bypass this initialization. Its output is a `vms` list rather than +the community module's single `instance` mapping, and a missing VM can return +an empty list. A full swap therefore needs API compatibility validation and +explicit response-shape adaptation, not just an FQCN change. [16][17][18] + +Ansibleize's certified-first policy does allow justified lower-tier content +when the higher-tier implementation cannot meet a required capability. Pending +confirmation of the legacy baseline, retain the SOAP-only gather as a proposed +compatibility exception; do not claim either module is officially supported on +all 6.x versions. Ask the operator to approve this narrower scope before +implementation. The certified SOAP power-state replacement remains the proposed +mutation path, subject to SDK/vendor compatibility verification. + +Scoped audit findings: + +- **MUST-FIX — Module hierarchy/compatibility:** replace the tombstoned power + module and declare the direct dependency. Do not introduce a REST-dependent + gather without establishing compatibility with the required legacy releases. +- **MUST-FIX — Task result accounting:** preserve real module failures and + classify mutation results instead of inferring success from pre-action facts. +- **MUST-FIX — Variable validation:** reject missing datacenter before an + enabled mutation; keep report-only mode read-only. +- **OK — Security contract:** retain toggleable secure logging, certificate + verification, MoID targeting, and the existing per-VM fail-closed gates. +- **SUGGEST — Testing:** automate installed-module resolution/schema checks and + mocked failed-mutation reporting; require authorized legacy lab validation + before advertising compatibility. + +Illustrative replacement only. Prevalidation must reject missing datacenter and +record a failed check without mutating. The reporting corrections above remain +required. Keep all existing plan/list/VM/disk gate computation. + +```yaml +- name: power_off | Power off validated source VMs (hard stop) + vmware.vmware.vm_powerstate: + hostname: "{{ validate_migration_vcenter_hostname }}" + username: "{{ validate_migration_vcenter_username }}" + password: "{{ validate_migration_vcenter_password }}" + validate_certs: "{{ validate_migration_vcenter_validate_certs | bool }}" + datacenter: "{{ validate_migration_vcenter_datacenter }}" + moid: "{{ __validate_migration_power_vm.instance.moid }}" + state: powered-off + loop: "{{ __validate_migration_power_facts.results | default([]) }}" + loop_control: + loop_var: __validate_migration_power_vm + label: "{{ __validate_migration_power_vm.item | default('unknown') }}" + when: + - validate_migration_power_off_source | bool + - validate_migration_vcenter_datacenter | length > 0 + - __validate_migration_power_gate.get(__validate_migration_power_vm.item, false) | bool + - not (__validate_migration_power_vm.failed | default(false) | bool) + - __validate_migration_power_vm.instance.moid is defined + - __validate_migration_power_vm.instance.moid | length > 0 + - (__validate_migration_power_vm.instance.hw_power_status | default('')) != 'poweredOff' + register: __validate_migration_power_results + ignore_errors: true + no_log: "{{ validate_migration_secure_logging }}" +``` + +## Deterministic test plan, no live power-off + +1. **Resolution/schema guard in CI/pre-commit:** run + `ansible-doc --json vmware.vmware.vm_powerstate` inside the actual EE and + parse its JSON. Fail unless the exact FQCN resolves, `moid` is a string, + `datacenter` is required, `powered-off` is a choice, `powered_off` is not, + and all task option names exist. Inspect the resolved installed argument + spec in a mocked module invocation to enforce mutual exclusion, required + datacenter, accepted types, and check-mode support. Documentation/schema + resolution alone does not execute validation. Test the lower supported + collection version and the locked production version; never accept lint's + mocked-module resolution as this guard. +2. **Mocked module tests:** patch connection/VM lookup/task monitoring before + entry, and deny real socket access. Assert already-off returns unchanged + with zero `PowerOff` calls; on-to-off calls it exactly once; check mode makes + zero mutation calls and predicts change. Assert `ShutdownGuest` and + `RebootGuest` are never called. Cover suspended state without force, missing + VM, invalid parameters, pending questions, login/TLS/library errors, VMware + task exception, and timeout after dispatch. Check actual failure payloads, + not a success-only stub. Upstream unit tests provide a starting pattern. [14] +3. **Mocked role tests:** intercept the real FQCN and record attempted calls. + For each false/missing fatal gate, disabled opt-in, failed lookup, empty + MoID/datacenter, and isolated `--tags poweroff`, assert zero mutations. + For two VMs with mixed validation outcomes, assert only the validated MoID + is called. Test failures on both lookup and power-off under Ansible's real + executor, ensuring the final check fails and never reports “Powered off”. + Cover skipped loop entries, already-off VMs, partial successes, malformed + results, and check-mode wording. Verify credentials remain censored. + +Executed here: local `ansible-doc --json` successfully resolved the installed +2.12.0 module and confirmed the documented parameters. No mock suite, EE build, +or live power operation ran. Implementation of the proposed guards/tests is +outside this research-only change. + +## Live validation addendum (2026-10-16, pool1) + +Read-only report-only run against the practice environment (OpenShift + +fake-vCenter emulator), plus an authorized fix-and-rerun of the failed e2e +migration. No credential values were exposed; the source was never mutated +in report-only mode. + +Verified live: + +- Both `vmware_guest_info` schemas work against the emulator: summary + (moid, hw_power_status, ipv4) and vsphere + (`config.hardware.device` → disks carrying `capacityInKB`) — the + disk-topology shape previously verified only against docs is confirmed + against a live endpoint. +- All seven checks produced correct verdicts on a failed migration; the + summary assert failed the play naming the failed checks; report-only mode + made no mutation and reported the source power state with its MoID. + +Two detail-path bugs found by the live run, fixed, and covered by new +offline regression cases (`plan failed`, `plan none` in +`tests/unit/test_validate_migration_power.py`): + +- Plan discovery: current forklift embeds the latest run's status snapshot + in `Plan.status.migration` (a dict, not the Migration CR name). The role + now discovers the latest Migration CR by `spec.plan.name` and + `creationTimestamp` instead. `mtv_migrate` knows its Migration name + because it created the object; validate_migration arrives after the fact and must + discover it. +- Disks `source_ok` conflated target-VM existence with vCenter lookup + success; a missing target VM reported "vCenter lookup failed … rotate the + exposed credential". `source_ok` now reflects only the source lookup, and + a missing target reports the honest disk-count mismatch. + +Environment finding (not a role issue): the pool1 practice nodes are Xeon +E5-2650 v2 (Ivy Bridge, no AVX2 — below the x86-64-v3 baseline). MTV 2.11.7 +and 2.12.10 both pass `--no-fstrim` to virt-v2v-inspector, but their shipped +rhel9 v2v images predate the flag, and their rhel10 images require +x86-64-v3 and die with SIGILL (exit 132) on those CPUs — no shipped image +combination can convert on that hardware. Nothing in this collection sets +the original `virt_v2v_image_fqin` pin that masked this. The fix applied on +pool1: `playbooks/utility_v2v_nofstrim_shim.yml` builds a shim image FROM +the installed MTV's own rhel9 v2v image (wrapping virt-v2v-inspector and +virt-v2v to strip only `--no-fstrim`, which the flag only uses to disable +an fstrim pass the rhel9 build never performed), pushes it to the cluster's +integrated registry (switched from Removed to Managed, emptyDir storage), +and pins `ForkliftController.virt_v2v_image_fqin` to it. With the shim the +conversion proceeds normally; the migration succeeds. + + + +[1]: https://github.com/ansible-collections/community.vmware/blob/7.0.0/meta/runtime.yml +[2]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/modules/vm_powerstate.py +[3]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/module_utils/_module_pyvmomi_base.py +[4]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/module_utils/argument_spec.py +[5]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/module_utils/clients/pyvmomi.py +[6]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/module_utils/_vsphere_tasks.py +[7]: https://github.com/ansible-collections/vmware.vmware/blob/1.11.0/plugins/modules/vm_powerstate.py +[8]: https://github.com/ansible/ansible/blob/stable-2.16/lib/ansible/executor/task_executor.py +[9]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/CHANGELOG.rst +[10]: https://github.com/ansible/ansible/blob/stable-2.16/setup.cfg +[11]: https://github.com/ansible-collections/community.vmware/blob/5.5.0/meta/runtime.yml +[12]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/requirements.txt +[13]: https://github.com/vmware/pyvmomi/blob/v8.0.3.0.1/README.rst +[14]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/tests/integration/targets/vm_powerstate/tasks/eco-vcenter.yml +[15]: https://catalog.redhat.com/en/software/collection/vmware/vmware +[16]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/modules/vm_info.py +[17]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/module_utils/_module_rest_base.py +[18]: https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/plugins/module_utils/clients/rest.py + +Additional exact-version evidence: + +- [Community removal changelog](https://github.com/ansible-collections/community.vmware/blob/7.0.0/CHANGELOG.rst) +- [1.11.0 core minimum](https://github.com/ansible-collections/vmware.vmware/blob/1.11.0/meta/runtime.yml), + [Python declaration](https://github.com/ansible-collections/vmware.vmware/blob/1.11.0/tests/config.yml), + [SDK requirements](https://github.com/ansible-collections/vmware.vmware/blob/1.11.0/requirements.txt) +- [2.12.0 core minimum](https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/meta/runtime.yml), + [Python declaration](https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/tests/config.yml), + [2.0.0 SDK requirements](https://github.com/ansible-collections/vmware.vmware/blob/2.0.0/requirements.txt) +- [Core 2.20 Python requirement](https://github.com/ansible/ansible/blob/stable-2.20/pyproject.toml), + [2.20 failure-override implementation](https://github.com/ansible/ansible/blob/stable-2.20/lib/ansible/executor/task_executor.py) +- [Upstream unit tests](https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/tests/unit/plugins/modules/test_vm_powerstate.py), + [integration workflow](https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/.github/workflows/ansible-integration.yaml) +- [Upstream certification/support statement](https://github.com/ansible-collections/vmware.vmware/blob/2.12.0/README.md), + [Automation Hub entry](https://console.redhat.com/ansible/automation-hub/repo/published/vmware/vmware/) diff --git a/galaxy.yml b/galaxy.yml index d15102a..2ef012f 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -26,6 +26,7 @@ dependencies: "redhat.openshift_virtualization": ">=2.1.0" "redhat.openshift": ">=4.0.0" "vmware.vmware_rest": ">=4.6.0" + "vmware.vmware": ">=1.11.0" # AAP 2.5 can leverage the latest version of the collection. 2.4 must use <=4.5.12 "ansible.controller": "*" "ansible.posix": ">=1.6.2" diff --git a/playbooks/utility_v2v_nofstrim_shim.yml b/playbooks/utility_v2v_nofstrim_shim.yml new file mode 100644 index 0000000..61b2cf5 --- /dev/null +++ b/playbooks/utility_v2v_nofstrim_shim.yml @@ -0,0 +1,339 @@ +--- +# ============================================================================= +# Workaround: MTV 2.11/2.12 migrations fail on pre-x86-64-v3 CPUs +# ============================================================================= +# +# PROBLEM +# On clusters whose nodes lack AVX2 (x86-64-v3; e.g. Xeon E5 v1/v2 practice +# hardware), MTV 2.11+ migrations fail during guest conversion with either: +# - virt-v2v-inspector: unrecognized option '--no-fstrim' +# (the controller passes a flag the rhel9 v2v image predates), or +# - conversion pod exit code 132 (SIGILL) +# (the default rhel10 v2v image requires x86-64-v3). +# Both shipped images are unusable on that hardware: rhel9 is too old for +# the controller's command line, rhel10 is too new for the CPU. +# +# FIX +# Build a shim image FROM the installed MTV's own rhel9 v2v image that +# wraps virt-v2v-inspector and virt-v2v, stripping only '--no-fstrim' +# (the flag disables an fstrim pass the rhel9 build never performs, so +# removing it restores the pre-flag behavior exactly). Push it to the +# cluster's integrated registry and pin ForkliftController.virt_v2v_image_fqin. +# +# REQUIREMENTS (controller running this playbook) +# - oc logged in with cluster-admin +# - podman (builds with --platform linux/amd64; works from Apple Silicon) +# +# USAGE +# ansible-playbook playbooks/utility_v2v_nofstrim_shim.yml \ +# -e v2v_shim_namespace=e2e-testing +# +# AFTER RUNNING +# Delete any terminal (failed) Migration CRs for the plan, then re-run the +# migration (vmf_migrate or the AAP job template). A Migration in a terminal +# state never re-executes. +# +# NOTES +# - The integrated registry is switched to Managed with emptyDir storage if +# it is Removed (set v2v_shim_ensure_registry=false to skip). emptyDir is +# ephemeral: re-run this playbook after a registry pod restart. +# - Idempotent: re-running rebuilds and re-pushes the same image and +# re-applies the same pin. +# - Verify the fix with a report-only validate_migration run before cutting over. +# ============================================================================= + +- name: Apply the virt-v2v no-fstrim shim for MTV on pre-x86-64-v3 CPUs + hosts: localhost + connection: local + gather_facts: false + + vars: + # Namespace where migration (conversion) pods run and where the shim + # image is pushed. Conversion pods use the namespace's default + # service account, which can pull images from its own namespace. + v2v_shim_namespace: e2e-testing + + # Flip the integrated registry Removed -> Managed when needed. + v2v_shim_ensure_registry: true + + # Best-effort informational CPU check (needs cluster-admin oc debug). + v2v_shim_check_cpus: true + + __v2v_shim_csv_namespace: openshift-mtv + + # Resolved during the run (lazily evaluated at use time). + __v2v_shim_build_dir: >- + {{ lookup('ansible.builtin.env', 'TMPDIR', default='/tmp') + ~ '/v2v-nofstrim-shim-' ~ (__v2v_shim_version | default('unknown')) }} + + tasks: + - name: Shim | Resolve the installed MTV operator CSV + kubernetes.core.k8s_info: + api_version: operators.coreos.com/v1alpha1 + kind: ClusterServiceVersion + namespace: "{{ __v2v_shim_csv_namespace }}" + register: __v2v_shim_csvs + + - name: Shim | Select the MTV operator CSV + ansible.builtin.set_fact: + __v2v_shim_csv: "{{ __v2v_shim_csvs.resources + | selectattr('metadata.name', 'match', '^mtv-operator\\.') + | sort(attribute='metadata.name') | last | default({}) }}" + + - name: Shim | Require an installed MTV operator + ansible.builtin.assert: + that: __v2v_shim_csv | length > 0 + fail_msg: >- + No mtv-operator CSV found in {{ __v2v_shim_csv_namespace }}. + Install MTV first. + quiet: true + + - name: Shim | Resolve the CSV's rhel9 v2v image + ansible.builtin.set_fact: + __v2v_shim_base_image: "{{ __v2v_shim_csv.spec.relatedImages + | selectattr('name', 'equalto', 'virt_v2v_rhel9') + | map(attribute='image') | first | default('') }}" + __v2v_shim_version: "{{ __v2v_shim_csv.metadata.name + | regex_replace('^mtv-operator\\.v', '') }}" + + - name: Shim | Require the rhel9 v2v image in the CSV + ansible.builtin.assert: + that: __v2v_shim_base_image | length > 0 + fail_msg: >- + {{ __v2v_shim_csv.metadata.name }} ships no virt_v2v_rhel9 image; + this workaround does not apply. Review its relatedImages. + quiet: true + + - name: Shim | Report the resolved base image + ansible.builtin.debug: + msg: >- + MTV {{ __v2v_shim_version }} rhel9 v2v base: + {{ __v2v_shim_base_image }} + + - name: Shim | Check node CPUs for the x86-64-v3 baseline + when: v2v_shim_check_cpus | bool + block: + - name: Shim | List worker nodes + # Command justification: oc node debugging has no module + # equivalent; reading /proc/cpuinfo requires the node context. + ansible.builtin.command: + cmd: oc get nodes -o name + register: __v2v_shim_nodes + changed_when: false + + - name: Shim | Read CPU flags from each node + ansible.builtin.command: + cmd: >- + oc debug {{ __v2v_shim_node }} -- chroot /host + sh -c 'grep -m1 -o avx2 /proc/cpuinfo || echo NO_AVX2' + loop: "{{ __v2v_shim_nodes.stdout_lines }}" + loop_control: + loop_var: __v2v_shim_node + register: __v2v_shim_cpu_checks + changed_when: false + failed_when: false + + - name: Shim | Warn when some node supports x86-64-v3 + ansible.builtin.debug: + msg: >- + NOTE: some nodes report AVX2 — the rhel10 default v2v image + may work there; this shim is only needed on nodes without it. + when: >- + __v2v_shim_cpu_checks.results + | selectattr('stdout', 'defined') + | selectattr('stdout', 'search', 'avx2') | list | length > 0 + + - name: Shim | Ensure the integrated registry is available + when: v2v_shim_ensure_registry | bool + block: + - name: Shim | Read the registry config + kubernetes.core.k8s_info: + api_version: imageregistry.operator.openshift.io/v1 + kind: Config + name: cluster + register: __v2v_shim_regconfig + + - name: Shim | Report a registry that was Removed + when: >- + (__v2v_shim_regconfig.resources | first).spec.managementState + | default('') == 'Removed' + ansible.builtin.debug: + msg: >- + Integrated registry was Removed — switching it to Managed with + emptyDir storage (ephemeral; re-run this playbook after a + registry pod restart). + + - name: Shim | Manage the registry (emptyDir storage) + kubernetes.core.k8s: + api_version: imageregistry.operator.openshift.io/v1 + kind: Config + name: cluster + state: patched + merge_type: merge + definition: + spec: + managementState: Managed + defaultRoute: true + storage: + emptyDir: {} + + - name: Shim | Wait for the registry deployment + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: Deployment + namespace: openshift-image-registry + name: image-registry + register: __v2v_shim_regdeploy + until: >- + (__v2v_shim_regdeploy.resources | first).status.readyReplicas + | default(0) > 0 + retries: 20 + delay: 15 + + - name: Shim | Resolve the registry public route + # Command justification: 'oc registry info' resolves the default + # route including its TLS setup; no module equivalent. + ansible.builtin.command: + cmd: oc registry info + register: __v2v_shim_registry + until: __v2v_shim_registry.stdout | length > 0 + retries: 10 + delay: 10 + changed_when: false + + - name: Shim | Trust the registry route certificate locally + # Command justification: extracting the serving chain into podman's + # certs.d is a local TLS operation with no module equivalent. + ansible.builtin.shell: | + set -o pipefail + mkdir -p ~/.config/containers/certs.d/{{ __v2v_shim_registry.stdout }} + echo | openssl s_client -connect {{ __v2v_shim_registry.stdout }}:443 \ + -servername {{ __v2v_shim_registry.stdout }} -showcerts 2>/dev/null \ + | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{print}' \ + > ~/.config/containers/certs.d/{{ __v2v_shim_registry.stdout }}/ca.crt + grep -q BEGIN ~/.config/containers/certs.d/{{ __v2v_shim_registry.stdout }}/ca.crt + args: + executable: /bin/bash + changed_when: true + + - name: Shim | Log the local container client into the registry + ansible.builtin.command: + cmd: oc registry login + changed_when: false + + - name: Shim | Stage the build directory + ansible.builtin.file: + path: "{{ __v2v_shim_build_dir }}" + state: directory + mode: "0755" + + - name: Shim | Stage the shim script + ansible.builtin.copy: + dest: "{{ __v2v_shim_build_dir }}/shim.py" + content: | + #!/usr/bin/python3 + """Strip --no-fstrim (this build predates it); exec the real binary.""" + import os + import sys + + real = os.path.abspath(__file__) + ".real" + args = [a for a in sys.argv[1:] if a != "--no-fstrim"] + os.execv(real, [sys.argv[0]] + args) + mode: "0644" + + - name: Shim | Stage the Containerfile + ansible.builtin.copy: + dest: "{{ __v2v_shim_build_dir }}/Containerfile" + content: | + FROM {{ __v2v_shim_base_image }} + + # Workaround image for MTV on pre-x86-64-v3 CPUs: the forklift + # controller passes --no-fstrim to virt-v2v-inspector, but this + # rhel9 build predates the flag; the rhel10 build that supports + # it requires the x86-64-v3 baseline. The flag only disables an + # fstrim pass this build never performs, so stripping it + # restores the pre-flag behavior exactly. + COPY shim.py /usr/bin/v2v-nofstrim-shim.py + RUN for b in virt-v2v-inspector virt-v2v; do \ + mv /usr/bin/$b /usr/bin/$b.real && \ + cp /usr/bin/v2v-nofstrim-shim.py /usr/bin/$b && \ + chmod 0755 /usr/bin/$b; \ + done + mode: "0644" + register: __v2v_shim_containerfile + + - name: Shim | Build the shim image (linux/amd64) + # Command justification: container builds are podman's domain. + ansible.builtin.command: + cmd: >- + podman build --platform linux/amd64 + --file {{ __v2v_shim_containerfile.dest }} + --tag v2v-nofstrim-shim:{{ __v2v_shim_version }} + {{ __v2v_shim_containerfile.dest | dirname }} + changed_when: true + + - name: Shim | Verify the shim strips only the target flag + # The shim must accept --no-fstrim (the workaround) and still reject + # unknown flags (no blanket argument swallowing). + ansible.builtin.command: + cmd: >- + podman run --rm --platform linux/amd64 --entrypoint sh + v2v-nofstrim-shim:{{ __v2v_shim_version }} -c + 'virt-v2v-inspector --no-fstrim --help >/dev/null 2>&1 + || exit 10; + virt-v2v-inspector --definitely-bogus-flag >/dev/null 2>&1 + && exit 20; + exit 0' + register: __v2v_shim_verify + changed_when: false + failed_when: __v2v_shim_verify.rc != 0 + + - name: Shim | Push the shim image to the integrated registry + ansible.builtin.command: + cmd: >- + podman push + v2v-nofstrim-shim:{{ __v2v_shim_version }} + {{ __v2v_shim_registry.stdout }}/{{ v2v_shim_namespace }}/v2v-nofstrim-shim:{{ __v2v_shim_version }} + changed_when: true + - name: Shim | Pin the ForkliftController to the shim image + kubernetes.core.k8s: + api_version: forklift.konveyor.io/v1beta1 + kind: ForkliftController + name: forklift + namespace: "{{ __v2v_shim_csv_namespace }}" + state: patched + merge_type: merge + definition: + spec: + virt_v2v_image_fqin: >- + {{ 'image-registry.openshift-image-registry.svc:5000/' ~ v2v_shim_namespace + ~ '/v2v-nofstrim-shim:' ~ __v2v_shim_version }} + + - name: Shim | Wait for the controller to render the pin + kubernetes.core.k8s_info: + api_version: apps/v1 + kind: Deployment + namespace: "{{ __v2v_shim_csv_namespace }}" + name: forklift-controller + register: __v2v_shim_controller + until: >- + (__v2v_shim_controller.resources | first).spec.template.spec.containers[0].env + | selectattr('name', 'equalto', 'VIRT_V2V_IMAGE') + | map(attribute='value') | join('') + is search('v2v-nofstrim-shim:' + __v2v_shim_version) + retries: 20 + delay: 15 + + - name: Shim | Report completion and next steps + ansible.builtin.debug: + msg: + - "Shim applied: MTV {{ __v2v_shim_version }} rhel9 v2v + --no-fstrim strip." + - "Pinned ForkliftController.virt_v2v_image_fqin to the integrated registry image." + - "Next: delete terminal (failed) Migration CRs for your plans —" + - " oc -n {{ v2v_shim_namespace }} delete migration " + - " (a Migration in a terminal state never re-executes) —" + - "then re-run the migration." + - "Note: registry storage is emptyDir; re-run this playbook after" + - "a registry pod restart." + +... diff --git a/playbooks/vmf_validate_migration.yml b/playbooks/vmf_validate_migration.yml new file mode 100644 index 0000000..d1894bd --- /dev/null +++ b/playbooks/vmf_validate_migration.yml @@ -0,0 +1,12 @@ +--- +- name: Validate migration outcomes after vmf_migrate completes + hosts: localhost + connection: local + gather_facts: false + + tasks: + - name: Run migration validation checks + ansible.builtin.import_role: + name: infra.openshift_virtualization_migration.validate_migration + +... diff --git a/requirements-dev.yml b/requirements-dev.yml index 89bc8da..8873332 100644 --- a/requirements-dev.yml +++ b/requirements-dev.yml @@ -7,6 +7,8 @@ collections: version: ">=4.0.0" - name: vmware.vmware_rest version: ">=4.6.0" + - name: vmware.vmware + version: ">=1.11.0" - name: ansible.controller version: ">=4.5.12" - name: ansible.posix diff --git a/roles/validate_migration/README.md b/roles/validate_migration/README.md new file mode 100644 index 0000000..3181d10 --- /dev/null +++ b/roles/validate_migration/README.md @@ -0,0 +1,591 @@ + +# validate_migration + +Validate migration outcomes after vmf_migrate completes. Runs every check and reports a summary, then fails the play if any check failed — one run reports everything that needs attention. + +| Check | What it verifies | +| --- | --- | +| Migration plan status | The Plan's latest Migration object reports `Succeeded` | +| Migrated VMs present and running | Every expected VM exists as a `VirtualMachine` in the target namespace with `printableStatus` `Running` | +| Target list matches | Every expected VM is present (expected subset); extra VMs in the namespace are reported as warnings | +| Guest agent responsive | `qemu-guest-agent` data present in the VMI status (warn-only) | +| IP addresses match | The migrated VM reports its source vCenter address (warn-only — DHCP may reassign the lease) | +| Disk topology matches | Source disk count (vCenter hardware) matches the migrated VM's data volume count | +| Source power-off | Hard-stops source VMs whose counterpart passed every fatal check — per-VM and fail-closed (docs/adr/0001-validate-migration-fail-closed-power-off.md); report-only when disabled | + +## Example + +```bash +ansible-playbook playbooks/vmf_validate_migration.yml \ + --inventory inventory-sample.yml \ + --extra-vars '{"mtv_migrate_plan_name": "plan-1", "mtv_migrate_plan_namespace": "migration", "validate_migration_vm_list": ["vm-a", "vm-b"]}' +``` + +From an AAP job template the target credential injects `K8S_AUTH_*` and the source credential injects `VMWARE_*` — the same role runs unchanged. The plan name and namespace default from `mtv_migrate_plan_name`/`mtv_migrate_plan_namespace`, so validate_migration runs seamlessly with the same extra vars as vmf_migrate. + +With the sample inventory the connection variables resolve from the target cluster host (`openshift_host`, `openshift_api_key`) and the source host (`host`, `username`, `password`) — selected by `validate_migration_target_name` and `validate_migration_source_name`, which default to the `target_name`/`source_name` extra vars (injected by the seeded credentials) or the first host of each group. + +All checks run on every invocation, and the summary assert requires every ran check green — the per-check tags (`plan`, `vms`, `list`, `agent`, `ip`, `disks`, `poweroff`) exist for isolated debugging; the summary asserts only over the checks that ran, so a tagged debug run ends green when the targeted check passed. A source whose `type` is not `vmware` (for example `ovirt`) skips the vCenter source checks. + +The source power-off is the only sanctioned mutation: per-VM and fail-closed, a source VM powers off only after its migrated counterpart passed every fatal check. Set `validate_migration_power_off_source: false` to run read-only. + +## VMware compatibility + +Gathering uses `community.vmware.vmware_guest_info` through SOAP only: the +certified `vmware.vmware.vm_info` also initializes REST, which is not an +acceptable prerequisite for unknown legacy vCenters and the demo emulator. +Power-off uses certified `vmware.vmware.vm_powerstate` (collection >=1.11.0), +also through SOAP, and requires `validate_migration_vcenter_datacenter` to be nonempty. +Missing datacenter fails the power check without issuing a power-off; it is +optional in report-only mode. + +SOAP transport is not a support guarantee. No legacy-vCenter or emulator +combination has yet been verified for this role. Validate each required +endpoint and execution-environment SDK combination in an authorized lab; +emulator success does not establish real-vCenter compatibility or vice versa. +Use report-only mode first. Missing/unsupported facts fail closed, and an +action error reports an unknown final power state, not a successful cutover. +MoID targets the object just gathered, but cannot distinguish an original +source from a VM recreated under the same name after migration. + +## Check Mode + +Supported for reading. The read probes (`kubernetes.core.k8s_info` queries and the `vmware_guest_info` lookups) run under `--check`, and no source VM is powered off — a check-mode run validates without mutating. Run without `--check` to cut over. + + + +## validate_migration + +``` +Role belongs to infra/openshift_virtualization_migration +Namespace - infra +Collection - openshift_virtualization_migration +Version - 1.25.0 +Repository - https://github.com/redhat-cop/openshift_virtualization_migration +``` + +Description: Validate migration outcomes after vmf_migrate completes — Migration status, migrated VM presence and state, target list match, guest agent, IP addresses, disk topology, and the fail-closed source power-off. + +### Argument Specifications + +
+🧩 Argument Specifications in `meta/argument_specs` + +#### Key: main + +* **Description**: ['Runs every validate_migration check and reports a summary, then fails the play if any check failed. Checks: migration plan status, migrated VM presence and running state, target list match, guest agent, IP addresses, disk topology, and the source power-off. An unconfigured target fails with guidance rather than crashing the play.', 'Designed to run from an AAP job template (credentials inject K8S_AUTH_* and VMWARE_*) or a controller with the sample inventory. Connection variables cascade from environment variables to inventory host variables.', 'The source power-off is the only sanctioned mutation: per-VM and fail-closed — a source VM powers off only after its counterpart passed every fatal check. Set validate_migration_power_off_source=false to run read-only.'] +* **Options**: + * **validate_migration_openshift_api_key**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: OpenShift API token. Resolves from K8S_AUTH_API_KEY env var or the openshift_api_key variable on the target cluster host (validate_migration_target_name). Required — the validate_migration aborts without it. + * **validate_migration_openshift_host**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: OpenShift API endpoint. Resolves from K8S_AUTH_HOST env var or the openshift_host variable on the target cluster host (validate_migration_target_name). Required — the validate_migration aborts without it. + * **validate_migration_openshift_verify_ssl**: + * **Required**: False + * **Type**: bool + * **Default**: True + * **Description**: Validate the OpenShift API TLS certificate. + * **validate_migration_plan_name**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: Name of the MTV (Forklift) Plan whose migration validate_migration validates. Defaults to the mtv_migrate_plan_name variable so validate_migration runs seamlessly after vmf_migrate. Required — the validate_migration aborts without it. + * **validate_migration_plan_namespace**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: Namespace holding the migration Plan and its Migration objects. Defaults to the mtv_migrate_plan_namespace variable. Required — the validate_migration aborts without it. + * **validate_migration_power_off_source**: + * **Required**: False + * **Type**: bool + * **Default**: True + * **Description**: Power off source VMs whose migrated counterpart passed every fatal check (per-VM, fail-closed — a failed check leaves that source running). The power-off is a hard stop via the vCenter API and the only sanctioned mutation in this role. Set false to run read-only: the role reports source power state without mutating. + * **validate_migration_secure_logging**: + * **Required**: False + * **Type**: bool + * **Default**: True + * **Description**: Whether to enable secure logging for tasks that handle credentials. Set to false to debug failed probes. Prefer a one-off CLI run (--tags ) over flipping the toggle on a job template; if you must, rotate the exposed credential afterwards. + * **validate_migration_source_name**: + * **Required**: False + * **Type**: str + * **Default**: none + * **Description**: Inventory host whose variables feed the vCenter checks. Defaults to the source_name extra var (injected by the seeded credentials) or the first vm_sources host. + * **validate_migration_target_name**: + * **Required**: False + * **Type**: str + * **Default**: none + * **Description**: Inventory host whose variables feed the OpenShift checks. Defaults to the target_name extra var (injected by the seeded credentials) or the first migration_clusters host. + * **validate_migration_target_namespace**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: Namespace holding the migrated VirtualMachines — the Plan's target namespace. Defaults to the Plan namespace. + * **validate_migration_vcenter_datacenter**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: Datacenter for source VM lookup and power-off. Required for enabled power-off; optional for read-only gathering. A missing value fails the power check without mutating any source VM. + * **validate_migration_vcenter_hostname**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: vCenter endpoint for the source VM checks. Resolves from VMWARE_HOST env var or the host variable on the source host (validate_migration_source_name). When unset, the source checks fail with instructions. + * **validate_migration_vcenter_password**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: vCenter password. Resolves from VMWARE_PASSWORD env var or the password variable on the source host (validate_migration_source_name). When validate_migration_power_off_source is true, the credential needs the power off permission. + * **validate_migration_vcenter_username**: + * **Required**: False + * **Type**: str + * **Default**: + * **Description**: vCenter username. Resolves from VMWARE_USER env var or the username variable on the source host (validate_migration_source_name). + * **validate_migration_vcenter_validate_certs**: + * **Required**: False + * **Type**: bool + * **Default**: True + * **Description**: Validate the vCenter TLS certificate. + * **validate_migration_vm_list**: + * **Required**: False + * **Type**: list + * **Default**: [] + * **Description**: Explicit list of source VM names to validate. MTV preserves VM names, so each name is also the migrated VirtualMachine's name in the target namespace. Required — the validate_migration aborts with an empty list. + +
+ +### Defaults + +**These are static variables with lower priority** + +#### File: defaults/main.yml + +| Var | Type | Value |Choices |Required | Title | +|--------------|--------------|-------------|-------------|-------------|-------------| +| [`validate_migration_openshift_api_key`](defaults/main.yml#L44) | str | `` | None | True | OpenShift API Key | +| [`validate_migration_openshift_host`](defaults/main.yml#L31) | str | `` | None | True | OpenShift Host | +| [`validate_migration_openshift_verify_ssl`](defaults/main.yml#L61) | str | `{{ (lookup('env', 'K8S_AUTH_VERIFY_SSL') ¦ default(hostvars[validate_migration_target_name]['openshift_verify_ssl'] ¦ default(true), true)) ¦ bool }}` | None | False | OpenShift Verify SSL | +| [`validate_migration_plan_name`](defaults/main.yml#L128) | str | `{{ mtv_migrate_plan_name ¦ default('', true) }}` | None | True | Migration Plan Name | +| [`validate_migration_plan_namespace`](defaults/main.yml#L135) | str | `{{ mtv_migrate_plan_namespace ¦ default('', true) }}` | None | True | Migration Plan Namespace | +| [`validate_migration_power_off_source`](defaults/main.yml#L161) | bool | `True` | None | False | Source Power-Off | +| [`validate_migration_secure_logging`](defaults/main.yml#L23) | str | `{{ secure_logging ¦ default(true) }}` | None | None | None | +| [`validate_migration_source_name`](defaults/main.yml#L16) | str | `{{ source_name ¦ default(groups['vm_sources'] ¦ default([], true) ¦ first ¦ default('', true), true) }}` | None | None | None | +| [`validate_migration_target_name`](defaults/main.yml#L13) | str | `{{ target_name ¦ default(groups['migration_clusters'] ¦ default([], true) ¦ first ¦ default('', true), true) }}` | None | None | None | +| [`validate_migration_target_namespace`](defaults/main.yml#L142) | str | `{{ validate_migration_plan_namespace }}` | None | False | Target Namespace | +| [`validate_migration_vcenter_datacenter`](defaults/main.yml#L119) | str | `` | None | False | vCenter Datacenter | +| [`validate_migration_vcenter_hostname`](defaults/main.yml#L71) | str | `` | None | False | vCenter Hostname | +| [`validate_migration_vcenter_password`](defaults/main.yml#L96) | str | `` | None | False | vCenter Password | +| [`validate_migration_vcenter_username`](defaults/main.yml#L82) | str | `` | None | False | vCenter Username | +| [`validate_migration_vcenter_validate_certs`](defaults/main.yml#L110) | str | `{{ (lookup('env', 'VMWARE_VALIDATE_CERTS') ¦ default(hostvars[validate_migration_source_name]['validate_certs'] ¦ default(true), true)) ¦ bool }}` | None | False | vCenter Verify SSL | +| [`validate_migration_vm_list`](defaults/main.yml#L150) | list | `[]` | None | True | VM List | + +🖇️ Full descriptions for vars in defaults/main.yml +
+`validate_migration_openshift_api_key`: >- +
+`validate_migration_openshift_host`: >- +
+`validate_migration_openshift_verify_ssl`: >- +
+`validate_migration_plan_name`: >- +
+`validate_migration_plan_namespace`: >- +
+`validate_migration_power_off_source`: >- +
+`validate_migration_secure_logging`: None +
+`validate_migration_source_name`: None +
+`validate_migration_target_name`: None +
+`validate_migration_target_namespace`: >- +
+`validate_migration_vcenter_datacenter`: >- +
+`validate_migration_vcenter_hostname`: >- +
+`validate_migration_vcenter_password`: >- +
+`validate_migration_vcenter_username`: >- +
+`validate_migration_vcenter_validate_certs`: >- +
+`validate_migration_vm_list`: >- +
+
+ +### Vars + +**These are variables with higher priority** + +#### File: vars/main.yml + +| Var | Type | Value | +|--------------|--------------|-------------| +| [__validate_migration_source_type](vars/main.yml#L4) | str | `{{ hostvars[validate_migration_source_name]['type'] ¦ default('', true) }}` | + +### Tasks + +#### File: tasks/main.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| Include pre-validation of required variables | `ansible.builtin.include_tasks` | False | +| Run migration validation checks | `block` | False | +| Include migration plan status check | `ansible.builtin.include_tasks` | False | +| Include migrated VM presence and state check | `ansible.builtin.include_tasks` | False | +| Include target list match check | `ansible.builtin.include_tasks` | False | +| Include guest agent check | `ansible.builtin.include_tasks` | False | +| Include IP address check | `ansible.builtin.include_tasks` | False | +| Include disk topology check | `ansible.builtin.include_tasks` | False | +| Include the source power-off action | `ansible.builtin.include_tasks` | False | + +#### File: tasks/power_off.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| power_off ¦ Gather source VM facts (moid and power state) | `community.vmware.vmware_guest_info` | True | +| power_off ¦ Compute the per-VM fail-closed gate | `ansible.builtin.set_fact` | False | +| power_off ¦ Normalize the source lookup results | `ansible.builtin.set_fact` | False | +| power_off ¦ Power off validated source VMs (hard stop) | `vmware.vmware.vm_powerstate` | True | +| power_off ¦ Classify and record the source power-off results | `ansible.builtin.include_tasks` | False | + +#### File: tasks/power_report.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| power_report ¦ Classify each expected source VM | `ansible.builtin.set_fact` | False | +| power_report ¦ Record the source power-off check | `ansible.builtin.set_fact` | False | + +#### File: tasks/pre_validate.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| pre_validate ¦ Validate required OpenShift connection variables | `ansible.builtin.assert` | False | +| pre_validate ¦ Validate required migration identifiers | `ansible.builtin.assert` | False | +| pre_validate ¦ Reject plaintext credential-bearing endpoints | `ansible.builtin.assert` | False | + +#### File: tasks/report.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| report ¦ Build the validate_migration summary | `ansible.builtin.set_fact` | False | +| report ¦ Display the validate_migration summary | `ansible.builtin.debug` | False | +| report ¦ Assert every ran validate_migration check passed | `ansible.builtin.assert` | False | +| report ¦ Remind the operator to rotate credentials exposed in debug output | `ansible.builtin.debug` | True | + +#### File: tasks/validate_agent.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| validate_agent ¦ Query VirtualMachineInstances in the target namespace | `kubernetes.core.k8s_info` | False | +| validate_agent ¦ Build the per-VM agent status map | `ansible.builtin.set_fact` | False | +| validate_agent ¦ Record the guest agent check result | `ansible.builtin.set_fact` | False | + +#### File: tasks/validate_disks.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| validate_disks ¦ Gather source disk topology from vCenter | `community.vmware.vmware_guest_info` | True | +| validate_disks ¦ Query the migrated VirtualMachines' volume counts | `kubernetes.core.k8s_info` | False | +| validate_disks ¦ Build the per-VM disk comparison map | `ansible.builtin.set_fact` | False | +| validate_disks ¦ Count the disk topology mismatches | `ansible.builtin.set_fact` | False | +| validate_disks ¦ Record the disk topology check result | `ansible.builtin.set_fact` | False | + +#### File: tasks/validate_ip.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| validate_ip ¦ Gather source VM facts from vCenter | `community.vmware.vmware_guest_info` | True | +| validate_ip ¦ Query VirtualMachineInstances for the target addresses | `kubernetes.core.k8s_info` | False | +| validate_ip ¦ Build the per-VM address comparison map | `ansible.builtin.set_fact` | False | +| validate_ip ¦ Record the IP address check result | `ansible.builtin.set_fact` | False | + +#### File: tasks/validate_list.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| validate_list ¦ Query migrated VirtualMachines in the target namespace | `kubernetes.core.k8s_info` | False | +| validate_list ¦ Resolve the found VM names | `ansible.builtin.set_fact` | False | +| validate_list ¦ Split the found VMs into missing and extra | `ansible.builtin.set_fact` | False | +| validate_list ¦ Record the target list check result | `ansible.builtin.set_fact` | False | + +#### File: tasks/validate_plan.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| validate_plan ¦ Query the migration Plan | `kubernetes.core.k8s_info` | False | +| validate_plan ¦ Query Migration objects in the plan namespace | `kubernetes.core.k8s_info` | False | +| validate_plan ¦ Resolve the latest Migration object for the plan | `ansible.builtin.set_fact` | False | +| validate_plan ¦ Record the resolved Migration object name | `ansible.builtin.set_fact` | False | +| validate_plan ¦ Interpret the Migration status | `infra.openshift_virtualization_migration.mtv_migration_status` | True | +| validate_plan ¦ Record the plan status check result | `ansible.builtin.set_fact` | False | + +#### File: tasks/validate_vms.yml + +| Name | Module | Has Conditions | +| ---- | ------ | --------- | +| validate_vms ¦ Query migrated VirtualMachines in the target namespace | `kubernetes.core.k8s_info` | False | +| validate_vms ¦ Build the per-VM presence and state map | `ansible.builtin.set_fact` | False | +| validate_vms ¦ Record the migrated VM check result | `ansible.builtin.set_fact` | False | + +## Task Flow Graphs + +### Graph for main.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Include task| Include_pre_validation_of_required_variables_pre_validate_yml_0[include pre validation of required variables
include_task: pre validate yml]:::includeTasks + Include_pre_validation_of_required_variables_pre_validate_yml_0-->|Block Start| Run_migration_validation_checks1_block_start_0[[run migration validation checks]]:::block + Run_migration_validation_checks1_block_start_0-->|Include task| Include_migration_plan_status_check_validate_plan_yml_0[include migration plan status check
include_task: validate plan yml]:::includeTasks + Include_migration_plan_status_check_validate_plan_yml_0-->|Include task| Include_migrated_VM_presence_and_state_check_validate_vms_yml_1[include migrated vm presence and state check
include_task: validate vms yml]:::includeTasks + Include_migrated_VM_presence_and_state_check_validate_vms_yml_1-->|Include task| Include_target_list_match_check_validate_list_yml_2[include target list match check
include_task: validate list yml]:::includeTasks + Include_target_list_match_check_validate_list_yml_2-->|Include task| Include_guest_agent_check_validate_agent_yml_3[include guest agent check
include_task: validate agent yml]:::includeTasks + Include_guest_agent_check_validate_agent_yml_3-->|Include task| Include_IP_address_check_validate_ip_yml_4[include ip address check
include_task: validate ip yml]:::includeTasks + Include_IP_address_check_validate_ip_yml_4-->|Include task| Include_disk_topology_check_validate_disks_yml_5[include disk topology check
include_task: validate disks yml]:::includeTasks + Include_disk_topology_check_validate_disks_yml_5-->|Include task| Include_the_source_power_off_action_power_off_yml_6[include the source power off action
include_task: power off yml]:::includeTasks + Include_the_source_power_off_action_power_off_yml_6-.->|End of Block| Run_migration_validation_checks1_block_start_0 + Include_the_source_power_off_action_power_off_yml_6-->End +``` + +### Graph for power_off.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| power_off___Gather_source_VM_facts__moid_and_power_state_0[power off gather source vm facts moid and power
state
When: **validate migration vcenter hostname length 0
and validate migration source type length 0
or validate migration source type vmware**]:::task + power_off___Gather_source_VM_facts__moid_and_power_state_0-->|Task| power_off___Compute_the_per_VM_fail_closed_gate1[power off compute the per vm fail closed gate]:::task + power_off___Compute_the_per_VM_fail_closed_gate1-->|Task| power_off___Normalize_the_source_lookup_results2[power off normalize the source lookup results]:::task + power_off___Normalize_the_source_lookup_results2-->|Task| power_off___Power_off_validated_source_VMs__hard_stop_3[power off power off validated source vms hard
stop
When: **validate migration power off source bool and not
ansible check mode and validate migration vcenter
datacenter trim length 0 and validate
migration power gate get validate migration
power name false bool and validate migration
power sources validate migration power name
valid bool and validate migration power
sources validate migration power name instance
hw power status poweredon**]:::task + power_off___Power_off_validated_source_VMs__hard_stop_3-->|Include task| power_off___Classify_and_record_the_source_power_off_results_power_report_yml_4[power off classify and record the source power
off results
include_task: power report yml]:::includeTasks + power_off___Classify_and_record_the_source_power_off_results_power_report_yml_4-->End +``` + +### Graph for power_report.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| power_report___Classify_each_expected_source_VM0[power report classify each expected source vm]:::task + power_report___Classify_each_expected_source_VM0-->|Task| power_report___Record_the_source_power_off_check1[power report record the source power off check]:::task + power_report___Record_the_source_power_off_check1-->End +``` + +### Graph for pre_validate.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| pre_validate___Validate_required_OpenShift_connection_variables0[pre validate validate required openshift
connection variables]:::task + pre_validate___Validate_required_OpenShift_connection_variables0-->|Task| pre_validate___Validate_required_migration_identifiers1[pre validate validate required migration
identifiers]:::task + pre_validate___Validate_required_migration_identifiers1-->|Task| pre_validate___Reject_plaintext_credential_bearing_endpoints2[pre validate reject plaintext credential bearing
endpoints]:::task + pre_validate___Reject_plaintext_credential_bearing_endpoints2-->End +``` + +### Graph for report.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| report___Build_the_validate_migration_summary0[report build the validate migration summary]:::task + report___Build_the_validate_migration_summary0-->|Task| report___Display_the_validate_migration_summary1[report display the validate migration summary]:::task + report___Display_the_validate_migration_summary1-->|Task| report___Assert_every_ran_validate_migration_check_passed2[report assert every ran validate migration check
passed]:::task + report___Assert_every_ran_validate_migration_check_passed2-->|Task| report___Remind_the_operator_to_rotate_credentials_exposed_in_debug_output3[report remind the operator to rotate credentials
exposed in debug output
When: **not validate migration secure logging default
true bool**]:::task + report___Remind_the_operator_to_rotate_credentials_exposed_in_debug_output3-->End +``` + +### Graph for validate_agent.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| validate_agent___Query_VirtualMachineInstances_in_the_target_namespace0[validate agent query virtualmachineinstances in
the target namespace]:::task + validate_agent___Query_VirtualMachineInstances_in_the_target_namespace0-->|Task| validate_agent___Build_the_per_VM_agent_status_map1[validate agent build the per vm agent status map]:::task + validate_agent___Build_the_per_VM_agent_status_map1-->|Task| validate_agent___Record_the_guest_agent_check_result2[validate agent record the guest agent check
result]:::task + validate_agent___Record_the_guest_agent_check_result2-->End +``` + +### Graph for validate_disks.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| validate_disks___Gather_source_disk_topology_from_vCenter0[validate disks gather source disk topology from
vcenter
When: **validate migration vcenter hostname length 0
and validate migration source type length 0
or validate migration source type vmware**]:::task + validate_disks___Gather_source_disk_topology_from_vCenter0-->|Task| validate_disks___Query_the_migrated_VirtualMachines__volume_counts1[validate disks query the migrated
virtualmachines volume counts]:::task + validate_disks___Query_the_migrated_VirtualMachines__volume_counts1-->|Task| validate_disks___Build_the_per_VM_disk_comparison_map2[validate disks build the per vm disk comparison
map]:::task + validate_disks___Build_the_per_VM_disk_comparison_map2-->|Task| validate_disks___Count_the_disk_topology_mismatches3[validate disks count the disk topology
mismatches]:::task + validate_disks___Count_the_disk_topology_mismatches3-->|Task| validate_disks___Record_the_disk_topology_check_result4[validate disks record the disk topology check
result]:::task + validate_disks___Record_the_disk_topology_check_result4-->End +``` + +### Graph for validate_ip.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| validate_ip___Gather_source_VM_facts_from_vCenter0[validate ip gather source vm facts from vcenter
When: **validate migration vcenter hostname length 0
and validate migration source type length 0
or validate migration source type vmware**]:::task + validate_ip___Gather_source_VM_facts_from_vCenter0-->|Task| validate_ip___Query_VirtualMachineInstances_for_the_target_addresses1[validate ip query virtualmachineinstances for
the target addresses]:::task + validate_ip___Query_VirtualMachineInstances_for_the_target_addresses1-->|Task| validate_ip___Build_the_per_VM_address_comparison_map2[validate ip build the per vm address comparison
map]:::task + validate_ip___Build_the_per_VM_address_comparison_map2-->|Task| validate_ip___Record_the_IP_address_check_result3[validate ip record the ip address check result]:::task + validate_ip___Record_the_IP_address_check_result3-->End +``` + +### Graph for validate_list.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| validate_list___Query_migrated_VirtualMachines_in_the_target_namespace0[validate list query migrated virtualmachines in
the target namespace]:::task + validate_list___Query_migrated_VirtualMachines_in_the_target_namespace0-->|Task| validate_list___Resolve_the_found_VM_names1[validate list resolve the found vm names]:::task + validate_list___Resolve_the_found_VM_names1-->|Task| validate_list___Split_the_found_VMs_into_missing_and_extra2[validate list split the found vms into missing
and extra]:::task + validate_list___Split_the_found_VMs_into_missing_and_extra2-->|Task| validate_list___Record_the_target_list_check_result3[validate list record the target list check
result]:::task + validate_list___Record_the_target_list_check_result3-->End +``` + +### Graph for validate_plan.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| validate_plan___Query_the_migration_Plan0[validate plan query the migration plan]:::task + validate_plan___Query_the_migration_Plan0-->|Task| validate_plan___Query_Migration_objects_in_the_plan_namespace1[validate plan query migration objects in the
plan namespace]:::task + validate_plan___Query_Migration_objects_in_the_plan_namespace1-->|Task| validate_plan___Resolve_the_latest_Migration_object_for_the_plan2[validate plan resolve the latest migration
object for the plan]:::task + validate_plan___Resolve_the_latest_Migration_object_for_the_plan2-->|Task| validate_plan___Record_the_resolved_Migration_object_name3[validate plan record the resolved migration
object name]:::task + validate_plan___Record_the_resolved_Migration_object_name3-->|Task| validate_plan___Interpret_the_Migration_status4[validate plan interpret the migration status
When: **validate migration latest migration length 0**]:::task + validate_plan___Interpret_the_Migration_status4-->|Task| validate_plan___Record_the_plan_status_check_result5[validate plan record the plan status check
result]:::task + validate_plan___Record_the_plan_status_check_result5-->End +``` + +### Graph for validate_vms.yml + +```mermaid +flowchart TD +Start +classDef block stroke:#3498db,stroke-width:2px; +classDef task stroke:#4b76bb,stroke-width:2px; +classDef includeTasks stroke:#16a085,stroke-width:2px; +classDef importTasks stroke:#34495e,stroke-width:2px; +classDef includeRole stroke:#2980b9,stroke-width:2px; +classDef importRole stroke:#699ba7,stroke-width:2px; +classDef includeVars stroke:#8e44ad,stroke-width:2px; +classDef rescue stroke:#665352,stroke-width:2px; + + Start-->|Task| validate_vms___Query_migrated_VirtualMachines_in_the_target_namespace0[validate vms query migrated virtualmachines in
the target namespace]:::task + validate_vms___Query_migrated_VirtualMachines_in_the_target_namespace0-->|Task| validate_vms___Build_the_per_VM_presence_and_state_map1[validate vms build the per vm presence and state
map]:::task + validate_vms___Build_the_per_VM_presence_and_state_map1-->|Task| validate_vms___Record_the_migrated_VM_check_result2[validate vms record the migrated vm check result]:::task + validate_vms___Record_the_migrated_VM_check_result2-->End +``` + +## Author Information + +Red Hat + +## License + +GPL-3.0-only + +## Minimum Ansible Version + +2.16 + +## Platforms + +* **EL**: ['all'] + + \ No newline at end of file diff --git a/roles/validate_migration/defaults/main.yml b/roles/validate_migration/defaults/main.yml new file mode 100644 index 0000000..fd1526d --- /dev/null +++ b/roles/validate_migration/defaults/main.yml @@ -0,0 +1,163 @@ +--- +# Migration validation role defaults. +# +# Connection variables cascade from AAP-injected environment variables to +# inventory host variables resolved through the target and source host +# names below, so the role runs identically from an AAP job template +# (target credential injects K8S_AUTH_*, source credential injects +# VMWARE_*) and from a controller with the sample inventory. + +# Resolver options — the inventory hosts whose variables feed the checks. +# target_name and source_name match the names the seeded credentials +# inject as extra vars; each falls back to the first host of its group. +validate_migration_target_name: "{{ target_name + | default(groups['migration_clusters'] | default([], true) + | first | default('', true), true) }}" +validate_migration_source_name: "{{ source_name + | default(groups['vm_sources'] | default([], true) + | first | default('', true), true) }}" + +# Secure logging toggle for tasks that handle credentials. +# NEVER hardcode no_log: true — always reference this variable so failures +# can be debugged with validate_migration_secure_logging=false. +validate_migration_secure_logging: "{{ secure_logging | default(true) }}" + +# title: OpenShift Host +# required: True +# description: >- +# OpenShift API endpoint. Cascades from K8S_AUTH_HOST env var (injected +# by the AAP target credential) or the openshift_host variable on the +# target cluster host (validate_migration_target_name). +validate_migration_openshift_host: >- + {{ + lookup('env', 'K8S_AUTH_HOST') + | default(hostvars[validate_migration_target_name]['openshift_host'] | default('', true), true) + }} + +# title: OpenShift API Key +# required: True +# description: >- +# OpenShift API token. Cascades from K8S_AUTH_API_KEY env var (injected +# by the AAP target credential) or the openshift_api_key variable on the +# target cluster host (validate_migration_target_name), falling back to +# openshift_temporary_api_key. +validate_migration_openshift_api_key: >- + {{ + lookup('env', 'K8S_AUTH_API_KEY') + | default( + hostvars[validate_migration_target_name]['openshift_api_key'] + | default(hostvars[validate_migration_target_name]['openshift_temporary_api_key'] | default('', true), true), + true + ) + }} + +# title: OpenShift Verify SSL +# required: False +# description: >- +# Validate the OpenShift API TLS certificate. Cascades from +# K8S_AUTH_VERIFY_SSL env var or the openshift_verify_ssl variable on +# the target cluster host. Defaults to true; the bool cast keeps the +# argument-spec type honest when a raw env string feeds the value. +validate_migration_openshift_verify_ssl: "{{ (lookup('env', 'K8S_AUTH_VERIFY_SSL') + | default(hostvars[validate_migration_target_name]['openshift_verify_ssl'] | default(true), true)) | bool }}" + +# title: vCenter Hostname +# required: False +# description: >- +# vCenter endpoint for the source VM checks. Cascades from VMWARE_HOST +# env var (injected by the AAP source credential) or the host variable +# on the source host (validate_migration_source_name). When unset, the source +# checks fail with instructions. +validate_migration_vcenter_hostname: >- + {{ + lookup('env', 'VMWARE_HOST') + | default(hostvars[validate_migration_source_name]['host'] | default('', true), true) + }} + +# title: vCenter Username +# required: False +# description: >- +# vCenter username. Cascades from VMWARE_USER env var or the username +# variable on the source host (validate_migration_source_name). +validate_migration_vcenter_username: >- + {{ + lookup('env', 'VMWARE_USER') + | default(hostvars[validate_migration_source_name]['username'] | default('', true), true) + }} + +# title: vCenter Password +# required: False +# description: >- +# vCenter password. Cascades from VMWARE_PASSWORD env var or the +# password variable on the source host (validate_migration_source_name). When +# the source power-off toggle is on, the credential needs the power +# off permission — a read-only credential validates but cannot cut +# over. +validate_migration_vcenter_password: >- + {{ + lookup('env', 'VMWARE_PASSWORD') + | default(hostvars[validate_migration_source_name]['password'] | default('', true), true) + }} + +# title: vCenter Verify SSL +# required: False +# description: >- +# Validate the vCenter TLS certificate. Cascades from +# VMWARE_VALIDATE_CERTS env var or the validate_certs variable on the +# source host. Defaults to true. The inner default carries a single +# argument so an explicit false survives the cascade — a two-argument +# default would convert the inventory false to true. +validate_migration_vcenter_validate_certs: "{{ (lookup('env', 'VMWARE_VALIDATE_CERTS') + | default(hostvars[validate_migration_source_name]['validate_certs'] | default(true), true)) | bool }}" + +# title: vCenter Datacenter +# required: False +# description: >- +# Datacenter to look source VMs up in. Required only when a VM name is +# not unique across the vCenter. Cascades from the datacenter variable +# on the source host. +validate_migration_vcenter_datacenter: >- + {{ hostvars[validate_migration_source_name]['datacenter'] | default('', true) }} + +# title: Migration Plan Name +# required: True +# description: >- +# Name of the MTV (Forklift) Plan whose migration validate_migration validates. +# Defaults to the mtv_migrate_plan_name variable so validate_migration runs +# seamlessly after vmf_migrate with the same extra vars. +validate_migration_plan_name: "{{ mtv_migrate_plan_name | default('', true) }}" + +# title: Migration Plan Namespace +# required: True +# description: >- +# Namespace holding the migration Plan and its Migration objects. +# Defaults to the mtv_migrate_plan_namespace variable. +validate_migration_plan_namespace: "{{ mtv_migrate_plan_namespace | default('', true) }}" + +# title: Target Namespace +# required: False +# description: >- +# Namespace holding the migrated VirtualMachines — the Plan's target +# namespace. Defaults to the Plan namespace. +validate_migration_target_namespace: "{{ validate_migration_plan_namespace }}" + +# title: VM List +# required: True +# description: >- +# Explicit list of source VM names to validate. MTV preserves VM +# names, so each name is also the migrated VirtualMachine's name in +# the target namespace. +validate_migration_vm_list: [] + +# title: Source Power-Off +# required: False +# description: >- +# Power off source VMs whose migrated counterpart passed every fatal +# check (per-VM, fail-closed — see docs/adr/0001-validate_migration-fail-closed +# -power-off.md). The power-off is a hard stop via the vCenter API and +# the only sanctioned mutation in this role. Set false to run +# read-only: the role then reports source power state without +# mutating. +validate_migration_power_off_source: true + +... diff --git a/roles/validate_migration/meta/argument_specs.yml b/roles/validate_migration/meta/argument_specs.yml new file mode 100644 index 0000000..2045c64 --- /dev/null +++ b/roles/validate_migration/meta/argument_specs.yml @@ -0,0 +1,175 @@ +--- +argument_specs: + main: + short_description: >- + Validate migration outcomes after vmf_migrate completes + description: + - >- + Runs every validate_migration check and reports a summary, then fails the + play if any check failed. Checks: migration plan status, migrated + VM presence and running state, target list match, guest agent, + IP addresses, disk topology, and the source power-off. An + unconfigured target fails with guidance rather than crashing the + play. + - >- + Designed to run from an AAP job template (credentials inject + K8S_AUTH_* and VMWARE_*) or a controller with the sample + inventory. Connection variables cascade from environment + variables to inventory host variables. + - >- + The source power-off is the only sanctioned mutation: per-VM and + fail-closed — a source VM powers off only after its counterpart + passed every fatal check. Set validate_migration_power_off_source=false + to run read-only. + options: + validate_migration_secure_logging: + type: bool + required: false + default: true + description: >- + Whether to enable secure logging for tasks that handle + credentials. Set to false to debug failed probes. Prefer a + one-off CLI run (--tags ) over flipping the toggle on a + job template; if you must, rotate the exposed credential + afterwards. + + validate_migration_target_name: + type: str + required: false + description: >- + Inventory host whose variables feed the OpenShift checks. + Defaults to the target_name extra var (injected by the seeded + credentials) or the first migration_clusters host. + + validate_migration_source_name: + type: str + required: false + description: >- + Inventory host whose variables feed the vCenter checks. + Defaults to the source_name extra var (injected by the seeded + credentials) or the first vm_sources host. + + validate_migration_openshift_host: + type: str + required: false + default: "" + description: >- + OpenShift API endpoint. Resolves from K8S_AUTH_HOST env var or + the openshift_host variable on the target cluster host + (validate_migration_target_name). Required — the validate_migration aborts + without it. + + validate_migration_openshift_api_key: + type: str + required: false + default: "" + no_log: true + description: >- + OpenShift API token. Resolves from K8S_AUTH_API_KEY env var or + the openshift_api_key variable on the target cluster host + (validate_migration_target_name). Required — the validate_migration aborts + without it. + + validate_migration_openshift_verify_ssl: + type: bool + required: false + default: true + description: >- + Validate the OpenShift API TLS certificate. + + validate_migration_vcenter_hostname: + type: str + required: false + default: "" + description: >- + vCenter endpoint for the source VM checks. Resolves from + VMWARE_HOST env var or the host variable on the source host + (validate_migration_source_name). When unset, the source checks fail + with instructions. + + validate_migration_vcenter_username: + type: str + required: false + default: "" + description: >- + vCenter username. Resolves from VMWARE_USER env var or the + username variable on the source host (validate_migration_source_name). + + validate_migration_vcenter_password: + type: str + required: false + default: "" + no_log: true + description: >- + vCenter password. Resolves from VMWARE_PASSWORD env var or the + password variable on the source host (validate_migration_source_name). + When validate_migration_power_off_source is true, the credential needs + the power off permission. + + validate_migration_vcenter_validate_certs: + type: bool + required: false + default: true + description: >- + Validate the vCenter TLS certificate. + + validate_migration_vcenter_datacenter: + type: str + required: false + default: "" + description: >- + Datacenter for source VM lookup and power-off. Required for + enabled power-off; optional for read-only gathering. A missing + value fails the power check without mutating any source VM. + + validate_migration_plan_name: + type: str + required: false + default: "" + description: >- + Name of the MTV (Forklift) Plan whose migration validate_migration + validates. Defaults to the mtv_migrate_plan_name variable so + validate_migration runs seamlessly after vmf_migrate. Required — the + validate_migration aborts without it. + + validate_migration_plan_namespace: + type: str + required: false + default: "" + description: >- + Namespace holding the migration Plan and its Migration + objects. Defaults to the mtv_migrate_plan_namespace variable. + Required — the validate_migration aborts without it. + + validate_migration_target_namespace: + type: str + required: false + default: "" + description: >- + Namespace holding the migrated VirtualMachines — the Plan's + target namespace. Defaults to the Plan namespace. + + validate_migration_vm_list: + type: list + elements: str + required: false + default: [] + description: >- + Explicit list of source VM names to validate. MTV preserves VM + names, so each name is also the migrated VirtualMachine's name + in the target namespace. Required — the validate_migration aborts with + an empty list. + + validate_migration_power_off_source: + type: bool + required: false + default: true + description: >- + Power off source VMs whose migrated counterpart passed every + fatal check (per-VM, fail-closed — a failed check leaves that + source running). The power-off is a hard stop via the vCenter + API and the only sanctioned mutation in this role. Set false to + run read-only: the role reports source power state without + mutating. + +... diff --git a/roles/validate_migration/meta/main.yml b/roles/validate_migration/meta/main.yml new file mode 100644 index 0000000..835b6ec --- /dev/null +++ b/roles/validate_migration/meta/main.yml @@ -0,0 +1,20 @@ +--- +galaxy_info: + role_name: validate_migration + author: Red Hat + description: >- + Validate migration outcomes after vmf_migrate completes — Migration + status, migrated VM presence and state, target list match, guest + agent, IP addresses, disk topology, and the fail-closed source + power-off. + company: Red Hat + license: GPL-3.0-only + min_ansible_version: "2.16" + platforms: + - name: EL + versions: + - all + +dependencies: [] + +... diff --git a/roles/validate_migration/tasks/main.yml b/roles/validate_migration/tasks/main.yml new file mode 100644 index 0000000..9012cba --- /dev/null +++ b/roles/validate_migration/tasks/main.yml @@ -0,0 +1,116 @@ +--- +# The migration validation pass runs after vmf_migrate completes. +# +# All checks run on every invocation; the summary report (always-tagged) +# fails the play if any ran check failed, so one run reports everything +# that needs attention. The summary asserts only over the checks that +# ran, so an isolated --tags debug run ends green when the targeted +# check passed. +# +# The source power-off is the only mutating step: per-VM and fail-closed +# (docs/adr/0001-validate-migration-fail-closed-power-off.md). A source VM powers +# off only after its migrated counterpart passed every fatal check — +# plan succeeded, present, running, target list match, and disk +# topology. Warn-only checks (guest agent, IP addresses) never block the +# power-off. Set validate_migration_power_off_source=false to run read-only; the +# role then reports source power state without mutating. + +- name: Include pre-validation of required variables + ansible.builtin.include_tasks: + file: pre_validate.yml + apply: + tags: + - always + tags: + - always + +- name: Run migration validation checks + block: + - name: Include migration plan status check + ansible.builtin.include_tasks: + file: validate_plan.yml + apply: + tags: + - plan + - validate + tags: + - plan + - validate + + - name: Include migrated VM presence and state check + ansible.builtin.include_tasks: + file: validate_vms.yml + apply: + tags: + - vms + - validate + tags: + - vms + - validate + + - name: Include target list match check + ansible.builtin.include_tasks: + file: validate_list.yml + apply: + tags: + - list + - validate + tags: + - list + - validate + + - name: Include guest agent check + ansible.builtin.include_tasks: + file: validate_agent.yml + apply: + tags: + - agent + - validate + tags: + - agent + - validate + + - name: Include IP address check + ansible.builtin.include_tasks: + file: validate_ip.yml + apply: + tags: + - ip + - validate + tags: + - ip + - validate + + - name: Include disk topology check + ansible.builtin.include_tasks: + file: validate_disks.yml + apply: + tags: + - disks + - validate + tags: + - disks + - validate + + - name: Include the source power-off action + ansible.builtin.include_tasks: + file: power_off.yml + apply: + tags: + - poweroff + - action + tags: + - poweroff + - action + + always: + - name: Include the validate_migration summary report + ansible.builtin.include_tasks: + file: report.yml + apply: + tags: + - always + tags: + - always + +... diff --git a/roles/validate_migration/tasks/power_off.yml b/roles/validate_migration/tasks/power_off.yml new file mode 100644 index 0000000..de514d4 --- /dev/null +++ b/roles/validate_migration/tasks/power_off.yml @@ -0,0 +1,109 @@ +--- +# The source power-off: the only sanctioned mutation in this role +# (docs/adr/0001-validate-migration-fail-closed-power-off.md). Per-VM and +# fail-closed: a source VM powers off only after its migrated +# counterpart passed every fatal check — plan succeeded, present, +# running, target list match, and disk topology. Warn-only checks +# (guest agent, IP addresses) never block the power-off. A VM whose +# gated checks did not run (--tags isolation) stays on: the gate +# defaults to false when the checks' facts are absent. With +# validate_migration_power_off_source=false the role gathers the same facts and +# reports source power state without mutating. +# +# Module tier justification: retain community.vmware.vmware_guest_info +# for SOAP-only gathering. The certified vmware.vmware.vm_info initializes +# REST even with schema=vsphere, which is unsuitable for an unknown legacy +# vCenter/emulator baseline. This is not a blanket support claim for 6.x. +# Use certified vmware.vmware.vm_powerstate for the SOAP hard stop. +# Neither path has yet been verified against the demo emulator or old +# customer vCenters; see docs/research/validate-migration-vmware-powerstate.md. +# MoID targets the object just gathered, but does not prove that a VM +# recreated under the same name is the original migration source. + +- name: power_off | Gather source VM facts (moid and power state) + when: + - validate_migration_vcenter_hostname | length > 0 + - __validate_migration_source_type | length == 0 or __validate_migration_source_type == 'vmware' + community.vmware.vmware_guest_info: + hostname: "{{ validate_migration_vcenter_hostname }}" + username: "{{ validate_migration_vcenter_username }}" + password: "{{ validate_migration_vcenter_password }}" + validate_certs: "{{ validate_migration_vcenter_validate_certs }}" + name: "{{ __validate_migration_source_vm }}" + datacenter: "{{ validate_migration_vcenter_datacenter + if validate_migration_vcenter_datacenter | length > 0 else omit }}" + loop: "{{ validate_migration_vm_list }}" + loop_control: + loop_var: __validate_migration_source_vm + register: __validate_migration_power_facts + # Preserve failed=true for the report; do not mask it with failed_when. + ignore_errors: true + no_log: "{{ validate_migration_secure_logging }}" + +- name: power_off | Compute the per-VM fail-closed gate + # Global gates (plan status, target list) plus the per-VM fatal bits + # from validate_vms and validate_disks. Absent facts default to false, + # so an isolated --tags poweroff run powers nothing off. + ansible.builtin.set_fact: + __validate_migration_power_gate: >- + {%- set plan_ok = (__validate_migration_result_plan.ran | default(false) | bool) + and (__validate_migration_result_plan.passed | default(false) | bool) -%} + {%- set list_ok = (__validate_migration_result_list.ran | default(false) | bool) + and (__validate_migration_result_list.passed | default(false) | bool) -%} + {%- set out = {} -%} + {%- for name in validate_migration_vm_list -%} + {%- set v = __validate_migration_vms_state.get(name, {}) if __validate_migration_vms_state is defined else {} -%} + {%- set d = __validate_migration_disks_state.get(name, {}) + if __validate_migration_disks_state is defined else {} -%} + {%- set vms_ok = (v.get('present', false) | bool) and (v.get('running', false) | bool) -%} + {%- set disks_ok = (d.get('source_ok', false) | bool) + and (d.get('source_disks', -1) == d.get('target_volumes', -2)) -%} + {%- set _ = out.update({name: plan_ok and vms_ok and list_ok and disks_ok}) -%} + {%- endfor -%} + {{- out -}} + +- name: power_off | Normalize the source lookup results + ansible.builtin.set_fact: + __validate_migration_power_sources: >- + {%- set out = {} -%} + {%- for name in validate_migration_vm_list -%} + {%- set r = __validate_migration_power_facts.results | default([]) + | selectattr('__validate_migration_source_vm', 'defined') + | selectattr('__validate_migration_source_vm', 'equalto', name) | first | default({}) -%} + {%- set vm = r.instance | default({}) -%} + {%- set valid = vm is mapping and not (r.failed | default(false) | bool) + and not (r.skipped | default(false) | bool) + and vm.moid is defined and vm.moid is string and vm.moid | length > 0 + and vm.hw_power_status | default('') in ['poweredOn', 'poweredOff'] -%} + {%- set _ = out.update({name: {'valid': valid, 'instance': vm}}) -%} + {%- endfor -%} + {{- out -}} + +- name: power_off | Power off validated source VMs (hard stop) + when: + - validate_migration_power_off_source | bool + - not ansible_check_mode + - validate_migration_vcenter_datacenter | trim | length > 0 + - __validate_migration_power_gate.get(__validate_migration_power_name, false) | bool + - __validate_migration_power_sources[__validate_migration_power_name].valid | bool + - __validate_migration_power_sources[__validate_migration_power_name].instance.hw_power_status == 'poweredOn' + vmware.vmware.vm_powerstate: + hostname: "{{ validate_migration_vcenter_hostname }}" + username: "{{ validate_migration_vcenter_username }}" + password: "{{ validate_migration_vcenter_password }}" + validate_certs: "{{ validate_migration_vcenter_validate_certs }}" + datacenter: "{{ validate_migration_vcenter_datacenter }}" + moid: "{{ __validate_migration_power_sources[__validate_migration_power_name].instance.moid }}" + state: powered-off + loop: "{{ validate_migration_vm_list }}" + loop_control: + loop_var: __validate_migration_power_name + label: "{{ __validate_migration_power_name }}" + register: __validate_migration_power_results + ignore_errors: true + no_log: "{{ validate_migration_secure_logging }}" + +- name: power_off | Classify and record the source power-off results + ansible.builtin.include_tasks: power_report.yml + +... diff --git a/roles/validate_migration/tasks/power_report.yml b/roles/validate_migration/tasks/power_report.yml new file mode 100644 index 0000000..52a2922 --- /dev/null +++ b/roles/validate_migration/tasks/power_report.yml @@ -0,0 +1,63 @@ +--- +# An empty vm_list here marks the check failed (fail-closed); pre_validate +# normally rejects an empty list first, so this is defensive only. +# Report only sanitized identities and outcomes, never raw module errors. +# A timeout can follow dispatch: failed action does not mean unchanged state. +- name: power_report | Classify each expected source VM + ansible.builtin.set_fact: + __validate_migration_power_outcomes: >- + {%- set out = {} -%} + {%- for name in validate_migration_vm_list -%} + {%- set source = __validate_migration_power_sources.get(name, {}) -%} + {%- set vm = source.instance | default({}) -%} + {%- set identity = name ~ ' (' ~ (vm.moid | default('unknown MoID')) ~ ')' -%} + {%- set action = __validate_migration_power_results.results | default([]) + | selectattr('__validate_migration_power_name', 'defined') + | selectattr('__validate_migration_power_name', 'equalto', name) | first | default({}) -%} + {%- set ok = true -%} + {%- if not (source.valid | default(false) | bool) -%} + {%- set ok = false -%} + {%- set detail = 'lookup failed or missing/unsupported source facts; no power-off attempted' -%} + {%- elif not (validate_migration_power_off_source | bool) -%} + {%- set detail = 'read-only: ' ~ vm.hw_power_status -%} + {%- elif validate_migration_vcenter_datacenter | trim | length == 0 -%} + {%- set ok = false -%} + {%- set detail = 'datacenter required for enabled power-off; no power-off attempted' -%} + {%- elif vm.hw_power_status == 'poweredOff' -%} + {%- set detail = 'already off' -%} + {%- elif not (__validate_migration_power_gate.get(name, false) | bool) -%} + {%- set detail = 'source stays on: fatal checks failed or did not run' -%} + {%- elif ansible_check_mode -%} + {%- set detail = 'check mode: would power off; no power-off attempted' -%} + {%- elif action.failed | default(false) | bool + or action.skipped | default(false) | bool + or action.vm is not defined or action.vm is not mapping + or action.vm.moid | default('') != vm.moid -%} + {%- set ok = false -%} + {%- set detail = 'power-off failed or unconfirmed; current power state unknown — inspect vCenter' -%} + {%- else -%} + {%- set detail = 'power-off completed (module result; no independent read-back)' -%} + {%- endif -%} + {%- set _ = out.update({name: {'passed': ok, 'detail': identity ~ ': ' ~ detail}}) -%} + {%- endfor -%} + {{- out -}} + +- name: power_report | Record the source power-off check + ansible.builtin.set_fact: + __validate_migration_result_power: + ran: true + passed: "{{ (__validate_migration_source_type | length > 0 and __validate_migration_source_type != 'vmware') + or ((validate_migration_vcenter_hostname | length > 0) + and validate_migration_vm_list | length > 0 + and (__validate_migration_power_outcomes.values() + | selectattr('passed', 'equalto', false) | list | length == 0)) }}" + detail: >- + {% if __validate_migration_source_type | length > 0 and __validate_migration_source_type != 'vmware' %} + Skipped — source type is {{ __validate_migration_source_type }}, not vmware + {% elif validate_migration_vcenter_hostname | length == 0 %} + Not configured — set source host/username/password (or VMWARE_*); + no source power-off attempted + {% else %} + {{ __validate_migration_power_outcomes.values() | map(attribute='detail') | join('; ') }} + {% endif %} +... diff --git a/roles/validate_migration/tasks/pre_validate.yml b/roles/validate_migration/tasks/pre_validate.yml new file mode 100644 index 0000000..9b8e4cc --- /dev/null +++ b/roles/validate_migration/tasks/pre_validate.yml @@ -0,0 +1,51 @@ +--- +# Required variables — without these nothing can run. Per-check inputs +# (vCenter) are validated inside their own check files so a missing input +# fails that check with instructions instead of aborting the play. + +- name: pre_validate | Validate required OpenShift connection variables + ansible.builtin.assert: + that: + - validate_migration_openshift_host | length > 0 + - validate_migration_openshift_api_key | length > 0 + fail_msg: >- + OpenShift connection is required. Set openshift_host and + openshift_api_key on the target cluster host in the inventory (or + run from AAP where the target credential injects K8S_AUTH_HOST and + K8S_AUTH_API_KEY). + success_msg: >- + OpenShift connection configured: {{ validate_migration_openshift_host }} + quiet: true + +- name: pre_validate | Validate required migration identifiers + ansible.builtin.assert: + that: + - validate_migration_plan_name | length > 0 + - validate_migration_plan_namespace | length > 0 + - validate_migration_target_namespace | length > 0 + - validate_migration_vm_list | length > 0 + fail_msg: >- + Plan name, Plan namespace, target namespace, and a non-empty VM + list are required. Set mtv_migrate_plan_name and + mtv_migrate_plan_namespace (or validate_migration_plan_name and + validate_migration_plan_namespace), validate_migration_target_namespace, and + validate_migration_vm_list. + success_msg: >- + Validating Plan {{ validate_migration_plan_name }} in + {{ validate_migration_plan_namespace }} — {{ validate_migration_vm_list | length }} + VM(s) in {{ validate_migration_target_namespace }} + quiet: true + +- name: pre_validate | Reject plaintext credential-bearing endpoints + ansible.builtin.assert: + that: + - (validate_migration_openshift_host | trim | lower) is not match('^http://') + - validate_migration_openshift_host is not search('://[^/@]+:[^/@]+@') + fail_msg: >- + The OpenShift endpoint must use https and must not embed user:pass@ + in the URL — bearer tokens sent to a plaintext endpoint transit + unencrypted. Set an https URL on the migration_clusters host in the + inventory. + quiet: true + +... diff --git a/roles/validate_migration/tasks/report.yml b/roles/validate_migration/tasks/report.yml new file mode 100644 index 0000000..83a15e3 --- /dev/null +++ b/roles/validate_migration/tasks/report.yml @@ -0,0 +1,88 @@ +--- +# Collect every check result and fail the play if any ran check failed. +# Check files register __validate_migration_result_* facts with ran: true; the +# summary assert only covers checks that ran, so an isolated --tags debug +# run ends green when the targeted check passed. A check that never ran +# (unexpected failure in a full run) still shows FAIL with "check did +# not run" in the summary, so the report never lies. +# +# The record tasks' passed values round-trip as real booleans on +# ansible-core 2.16 (a full-string-expression set_fact preserves the +# native type, verified), and the un-run defaults carry real false +# booleans. Check outputs still produce mixed forms, so the | bool +# filter accepts both. Note: the bare `falsy` test does NOT convert +# the string "False" — pass convert_bool=true +# (selectattr(..., 'falsy', true)) when filtering summary values by +# key, as the fail_msg does. + +- name: report | Build the validate_migration summary + ansible.builtin.set_fact: + __validate_migration_summary: + Migration plan status: >- + {{ __validate_migration_result_plan + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + Migrated VMs present and running: >- + {{ __validate_migration_result_vms + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + Target list matches: >- + {{ __validate_migration_result_list + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + Guest agent responsive: >- + {{ __validate_migration_result_agent + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + IP addresses match: >- + {{ __validate_migration_result_ip + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + Disk topology matches: >- + {{ __validate_migration_result_disks + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + Source power-off: >- + {{ __validate_migration_result_power + | default({'ran': false, 'passed': false, 'detail': 'check did not run'}) }} + +- name: report | Display the validate_migration summary + ansible.builtin.debug: + msg: >- + {{ __validate_migration_check.value.passed | bool | ternary('PASS', 'FAIL') }} | + {{ __validate_migration_check.key }}: {{ __validate_migration_check.value.detail }} + loop: "{{ __validate_migration_summary | dict2items }}" + loop_control: + loop_var: __validate_migration_check + label: "{{ __validate_migration_check.key }}" + +- name: report | Assert every ran validate_migration check passed + ansible.builtin.assert: + that: + - >- + __validate_migration_summary | dict2items + | selectattr('value.ran', 'equalto', true) + | map(attribute='value.passed') + | map('bool') + | select('equalto', false) + | list | length == 0 + fail_msg: >- + Migration validation FAILED: + {{ __validate_migration_summary | dict2items + | selectattr('value.ran', 'equalto', true) + | selectattr('value.passed', 'falsy', true) + | map(attribute='key') | join(', ') }}. + Fix each failed check before treating the migration as complete — + do not power off a source VM whose counterpart failed validation. + success_msg: >- + Migration validation passed: all + {{ __validate_migration_summary | dict2items + | selectattr('value.ran', 'equalto', true) | list | length }} + checks green. The migration is complete. + quiet: true + +- name: report | Remind the operator to rotate credentials exposed in debug output + ansible.builtin.debug: + msg: >- + validate_migration_secure_logging is false — task parameters and API + responses were printed to job output. If this ran on a persistent + AAP job template, rotate the exposed credentials now: the OpenShift + API key (K8S_AUTH_API_KEY) and the vCenter password + (VMWARE_PASSWORD). + when: not (validate_migration_secure_logging | default(true) | bool) + +... diff --git a/roles/validate_migration/tasks/validate_agent.yml b/roles/validate_migration/tasks/validate_agent.yml new file mode 100644 index 0000000..f0e3fd7 --- /dev/null +++ b/roles/validate_migration/tasks/validate_agent.yml @@ -0,0 +1,60 @@ +--- +# Check the qemu-guest-agent is responsive in each migrated VM. KubeVirt +# populates VMI status.guestOSInfo when the guest agent reports, so a +# non-empty guestOSInfo is the agent signal. Warn-only: a VM without a +# responsive agent is not a failed migration, and this check never +# blocks the source power-off. + +- name: validate_agent | Query VirtualMachineInstances in the target namespace + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: kubevirt.io/v1 + kind: VirtualMachineInstance + namespace: "{{ validate_migration_target_namespace }}" + register: __validate_migration_vmi_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_agent | Build the per-VM agent status map + ansible.builtin.set_fact: + __validate_migration_agents: >- + {%- set vmis = __validate_migration_vmi_query.resources | default([]) -%} + {%- set out = {} -%} + {%- for name in validate_migration_vm_list -%} + {%- set vmi = vmis | selectattr('metadata.name', 'equalto', name) | list | first | default({}) -%} + {%- set _ = out.update({name: { + 'vmi': vmi | length > 0, + 'agent': (vmi.status.guestOSInfo | default([])) | length > 0 + }}) -%} + {%- endfor -%} + {{- out -}} + +- name: validate_agent | Record the guest agent check result + ansible.builtin.set_fact: + __validate_migration_result_agent: + ran: true + passed: "{{ __validate_migration_vmi_query.resources is defined }}" + detail: >- + {% if __validate_migration_vmi_query.resources is not defined %} + Cluster API query failed in {{ validate_migration_target_namespace }} — + check the OpenShift connection and credentials first; the agent + check is inconclusive when the API is unreachable + {% else %} + {% set no_agent = __validate_migration_agents.items() + | selectattr('1.agent', 'equalto', false) + | map(attribute='0') | list %} + {% if no_agent | length == 0 %} + Guest agent responsive in all {{ validate_migration_vm_list | length }} + VM(s) (VMI status.guestOSInfo populated) + {% else %} + Warning: no guest agent data in + {{ no_agent | length }} of {{ validate_migration_vm_list | length }} + VM(s): {{ no_agent | join(', ') }} — install the qemu-guest-agent + in the migrated VM for full guest visibility; this does not fail + the migration or block the power-off + {% endif %} + {% endif %} + +... diff --git a/roles/validate_migration/tasks/validate_disks.yml b/roles/validate_migration/tasks/validate_disks.yml new file mode 100644 index 0000000..abb7707 --- /dev/null +++ b/roles/validate_migration/tasks/validate_disks.yml @@ -0,0 +1,129 @@ +--- +# Check the source VM's disk topology survived the migration: the disk +# count on the source (vCenter virtual hardware) must match the target +# volume count (the migrated VirtualMachine's data volumes). Fatal: a +# missing disk means the migration did not copy what it should, which +# also blocks that VM's source power-off. The individual disk sizes are +# reported in the detail for human review — unit parsing across vCenter +# KB and Kubernetes quantity strings is intentionally not automated, so +# a rounding difference cannot false-fail the cutover. +# +# Module tier justification (community content): community.vmware +# .vmware_guest_info returns config.hardware.device through SOAP alone. +# Certified vmware.vmware.vm_info initializes REST even for vsphere +# schema. Retain SOAP-only gathering for unknown legacy vCenters and the +# demo emulator; this does not assert support for every old version. + +- name: validate_disks | Gather source disk topology from vCenter + when: + - validate_migration_vcenter_hostname | length > 0 + - __validate_migration_source_type | length == 0 or __validate_migration_source_type == 'vmware' + community.vmware.vmware_guest_info: + hostname: "{{ validate_migration_vcenter_hostname }}" + username: "{{ validate_migration_vcenter_username }}" + password: "{{ validate_migration_vcenter_password }}" + validate_certs: "{{ validate_migration_vcenter_validate_certs }}" + name: "{{ __validate_migration_source_vm }}" + datacenter: "{{ validate_migration_vcenter_datacenter + if validate_migration_vcenter_datacenter | length > 0 else omit }}" + schema: vsphere + properties: + - config.hardware.device + loop: "{{ validate_migration_vm_list }}" + loop_control: + loop_var: __validate_migration_source_vm + register: __validate_migration_source_disks + # Keep failed=true so lookup faults remain visible to result accounting. + ignore_errors: true + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_disks | Query the migrated VirtualMachines' volume counts + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: kubevirt.io/v1 + kind: VirtualMachine + namespace: "{{ validate_migration_target_namespace }}" + register: __validate_migration_disks_vm_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_disks | Build the per-VM disk comparison map + ansible.builtin.set_fact: + __validate_migration_disks_state: >- + {%- set vms = __validate_migration_disks_vm_query.resources | default([]) -%} + {%- set out = {} -%} + {%- for r in __validate_migration_source_disks.results | default([]) -%} + {%- set name = r.__validate_migration_source_vm | default('') -%} + {%- set devices = (r.instance.config.hardware.device | default([])) if r.instance is defined else [] -%} + {%- set source_disks = devices | selectattr('capacityInKB', 'defined') | list -%} + {%- set vm = vms | selectattr('metadata.name', 'equalto', name) | list | first | default({}) -%} + {%- set vols = vm.spec.template.spec.volumes | default([]) -%} + {%- set target_volumes = (vols | selectattr('dataVolume', 'defined') | list) + + (vols | selectattr('persistentVolumeClaim', 'defined') | list) -%} + {%- set _ = out.update({name: { + 'source_ok': r.instance.config.hardware.device is defined + and r.instance.config.hardware.device is sequence + and r.instance.config.hardware.device is not string + and not (r.failed | default(false) | bool) + and not (r.skipped | default(false) | bool), + 'source_disks': source_disks | length, + 'source_sizes': source_disks | map(attribute='capacityInKB') | list, + 'target_volumes': target_volumes | length + }}) -%} + {%- endfor -%} + {{- out -}} + +- name: validate_disks | Count the disk topology mismatches + ansible.builtin.set_fact: + __validate_migration_disks_mismatched: >- + {%- set out = [] -%} + {%- for e in __validate_migration_disks_state.items() -%} + {%- if e[1].source_ok and e[1].source_disks != e[1].target_volumes -%} + {%- set _ = out.append(e[0]) -%} + {%- endif -%} + {%- endfor -%} + {{- out -}} + +- name: validate_disks | Record the disk topology check result + ansible.builtin.set_fact: + __validate_migration_result_disks: + ran: true + passed: "{{ (__validate_migration_source_type | length > 0 and __validate_migration_source_type != 'vmware') + or ((validate_migration_vcenter_hostname | length > 0) + and (__validate_migration_disks_state | length == validate_migration_vm_list | length) + and (__validate_migration_disks_state.values() | selectattr('source_ok', 'equalto', false) | list | length == 0) + and __validate_migration_disks_mismatched | length == 0) }}" + detail: >- + {% if __validate_migration_source_type | length > 0 and __validate_migration_source_type != 'vmware' %} + Skipped — source type is {{ __validate_migration_source_type }}, not vmware; + the vCenter source checks do not apply + {% elif validate_migration_vcenter_hostname | length == 0 %} + Not configured — set host/username/password on the source host + (or run from AAP where the source credential injects VMWARE_*) to + enable the disk topology check + {% else %} + {% set failed = __validate_migration_disks_state.items() + | selectattr('1.source_ok', 'equalto', false) + | map(attribute='0') | list %} + {% if failed | length > 0 %} + vCenter lookup failed for {{ failed | length }} of + {{ validate_migration_vm_list | length }} VM(s): {{ failed | join(', ') }} + — re-run the disk check alone (--tags disks) as a one-off CLI run + with validate_migration_secure_logging=false to see the fault, then + rotate the exposed credential + {% elif __validate_migration_disks_mismatched | length > 0 %} + Disk count mismatch for {{ __validate_migration_disks_mismatched | length }} + VM(s): {{ __validate_migration_disks_mismatched | join(', ') }} — the + migration did not copy every source disk; do not cut over until + the topology matches + {% else %} + Disk topology matches for all {{ validate_migration_vm_list | length }} + VM(s): {% for e in __validate_migration_disks_state.items() %}{{ e[0] }} + ({{ e[1].source_disks }} disks, {{ e[1].source_sizes | join('/') + }} KB source){% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} + {% endif %} + +... diff --git a/roles/validate_migration/tasks/validate_ip.yml b/roles/validate_migration/tasks/validate_ip.yml new file mode 100644 index 0000000..78d073e --- /dev/null +++ b/roles/validate_migration/tasks/validate_ip.yml @@ -0,0 +1,127 @@ +--- +# Check the migrated VM's IP addresses against the source VM's address +# from the vCenter inventory. Warn-only: DHCP can reassign a lease after +# migration, so a mismatch is reported in the detail and never fails the +# play or blocks the power-off. A vCenter that cannot be reached fails +# the check — that is a probe failure, not an address mismatch. +# +# Module tier justification (community content): community.vmware +# .vmware_guest_info gathers through SOAP without a REST prerequisite. +# Certified vmware.vmware.vm_info initializes REST even for vsphere +# schema. Unknown legacy vCenters and the demo emulator require retaining +# this lower-tier path; no blanket compatibility claim is made. + +- name: validate_ip | Gather source VM facts from vCenter + when: + - validate_migration_vcenter_hostname | length > 0 + - __validate_migration_source_type | length == 0 or __validate_migration_source_type == 'vmware' + community.vmware.vmware_guest_info: + hostname: "{{ validate_migration_vcenter_hostname }}" + username: "{{ validate_migration_vcenter_username }}" + password: "{{ validate_migration_vcenter_password }}" + validate_certs: "{{ validate_migration_vcenter_validate_certs }}" + name: "{{ __validate_migration_source_vm }}" + datacenter: "{{ validate_migration_vcenter_datacenter + if validate_migration_vcenter_datacenter | length > 0 else omit }}" + loop: "{{ validate_migration_vm_list }}" + loop_control: + loop_var: __validate_migration_source_vm + register: __validate_migration_source_facts + # Keep failed=true so lookup faults remain visible to result accounting. + ignore_errors: true + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_ip | Query VirtualMachineInstances for the target addresses + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: kubevirt.io/v1 + kind: VirtualMachineInstance + namespace: "{{ validate_migration_target_namespace }}" + register: __validate_migration_ip_vmi_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_ip | Build the per-VM address comparison map + ansible.builtin.set_fact: + __validate_migration_ip_state: >- + {%- set vmis = __validate_migration_ip_vmi_query.resources | default([]) -%} + {%- set out = {} -%} + {%- for r in __validate_migration_source_facts.results | default([]) -%} + {%- set name = r.__validate_migration_source_vm | default('') -%} + {%- set instance = r.instance | default({}) -%} + {%- set vmi = vmis | selectattr('metadata.name', 'equalto', name) | list | first | default({}) -%} + {%- set vmi_addrs = [] -%} + {%- for iface in vmi.status.interfaces | default([]) -%} + {%- set _ = vmi_addrs.append(iface.ipAddress | default('')) -%} + {%- for a in iface.ipAddresses | default([]) -%} + {%- set _ = vmi_addrs.append(a) -%} + {%- endfor -%} + {%- endfor -%} + {%- set _ = out.update({name: { + 'source_ok': instance is mapping and instance | length > 0 + and not (r.failed | default(false) | bool) + and not (r.skipped | default(false) | bool), + 'source_ipv4': instance.ipv4 | default('', true), + 'vmi_addrs': vmi_addrs | reject('equalto', '') | list + }}) -%} + {%- endfor -%} + {{- out -}} + +- name: validate_ip | Record the IP address check result + ansible.builtin.set_fact: + __validate_migration_result_ip: + ran: true + passed: "{{ (__validate_migration_source_type | length > 0 and __validate_migration_source_type != 'vmware') + or ((validate_migration_vcenter_hostname | length > 0) + and (__validate_migration_ip_state | length == validate_migration_vm_list | length) + and (__validate_migration_ip_state.values() + | selectattr('source_ok', 'equalto', false) | list | length == 0)) }}" + detail: >- + {% if __validate_migration_source_type | length > 0 and __validate_migration_source_type != 'vmware' %} + Skipped — source type is {{ __validate_migration_source_type }}, not vmware; + the vCenter source checks do not apply + {% elif validate_migration_vcenter_hostname | length == 0 %} + Not configured — set host/username/password on the source host + (or run from AAP where the source credential injects VMWARE_*) to + enable the IP address check + {% else %} + {% set failed = __validate_migration_ip_state.items() + | selectattr('1.source_ok', 'equalto', false) + | map(attribute='0') | list %} + {% set mismatched_names = [] %} + {% set no_address = [] %} + {% for e in __validate_migration_ip_state.items() %} + {% if e[1].source_ok and e[1].source_ipv4 | length > 0 and e[1].source_ipv4 not in e[1].vmi_addrs %} + {% set _ = mismatched_names.append(e[0]) %} + {% elif e[1].source_ok and e[1].source_ipv4 | length == 0 %} + {% set _ = no_address.append(e[0]) %} + {% endif %} + {% endfor %} + {% if failed | length > 0 %} + vCenter lookup failed for {{ failed | length }} of + {{ validate_migration_vm_list | length }} VM(s): {{ failed | join(', ') }} + — re-run the IP check alone (--tags ip) as a one-off CLI run with + validate_migration_secure_logging=false to see the fault, then rotate the + exposed credential + {% else %} + All {{ validate_migration_vm_list | length }} VM(s) looked up in vCenter + {% if mismatched_names | length > 0 %}; + warning: {{ mismatched_names | length }} VM(s) report a different + address in OpenShift than the vCenter inventory + ({{ mismatched_names | join(', ') }}) — DHCP may have reassigned + the lease; this does not fail the migration or block the + power-off + {% endif %} + {% if no_address | length > 0 %}; warning: {{ no_address | length }} + VM(s) report no source address from vCenter + ({{ no_address | join(', ') }}) — the source VM may be powered + off or unnetworked; this does not fail the migration or block + the power-off + {% elif mismatched_names | length == 0 %}; every VM reports its source address in OpenShift + {% endif %} + {% endif %} + {% endif %} + +... diff --git a/roles/validate_migration/tasks/validate_list.yml b/roles/validate_migration/tasks/validate_list.yml new file mode 100644 index 0000000..110c248 --- /dev/null +++ b/roles/validate_migration/tasks/validate_list.yml @@ -0,0 +1,52 @@ +--- +# Check the expected VM list matches the migrated set: every expected VM +# must exist (expected subset). Extra VirtualMachines in the namespace +# are reported in the detail as warnings — they do not fail the check, +# so a namespace shared with other workloads does not false-positive. + +- name: validate_list | Query migrated VirtualMachines in the target namespace + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: kubevirt.io/v1 + kind: VirtualMachine + namespace: "{{ validate_migration_target_namespace }}" + register: __validate_migration_list_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_list | Resolve the found VM names + ansible.builtin.set_fact: + __validate_migration_list_found: "{{ __validate_migration_list_query.resources + | default([]) | map(attribute='metadata.name') | list }}" + +- name: validate_list | Split the found VMs into missing and extra + ansible.builtin.set_fact: + __validate_migration_list_missing: "{{ validate_migration_vm_list | difference(__validate_migration_list_found) }}" + __validate_migration_list_extra: "{{ __validate_migration_list_found | difference(validate_migration_vm_list) }}" + +- name: validate_list | Record the target list check result + ansible.builtin.set_fact: + __validate_migration_result_list: + ran: true + passed: "{{ __validate_migration_list_missing | length == 0 }}" + detail: >- + {% if __validate_migration_list_query.resources is not defined %} + Cluster API query failed in {{ validate_migration_target_namespace }} — + check the OpenShift connection and credentials first; the list + check is inconclusive when the API is unreachable + {% elif __validate_migration_list_missing | length > 0 %} + {{ __validate_migration_list_missing | length }} expected VM(s) missing + from the migrated set in {{ validate_migration_target_namespace }}: + {{ __validate_migration_list_missing | join(', ') }} + {% else %} + All {{ validate_migration_vm_list | length }} expected VM(s) present in + {{ validate_migration_target_namespace }}{% if __validate_migration_list_extra | length > 0 %}; + warning: {{ __validate_migration_list_extra | length }} additional + VirtualMachine(s) in the namespace beyond the expected list + ({{ __validate_migration_list_extra | join(', ') }}){% else %}; no + additional VirtualMachines in the namespace{% endif %} + {% endif %} + +... diff --git a/roles/validate_migration/tasks/validate_plan.yml b/roles/validate_migration/tasks/validate_plan.yml new file mode 100644 index 0000000..5552539 --- /dev/null +++ b/roles/validate_migration/tasks/validate_plan.yml @@ -0,0 +1,89 @@ +--- +# Check the migration plan outcome: locate the Plan, resolve its latest +# Migration object, and interpret the Migration status through the +# collection's mtv_migration_status module — the established pattern from +# mtv_migrate. kubernetes.core.k8s_info is the collection's established +# pattern for cluster queries. + +- name: validate_plan | Query the migration Plan + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: forklift.konveyor.io/v1beta1 + kind: Plan + name: "{{ validate_migration_plan_name }}" + namespace: "{{ validate_migration_plan_namespace }}" + register: __validate_migration_plan_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_plan | Query Migration objects in the plan namespace + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: forklift.konveyor.io/v1beta1 + kind: Migration + namespace: "{{ validate_migration_plan_namespace }}" + register: __validate_migration_migrations_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_plan | Resolve the latest Migration object for the plan + # Migration CRs reference their plan through spec.plan.name; the latest + # run is the newest creationTimestamp. Plan.status.migration embeds the + # run's status snapshot in current forklift (a dict, not the CR name), + # so it cannot be used for the lookup — mtv_migrate knows its Migration + # name because it created the object; validate_migration discovers it. + # Trust boundary: discovery trusts Migration CRs in the plan namespace; + # creating one requires namespace write privileges (the same principal + # MTV itself runs as). The per-VM checks are independent corroboration — + # a planted Migration cannot make absent VirtualMachines pass. + ansible.builtin.set_fact: + __validate_migration_latest_migration: "{{ __validate_migration_migrations_query.resources | default([]) + | selectattr('spec.plan.name', 'defined') + | selectattr('spec.plan.name', 'equalto', validate_migration_plan_name) + | sort(attribute='metadata.creationTimestamp') | last | default({}) }}" + +- name: validate_plan | Record the resolved Migration object name + ansible.builtin.set_fact: + __validate_migration_migration_name: "{{ __validate_migration_latest_migration.metadata.name | default('') }}" + +- name: validate_plan | Interpret the Migration status + infra.openshift_virtualization_migration.mtv_migration_status: + resource: "{{ __validate_migration_latest_migration }}" + register: __validate_migration_migration_status + when: __validate_migration_latest_migration | length > 0 + +- name: validate_plan | Record the plan status check result + ansible.builtin.set_fact: + __validate_migration_result_plan: + ran: true + passed: "{{ (__validate_migration_migration_status.succeeded | default(false)) | bool }}" + detail: >- + {% if __validate_migration_plan_query.resources is not defined %} + Cluster API query failed in {{ validate_migration_plan_namespace }} — + check the OpenShift connection and credentials first; the plan + check is inconclusive when the API is unreachable + {% elif __validate_migration_plan_query.resources | length == 0 %} + Plan {{ validate_migration_plan_name }} not found in + {{ validate_migration_plan_namespace }} — check the plan name and + namespace + {% elif __validate_migration_latest_migration | length == 0 %} + Plan {{ validate_migration_plan_name }} has no Migration object in + {{ validate_migration_plan_namespace }} — either vmf_migrate has not run + or its Migration was deleted + {% elif (__validate_migration_migration_status.status | default('')) != 'Succeeded' %} + Migration {{ __validate_migration_migration_name }} is + {{ __validate_migration_migration_status.status | default('unknown') }} + ({{ __validate_migration_migration_status.completed_vms | default(0) }} of + {{ __validate_migration_migration_status.total_vms | default(0) }} VMs + complete, {{ __validate_migration_migration_status.failed_vms | default(0) }} + failed) — expected Succeeded + {% else %} + Migration {{ __validate_migration_migration_name }} Succeeded + ({{ __validate_migration_migration_status.total_vms | default(0) }} VMs) + {% endif %} + +... diff --git a/roles/validate_migration/tasks/validate_vms.yml b/roles/validate_migration/tasks/validate_vms.yml new file mode 100644 index 0000000..6e1b6b4 --- /dev/null +++ b/roles/validate_migration/tasks/validate_vms.yml @@ -0,0 +1,68 @@ +--- +# Check every expected VM exists as a migrated VirtualMachine in the +# target namespace and is Running. MTV preserves VM names, so the input +# name is the migrated VirtualMachine's name. + +- name: validate_vms | Query migrated VirtualMachines in the target namespace + kubernetes.core.k8s_info: + host: "{{ validate_migration_openshift_host }}" + api_key: "{{ validate_migration_openshift_api_key }}" + validate_certs: "{{ validate_migration_openshift_verify_ssl }}" + api_version: kubevirt.io/v1 + kind: VirtualMachine + namespace: "{{ validate_migration_target_namespace }}" + register: __validate_migration_vm_query + failed_when: false + no_log: "{{ validate_migration_secure_logging }}" + +- name: validate_vms | Build the per-VM presence and state map + ansible.builtin.set_fact: + __validate_migration_vms_state: >- + {%- set vms = __validate_migration_vm_query.resources | default([]) -%} + {%- set out = {} -%} + {%- for name in validate_migration_vm_list -%} + {%- set vm = vms | selectattr('metadata.name', 'equalto', name) | list | first | default({}) -%} + {%- set _ = out.update({name: { + 'present': vm | length > 0, + 'running': (vm.status.printableStatus | default('')) == 'Running' + }}) -%} + {%- endfor -%} + {{- out -}} + +- name: validate_vms | Record the migrated VM check result + ansible.builtin.set_fact: + __validate_migration_result_vms: + ran: true + passed: "{{ (__validate_migration_vms_state.values() + | selectattr('running', 'equalto', true) | list | length) + == (validate_migration_vm_list | length) }}" + detail: >- + {% if __validate_migration_vm_query.resources is not defined %} + Cluster API query failed in {{ validate_migration_target_namespace }} — + check the OpenShift connection and credentials first; the VM + check is inconclusive when the API is unreachable + {% else %} + {% set missing = __validate_migration_vms_state.items() + | selectattr('1.present', 'equalto', false) + | map(attribute='0') | list %} + {% set not_running = __validate_migration_vms_state.items() + | selectattr('1.present', 'equalto', true) + | selectattr('1.running', 'equalto', false) + | map(attribute='0') | list %} + {% if missing | length > 0 %} + {{ missing | length }} of {{ validate_migration_vm_list | length }} VM(s) + not found in {{ validate_migration_target_namespace }}: + {{ missing | join(', ') }} — check the migration plan and the + VM list + {% elif not_running | length > 0 %} + {{ not_running | length }} of {{ validate_migration_vm_list | length }} + VM(s) present but not Running: + {{ not_running | join(', ') }} — expected printableStatus + Running + {% else %} + All {{ validate_migration_vm_list | length }} VM(s) present and Running + in {{ validate_migration_target_namespace }} + {% endif %} + {% endif %} + +... diff --git a/roles/validate_migration/vars/main.yml b/roles/validate_migration/vars/main.yml new file mode 100644 index 0000000..9f61bc0 --- /dev/null +++ b/roles/validate_migration/vars/main.yml @@ -0,0 +1,6 @@ +--- +# Internal derived values — not user-facing. Users set the source host's +# type in the inventory; this resolves it for the vCenter gate. +__validate_migration_source_type: "{{ hostvars[validate_migration_source_name]['type'] | default('', true) }}" + +... diff --git a/scripts/check-validate-migration-vmware.py b/scripts/check-validate-migration-vmware.py new file mode 100644 index 0000000..a485b2c --- /dev/null +++ b/scripts/check-validate-migration-vmware.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Fail early if the real installed VMware module contract is unavailable. + +Guards both vCenter modules the validate_migration role dispatches: +- vmware.vmware.vm_powerstate (the mutation) — its hard-stop contract +- community.vmware.vmware_guest_info (the SOAP-only gather) — the same + tombstone risk class that removed vmware_guest_powerstate in + community.vmware 7.0.0 + +Prints the resolved module paths so a stub tree is visible in the output. +""" + +import json +import os +from pathlib import Path +import subprocess +import sys + + +def powerstate_assertions(options): + required = {"hostname", "username", "password", "validate_certs", + "datacenter", "moid", "state"} + return [ + (required <= options.keys(), "vm_powerstate: missing task parameters"), + (options["datacenter"].get("required"), + "vm_powerstate: datacenter contract changed"), + (options["moid"].get("type") == "str", + "vm_powerstate: MoID must be a string"), + ("powered-off" in options["state"]["choices"], + "vm_powerstate: hard-stop state unavailable"), + ("powered_off" not in options["state"]["choices"], + "vm_powerstate: review changed state schema"), + ] + + +def guest_info_assertions(options): + required = {"hostname", "username", "password", "validate_certs", + "name", "datacenter", "schema", "properties"} + return [ + (required <= options.keys(), + "vmware_guest_info: missing gather parameters"), + ({"summary", "vsphere"} <= set(options["schema"].get("choices", [])), + "vmware_guest_info: summary/vsphere schemas unavailable"), + (options["properties"].get("type") == "list", + "vmware_guest_info: properties must be a list"), + ] + + +def check_module(env, root, fqcn, assertions): + """Return the resolved module filename, or raise on contract drift.""" + result = subprocess.run( + ["ansible-doc", "--json", fqcn], env=env, cwd=root, + capture_output=True, text=True, check=True, + ) + doc = json.loads(result.stdout)[fqcn]["doc"] + options = doc["options"] + problems = [message for valid, message in assertions(options) if not valid] + if problems: + raise ValueError("; ".join(problems)) + return doc.get("filename", "unknown path") + + +def main(): + root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + local = root / ".ansible" / "collections" + if local.is_dir() and not env.get("ANSIBLE_COLLECTIONS_PATH"): + env["ANSIBLE_COLLECTIONS_PATH"] = os.pathsep.join( + [str(local), str(Path.home() / ".ansible/collections")] + ) + # Fail closed: verify THE required collection, not whichever version + # happens to sit on sys.path. ansible-doc honors this env var. The + # resolved module path is printed so a caller-set stub tree is visible. + env["ANSIBLE_COLLECTIONS_SCAN_SYS_PATH"] = "false" + checks = [ + ("vmware.vmware.vm_powerstate", powerstate_assertions), + ("community.vmware.vmware_guest_info", guest_info_assertions), + ] + try: + resolved = [(fqcn, check_module(env, root, fqcn, assertions)) + for fqcn, assertions in checks] + except (OSError, subprocess.CalledProcessError, ValueError, + KeyError, TypeError) as error: + print(f"VMware module contract check failed: {error}", file=sys.stderr) + print("Install the collections (requirements-dev.yml, or" + " 'ansible-galaxy collection install vmware.vmware" + " community.vmware'); do not rely on lint mocks.", file=sys.stderr) + return 1 + for fqcn, path in resolved: + print(f"Verified installed module contract: {fqcn} ({path})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/integration/targets/validate_migration_report/tasks/main.yml b/tests/integration/targets/validate_migration_report/tasks/main.yml new file mode 100644 index 0000000..897afe5 --- /dev/null +++ b/tests/integration/targets/validate_migration_report/tasks/main.yml @@ -0,0 +1,215 @@ +--- +# Regression test for the validate_migration report aggregation. +# Runs the report tasks against pre-populated check facts — no real +# endpoints — covering the three summary paths: an isolated tagged run, +# an all-passed full run with mixed boolean forms, and a failed full run +# whose assert names the failed checks. Also covers pre_validate's +# required-identifier and plaintext-endpoint paths, and the fail-closed +# power-off gate: pre-populated gate facts assert that unvalidated VMs +# (failed plan, disk mismatch, absent facts) cannot power off. + +- name: Main | Isolated run — two checks ran, the assert covers those only + vars: + __validate_migration_result_plan: {ran: true, passed: true, detail: 'isolated pass'} + __validate_migration_result_power: {ran: true, passed: 'True', detail: 'isolated pass (string form)'} + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: report.yml + +- name: Main | Full run — all checks ran and passed in mixed boolean forms + vars: + __validate_migration_result_plan: {ran: true, passed: true, detail: 'bool true'} + __validate_migration_result_vms: {ran: true, passed: 'True', detail: 'string true'} + __validate_migration_result_list: {ran: true, passed: true, detail: 'bool true'} + __validate_migration_result_agent: {ran: true, passed: 'True', detail: 'string true'} + __validate_migration_result_ip: {ran: true, passed: true, detail: 'bool true'} + __validate_migration_result_disks: {ran: true, passed: 'True', detail: 'string true'} + __validate_migration_result_power: {ran: true, passed: true, detail: 'bool true'} + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: report.yml + +- name: Main | Full run — ran checks failed, the assert names them + block: + - name: Main | Run the report with failed checks + vars: + __validate_migration_result_plan: {ran: true, passed: true, detail: 'pass'} + __validate_migration_result_vms: {ran: true, passed: false, detail: 'failed'} + __validate_migration_result_list: {ran: true, passed: 'False', detail: 'string false form'} + __validate_migration_result_agent: {ran: true, passed: 'True', detail: 'pass'} + __validate_migration_result_ip: {ran: true, passed: true, detail: 'pass'} + __validate_migration_result_disks: {ran: true, passed: false, detail: 'disk count mismatch'} + __validate_migration_result_power: {ran: true, passed: 'True', detail: 'pass'} + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: report.yml + rescue: + - name: Main | Mark the expected failure observed + ansible.builtin.set_fact: + __validate_migration_report_failure_observed: true + + - name: Main | Assert the summary marks the failed checks + ansible.builtin.assert: + that: + - not (__validate_migration_summary['Migrated VMs present and running'].passed | bool) + - not (__validate_migration_summary['Target list matches'].passed | bool) + - not (__validate_migration_summary['Disk topology matches'].passed | bool) + quiet: true + +- name: Main | Assert the expected failure was observed + ansible.builtin.assert: + that: __validate_migration_report_failure_observed | default(false) | bool + quiet: true + +- name: Main | pre_validate accepts clean inputs + vars: + validate_migration_openshift_host: https://api.cluster.example.com + validate_migration_openshift_api_key: test-token + validate_migration_plan_name: test-plan + validate_migration_plan_namespace: migration + validate_migration_target_namespace: migration + validate_migration_vm_list: [vm-a, vm-b] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: pre_validate.yml + +- name: Main | pre_validate rejects an empty VM list + block: + - name: Main | Run pre_validate with an empty VM list + vars: + validate_migration_openshift_host: https://api.cluster.example.com + validate_migration_openshift_api_key: test-token + validate_migration_plan_name: test-plan + validate_migration_plan_namespace: migration + validate_migration_target_namespace: migration + validate_migration_vm_list: [] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: pre_validate.yml + rescue: + - name: Main | Mark the empty-list rejection observed + ansible.builtin.set_fact: + __validate_migration_empty_list_rejection_observed: true + +- name: Main | pre_validate rejects a user:pass@ endpoint + block: + - name: Main | Run pre_validate with embedded credentials + vars: + validate_migration_openshift_host: https://svc-user:svc-pass@cluster.example.com + validate_migration_openshift_api_key: test-token + validate_migration_plan_name: test-plan + validate_migration_plan_namespace: migration + validate_migration_target_namespace: migration + validate_migration_vm_list: [vm-a] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: pre_validate.yml + rescue: + - name: Main | Mark the userinfo rejection observed + ansible.builtin.set_fact: + __validate_migration_userinfo_rejection_observed: true + +- name: Main | Assert both rejections fired + ansible.builtin.assert: + that: + - __validate_migration_empty_list_rejection_observed | default(false) | bool + - __validate_migration_userinfo_rejection_observed | default(false) | bool + quiet: true + +# Fail-closed gate coverage (the safety-critical logic in power_off.yml): +# each case pre-populates the gate facts via set_fact and runs +# power_off.yml with an empty vCenter hostname — the gather skips, so no +# mutation can occur, and the computed gate is asserted directly. + +- name: Main | Set clean gate inputs — absent per-VM facts + ansible.builtin.set_fact: + __validate_migration_result_plan: {ran: true, passed: true, detail: 'pass'} + __validate_migration_result_list: {ran: true, passed: true, detail: 'ok'} + +- name: Main | Gate — absent per-VM facts leave the gate closed + vars: + validate_migration_vcenter_hostname: '' + validate_migration_vm_list: ['vm-a'] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: power_off.yml + +- name: Main | Assert the gate stays closed with absent per-VM facts + ansible.builtin.assert: + that: + - not (__validate_migration_power_gate['vm-a'] | default(false) | bool) + quiet: true + +- name: Main | Set gate inputs — plan failed, VM otherwise valid + ansible.builtin.set_fact: + __validate_migration_result_plan: {ran: true, passed: false, detail: 'plan failed'} + __validate_migration_vms_state: {'vm-a': {present: true, running: true}} + __validate_migration_disks_state: + vm-a: + source_ok: true + source_disks: 2 + source_sizes: [171798691840] + target_volumes: 2 + +- name: Main | Gate — a failed plan leaves the gate closed + vars: + validate_migration_vcenter_hostname: '' + validate_migration_vm_list: ['vm-a'] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: power_off.yml + +- name: Main | Assert the gate stays closed on a failed plan + ansible.builtin.assert: + that: + - not (__validate_migration_power_gate['vm-a'] | default(false) | bool) + quiet: true + +- name: Main | Set gate inputs — disk count mismatch + ansible.builtin.set_fact: + __validate_migration_result_plan: {ran: true, passed: true, detail: 'pass'} + __validate_migration_disks_state: + vm-a: + source_ok: true + source_disks: 2 + source_sizes: [171798691840, 17179869184] + target_volumes: 1 + +- name: Main | Gate — a disk mismatch leaves the gate closed + vars: + validate_migration_vcenter_hostname: '' + validate_migration_vm_list: ['vm-a'] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: power_off.yml + +- name: Main | Assert the gate stays closed on a disk mismatch + ansible.builtin.assert: + that: + - not (__validate_migration_power_gate['vm-a'] | default(false) | bool) + quiet: true + +- name: Main | Set gate inputs — every fatal check passed + ansible.builtin.set_fact: + __validate_migration_disks_state: + vm-a: + source_ok: true + source_disks: 2 + source_sizes: [171798691840, 17179869184] + target_volumes: 2 + +- name: Main | Gate — every fatal check passed opens the gate + vars: + validate_migration_vcenter_hostname: '' + validate_migration_vm_list: ['vm-a'] + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.validate_migration + tasks_from: power_off.yml + +- name: Main | Assert the gate opens when every fatal check passed + ansible.builtin.assert: + that: + - __validate_migration_power_gate['vm-a'] | default(false) | bool + quiet: true + +... diff --git a/tests/unit/test_validate_migration_power.py b/tests/unit/test_validate_migration_power.py new file mode 100644 index 0000000..41813fd --- /dev/null +++ b/tests/unit/test_validate_migration_power.py @@ -0,0 +1,258 @@ +"""Exercise the real Ansible executor with network-free VMware module doubles. + +The temporary collection overrides both VMware FQCNs. Doubles contain no SDK +or connection code; no credentials or customer endpoints are used. These are +role orchestration tests, NOT vCenter/emulator compatibility evidence. +""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +LOOKUP = """ +from ansible.module_utils.basic import AnsibleModule +import json +import os +m = AnsibleModule(argument_spec=dict( + hostname=dict(type='str'), username=dict(type='str'), + password=dict(type='str', no_log=True), validate_certs=dict(type='bool'), + name=dict(type='str'), datacenter=dict(type='str'), + schema=dict(type='str', default='summary'), properties=dict(type='list', elements='str'), +), supports_check_mode=True) +name = m.params['name'] +fixture = json.loads(os.environ['VALIDATE_MIGRATION_TEST_FIXTURE']) +kind = fixture.get('lookup', {}).get(name, 'on') +if kind == 'failure': + m.fail_json(msg='mock private failure payload') +if kind == 'malformed': + m.exit_json(changed=False, instance={}) +if kind == 'unknown': + m.exit_json(changed=False, instance={'moid': 'id-' + name, 'hw_power_status': 'unknown'}) +if m.params['schema'] == 'vsphere': + m.exit_json(changed=False, instance={'config': {'hardware': { + 'device': [{'capacityInKB': 1024}, {'label': 'network adapter'}]}}}) +m.exit_json(changed=False, instance={ + 'moid': 'id-' + name, 'hw_power_status': 'poweredOff' if kind == 'off' else 'poweredOn', + 'ipv4': '192.0.2.10', +}) +""" +POWER = """ +from ansible.module_utils.basic import AnsibleModule +import json +import os +m = AnsibleModule(argument_spec=dict( + hostname=dict(type='str'), username=dict(type='str'), + password=dict(type='str', no_log=True), validate_certs=dict(type='bool'), + datacenter=dict(type='str', required=True), moid=dict(type='str'), + state=dict(type='str', choices=['powered-off']), +), supports_check_mode=True) +fixture = json.loads(os.environ['VALIDATE_MIGRATION_TEST_FIXTURE']) +kind = fixture.get('action', {}).get(m.params['moid'], 'success') +if kind == 'failure': + m.fail_json(msg='mock private failure payload') +if kind == 'malformed': + m.exit_json(changed=True) +m.exit_json(changed=True, vm={'moid': m.params['moid']}, validate_migration_mock=True) +""" +K8S = """ +from ansible.module_utils.basic import AnsibleModule +import json +import os +m = AnsibleModule(argument_spec=dict( + host=dict(type='str'), api_key=dict(type='str', no_log=True), + validate_certs=dict(type='bool'), api_version=dict(type='str'), + kind=dict(type='str'), namespace=dict(type='str'), name=dict(type='str'), +), supports_check_mode=True) +fixture = json.loads(os.environ.get('VALIDATE_MIGRATION_TEST_FIXTURE', '{}')) +kind = m.params['kind'] +if kind == 'VirtualMachine': + # MTV references migrated disks as persistentVolumeClaim (the bound + # DataVolume's PVC); dataVolume-style is the in-flight form. + m.exit_json(changed=False, resources=[{'metadata': {'name': 'a'}, + 'status': {'printableStatus': 'Running'}, + 'spec': {'template': {'spec': {'volumes': [ + {'name': 'vol-0', 'persistentVolumeClaim': {'claimName': 'disk'}}]}}}}]) +if kind == 'VirtualMachineInstance': + m.exit_json(changed=False, resources=[{'metadata': {'name': 'a'}, 'status': { + 'guestOSInfo': {'name': 'fixture'}, 'interfaces': [{'ipAddress': '192.0.2.10'}]}}]) +if kind == 'Plan': + m.exit_json(changed=False, resources=[ + {'metadata': {'name': 'a-plan', 'namespace': 'fixture'}}]) +if kind == 'Migration': + if fixture.get('no_migrations'): + m.exit_json(changed=False, resources=[]) + m.exit_json(changed=False, resources=[ + {'metadata': {'name': 'mig-old', 'creationTimestamp': '2026-01-01T00:00:00Z'}, + 'spec': {'plan': {'name': 'a-plan', 'namespace': 'fixture'}}, + 'status': {'conditions': [{'type': 'Succeeded', 'status': 'True'}], 'vms': []}}, + {'metadata': {'name': 'mig-new', 'creationTimestamp': '2026-01-02T00:00:00Z'}, + 'spec': {'plan': {'name': 'a-plan', 'namespace': 'fixture'}}, + 'status': {'conditions': [{'type': 'Failed', 'status': 'True'}], + 'vms': [{'id': 'vm-a', 'name': 'a', 'phase': 'Completed', + 'conditions': [{'type': 'Failed', 'status': 'True'}]}]}}, + ]) +m.exit_json(changed=False, resources=[]) +""" + + +class ValidateMigrationPowerTests(unittest.TestCase): + def test_offline_power_paths(self): + cases = [ + # name, fixture, overrides, expected passed, expected actions, detail + ("success", {}, {}, True, 1, "power-off completed"), + ("action failure", {"action": {"id-a": "failure"}}, {}, False, 0, "power-off failed"), + ("malformed action", {"action": {"id-a": "malformed"}}, {}, False, 1, "unconfirmed"), + ("lookup failure", {"lookup": {"a": "failure"}}, {}, False, 0, "lookup failed"), + ("malformed lookup", {"lookup": {"a": "malformed"}}, {}, False, 0, "lookup failed"), + ("unknown state", {"lookup": {"a": "unknown"}}, {}, False, 0, "unsupported source facts"), + ("already off", {"lookup": {"a": "off"}}, {}, True, 0, "already off"), + ("read only", {}, {"validate_migration_power_off_source": False, + "validate_migration_vcenter_datacenter": ""}, True, 0, "read-only"), + ("missing datacenter", {}, {"validate_migration_vcenter_datacenter": ""}, False, 0, "datacenter required"), + ("plan failed", {}, {"__validate_migration_result_plan": {"ran": True, "passed": False}}, + True, 0, "source stays on"), + ("plan absent", {}, {"__validate_migration_result_plan": {}}, True, 0, "source stays on"), + ("list failed", {}, {"__validate_migration_result_list": {"ran": True, "passed": False}}, + True, 0, "source stays on"), + ("missing target", {}, {"__validate_migration_vms_state": {"a": {"present": False, "running": False}}}, + True, 0, "source stays on"), + ("target stopped", {}, {"__validate_migration_vms_state": {"a": {"present": True, "running": False}}}, + True, 0, "source stays on"), + ("disk mismatch", {}, {"__validate_migration_disks_state": { + "a": {"source_ok": True, "source_disks": 2, "target_volumes": 1}}}, + True, 0, "source stays on"), + ("disk lookup failed", {}, {"__validate_migration_disks_state": { + "a": {"source_ok": False, "source_disks": 1, "target_volumes": 1}}}, + True, 0, "source stays on"), + ("absent facts", {}, {"__validate_migration_vms_state": {}, "__validate_migration_disks_state": {}}, + True, 0, "source stays on"), + ("mixed gates", {}, {"validate_migration_vm_list": ["a", "b"]}, True, 1, "b (id-b): source stays on"), + ("partial failure", {"lookup": {"b": "failure"}}, {"validate_migration_vm_list": ["a", "b"]}, + False, 1, "power-off completed"), + ("check mode", {"check": True}, {}, True, 0, "would power off"), + ("plan failed", {"tasks_from": "validate_plan.yml"}, + {"validate_migration_plan_name": "a-plan", "validate_migration_plan_namespace": "fixture"}, + False, 0, "is Failed"), + ("plan none", {"tasks_from": "validate_plan.yml", "no_migrations": True}, + {"validate_migration_plan_name": "a-plan", "validate_migration_plan_namespace": "fixture"}, + False, 0, "no Migration object"), + ("disk map", {"tasks_from": "validate_disks.yml"}, {}, True, 0, "Disk topology matches"), + ("disk failure", {"tasks_from": "validate_disks.yml", "lookup": {"a": "failure"}}, + {}, False, 0, "vCenter lookup failed"), + ("disk malformed", {"tasks_from": "validate_disks.yml", "lookup": {"a": "malformed"}}, + {}, False, 0, "vCenter lookup failed"), + ("IP map", {"tasks_from": "validate_ip.yml"}, {}, True, 0, "every VM reports"), + ("IP lookup failure", {"tasks_from": "validate_ip.yml", "lookup": {"a": "failure"}}, + {}, False, 0, "vCenter lookup failed"), + ("VM map", {"tasks_from": "validate_vms.yml"}, {}, True, 0, "present and Running"), + ("agent map", {"tasks_from": "validate_agent.yml"}, {}, True, 0, "Guest agent responsive"), + ] + with tempfile.TemporaryDirectory(prefix="validate_migration-offline-") as tmp: + base = Path(tmp) + collections = base / "collections" / "ansible_collections" + infra = collections / "infra/openshift_virtualization_migration" + infra.parent.mkdir(parents=True) + # Whole collection: the validate_migration role AND the real + # mtv_migration_status module with its module_utils. + infra.symlink_to(ROOT, target_is_directory=True) + for collection, module, source in [ + ("community/vmware", "vmware_guest_info", LOOKUP), + ("vmware/vmware", "vm_powerstate", POWER), + ("kubernetes/core", "k8s_info", K8S), + ]: + modules = collections / collection / "plugins/modules" + modules.mkdir(parents=True) + (modules / f"{module}.py").write_text(textwrap.dedent(source)) + config = base / "ansible.cfg" + config.write_text("[defaults]\nretry_files_enabled = False\nhost_key_checking = True\n") + for name, fixture, overrides, passed, actions, detail in cases: + with self.subTest(name=name): + variables = { + "validate_migration_vcenter_hostname": "offline.invalid", + "validate_migration_vcenter_username": "fixture", + "validate_migration_vcenter_password": "fixture", + "validate_migration_vcenter_datacenter": "fixture-dc", + "validate_migration_vm_list": ["a"], + "validate_migration_openshift_host": "https://offline.invalid", + "validate_migration_openshift_api_key": "fixture", + "validate_migration_target_namespace": "fixture", + "__validate_migration_result_plan": {"ran": True, "passed": True}, + "__validate_migration_result_list": {"ran": True, "passed": True}, + "__validate_migration_vms_state": {"a": {"present": True, "running": True}}, + "__validate_migration_disks_state": { + "a": {"source_ok": True, "source_disks": 1, "target_volumes": 1}}, + } + variables.update(overrides) + tasks_from = fixture.get("tasks_from", "power_off.yml") + checks = [ + "__validate_migration_power_gate is mapping", + "__validate_migration_result_power.passed | bool == " + str(passed).lower(), + json.dumps(detail) + " in __validate_migration_result_power.detail", + "'mock private failure payload' not in __validate_migration_result_power.detail", + "(__validate_migration_power_results.results | selectattr('changed', 'equalto', true)" + f" | list | length) == {actions}", + "(__validate_migration_power_results.results | selectattr('vm', 'defined')" + " | map(attribute='vm.moid') | list | difference(['id-a']) | length) == 0", + ] + if tasks_from == "validate_plan.yml": + # The check sets its own result fact; drop the + # play-var shadows so set_fact is observable. + for key in ("__validate_migration_result_plan", "__validate_migration_result_list", + "__validate_migration_vms_state", "__validate_migration_disks_state"): + variables.pop(key, None) + checks = [ + "__validate_migration_result_plan.passed | bool == " + str(passed).lower(), + json.dumps(detail) + " in __validate_migration_result_plan.detail", + ] + if not fixture.get("no_migrations"): + checks.append("__validate_migration_migration_name == 'mig-new'") + elif tasks_from != "power_off.yml": + check, state = { + "validate_disks.yml": ("disks", "disks_state"), + "validate_ip.yml": ("ip", "ip_state"), + "validate_vms.yml": ("vms", "vms_state"), + "validate_agent.yml": ("agent", "agents"), + }[tasks_from] + # Exercise task-produced maps, not pre-populated facts. + variables.pop("__validate_migration_vms_state", None) + variables.pop("__validate_migration_disks_state", None) + checks = [ + f"__validate_migration_{state} is mapping", + f"__validate_migration_result_{check}.passed | bool == " + str(passed).lower(), + json.dumps(detail) + f" in __validate_migration_result_{check}.detail", + ] + if check == "disks": + checks.append("__validate_migration_disks_mismatched is not string") + play = [{"hosts": "localhost", "gather_facts": False, "connection": "local", + "vars": variables, "tasks": [ + {"ansible.builtin.include_role": { + "name": "infra.openshift_virtualization_migration.validate_migration", + "tasks_from": tasks_from}}, + {"ansible.builtin.assert": {"that": checks}}, + ]}] + runner = base / "play.json" + runner.write_text(json.dumps(play)) + env = os.environ.copy() + env.update({"ANSIBLE_CONFIG": str(config), + "ANSIBLE_COLLECTIONS_PATH": str(collections.parent), + # Determinism insurance: resolve only the fake + # collections, never a sys-path copy. + "ANSIBLE_COLLECTIONS_SCAN_SYS_PATH": "false", + "VALIDATE_MIGRATION_TEST_FIXTURE": json.dumps(fixture), + "ANSIBLE_NOCOLOR": "1"}) + command = ["ansible-playbook", "--inventory", "localhost,", str(runner)] + if fixture.get("check"): + command.append("--check") + result = subprocess.run(command, env=env, cwd=base, capture_output=True, + text=True, timeout=90) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() From 502d4bbd2d7bbbaa79b53764d42742e835541f76 Mon Sep 17 00:00:00 2001 From: Vinny Valdez Date: Fri, 23 Oct 2026 19:39:08 -0500 Subject: [PATCH 2/2] fix(validate): match the codebase's tag conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the task-level tags (always/plan/validate/vms/list/agent/ip/ disks/poweroff/action) from validate_migration — no other role in the collection uses task tags, and all checks already run on every invocation regardless. Add the empty galaxy_tags key to meta/main.yml to match the existing roles' metadata shape. Assisted-by: opencode:enmaas/rits/zai-org/glm-5-3 --- roles/validate_migration/meta/main.yml | 1 + roles/validate_migration/tasks/main.yml | 68 ++----------------------- 2 files changed, 4 insertions(+), 65 deletions(-) diff --git a/roles/validate_migration/meta/main.yml b/roles/validate_migration/meta/main.yml index 835b6ec..a4214af 100644 --- a/roles/validate_migration/meta/main.yml +++ b/roles/validate_migration/meta/main.yml @@ -14,6 +14,7 @@ galaxy_info: - name: EL versions: - all + galaxy_tags: [] dependencies: [] diff --git a/roles/validate_migration/tasks/main.yml b/roles/validate_migration/tasks/main.yml index 9012cba..1b4159f 100644 --- a/roles/validate_migration/tasks/main.yml +++ b/roles/validate_migration/tasks/main.yml @@ -1,11 +1,9 @@ --- # The migration validation pass runs after vmf_migrate completes. # -# All checks run on every invocation; the summary report (always-tagged) -# fails the play if any ran check failed, so one run reports everything -# that needs attention. The summary asserts only over the checks that -# ran, so an isolated --tags debug run ends green when the targeted -# check passed. +# All checks run on every invocation; the summary report fails the play +# if any ran check failed, so one run reports everything that needs +# attention. # # The source power-off is the only mutating step: per-VM and fail-closed # (docs/adr/0001-validate-migration-fail-closed-power-off.md). A source VM powers @@ -18,99 +16,39 @@ - name: Include pre-validation of required variables ansible.builtin.include_tasks: file: pre_validate.yml - apply: - tags: - - always - tags: - - always - name: Run migration validation checks block: - name: Include migration plan status check ansible.builtin.include_tasks: file: validate_plan.yml - apply: - tags: - - plan - - validate - tags: - - plan - - validate - name: Include migrated VM presence and state check ansible.builtin.include_tasks: file: validate_vms.yml - apply: - tags: - - vms - - validate - tags: - - vms - - validate - name: Include target list match check ansible.builtin.include_tasks: file: validate_list.yml - apply: - tags: - - list - - validate - tags: - - list - - validate - name: Include guest agent check ansible.builtin.include_tasks: file: validate_agent.yml - apply: - tags: - - agent - - validate - tags: - - agent - - validate - name: Include IP address check ansible.builtin.include_tasks: file: validate_ip.yml - apply: - tags: - - ip - - validate - tags: - - ip - - validate - name: Include disk topology check ansible.builtin.include_tasks: file: validate_disks.yml - apply: - tags: - - disks - - validate - tags: - - disks - - validate - name: Include the source power-off action ansible.builtin.include_tasks: file: power_off.yml - apply: - tags: - - poweroff - - action - tags: - - poweroff - - action always: - name: Include the validate_migration summary report ansible.builtin.include_tasks: file: report.yml - apply: - tags: - - always - tags: - - always - ...