Initial commit: Debian Manager — управление Debian-серверами
- SSH/SFTP управление без агентов - Пакеты (apt), Docker, файлы, systemd, ufw, cron - Плейбуки, групповые операции, планировщик - Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy - Терминал, Telegram-уведомления, тёмная/светлая тема
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Плейбуки: 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
|
||||
Reference in New Issue
Block a user