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
197 lines
6.9 KiB
Python
Executable File
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())
|