feat: read-only service-registry to Proxmox drift gate

playbooks/validate.yml (make validate): reads pct config for every service in
homelab_services and fails if hostname, IP, cores, memory or swap disagree
with the registry. Read-only; meant as a pre/post gate around any inventory or
provisioning change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
This commit is contained in:
Dmitry
2026-09-03 07:04:29 +03:00
co-authored by Claude Sonnet 5
parent 646f2bbc8f
commit 7031d7dbb3
+85
View File
@@ -0,0 +1,85 @@
---
# Дрейф между реестром homelab_services и фактическим состоянием Proxmox.
#
# ЗАЧЕМ
# Реестр — источник правды, но программно его потребляют лишь несколько мест.
# VMID, адрес и ресурсы легко разъезжаются с реальностью, и заметить это
# раньше было нечем: `make status` показывает состояние, но не сверяет его
# с задекларированным, и его exit code намеренно не является gate'ом.
#
# ОТЛИЧИЕ ОТ status.yml
# Этот плейбук — именно gate: при любом расхождении он завершается ошибкой.
# Годится для cron и CI. Строго read-only: только `pct config`.
#
# make validate
# make validate EXTRA="--limit cloud-pc"
- name: Read actual LXC configuration from the Proxmox nodes
hosts: pve_nodes
gather_facts: false
tasks:
- name: Read pct config for the services declared on this node
ansible.builtin.command: "pct config {{ item.value.vmid }}"
loop: >-
{{ homelab_services | dict2items
| selectattr('value.node', 'eq', inventory_hostname) | list }}
loop_control:
label: "{{ item.key }} ({{ item.value.vmid }})"
register: validate_pct
changed_when: false
failed_when: false
check_mode: false
# Сравниваются только однозначные поля. rootfs намеренно пропущен: в реестре
# он записан как "data:32", а pct отдаёт "data:vm-141-disk-0,size=32G" —
# это разные представления, и их сверка требует отдельного парсера.
- name: Collect drift for this node
ansible.builtin.set_fact:
validate_drift: "{{ validate_drift | default([]) + item_drift }}"
loop: "{{ validate_pct.results }}"
loop_control:
label: "{{ item.item.key }}"
vars:
svc: "{{ item.item.value }}"
sname: "{{ item.item.key }}"
gone: "{{ item.rc | default(1) != 0 }}"
out: "{{ item.stdout | default('') }}"
a_host: "{{ out | regex_search('(?m)^hostname: (\\S+)', '\\1') | default([''], true) | first }}"
a_cores: "{{ out | regex_search('(?m)^cores: (\\d+)', '\\1') | default([''], true) | first }}"
a_mem: "{{ out | regex_search('(?m)^memory: (\\d+)', '\\1') | default([''], true) | first }}"
a_swap: "{{ out | regex_search('(?m)^swap: (\\d+)', '\\1') | default([''], true) | first }}"
a_ip: "{{ out | regex_search('ip=([0-9.]+)', '\\1') | default([''], true) | first }}"
item_drift: >-
{{ ([sname ~ ': VMID ' ~ svc.vmid ~ ' отсутствует на узле ' ~ inventory_hostname] if gone else [])
+ ([sname ~ ': hostname=' ~ a_host ~ ', в реестре ' ~ svc.hostname]
if (not gone and a_host != svc.hostname) else [])
+ ([sname ~ ': ip=' ~ a_ip ~ ', в реестре ' ~ svc.ip]
if (not gone and a_ip != svc.ip) else [])
+ ([sname ~ ': cores=' ~ a_cores ~ ', в реестре ' ~ svc.lxc.cores]
if (not gone and svc.lxc.cores is not none and a_cores | string != svc.lxc.cores | string) else [])
+ ([sname ~ ': memory=' ~ a_mem ~ ', в реестре ' ~ svc.lxc.memory]
if (not gone and svc.lxc.memory is not none and a_mem | string != svc.lxc.memory | string) else [])
+ ([sname ~ ': swap=' ~ a_swap ~ ', в реестре ' ~ svc.lxc.swap]
if (not gone and svc.lxc.swap is not none and a_swap | string != svc.lxc.swap | string) else []) }}
- name: Report registry drift
hosts: pve_nodes
gather_facts: false
run_once: true
tasks:
- name: Fail when the registry disagrees with Proxmox
ansible.builtin.assert:
that:
- all_drift | length == 0
success_msg: >-
Реестр совпадает с Proxmox: проверено сервисов —
{{ homelab_services | dict2items
| selectattr('value.node', 'in', groups['pve_nodes']) | list | length }}.
fail_msg: >-
{{ ['Реестр разошёлся с Proxmox:']
+ (all_drift | map('regex_replace', '^', ' - ') | list) }}
vars:
all_drift: >-
{{ groups['pve_nodes']
| map('extract', hostvars, 'validate_drift')
| select('defined') | flatten }}