Files

189 lines
7.0 KiB
Python
Raw Permalink Normal View History

"""Плейбуки: 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