Files
infra/ansible/scripts/gen-inventory-docs.py
DmitryandClaude Opus 5 ef234b17f5 Add Makefile as the entry point for manual operation
Knowing how to run something required reading ansible/README.md and
remembering to source .env first. Provide `make help` instead, with
targets grouped by purpose and pattern rules for the repetitive families:
deploy-%, dry-%, update-% and play-%.

.env is sourced automatically; targets that need Proxmox credentials fail
with an actionable message when it is missing. Destructive targets --
mihomo-harden, which rotates live credentials, the frozen monitoring
stack, and update-all -- require CONFIRM=1.

The interpreter is resolved at runtime rather than hardcoded to .venv:
the repository's venv is currently broken, so the Makefile falls back to
whatever is on PATH, which is what the Nix devshell provides.

gen-inventory-docs.py prints the host and group tables from
ansible-inventory, so documentation can be regenerated instead of being
maintained by hand and drifting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
2026-08-26 22:10:29 +03:00

197 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Печатает markdown-документацию по Ansible-инвентарю HomeLab в stdout.
Источник правды — `ansible-inventory -i inventory/hosts.yml --list`, поэтому вывод
всегда совпадает с тем, что реально видит Ansible. Файлы не пишутся: результат
вставляется в README/Obsidian вручную.
Запуск:
make docs
python3 scripts/gen-inventory-docs.py
Бинарь ansible-inventory ищется в порядке:
1. $ANSIBLE_INVENTORY_BIN
2. ./.venv/bin/ansible-inventory
3. ansible-inventory из PATH
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
ANSIBLE_DIR = Path(__file__).resolve().parent.parent
INVENTORY = ANSIBLE_DIR / "inventory" / "hosts.yml"
# Служебные группы, которые не несут смысла в документации.
HIDDEN_GROUPS = {"all", "ungrouped"}
EMPTY = "—"
def inventory_binaries() -> list[str]:
candidates: list[str] = []
from_env = os.environ.get("ANSIBLE_INVENTORY_BIN", "").strip()
if from_env:
candidates.append(from_env)
candidates.append(str(ANSIBLE_DIR / ".venv" / "bin" / "ansible-inventory"))
on_path = shutil.which("ansible-inventory")
if on_path:
candidates.append(on_path)
unique: list[str] = []
for candidate in candidates:
if candidate not in unique:
unique.append(candidate)
return unique
def run_inventory(binary: str, env: dict) -> tuple[dict | None, str]:
"""Один запуск ansible-inventory. Возвращает (данные, описание ошибки)."""
try:
done = subprocess.run(
[binary, "-i", str(INVENTORY), "--list"],
cwd=str(ANSIBLE_DIR),
env=env,
# stdin закрыт, чтобы ansible не ушёл в интерактивный запрос пароля Vault.
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
return None, str(exc)
if done.returncode != 0:
tail = (done.stderr or done.stdout or "").strip().splitlines()
return None, tail[-1] if tail else f"код возврата {done.returncode}"
try:
return json.loads(done.stdout), ""
except json.JSONDecodeError as exc:
return None, f"не удалось разобрать JSON ({exc})"
def load_inventory() -> dict:
base_env = dict(os.environ)
base_env["ANSIBLE_NOCOLOR"] = "1"
base_env.pop("ANSIBLE_INVENTORY_BIN", None)
# Запасной режим: host_vars/group_vars могут быть зашифрованы Ansible Vault
# (inventory/host_vars/gyro/vault.yml). Для таблицы хостов они не нужны, поэтому
# при отказе основного режима vars-плагины отключаются и инвентарь читается
# только из hosts.yml.
no_vars_env = dict(base_env)
no_vars_env["ANSIBLE_VARS_ENABLED"] = ""
problems: list[str] = []
for binary in inventory_binaries():
data, error = run_inventory(binary, base_env)
if data is not None:
return data
problems.append(f"{binary}: {error}")
data, fallback_error = run_inventory(binary, no_vars_env)
if data is not None:
sys.stderr.write(
"ВНИМАНИЕ: host_vars/group_vars не прочитаны "
f"({error}); таблица построена только по {INVENTORY.name}.\n"
)
return data
problems.append(f"{binary} (без vars-плагинов): {fallback_error}")
sys.stderr.write("ОШИБКА: не удалось прочитать инвентарь.\n")
for problem in problems or ["ansible-inventory не найден"]:
sys.stderr.write(f" - {problem}\n")
sys.stderr.write(
"Установи зависимости (`make setup`), войди в nix-окружение "
"или задай ANSIBLE_INVENTORY_BIN явно.\n"
)
raise SystemExit(1)
def parse_groups(data: dict) -> dict[str, dict[str, list[str]]]:
groups: dict[str, dict[str, list[str]]] = {}
for name, body in data.items():
if name == "_meta" or not isinstance(body, dict):
continue
groups[name] = {
"hosts": list(body.get("hosts", [])),
"children": list(body.get("children", [])),
}
return groups
def resolve_hosts(group: str, groups: dict, seen: set[str] | None = None) -> set[str]:
"""Все хосты группы с учётом вложенных children."""
seen = seen or set()
if group in seen or group not in groups:
return set()
seen.add(group)
hosts = set(groups[group]["hosts"])
for child in groups[group]["children"]:
hosts |= resolve_hosts(child, groups, seen)
return hosts
def escape(value: object) -> str:
text = str(value).strip()
return text.replace("|", "\\|") if text else EMPTY
def main() -> int:
data = load_inventory()
hostvars = data.get("_meta", {}).get("hostvars", {})
groups = parse_groups(data)
resolved = {name: resolve_hosts(name, groups) for name in groups}
all_hosts = set(hostvars)
for members in resolved.values():
all_hosts |= members
host_groups: dict[str, list[str]] = {}
for host in all_hosts:
host_groups[host] = sorted(
name
for name, members in resolved.items()
if host in members and name not in HIDDEN_GROUPS
)
out = sys.stdout.write
out("## Хосты\n\n")
out("| Хост | ansible_host | expected_lan_ip | Группы |\n")
out("|---|---|---|---|\n")
for host in sorted(all_hosts):
facts = hostvars.get(host, {})
out(
"| `{host}` | {ansible_host} | {lan_ip} | {groups} |\n".format(
host=host,
ansible_host=escape(facts.get("ansible_host", "")),
lan_ip=escape(facts.get("expected_lan_ip", "")),
groups=", ".join(f"`{g}`" for g in host_groups[host]) or EMPTY,
)
)
out("\n## Группы\n\n")
for name in sorted(groups):
if name in HIDDEN_GROUPS:
continue
members = sorted(resolved[name])
children = sorted(groups[name]["children"])
out(f"### `{name}` ({len(members)})\n\n")
if children:
out("- Вложенные группы: " + ", ".join(f"`{c}`" for c in children) + "\n")
out("- Хосты: " + (", ".join(f"`{m}`" for m in members) or EMPTY) + "\n\n")
out(f"_Сгенерировано из `{INVENTORY.relative_to(ANSIBLE_DIR)}` "
"через `make docs`._\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())