f624fee5e0
- SSH/SFTP управление без агентов - Пакеты (apt), Docker, файлы, systemd, ufw, cron - Плейбуки, групповые операции, планировщик - Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy - Терминал, Telegram-уведомления, тёмная/светлая тема
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""Плейбуки: 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
|