"""Плейбуки: YAML + Jinja2, типы шагов, выполнение.""" from __future__ import annotations from pathlib import Path from typing import Callable, Coroutine, List, Optional import yaml from jinja2 import Template from app.core.ssh import SSHClient from app.db.models import Server LogCallback = Callable[[str], None] PLAYBOOKS_DIR = Path.home() / ".debian-manager" / "playbooks" PLAYBOOKS_DIR.mkdir(parents=True, exist_ok=True) BUILTIN = { "Новый сервер": """\ name: "Настройка нового сервера" vars: ssh_port: "22" steps: - name: Обновить систему apt: {update: true, upgrade: dist} - name: Установить базовые утилиты apt: {install: [curl, htop, git, ufw, fail2ban]} - name: Firewall (SSH + исходящие) ufw: {preset: "Только SSH"} - name: Включить ufw shell: "yes | ufw enable" """, "Docker-хост": """\ name: "Docker-хост" steps: - name: Обновить индекс пакетов apt: {update: true} - name: Установить Docker apt: {install: [docker.io, docker-compose-v2]} - name: Запустить docker systemd: {unit: docker, action: "enable --now"} """, "Обновить и перезагрузить": """\ name: "Обновить всё и перезагрузить" steps: - name: Update apt: {update: true} - name: Upgrade apt: {upgrade: dist} - name: Autoremove apt: {autoremove: true} - name: Reboot reboot: {wait: true} """, } def list_playbooks() -> List[str]: names = sorted(p.stem for p in PLAYBOOKS_DIR.glob("*.yml")) return list(BUILTIN.keys()) + names def load(name: str) -> dict: if name in BUILTIN: return yaml.safe_load(BUILTIN[name]) path = PLAYBOOKS_DIR / f"{name}.yml" return yaml.safe_load(path.read_text(encoding="utf-8")) def save(name: str, content: str) -> None: yaml.safe_load(content) # валидация (PLAYBOOKS_DIR / f"{name}.yml").write_text(content, encoding="utf-8") def delete(name: str) -> None: p = PLAYBOOKS_DIR / f"{name}.yml" if p.exists(): p.unlink() def _render(value, ctx: dict): if isinstance(value, str): return Template(value).render(**ctx) if isinstance(value, list): return [_render(v, ctx) for v in value] if isinstance(value, dict): return {k: _render(v, ctx) for k, v in value.items()} return value async def run_playbook(c: SSHClient, srv: Server, pb: dict, log: LogCallback, extra_vars: Optional[dict] = None) -> None: ctx = dict(pb.get("vars") or {}) ctx.update(extra_vars or {}) ctx.setdefault("ssh_port", str(srv.port)) log(f"=== Плейбук: {pb.get('name', '?')} | сервер: {srv.name} ===") for i, step in enumerate(pb.get("steps", []), 1): name = _render(step.get("name", f"шаг {i}"), ctx) log(f"\n--- [{i}] {name} ---") when = step.get("when") if when: cond = str(_render(when, ctx)).strip() if cond.lower() in ("false", "0", "", "no"): log("пропущен (when)") continue try: if "shell" in step: cmd = _render(step["shell"], ctx) log(f"$ {cmd}") async for line in c.run_stream(cmd, use_sudo=True): log(line) elif "apt" in step: a = _render(step["apt"], ctx) if a.get("update"): log("$ apt-get update") r = await c.sudo("DEBIAN_FRONTEND=noninteractive apt-get update", 900) log(r.stdout[-1500:]) if a.get("upgrade"): kind = "dist-upgrade" if a["upgrade"] == "dist" else "upgrade" log(f"$ apt-get {kind} -y") async for line in c.run_stream( f"DEBIAN_FRONTEND=noninteractive apt-get {kind} -y", use_sudo=True ): log(line) if a.get("install"): pkgs = " ".join(a["install"]) log(f"$ apt-get install -y {pkgs}") async for line in c.run_stream( f"DEBIAN_FRONTEND=noninteractive apt-get install -y {pkgs}", use_sudo=True, ): log(line) if a.get("remove"): pkgs = " ".join(a["remove"]) log(f"$ apt-get remove -y {pkgs}") async for line in c.run_stream( f"DEBIAN_FRONTEND=noninteractive apt-get remove -y {pkgs}", use_sudo=True, ): log(line) if a.get("autoremove"): async for line in c.run_stream( "DEBIAN_FRONTEND=noninteractive apt-get autoremove -y", use_sudo=True ): log(line) elif "ufw" in step: from app.core import firewall u = _render(step["ufw"], ctx) if "preset" in u: log(await firewall.apply_preset(c, u["preset"], int(ctx["ssh_port"]))) if u.get("enable"): log(await firewall.enable(c, int(ctx["ssh_port"]))) elif "systemd" in step: from app.core import systemd_svc sd = _render(step["systemd"], ctx) log(await systemd_svc.action(c, sd["unit"], sd["action"])) elif "docker" in step: d = _render(step["docker"], ctx) workdir = d.get("workdir", "/opt/stacks/app") log(await _docker_compose(c, workdir, d.get("action", "up -d"))) elif "reboot" in step: from app.core.ssh import wait_online import asyncio log("перезагрузка...") await c.sudo("(sleep 1; reboot) &", timeout=10) if step["reboot"].get("wait", True): await asyncio.sleep(10) if not await wait_online(srv, 300): raise RuntimeError("сервер не вернулся после reboot") log("сервер снова онлайн — переподключение") c.close() await c.connect() else: log(f"неизвестный тип шага: {list(step.keys())}") except Exception as e: if step.get("ignore_errors"): log(f"[ошибка проигнорирована] {e}") else: raise RuntimeError(f"шаг «{name}» упал: {e}") log("\n=== Плейбук завершён ===") async def _docker_compose(c: SSHClient, workdir: str, action: str) -> str: r = await c.sudo(f"docker compose -f {workdir}/docker-compose.yml {action}", 900) return r.stdout + r.stderr