diff --git a/ansible/Makefile b/ansible/Makefile new file mode 100644 index 0000000..2225f05 --- /dev/null +++ b/ansible/Makefile @@ -0,0 +1,241 @@ +# HomeLab Ansible — единая точка входа для РУЧНОГО управления инфраструктурой. +# +# Запускать из каталога ansible/ (или через `make -C ansible <цель>`): +# +# make список целей +# make check базовая проверка связности +# make deploy-gitea playbooks/pve-gitea.yml с загруженным .env +# make dry-gitea то же самое в режиме --check --diff +# make update-gitea playbooks/gitea-update.yml +# make docs markdown-таблица хостов в stdout +# +# Дополнительные флаги ansible-playbook передаются через EXTRA: +# +# make check EXTRA="--limit pve_nodes -vv" +# +# Интерпретатор определяется автоматически: если рядом есть ./.venv — берётся он, +# иначе бинарь ищется в PATH (nix devshell, системный ansible). Любой путь можно +# переопределить переменной окружения, например: +# +# ANSIBLE_PLAYBOOK=$(which ansible-playbook) make check + +SHELL := /bin/sh +.DEFAULT_GOAL := help + +MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST))) +ANSIBLE_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH))) +VENV_BIN := $(ANSIBLE_DIR)/.venv/bin + +# Разрешение бинарей: сначала рабочий локальный venv, иначе PATH (nix devshell, +# системный ansible). VENV_OK проверяет, что интерпретатор venv реально запускается, +# — иначе сломанный .venv (например, после смены системного python) молча ломал бы +# все цели. Переменные окружения имеют приоритет благодаря `?=`. +VENV_OK := $(shell [ -x '$(VENV_BIN)/python3' ] && '$(VENV_BIN)/python3' -c '' >/dev/null 2>&1 && echo yes) +venv_bin = $(if $(VENV_OK),$(if $(wildcard $(VENV_BIN)/$(1)),$(VENV_BIN)/$(1),$(1)),$(1)) + +ANSIBLE_PLAYBOOK ?= $(call venv_bin,ansible-playbook) +ANSIBLE_INVENTORY_BIN ?= $(call venv_bin,ansible-inventory) +ANSIBLE_GALAXY ?= $(call venv_bin,ansible-galaxy) +ANSIBLE_LINT ?= $(call venv_bin,ansible-lint) +YAMLLINT ?= $(call venv_bin,yamllint) +# scripts/gen-inventory-docs.py обходится стандартной библиотекой, поэтому берём +# python3 из PATH, а не из .venv. +PYTHON ?= python3 + +ENV_FILE ?= .env + +# Дополнительные аргументы ansible-playbook для любой цели. +EXTRA ?= +# Спрашивать sudo-пароль там, где нужен become. `make openvpn ASK_BECOME=` отключает. +ASK_BECOME ?= -K +# Спрашивать пароль Ansible Vault. `make gyro ASK_VAULT=` отключает. +ASK_VAULT ?= --ask-vault-pass + +# Строгая загрузка ansible/.env: обязательна для Proxmox API и секретов. +# Каждая строка рецепта make — отдельный шелл, поэтому source и запуск идут одной строкой. +REQUIRE_ENV = if [ ! -f '$(ENV_FILE)' ]; then \ + printf 'ОШИБКА: не найден %s/%s\n' '$(ANSIBLE_DIR)' '$(ENV_FILE)' >&2; \ + printf 'Создай его и заполни реальными значениями:\n' >&2; \ + printf ' cp .env.example .env\n' >&2; \ + printf 'Нужны PROXMOX_* (и MONITORING_* / EMERGENCY_* для профильных целей).\n' >&2; \ + exit 1; \ + fi; \ + set -a; . './$(ENV_FILE)'; set +a + +# Мягкая загрузка: .env подхватывается если есть, иначе просто предупреждение. +LOAD_ENV = if [ -f '$(ENV_FILE)' ]; then set -a; . './$(ENV_FILE)'; set +a; \ + else printf 'ВНИМАНИЕ: %s не найден, продолжаю без него.\n' '$(ENV_FILE)' >&2; fi + +# Защита от случайного запуска опасных плейбуков. +REQUIRE_CONFIRM = if [ "$(CONFIRM)" != "1" ]; then \ + printf 'ОПАСНАЯ ЦЕЛЬ. Повтори явно: make $@ CONFIRM=1\n' >&2; \ + exit 1; \ + fi + +# host_vars/gyro/vault.yml зашифрован Vault. Для чтения инвентаря он не нужен, +# поэтому vars-плагины отключаются, чтобы make не спрашивал пароль Vault. +INVENTORY_ENV = ANSIBLE_VARS_ENABLED= ANSIBLE_NOCOLOR=1 + +##@ Справка + +.PHONY: help +help: ## Показать этот список целей + @awk 'BEGIN { FS = ":.*##"; \ + print ""; \ + print "HomeLab Ansible — ручное управление инфраструктурой"; \ + print ""; \ + print " Использование: make <цель> [EXTRA=\"--limit host -vv\"]"; \ + print " Плейбуки Proxmox API сами подхватывают ./.env"; \ + } \ + /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \ + /^[a-zA-Z0-9_%.-]+:.*##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 } \ + END { print "" }' $(MAKEFILE_PATH) + +##@ Setup + +.PHONY: setup +setup: ## Создать .venv, поставить requirements.txt и galaxy-коллекции (не нужно в nix) + $(PYTHON) -m venv $(ANSIBLE_DIR)/.venv + $(VENV_BIN)/pip install --upgrade pip + $(VENV_BIN)/pip install -r requirements.txt + $(VENV_BIN)/ansible-galaxy collection install -r requirements.yml -p collections + +.PHONY: collections +collections: ## Доустановить только galaxy-коллекции из requirements.yml + $(ANSIBLE_GALAXY) collection install -r requirements.yml -p collections + +.PHONY: env-check +env-check: ## Проверить наличие .env и заполненность ключевых переменных + @$(REQUIRE_ENV); \ + rc=0; \ + for v in PROXMOX_HOST PROXMOX_USER PROXMOX_TOKEN_ID PROXMOX_TOKEN_SECRET; do \ + eval "val=\$$$$v"; \ + if [ -z "$$val" ] || [ "$$val" = 'replace-me' ]; then \ + printf 'не задано: %s\n' "$$v"; rc=1; \ + else printf 'ok: %s\n' "$$v"; fi; \ + done; \ + exit $$rc + +##@ Проверки и диагностика + +.PHONY: check +check: ## Проверка связности и ожидаемых IP (playbooks/check.yml) + $(ANSIBLE_PLAYBOOK) playbooks/check.yml $(EXTRA) + +.PHONY: status +status: ## Сводный статус инфраструктуры (playbooks/status.yml) + ANSIBLE_CALLBACK_RESULT_FORMAT=yaml $(ANSIBLE_PLAYBOOK) playbooks/status.yml $(EXTRA) + +.PHONY: lint +lint: ## Прогнать ansible-lint и yamllint по репозиторию + $(ANSIBLE_LINT) + $(YAMLLINT) . + +.PHONY: docs +docs: ## Напечатать markdown-таблицу хостов и групп в stdout + @ANSIBLE_INVENTORY_BIN='$(ANSIBLE_INVENTORY_BIN)' $(PYTHON) scripts/gen-inventory-docs.py + +.PHONY: inventory +inventory: ## Показать дерево инвентаря (ansible-inventory --graph) + @$(INVENTORY_ENV) $(ANSIBLE_INVENTORY_BIN) -i inventory/hosts.yml --graph + +.PHONY: openvpn-check +openvpn-check: ## Проверить OpenVPN-транспорт ru-vps <-> ovpn-mini + $(ANSIBLE_PLAYBOOK) playbooks/openvpn-check.yml $(EXTRA) + +.PHONY: backup-audit +backup-audit: ## Аудит свежести бэкапов PBS и offsite restic + $(ANSIBLE_PLAYBOOK) playbooks/backup-audit.yml $(EXTRA) + +dry-%: ## Прогон playbooks/pve-<имя>.yml в режиме --check --diff (пример: make dry-gitea) + @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-$*.yml --check --diff $(EXTRA) + +##@ Деплой LXC/VM через Proxmox API (требует .env) + +deploy-%: ## Применить playbooks/pve-<имя>.yml (пример: make deploy-gitea, deploy-adguard, deploy-monitoring) + @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-$*.yml $(EXTRA) + +.PHONY: backup-jobs +backup-jobs: ## Настроить PBS backup jobs (playbooks/pve-backup-jobs.yml) + @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-backup-jobs.yml $(EXTRA) + +.PHONY: bootstrap-pve-token +bootstrap-pve-token: ## Выпустить Proxmox API-токен прямо с ноды (нужен sudo) + $(ANSIBLE_PLAYBOOK) playbooks/bootstrap-pve-api-token.yml $(ASK_BECOME) $(EXTRA) + +.PHONY: bootstrap-monitoring-token +bootstrap-monitoring-token: ## Выпустить read-only PVE-токен для мониторинга + $(ANSIBLE_PLAYBOOK) playbooks/bootstrap-monitoring-pve-token.yml $(EXTRA) + +.PHONY: bootstrap-ansible-user +bootstrap-ansible-user: ## Завести сервисный аккаунт ansible на shell-хостах (нужен sudo) + $(ANSIBLE_PLAYBOOK) playbooks/bootstrap-ansible-user.yml $(ASK_BECOME) $(EXTRA) + +##@ Настройка сервисов + +.PHONY: reverse-proxy +reverse-proxy: ## Единый reverse-proxy для gitea/vaultwarden/grimmory + @$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/reverse-proxy.yml $(EXTRA) + +.PHONY: openvpn +openvpn: ## Поднять OpenVPN-транспорт ru-vps <-> ovpn-mini (нужен sudo) + $(ANSIBLE_PLAYBOOK) playbooks/openvpn-vps-mini.yml $(ASK_BECOME) $(EXTRA) + +.PHONY: openvpn-laptop +openvpn-laptop: ## Настроить OpenVPN-профиль ноутбука + $(ANSIBLE_PLAYBOOK) playbooks/openvpn-laptop.yml $(EXTRA) + +.PHONY: bash-config +bash-config: ## Раскатать единый bash-конфиг на shell_hosts + $(ANSIBLE_PLAYBOOK) playbooks/bash-config.yml $(EXTRA) + +.PHONY: user-ssh-key +user-ssh-key: ## Разложить публичный SSH-ключ пользователя на все хосты + $(ANSIBLE_PLAYBOOK) playbooks/user-ssh-key.yml $(EXTRA) + +.PHONY: emergency-access +emergency-access: ## Настроить emergency reverse-SSH и Telegram-бота (нужны EMERGENCY_* в .env) + @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/emergency-access.yml $(EXTRA) + +.PHONY: gyro +gyro: ## Настроить gyro-аллокатор в CT 150 (спрашивает пароль Vault) + @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/gyro.yml $(ASK_VAULT) $(EXTRA) + +.PHONY: uptime-kuma +uptime-kuma: ## Развернуть/обновить Uptime Kuma на monitoring LXC + $(ANSIBLE_PLAYBOOK) playbooks/uptime-kuma.yml $(EXTRA) + +.PHONY: offsite-restic +offsite-restic: ## Настроить offsite restic-бэкапы на Yandex.Disk + @$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/offsite-restic-yadisk.yml $(EXTRA) + +play-%: ## Запустить произвольный playbooks/<имя>.yml с загруженным .env (пример: make play-check) + @$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/$*.yml $(EXTRA) + +##@ Обновления сервисов (сначала свежий бэкап, потом апдейт, потом проверка) + +update-%: ## Обновить сервис через playbooks/<имя>-update.yml (gitea, vaultwarden, adguard, mihomo, grimmory) + @$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/$*-update.yml $(EXTRA) + +.PHONY: update-all +update-all: ## Последовательно обновить gitea, vaultwarden, adguard, mihomo, grimmory (требует CONFIRM=1) + @$(REQUIRE_CONFIRM) + $(MAKE) update-vaultwarden + $(MAKE) update-gitea + $(MAKE) update-adguard + $(MAKE) update-mihomo + $(MAKE) update-grimmory + +##@ Опасное (только осознанно, требует CONFIRM=1) + +.PHONY: mihomo-harden +mihomo-harden: ## РОТИРУЕТ живые SOCKS-креды Mihomo на ru-vps и убирает публичный доступ + @$(REQUIRE_CONFIRM) + @printf 'Ротация кредов необратима для клиентов без отката бэкапа конфига.\n' >&2 + $(ANSIBLE_PLAYBOOK) playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true $(EXTRA) + +.PHONY: monitoring +monitoring: ## ЗАМОРОЖЕН: стек Prometheus. Запускать только при восстановлении мониторинга + @$(REQUIRE_CONFIRM) + @printf 'monitoring.yml заморожен, пока используется Uptime Kuma.\n' >&2 + @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/monitoring.yml $(EXTRA) diff --git a/ansible/scripts/gen-inventory-docs.py b/ansible/scripts/gen-inventory-docs.py new file mode 100755 index 0000000..792507a --- /dev/null +++ b/ansible/scripts/gen-inventory-docs.py @@ -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())