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
This commit is contained in:
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user