From 9809192ab537521bd38d821d75f6d6838f4bac28 Mon Sep 17 00:00:00 2001 From: sfulmer Date: Wed, 15 Apr 2026 09:43:29 -0400 Subject: [PATCH] feat(vm-consumption-report): add VM consumption reporting role Add role and playbook for generating consumption reports of OpenShift Virtualization VMs. Supports querying VMs by namespace and label selectors, reconciliation of directly managed hosts with API-managed VMs via UUID/hostname/IP matching, and output in JSON or CSV format. Closes: MFG-390 Co-Authored-By: Claude Opus 4.6 --- playbooks/vm_consumption_report.yml | 15 +++ roles/vm_consumption_report/README.md | 66 ++++++++++ roles/vm_consumption_report/defaults/main.yml | 66 ++++++++++ roles/vm_consumption_report/meta/main.yml | 13 ++ .../tasks/_build_report.yml | 114 ++++++++++++++++++ .../tasks/_collect_vmis.yml | 18 +++ .../tasks/_collect_vms.yml | 30 +++++ roles/vm_consumption_report/tasks/main.yml | 59 +++++++++ .../templates/report.csv.j2 | 7 ++ 9 files changed, 388 insertions(+) create mode 100644 playbooks/vm_consumption_report.yml create mode 100644 roles/vm_consumption_report/README.md create mode 100644 roles/vm_consumption_report/defaults/main.yml create mode 100644 roles/vm_consumption_report/meta/main.yml create mode 100644 roles/vm_consumption_report/tasks/_build_report.yml create mode 100644 roles/vm_consumption_report/tasks/_collect_vmis.yml create mode 100644 roles/vm_consumption_report/tasks/_collect_vms.yml create mode 100644 roles/vm_consumption_report/tasks/main.yml create mode 100644 roles/vm_consumption_report/templates/report.csv.j2 diff --git a/playbooks/vm_consumption_report.yml b/playbooks/vm_consumption_report.yml new file mode 100644 index 0000000..631f738 --- /dev/null +++ b/playbooks/vm_consumption_report.yml @@ -0,0 +1,15 @@ +--- + +- name: Generate VM Consumption Report + hosts: localhost + connection: local + gather_facts: false + tasks: + - name: Invoke VM Consumption Report Role + ansible.builtin.include_role: + name: infra.openshift_virtualization_migration.vm_consumption_report + vars: + openshift_host: "{{ lookup('ansible.builtin.env', 'K8S_AUTH_HOST', default=Undefined) | default('', True) }}" + openshift_api_key: "{{ lookup('ansible.builtin.env', 'K8S_AUTH_API_KEY', default=Undefined) | default('', True) }}" # noqa: yaml[line-length] + openshift_verify_ssl: "{{ lookup('ansible.builtin.env', 'K8S_AUTH_VERIFY_SSL', default='') | default(false) | bool }}" # noqa: yaml[line-length] +... diff --git a/roles/vm_consumption_report/README.md b/roles/vm_consumption_report/README.md new file mode 100644 index 0000000..7bb173a --- /dev/null +++ b/roles/vm_consumption_report/README.md @@ -0,0 +1,66 @@ +# vm_consumption_report + +Generate consumption reports for OpenShift Virtualization VMs managed +via the `redhat.openshift_virtualization` collection. The report +distinguishes between **directly managed hosts** (contacted via SSH or +WinRM) and **indirectly managed VMs** (automated via the Kubernetes +API), and supports **reconciliation** of hosts that are managed through +both methods. + +## Requirements + +- `kubernetes.core` collection +- OpenShift API access with permission to list VirtualMachine and + VirtualMachineInstance resources + +## Role Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `vm_consumption_report_request` | yes | `[]` | List of query entries (namespace, names, label_selectors) | +| `vm_consumption_report_direct_hosts` | no | `[]` | List of directly managed hosts for reconciliation | +| `vm_consumption_report_openshift_host` | yes | `{{ openshift_host }}` | OpenShift API endpoint | +| `vm_consumption_report_openshift_api_key` | yes | `{{ openshift_api_key }}` | OpenShift API token | +| `vm_consumption_report_openshift_verify_ssl` | yes | `{{ openshift_verify_ssl }}` | Verify SSL certificates | +| `vm_consumption_report_output_format` | no | `json` | Output format: `json` or `csv` | +| `vm_consumption_report_output_path` | no | `""` | File path to write the report | +| `vm_consumption_report_include_status` | no | `true` | Query VMIs for running status and guest OS info | + +## Output + +The role sets `vm_consumption_report_result` containing: + +- `summary` — Counts of total VMs, indirect-only, direct-only, and reconciled +- `indirect_vms` — List of OpenShift VMs with UUID, namespace, status, guest hostname, and management type +- `direct_only_hosts` — Hosts that are directly managed but not matched to any OpenShift VM + +## Reconciliation + +When `vm_consumption_report_direct_hosts` is provided, the role +matches direct hosts to OpenShift VMs by: + +1. **UUID match** — Direct host UUID matches VM `metadata.uid` +2. **Hostname match** — Direct host hostname matches the guest OS + hostname reported by the VMI +3. **IP match** — Direct host hostname matches an IP address on a VMI + network interface + +Matched entries are marked `management_type: reconciled`. + +## Example + +```yaml +vm_consumption_report_request: + - namespace: production-vms + - namespace: staging-vms + label_selectors: + - app=web + +vm_consumption_report_direct_hosts: + - hostname: web-server-01.example.com + - hostname: db-server-02.example.com + uuid: 5a3f8c2e-1234-5678-9abc-def012345678 + +vm_consumption_report_output_format: json +vm_consumption_report_output_path: /tmp/consumption_report.json +``` diff --git a/roles/vm_consumption_report/defaults/main.yml b/roles/vm_consumption_report/defaults/main.yml new file mode 100644 index 0000000..d17e731 --- /dev/null +++ b/roles/vm_consumption_report/defaults/main.yml @@ -0,0 +1,66 @@ +--- +# defaults file for vm_consumption_report + +# title: Consumption Report Request +# required: True +# description: >- +# List of namespaces (or all namespaces) to include in the +# consumption report. Each entry may filter by namespace, names, +# or label selectors. +vm_consumption_report_request: [] +# - namespace: # Namespace to query (omit for all namespaces) +# names: # Optional list of VM names to include +# - +# label_selectors: # Optional label selectors (cannot be combined with names) +# - = + +# title: Directly Managed Hosts +# required: False +# description: >- +# List of directly managed hosts (contacted via SSH/WinRM) with +# their hostnames for reconciliation against indirectly managed +# OpenShift VMs. Each entry should include hostname and optionally +# a UUID if known. +vm_consumption_report_direct_hosts: [] +# - hostname: # Hostname or IP used by AAP/SSH +# uuid: # Optional UUID if already known + +# title: OpenShift host +# required: True +# description: OpenShift host +vm_consumption_report_openshift_host: "{{ openshift_host }}" + +# title: OpenShift API Key +# required: True +# description: OpenShift API Key +vm_consumption_report_openshift_api_key: "{{ openshift_api_key }}" + +# title: Verify SSL Certificate +# required: True +# description: Variable to enable SSL verification +vm_consumption_report_openshift_verify_ssl: "{{ openshift_verify_ssl }}" + +# title: KubeVirt API Version +# required: True +# description: KubeVirt API Version +vm_consumption_report_kubevirt_api_version: kubevirt.io/v1 + +# title: Report Output Format +# required: True +# description: Output format for the consumption report (json or csv) +vm_consumption_report_output_format: json + +# title: Report Output Path +# required: False +# description: >- +# File path to write the consumption report. If not set, the report +# is returned as a variable only. +vm_consumption_report_output_path: "" + +# title: Include Running Status +# required: True +# description: >- +# When true, queries VirtualMachineInstance resources to determine +# current running state and guest OS info for reconciliation. +vm_consumption_report_include_status: true +... diff --git a/roles/vm_consumption_report/meta/main.yml b/roles/vm_consumption_report/meta/main.yml new file mode 100644 index 0000000..c194d05 --- /dev/null +++ b/roles/vm_consumption_report/meta/main.yml @@ -0,0 +1,13 @@ +--- +galaxy_info: + author: "" + description: >- + Generate consumption reports for OpenShift Virtualization VMs, + distinguishing directly and indirectly managed hosts with + UUID-based reconciliation. + company: Red Hat + license: GPL-3.0-only + min_ansible_version: 2.15.0 + galaxy_tags: [] +dependencies: [] +... diff --git a/roles/vm_consumption_report/tasks/_build_report.yml b/roles/vm_consumption_report/tasks/_build_report.yml new file mode 100644 index 0000000..5b24ec2 --- /dev/null +++ b/roles/vm_consumption_report/tasks/_build_report.yml @@ -0,0 +1,114 @@ +--- + +- name: Build VM Entries + ansible.builtin.set_fact: + _vm_consumption_report_entries: >- + {{ _vm_consumption_report_entries | default([]) + [_entry] }} + vars: + _vmi_match: >- + {{ _vm_consumption_report_vmis + | selectattr('metadata.namespace', 'equalto', _vm.metadata.namespace) + | selectattr('metadata.name', 'equalto', _vm.metadata.name) + | list | first | default({}) }} + _vm_uuid: "{{ _vm.metadata.uid }}" + _guest_hostname: >- + {{ _vmi_match.status.guestOSInfo.hostname | default('') }} + _guest_ip_addresses: >- + {{ _vmi_match.status.interfaces + | default([]) + | map(attribute='ipAddress') + | select('defined') + | list }} + _direct_match: >- + {{ vm_consumption_report_direct_hosts + | selectattr('uuid', 'defined') + | selectattr('uuid', 'equalto', _vm_uuid) + | list + + (vm_consumption_report_direct_hosts + | selectattr('hostname', 'defined') + | selectattr('hostname', 'equalto', _guest_hostname) + | list if _guest_hostname | length > 0 else []) + + (vm_consumption_report_direct_hosts + | selectattr('hostname', 'defined') + | selectattr('hostname', 'in', _guest_ip_addresses) + | list if _guest_ip_addresses | length > 0 else []) }} + _management_type: >- + {{ 'reconciled' if _direct_match | length > 0 + else 'indirect' }} + _entry: + name: "{{ _vm.metadata.name }}" + namespace: "{{ _vm.metadata.namespace }}" + uuid: "{{ _vm_uuid }}" + management_type: "{{ _management_type }}" + running: "{{ _vm.spec.running | default(_vm.spec.runStrategy | default('Unknown')) }}" + created_at: "{{ _vm.metadata.creationTimestamp }}" + last_transition: >- + {{ _vm.status.conditions + | default([]) + | sort(attribute='lastTransitionTime') + | map(attribute='lastTransitionTime') + | list | last | default(_vm.metadata.creationTimestamp) }} + guest_hostname: "{{ _guest_hostname }}" + ip_addresses: "{{ _guest_ip_addresses }}" + direct_host_match: "{{ _direct_match | first | default({}) }}" + labels: "{{ _vm.metadata.labels | default({}) }}" + loop: "{{ _vm_consumption_report_vms }}" + loop_control: + loop_var: _vm + label: "{{ _vm.metadata.namespace }}/{{ _vm.metadata.name }}" + +- name: Build Direct-Only Entries + ansible.builtin.set_fact: + _vm_consumption_report_direct_only: >- + {{ _vm_consumption_report_direct_only | default([]) + [_direct_entry] }} + vars: + _matched_uuids: >- + {{ (_vm_consumption_report_entries | default([])) + | selectattr('management_type', 'equalto', 'reconciled') + | map(attribute='direct_host_match') + | select('mapping') + | map(attribute='hostname', default='') + | list }} + _matched_hosts: >- + {{ (_vm_consumption_report_entries | default([])) + | selectattr('management_type', 'equalto', 'reconciled') + | map(attribute='direct_host_match') + | select('mapping') + | map(attribute='hostname', default='') + | list }} + _direct_entry: + hostname: "{{ _host.hostname }}" + uuid: "{{ _host.uuid | default('') }}" + management_type: "direct" + loop: "{{ vm_consumption_report_direct_hosts }}" + loop_control: + loop_var: _host + label: "{{ _host.hostname }}" + when: + - _host.hostname not in _matched_hosts + - (_host.uuid | default('')) not in ((_vm_consumption_report_entries | default([])) | map(attribute='uuid') | list) + +- name: Compile Final Report + ansible.builtin.set_fact: + vm_consumption_report_result: + generated_at: "{{ now(utc=true, fmt='%Y-%m-%dT%H:%M:%SZ') }}" + cluster: "{{ vm_consumption_report_openshift_host }}" + summary: + total_vms: "{{ (_vm_consumption_report_entries | default([])) | length }}" + indirect_only: >- + {{ (_vm_consumption_report_entries | default([])) + | selectattr('management_type', 'equalto', 'indirect') + | list | length }} + direct_only: "{{ (_vm_consumption_report_direct_only | default([])) | length }}" + reconciled: >- + {{ (_vm_consumption_report_entries | default([])) + | selectattr('management_type', 'equalto', 'reconciled') + | list | length }} + total_managed_hosts: >- + {{ (_vm_consumption_report_entries | default([])) + | length + + (_vm_consumption_report_direct_only | default([])) + | length }} + indirect_vms: "{{ _vm_consumption_report_entries | default([]) }}" + direct_only_hosts: "{{ _vm_consumption_report_direct_only | default([]) }}" +... diff --git a/roles/vm_consumption_report/tasks/_collect_vmis.yml b/roles/vm_consumption_report/tasks/_collect_vmis.yml new file mode 100644 index 0000000..f6d686c --- /dev/null +++ b/roles/vm_consumption_report/tasks/_collect_vmis.yml @@ -0,0 +1,18 @@ +--- + +- name: "Query VirtualMachineInstances — {{ _vm_consumption_report_query.namespace | default('all-namespaces') }}" + kubernetes.core.k8s_info: + api_key: "{{ vm_consumption_report_openshift_api_key }}" + host: "{{ vm_consumption_report_openshift_host }}" + api_version: "{{ vm_consumption_report_kubevirt_api_version }}" + kind: VirtualMachineInstance + namespace: "{{ _vm_consumption_report_query.namespace | default(omit) }}" + label_selectors: "{{ _vm_consumption_report_query.label_selectors | default(omit) }}" + validate_certs: "{{ vm_consumption_report_openshift_verify_ssl }}" + register: _vm_consumption_report_vmi_response + +- name: Append VMIs to Collection + ansible.builtin.set_fact: + _vm_consumption_report_vmis: >- + {{ _vm_consumption_report_vmis + _vm_consumption_report_vmi_response.resources }} +... diff --git a/roles/vm_consumption_report/tasks/_collect_vms.yml b/roles/vm_consumption_report/tasks/_collect_vms.yml new file mode 100644 index 0000000..86a1a3b --- /dev/null +++ b/roles/vm_consumption_report/tasks/_collect_vms.yml @@ -0,0 +1,30 @@ +--- + +- name: "Query VirtualMachines — {{ _vm_consumption_report_query.namespace | default('all-namespaces') }}" + kubernetes.core.k8s_info: + api_key: "{{ vm_consumption_report_openshift_api_key }}" + host: "{{ vm_consumption_report_openshift_host }}" + api_version: "{{ vm_consumption_report_kubevirt_api_version }}" + kind: VirtualMachine + namespace: "{{ _vm_consumption_report_query.namespace | default(omit) }}" + label_selectors: "{{ _vm_consumption_report_query.label_selectors | default(omit) }}" + validate_certs: "{{ vm_consumption_report_openshift_verify_ssl }}" + register: _vm_consumption_report_vm_response + +- name: Filter VMs by Name (if specified) + ansible.builtin.set_fact: + _vm_consumption_report_filtered: >- + {{ _vm_consumption_report_vm_response.resources + | selectattr('metadata.name', 'in', _vm_consumption_report_query.names) + | list }} + when: "'names' in _vm_consumption_report_query" + +- name: Use All Returned VMs (no name filter) + ansible.builtin.set_fact: + _vm_consumption_report_filtered: "{{ _vm_consumption_report_vm_response.resources }}" + when: "'names' not in _vm_consumption_report_query" + +- name: Append VMs to Collection + ansible.builtin.set_fact: + _vm_consumption_report_vms: "{{ _vm_consumption_report_vms + _vm_consumption_report_filtered }}" +... diff --git a/roles/vm_consumption_report/tasks/main.yml b/roles/vm_consumption_report/tasks/main.yml new file mode 100644 index 0000000..8ad6808 --- /dev/null +++ b/roles/vm_consumption_report/tasks/main.yml @@ -0,0 +1,59 @@ +--- + +- name: Verify Request Variable Provided + ansible.builtin.assert: + that: + - vm_consumption_report_request | default([], true) | length > 0 + fail_msg: "'vm_consumption_report_request' must contain at least one entry" + quiet: true + +- name: Initialize Report Data + ansible.builtin.set_fact: + _vm_consumption_report_vms: [] + _vm_consumption_report_vmis: [] + +- name: Collect VirtualMachines + ansible.builtin.include_tasks: _collect_vms.yml + loop: "{{ vm_consumption_report_request }}" + loop_control: + loop_var: _vm_consumption_report_query + label: "{{ _vm_consumption_report_query.namespace | default('all-namespaces') }}" + +- name: Collect VirtualMachineInstances for Running Status + ansible.builtin.include_tasks: _collect_vmis.yml + loop: "{{ vm_consumption_report_request }}" + loop_control: + loop_var: _vm_consumption_report_query + label: "{{ _vm_consumption_report_query.namespace | default('all-namespaces') }}" + when: vm_consumption_report_include_status | bool + +- name: Build Consumption Report + ansible.builtin.include_tasks: _build_report.yml + +- name: Write Report to File + ansible.builtin.copy: + content: "{{ vm_consumption_report_result | to_nice_json }}" + dest: "{{ vm_consumption_report_output_path }}" + mode: "0644" + when: + - vm_consumption_report_output_path | default("", true) | length > 0 + - vm_consumption_report_output_format == "json" + +- name: Write CSV Report to File + ansible.builtin.template: + src: report.csv.j2 + dest: "{{ vm_consumption_report_output_path }}" + mode: "0644" + when: + - vm_consumption_report_output_path | default("", true) | length > 0 + - vm_consumption_report_output_format == "csv" + +- name: Display Report Summary + ansible.builtin.debug: + msg: + total_vms: "{{ vm_consumption_report_result.summary.total_vms }}" + indirect_only: "{{ vm_consumption_report_result.summary.indirect_only }}" + direct_only: "{{ vm_consumption_report_result.summary.direct_only }}" + reconciled: "{{ vm_consumption_report_result.summary.reconciled }}" + report_generated: "{{ vm_consumption_report_result.generated_at }}" +... diff --git a/roles/vm_consumption_report/templates/report.csv.j2 b/roles/vm_consumption_report/templates/report.csv.j2 new file mode 100644 index 0000000..2d52ec7 --- /dev/null +++ b/roles/vm_consumption_report/templates/report.csv.j2 @@ -0,0 +1,7 @@ +type,name,namespace,uuid,management_type,running,created_at,last_transition,guest_hostname,ip_addresses +{% for vm in vm_consumption_report_result.indirect_vms %} +vm,{{ vm.name }},{{ vm.namespace }},{{ vm.uuid }},{{ vm.management_type }},{{ vm.running }},{{ vm.created_at }},{{ vm.last_transition }},{{ vm.guest_hostname }},"{{ vm.ip_addresses | join(';') }}" +{% endfor %} +{% for host in vm_consumption_report_result.direct_only_hosts %} +host,{{ host.hostname }},,{{ host.uuid }},{{ host.management_type }},,,,{{ host.hostname }}, +{% endfor %}