Initial commit: Debian Manager — управление Debian-серверами

- SSH/SFTP управление без агентов
- Пакеты (apt), Docker, файлы, systemd, ufw, cron
- Плейбуки, групповые операции, планировщик
- Бэкапы, отчёты, Portainer, Proxmox VE, Gitea Deploy
- Терминал, Telegram-уведомления, тёмная/светлая тема
This commit is contained in:
dinlo
2026-09-05 19:46:45 +08:00
commit f624fee5e0
65 changed files with 6981 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
"""Агент-режим: установка агента на сервер, WS-сервер, приём метрик."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from typing import Callable, Optional
from app.core.ssh import SSHClient
from app.db.database import get_session
from app.db.models import MetricSnapshot, Server, Setting
# последние метрики по server_id: {"cpu": .., "mem": .., "disk": .., "ts": ..}
agent_metrics: dict[int, dict] = {}
_http_server = None
AGENT_VERSION = "0.1"
AGENT_SCRIPT = r'''#!/usr/bin/env python3
"""Debian Manager agent: метрики по WebSocket. Только stdlib."""
import json, socket, ssl, sys, time, urllib.request, os
try:
import psutil
except ImportError:
psutil = None
SERVER_WS = os.environ.get("DM_SERVER", "ws://127.0.0.1:9101")
TOKEN = os.environ.get("DM_TOKEN", "")
SERVER_ID = int(os.environ.get("DM_SERVER_ID", "0"))
INTERVAL = float(os.environ.get("DM_INTERVAL", "5"))
def metrics():
cpu = 0.0
if psutil:
cpu = psutil.cpu_percent(interval=None)
vm = psutil.virtual_memory()
mem_used, mem_total = vm.used // 1048576, vm.total // 1048576
du = psutil.disk_usage("/")
disk = du.percent
else:
with open("/proc/stat") as f:
parts = f.readline().split()
idle = int(parts[4]) + int(parts[5])
total = sum(int(x) for x in parts[1:8])
cpu = round((1 - idle / total) * 100, 1) if total else 0
with open("/proc/meminfo") as f:
mi = {}
for line in f:
k, v = line.split(":")
mi[k] = int(v.split()[0])
mem_total = mi["MemTotal"] // 1024
mem_used = (mi["MemTotal"] - mi.get("MemAvailable", mi["MemFree"])) // 1024
st = os.statvfs("/")
disk = round((st.f_blocks - st.f_bavail) * 100 / st.f_blocks, 1)
with open("/proc/loadavg") as f:
load = float(f.read().split()[0])
return {"cpu": cpu, "mem_used": mem_used, "mem_total": mem_total,
"mem_pct": round(mem_used * 100 / mem_total, 1) if mem_total else 0,
"disk": disk, "load": load, "ts": time.time()}
def main():
# минимальный WebSocket-клиент на stdlib (только текстовые фреймы, без маскировки сложная;
# для простоты используем HTTP POST каждые INTERVAL сек — надёжнее и без библиотек)
url = SERVER_WS.replace("ws://", "http://").replace("wss://", "https://") + "/metrics"
while True:
try:
data = json.dumps({"server_id": SERVER_ID, "token": TOKEN,
"metrics": metrics()}).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=10)
except Exception as e:
print("send error:", e, file=sys.stderr)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
'''
def _get(key: str) -> str:
with get_session() as s:
row = s.get(Setting, key)
return row.value if row else ""
def _set(key: str, value: str) -> None:
with get_session() as s:
row = s.get(Setting, key) or Setting(key=key)
row.value = value
s.add(row)
s.commit()
def get_agent_port() -> int:
return int(_get("agent_port") or "9102")
def get_or_create_token(server_id: int) -> str:
key = f"agent_token_{server_id}"
token = _get(key)
if not token:
token = secrets.token_hex(16)
_set(key, token)
return token
async def install_agent(srv: Server, app_host: str, log=None) -> bool:
"""Установить агента на сервер через SSH."""
def _log(m):
if log:
log(m)
token = get_or_create_token(srv.id)
port = get_agent_port()
env = (f"DM_SERVER=http://{app_host}:{port} DM_TOKEN={token} "
f"DM_SERVER_ID={srv.id} DM_INTERVAL=5")
script_b64 = __import__("base64").b64encode(AGENT_SCRIPT.encode()).decode()
unit = f"""[Unit]
Description=Debian Manager Agent
After=network.target
[Service]
Type=simple
Environment="{env.replace('"', chr(92)+'"')}"
ExecStart=/usr/bin/python3 /opt/dm-agent/agent.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
"""
unit_b64 = __import__("base64").b64encode(unit.encode()).decode()
async with SSHClient(srv) as c:
_log("создаю /opt/dm-agent")
await c.sudo("mkdir -p /opt/dm-agent")
_log("записываю agent.py")
await c.sudo(f"echo {script_b64} | base64 -d > /opt/dm-agent/agent.py")
await c.sudo(f"echo {unit_b64} | base64 -d > /etc/systemd/system/dm-agent.service")
_log("запускаю systemd unit")
r = await c.sudo("systemctl daemon-reload && systemctl enable --now dm-agent")
if not r.ok:
_log(f"ошибка: {r.stderr}")
return False
_log("агент установлен и запущен")
return True
async def uninstall_agent(srv: Server, log=None) -> None:
def _log(m):
if log:
log(m)
async with SSHClient(srv) as c:
await c.sudo("systemctl disable --now dm-agent 2>/dev/null; "
"rm -f /etc/systemd/system/dm-agent.service; "
"rm -rf /opt/dm-agent; systemctl daemon-reload")
_set(f"agent_token_{srv.id}", "")
_log("агент удалён")
async def agent_status(srv: Server) -> str:
async with SSHClient(srv) as c:
r = await c.sudo("systemctl is-active dm-agent 2>/dev/null")
return r.stdout.strip() or "не установлен"
# ---------- HTTP-приёмник метрик ----------
async def _metrics_handler(reader, writer):
try:
req = await reader.readuntil(b"\r\n\r\n")
head = req.decode(errors="replace")
length = 0
for line in head.splitlines():
if line.lower().startswith("content-length:"):
length = int(line.split(":", 1)[1].strip())
body = await reader.readexactly(length) if length else b"{}"
data = json.loads(body)
sid = int(data.get("server_id", 0))
if data.get("token") != _get(f"agent_token_{sid}"):
raise ValueError("bad token")
m = data.get("metrics", {})
agent_metrics[sid] = m
with get_session() as s:
s.add(MetricSnapshot(
server_id=sid,
cpu_percent=m.get("cpu", 0),
mem_percent=m.get("mem_pct", 0),
mem_used_mb=m.get("mem_used", 0),
mem_total_mb=m.get("mem_total", 0),
disk_percent=m.get("disk", 0),
load1=m.get("load", 0),
))
s.commit()
resp = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"
except Exception:
resp = b"HTTP/1.1 400 Bad\r\nContent-Length: 0\r\n\r\n"
writer.write(resp)
await writer.drain()
try:
writer.close()
except Exception:
pass
async def start(port: Optional[int] = None) -> None:
global _http_server
if _http_server:
return
port = port or get_agent_port()
_http_server = await asyncio.start_server(_metrics_handler, "0.0.0.0", port)
def is_running() -> bool:
return _http_server is not None
+98
View File
@@ -0,0 +1,98 @@
"""Резервное копирование: tar на сервере → SFTP → локальный архив."""
from __future__ import annotations
import asyncio
import shlex
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from sqlmodel import select
from app.core import files as fcore
from app.core.ssh import SSHClient
from app.db.database import get_session
from app.db.models import BackupRecord, Server
BACKUP_DIR = Path.home() / ".debian-manager" / "backups"
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
async def run_backup(srv: Server, paths: str, download: bool = True,
log=None) -> BackupRecord:
"""Создать tar.gz на сервере и скачать. log — callable(str)."""
def _log(line: str):
if log:
log(line)
with get_session() as s:
rec = BackupRecord(server_id=srv.id, paths=paths, status="pending")
s.add(rec)
s.commit()
s.refresh(rec)
rec_id = rec.id
def _set(**fields):
with get_session() as s:
r = s.get(BackupRecord, rec_id)
for k, v in fields.items():
setattr(r, k, v)
s.add(r)
s.commit()
try:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
name = f"dm-backup-{srv.name}-{ts}.tar.gz"
remote = f"/tmp/{name}"
path_args = " ".join(shlex.quote(p) for p in paths.split())
async with SSHClient(srv) as c:
_log(f"tar -czf {remote} {path_args}")
r = await c.sudo(
f"tar -czf {remote} --ignore-failed-read {path_args} 2>&1", timeout=1800
)
if not r.ok and "tar:" not in (r.stdout + r.stderr)[:20]:
# tar возвращает 1 при --ignore-failed-read если часть файлов пропущена — ок
pass
r = await c.sudo(f"stat -c %s {remote}", timeout=30)
size = int(r.stdout.strip() or 0)
_log(f"архив на сервере: {size // 1024} КБ")
# дать доступ пользователю для SFTP
await c.sudo(f"chmod 644 {remote} && chown {srv.username}:{srv.username} {remote}")
local = ""
if download:
local_path = BACKUP_DIR / srv.name / name
local_path.parent.mkdir(parents=True, exist_ok=True)
_log(f"скачивание → {local_path}")
await fcore.download(c, remote, str(local_path),
lambda d, t: _log(f" {d//1024}/{t//1024} КБ"))
local = str(local_path)
await c.run(f"rm -f {remote}")
_set(status="success", file=local, size=size, log="")
rec = get_record(rec_id)
return rec
except Exception as e:
_set(status="error", log=str(e))
return get_record(rec_id)
def get_record(rec_id: int) -> BackupRecord:
with get_session() as s:
return s.get(BackupRecord, rec_id)
def list_backups(server_id: Optional[int] = None, limit: int = 50) -> List[BackupRecord]:
with get_session() as s:
q = select(BackupRecord).order_by(BackupRecord.id.desc()).limit(limit)
if server_id:
q = q.where(BackupRecord.server_id == server_id)
return list(s.exec(q).all())
def stats() -> tuple[int, int]:
"""(кол-во, суммарный размер байт)"""
recs = list_backups(limit=1000)
ok = [r for r in recs if r.status == "success"]
return len(ok), sum(r.size for r in ok)
+126
View File
@@ -0,0 +1,126 @@
"""Cron: чтение/запись crontab, версии, описание расписаний."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from croniter import croniter
from app.core.ssh import SSHClient
from app.db.database import get_session
from app.db.models import CronBackup
@dataclass
class CronLine:
raw: str
enabled: bool
schedule: str
command: str
comment: str = ""
@property
def is_entry(self) -> bool:
return bool(self.schedule)
def parse_crontab(content: str) -> List[CronLine]:
lines: List[CronLine] = []
for raw in content.splitlines():
stripped = raw.strip()
enabled = True
candidate = stripped
if not candidate:
lines.append(CronLine(raw, True, "", ""))
continue
if candidate.startswith("#"):
enabled = False
candidate = candidate.lstrip("#").strip()
parts = candidate.split(None, 5)
if candidate.startswith("@") and len(candidate.split(None, 1)) == 2:
sched, cmd = candidate.split(None, 1)
lines.append(CronLine(raw, enabled, sched, cmd))
elif len(parts) == 6 and _looks_like_schedule(parts[:5]):
lines.append(CronLine(raw, enabled, " ".join(parts[:5]), parts[5]))
else:
lines.append(CronLine(raw, enabled, "", "", comment=stripped))
return lines
def _looks_like_schedule(fields: List[str]) -> bool:
for f in fields:
for ch in f:
if ch not in "0123456789*,-/":
return False
return True
def humanize(schedule: str) -> str:
try:
if schedule.startswith("@"):
return {"@reboot": "при загрузке", "@daily": "ежедневно",
"@hourly": "ежечасно", "@weekly": "еженедельно",
"@monthly": "ежемесячно", "@yearly": "ежегодно"}.get(schedule, schedule)
it = croniter(schedule)
nxt = it.get_next(__import__("datetime").datetime)
return f"следующий запуск: {nxt:%Y-%m-%d %H:%M}"
except Exception:
return "неверное выражение"
def render(lines: List[CronLine]) -> str:
out = []
for l in lines:
if not l.is_entry:
out.append(l.raw)
continue
entry = f"{l.schedule} {l.command}"
out.append(entry if l.enabled else f"# {entry}")
return "\n".join(out) + "\n"
async def get_crontab(c: SSHClient, user: Optional[str] = None) -> str:
cmd = f"crontab -u {user} -l" if user else "crontab -l"
r = await c.sudo(cmd, timeout=30)
if not r.ok and "no crontab" in (r.stderr + r.stdout):
return ""
return r.stdout
async def set_crontab(c: SSHClient, content: str, server_id: int,
user: Optional[str] = None) -> None:
# бэкап текущего
current = await get_crontab(c, user)
with get_session() as s:
s.add(CronBackup(server_id=server_id, user=user or "current", content=current))
s.commit()
import base64
b64 = base64.b64encode(content.encode()).decode()
target = f"crontab -u {user} -" if user else "crontab -"
r = await c.sudo(f"echo {b64} | base64 -d | {target}", timeout=30)
if not r.ok:
raise RuntimeError(r.stderr)
async def list_cron_files(c: SSHClient) -> dict:
"""Системные cron-файлы: /etc/crontab + /etc/cron.d/*"""
out = {}
r = await c.sudo("cat /etc/crontab 2>/dev/null", timeout=30)
if r.ok:
out["/etc/crontab"] = r.stdout
r = await c.sudo("ls /etc/cron.d/ 2>/dev/null", timeout=30)
for name in r.stdout.split():
rr = await c.sudo(f"cat '/etc/cron.d/{name}'", timeout=30)
if rr.ok:
out[f"/etc/cron.d/{name}"] = rr.stdout
return out
def backups(server_id: int, user: str, limit: int = 10) -> List[CronBackup]:
from sqlmodel import select
with get_session() as s:
return list(s.exec(
select(CronBackup)
.where(CronBackup.server_id == server_id, CronBackup.user == user)
.order_by(CronBackup.id.desc()).limit(limit)
).all())
+43
View File
@@ -0,0 +1,43 @@
"""Diff пакетов между серверами."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
from app.core.packages import Package, list_packages
from app.core.ssh import SSHClient
@dataclass
class VersionDiff:
name: str
version_a: str
version_b: str
@dataclass
class PackageDiff:
only_a: List[Package]
only_b: List[Package]
version_mismatch: List[VersionDiff]
async def diff_servers(ca: SSHClient, cb: SSHClient,
user_only: bool = True) -> PackageDiff:
pa = await list_packages(ca)
pb = await list_packages(cb)
if user_only:
pa = [p for p in pa if not p.is_system and p.manual]
pb = [p for p in pb if not p.is_system and p.manual]
map_a: Dict[str, Package] = {p.name: p for p in pa}
map_b: Dict[str, Package] = {p.name: p for p in pb}
only_a = [map_a[n] for n in sorted(map_a.keys() - map_b.keys())]
only_b = [map_b[n] for n in sorted(map_b.keys() - map_a.keys())]
mismatch = [
VersionDiff(n, map_a[n].version, map_b[n].version)
for n in sorted(map_a.keys() & map_b.keys())
if map_a[n].version != map_b[n].version
]
return PackageDiff(only_a, only_b, mismatch)
+109
View File
@@ -0,0 +1,109 @@
"""Docker по SSH: команды docker с JSON-выводом. Не требует docker CLI на клиенте."""
from __future__ import annotations
import json
from typing import List, Optional
from app.core.ssh import SSHClient
async def _docker(c: SSHClient, args: str, timeout: int = 120) -> str:
r = await c.sudo(f"docker {args}", timeout=timeout)
return r.stdout
async def available(c: SSHClient) -> bool:
r = await c.sudo("docker version --format '{{.Server.Version}}'", timeout=30)
return r.ok and bool(r.stdout.strip())
async def server_version(c: SSHClient) -> str:
r = await c.sudo("docker version --format '{{.Server.Version}}'", timeout=30)
return r.stdout.strip()
# ---------- контейнеры ----------
async def list_containers(c: SSHClient) -> List[dict]:
out = await _docker(c, "ps -a --format '{{json .}}'")
return [json.loads(l) for l in out.splitlines() if l.strip()]
async def container_action(c: SSHClient, cid: str, action: str) -> str:
assert action in ("start", "stop", "restart", "kill", "rm")
return await _docker(c, f"{action} {cid}")
async def container_logs(c: SSHClient, cid: str, tail: int = 200) -> str:
return await _docker(c, f"logs --tail {tail} {cid}")
async def container_inspect(c: SSHClient, cid: str) -> dict:
out = await _docker(c, f"inspect {cid}")
data = json.loads(out)
return data[0] if data else {}
async def container_stats(c: SSHClient) -> List[dict]:
out = await _docker(
c, "stats --no-stream --format '{{json .}}'", timeout=60
)
return [json.loads(l) for l in out.splitlines() if l.strip()]
# ---------- образы ----------
async def list_images(c: SSHClient) -> List[dict]:
out = await _docker(c, "images --format '{{json .}}'")
return [json.loads(l) for l in out.splitlines() if l.strip()]
async def pull(c: SSHClient, image: str):
return c.run_stream(f"docker pull {image}", use_sudo=True)
async def remove_image(c: SSHClient, image: str) -> str:
return await _docker(c, f"rmi {image}")
# ---------- тома ----------
async def list_volumes(c: SSHClient) -> List[dict]:
out = await _docker(c, "volume ls --format '{{json .}}'")
return [json.loads(l) for l in out.splitlines() if l.strip()]
async def remove_volume(c: SSHClient, name: str) -> str:
return await _docker(c, f"volume rm {name}")
# ---------- сети ----------
async def list_networks(c: SSHClient) -> List[dict]:
out = await _docker(c, "network ls --format '{{json .}}'")
return [json.loads(l) for l in out.splitlines() if l.strip()]
# ---------- система ----------
async def system_df(c: SSHClient) -> str:
return await _docker(c, "system df")
async def system_prune(c: SSHClient) -> str:
return await _docker(c, "system prune -f", timeout=600)
# ---------- compose ----------
async def compose_projects(c: SSHClient) -> List[dict]:
out = await _docker(c, "compose ls --format json")
try:
return json.loads(out)
except json.JSONDecodeError:
return []
async def compose_action(c: SSHClient, workdir: str, action: str, timeout: int = 600) -> str:
assert action in ("up -d", "down", "restart", "pull", "ps", "logs")
return await _docker(c, f"compose -f {workdir}/docker-compose.yml {action}", timeout)
+123
View File
@@ -0,0 +1,123 @@
"""SFTP файловый менеджер."""
from __future__ import annotations
import stat
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Callable, List, Optional
import asyncssh
from app.core.ssh import SSHClient
@dataclass
class FileEntry:
name: str
path: str
is_dir: bool
size: int
mtime: float
permissions: str
def _fmt_perms(mode: int) -> str:
return stat.filemode(mode) if mode else "?"
async def list_dir(c: SSHClient, path: str) -> List[FileEntry]:
async with c.conn.start_sftp_client() as sftp:
entries = []
try:
attrs = await sftp.readdir(path)
except asyncssh.SFTPFailure as e:
raise PermissionError(str(e))
for a in attrs:
if a.filename in (".", ".."):
continue
full = str(PurePosixPath(path) / a.filename)
is_dir = stat.S_ISDIR(a.attrs.permissions or 0)
entries.append(
FileEntry(
name=a.filename,
path=full,
is_dir=is_dir,
size=a.attrs.size or 0,
mtime=a.attrs.mtime or 0,
permissions=_fmt_perms(a.attrs.permissions or 0),
)
)
entries.sort(key=lambda e: (not e.is_dir, e.name.lower()))
return entries
async def download(c: SSHClient, remote: str, local: str,
progress: Optional[Callable[[int, int], None]] = None) -> None:
async with c.conn.start_sftp_client() as sftp:
size = (await sftp.stat(remote)).size or 0
done = 0
async with sftp.open(remote, "rb") as rf, open(local, "wb") as lf:
while True:
chunk = await rf.read(65536)
if not chunk:
break
lf.write(chunk)
done += len(chunk)
if progress:
progress(done, size)
async def upload(c: SSHClient, local: str, remote: str,
progress: Optional[Callable[[int, int], None]] = None) -> None:
import os
size = os.path.getsize(local)
done = 0
async with c.conn.start_sftp_client() as sftp:
async with sftp.open(remote, "wb") as rf, open(local, "rb") as lf:
while True:
chunk = lf.read(65536)
if not chunk:
break
await rf.write(chunk)
done += len(chunk)
if progress:
progress(done, size)
async def delete(c: SSHClient, path: str, is_dir: bool) -> None:
async with c.conn.start_sftp_client() as sftp:
if is_dir:
await c.run(f"rm -rf {path!r}")
else:
await sftp.remove(path)
async def mkdir(c: SSHClient, path: str) -> None:
async with c.conn.start_sftp_client() as sftp:
await sftp.makedirs(path, exist_ok=True)
async def rename(c: SSHClient, old: str, new: str) -> None:
async with c.conn.start_sftp_client() as sftp:
await sftp.rename(old, new)
async def read_text(c: SSHClient, path: str, limit: int = 500_000) -> str:
async with c.conn.start_sftp_client() as sftp:
async with sftp.open(path, "rb") as f:
data = await f.read(limit)
return data.decode("utf-8", errors="replace")
async def write_text(c: SSHClient, path: str, content: str, use_sudo: bool = False) -> None:
if use_sudo:
# запись в root-owned файл через sudo tee
import base64
b64 = base64.b64encode(content.encode()).decode()
r = await c.sudo(f"echo {b64} | base64 -d > {path!r}")
if not r.ok:
raise PermissionError(r.stderr)
else:
async with c.conn.start_sftp_client() as sftp:
async with sftp.open(path, "wb") as f:
await f.write(content.encode())
+98
View File
@@ -0,0 +1,98 @@
"""UFW файрвол."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from app.core.ssh import SSHClient
@dataclass
class Rule:
number: int
to: str
action: str
from_: str
raw: str
async def is_installed(c: SSHClient) -> bool:
r = await c.run("which ufw")
return r.ok and bool(r.stdout.strip())
async def status(c: SSHClient) -> tuple[bool, str]:
"""(active, raw_status)"""
r = await c.sudo("ufw status verbose", timeout=30)
active = "Status: active" in r.stdout
return active, r.stdout
async def list_rules(c: SSHClient) -> List[Rule]:
r = await c.sudo("ufw status numbered", timeout=30)
rules: List[Rule] = []
for line in r.stdout.splitlines():
line = line.strip()
if line.startswith("[") and "]" in line:
num = int(line[1:line.index("]")].strip())
rest = line[line.index("]") + 1:].strip()
parts = rest.split()
# "22/tcp ALLOW IN Anywhere"
to = parts[0] if parts else ""
action = " ".join(parts[1:3]) if len(parts) > 2 else (parts[1] if len(parts) > 1 else "")
from_ = " ".join(parts[3:]) if len(parts) > 3 else ""
rules.append(Rule(num, to, action, from_, line))
return rules
async def ensure_ssh_allowed(c: SSHClient, ssh_port: int) -> None:
"""Защита от отстрела себя: разрешить текущий SSH-порт."""
rules = await list_rules(c)
for rule in rules:
if str(ssh_port) in rule.to and "ALLOW" in rule.action.upper():
return
await c.sudo(f"ufw allow {ssh_port}/tcp", timeout=30)
async def enable(c: SSHClient, ssh_port: int) -> str:
await ensure_ssh_allowed(c, ssh_port)
r = await c.sudo("yes | ufw enable", timeout=30)
return r.stdout + r.stderr
async def disable(c: SSHClient) -> str:
r = await c.sudo("ufw disable", timeout=30)
return r.stdout + r.stderr
async def add_rule(c: SSHClient, port: str, action: str = "allow",
proto: str = "tcp", from_addr: str = "") -> str:
assert action in ("allow", "deny", "limit", "reject")
src = f"from {from_addr} " if from_addr else ""
r = await c.sudo(f"ufw {action} {src}to any port {port} proto {proto}", timeout=30)
return r.stdout + r.stderr
async def delete_rule(c: SSHClient, number: int) -> str:
r = await c.sudo(f"yes | ufw delete {number}", timeout=30)
return r.stdout + r.stderr
async def listening_ports(c: SSHClient) -> List[str]:
r = await c.sudo("ss -tlnp | awk 'NR>1{print $4, $6}'", timeout=30)
return [l for l in r.stdout.splitlines() if l.strip()]
PRESETS = {
"Только SSH": [("{ssh_port}", "allow", "tcp")],
"Веб-сервер": [("{ssh_port}", "allow", "tcp"), ("80", "allow", "tcp"), ("443", "allow", "tcp")],
"Docker-хост": [("{ssh_port}", "allow", "tcp"), ("2375", "deny", "tcp"), ("2376", "deny", "tcp")],
}
async def apply_preset(c: SSHClient, preset_name: str, ssh_port: int) -> str:
log = []
for port, action, proto in PRESETS[preset_name]:
p = port.format(ssh_port=ssh_port)
log.append(await add_rule(c, p, action, proto))
return "\n".join(log)
+110
View File
@@ -0,0 +1,110 @@
"""Gitea: репозитории, raw-файлы, деплой compose на сервер."""
from __future__ import annotations
from typing import List, Optional
import httpx
from app.core.ssh import SSHClient
from app.db.database import get_session
from app.db.models import Setting
DEFAULT_URL = "http://192.168.1.171:3000"
def _get(key: str) -> str:
with get_session() as s:
row = s.get(Setting, key)
return row.value if row else ""
def _set(key: str, value: str) -> None:
with get_session() as s:
row = s.get(Setting, key) or Setting(key=key)
row.value = value
s.add(row)
s.commit()
def get_config() -> dict:
return {"url": _get("gitea_url") or DEFAULT_URL, "token": _get("gitea_token")}
def save_config(url: str, token: str) -> None:
_set("gitea_url", url.rstrip("/"))
_set("gitea_token", token)
async def _client() -> httpx.AsyncClient:
cfg = get_config()
headers = {"Authorization": f"token {cfg['token']}"} if cfg["token"] else {}
return httpx.AsyncClient(base_url=cfg["url"], headers=headers, timeout=20)
async def list_repos() -> List[dict]:
async with await _client() as cl:
r = await cl.get("/api/v1/user/repos", params={"limit": 50})
r.raise_for_status()
return [{"name": x["name"], "full_name": x["full_name"],
"clone_url": x["clone_url"], "default_branch": x["default_branch"]}
for x in r.json()]
async def get_file(repo: str, path: str, ref: str = "main") -> str:
async with await _client() as cl:
r = await cl.get(f"/api/v1/repos/{repo}/raw/{path}", params={"ref": ref})
r.raise_for_status()
return r.text
async def test_connection() -> tuple[bool, str]:
try:
async with await _client() as cl:
r = await cl.get("/api/v1/version")
if r.status_code == 200:
return True, f"Gitea {r.json().get('version')}"
return False, f"HTTP {r.status_code}"
except Exception as e:
return False, str(e)
# ---------- деплой compose ----------
async def deploy(c: SSHClient, clone_url: str, repo_name: str, branch: str,
compose_path: str, env_content: Optional[str], log) -> None:
"""git clone/pull в /opt/stacks/<repo> + docker compose up -d."""
base = "/opt/stacks"
workdir = f"{base}/{repo_name}"
await c.sudo(f"mkdir -p {base}")
r = await c.sudo(f"test -d {workdir}/.git && echo exists || echo missing")
if "exists" in r.stdout:
log(f"git pull в {workdir}")
r = await c.sudo(f"git -C {workdir} fetch --all && git -C {workdir} checkout {branch} "
f"&& git -C {workdir} pull origin {branch}", timeout=300)
else:
log(f"git clone {clone_url} → {workdir}")
url = clone_url
r = await c.sudo(f"git clone -b {branch} {url} {workdir}", timeout=300)
log(r.stdout[-1000:] + r.stderr[-500:])
if not r.ok:
raise RuntimeError("git операция не удалась")
if env_content is not None:
import base64
b64 = base64.b64encode(env_content.encode()).decode()
await c.sudo(f"echo {b64} | base64 -d > {workdir}/.env")
log(".env записан")
log("docker compose up -d")
r = await c.sudo(f"docker compose -f {workdir}/{compose_path} up -d", timeout=900)
log(r.stdout + r.stderr)
if not r.ok:
raise RuntimeError("compose up не удался")
async def stack_action(c: SSHClient, repo_name: str, compose_path: str, action: str) -> str:
assert action in ("up -d", "down", "restart", "pull", "logs --tail 100", "ps")
r = await c.sudo(f"docker compose -f /opt/stacks/{repo_name}/{compose_path} {action}",
timeout=600)
return r.stdout + r.stderr
+143
View File
@@ -0,0 +1,143 @@
"""Групповые операции по нескольким серверам с rolling-режимом."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Callable, Coroutine, Dict, List, Optional
from app.core.ssh import SSHClient, check_server, wait_online
from app.db.models import Server
LogCallback = Callable[[str], None]
@dataclass
class ServerResult:
server_id: int
name: str
status: str = "pending" # pending/running/success/error
log: str = ""
@dataclass
class GroupResult:
results: Dict[int, ServerResult] = field(default_factory=dict)
@property
def ok_count(self) -> int:
return sum(1 for r in self.results.values() if r.status == "success")
@property
def err_count(self) -> int:
return sum(1 for r in self.results.values() if r.status == "error")
async def run_group(
servers: List[Server],
operation: Callable[[SSHClient, Server, LogCallback], Coroutine],
concurrency: int = 5,
fail_fast: bool = False,
rolling: bool = False,
on_status: Optional[Callable[[GroupResult], None]] = None,
) -> GroupResult:
"""Выполнить operation(c, server, log) на группе серверов."""
result = GroupResult(results={
s.id: ServerResult(server_id=s.id, name=s.name) for s in servers
})
sem = asyncio.Semaphore(1 if rolling else concurrency)
stop_flag = asyncio.Event()
async def worker(srv: Server):
if stop_flag.is_set():
result.results[srv.id].status = "error"
result.results[srv.id].log = "пропущено (fail-fast)"
return
async with sem:
r = result.results[srv.id]
r.status = "running"
if on_status:
on_status(result)
def log(line: str) -> None:
r.log += line + "\n"
if on_status:
on_status(result)
try:
async with SSHClient(srv) as c:
await operation(c, srv, log)
r.status = "success"
except Exception as e:
r.status = "error"
r.log += f"[ОШИБКА] {e}\n"
if fail_fast:
stop_flag.set()
if on_status:
on_status(result)
if rolling:
for srv in servers:
await worker(srv)
if stop_flag.is_set():
break
else:
await asyncio.gather(*(worker(s) for s in servers))
return result
# ---------- готовые операции ----------
def op_apt_update():
async def op(c: SSHClient, srv: Server, log: LogCallback):
log("$ apt-get update")
r = await c.sudo("DEBIAN_FRONTEND=noninteractive apt-get update", timeout=600)
log(r.stdout[-2000:] or "(пустой вывод)")
if not r.ok:
raise RuntimeError(r.stderr[-500:])
return op
def op_upgrade(dist: bool = False):
async def op(c: SSHClient, srv: Server, log: LogCallback):
kind = "dist-upgrade" if 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)
return op
def op_reboot(wait: bool = True):
async def op(c: SSHClient, srv: Server, log: LogCallback):
log("$ reboot")
await c.sudo("(sleep 1; reboot) &", timeout=10)
if wait:
log("ожидание возврата сервера...")
await asyncio.sleep(10)
ok = await wait_online(srv, timeout_s=300)
if not ok:
raise RuntimeError("сервер не вернулся за 5 минут")
log("сервер снова онлайн")
return op
def op_install_package(names: List[str]):
async def op(c: SSHClient, srv: Server, log: LogCallback):
log(f"$ apt-get install -y {' '.join(names)}")
async for line in c.run_stream(
"DEBIAN_FRONTEND=noninteractive apt-get install -y " + " ".join(names),
use_sudo=True,
):
log(line)
return op
def op_shell(cmd: str):
async def op(c: SSHClient, srv: Server, log: LogCallback):
log(f"$ {cmd}")
r = await c.sudo(cmd, timeout=600)
log(r.stdout[-3000:])
if not r.ok:
raise RuntimeError(r.stderr[-500:] or f"rc={r.returncode}")
return op
+94
View File
@@ -0,0 +1,94 @@
"""Мониторинг: сбор метрик по SSH, снапшоты в БД."""
from __future__ import annotations
from typing import Optional
from sqlmodel import select
from app.core.ssh import SSHClient
from app.db.database import get_session
from app.db.models import MetricSnapshot
# Каждая команда выводит одну строку "KEY value..."; парсер ниже.
# CPU считаем по дельте двух замеров /proc/stat за 1 секунду.
_METRICS_CMD = (
# CPU: первая строка cpu в /proc/stat (user nice system idle iowait irq softirq steal)
"read cpu u n s idle iow irq sirq st rest < /proc/stat; "
"t1=$((u+n+s+idle+iow+irq+sirq+st)); i1=$((idle+iow)); "
"sleep 1; "
"read cpu u n s idle iow irq sirq st rest < /proc/stat; "
"t2=$((u+n+s+idle+iow+irq+sirq+st)); i2=$((idle+iow)); "
"dt=$((t2-t1)); di=$((i2-i1)); "
"if [ $dt -gt 0 ]; then awk -v a=$dt -v b=$di 'BEGIN{printf \"CPU %.1f\\n\", (a-b)*100/a}'; else echo 'CPU 0.0'; fi; "
# RAM: used/total в МБ
"free -m | awk 'NR==2{printf \"MEM %d %d\\n\", $3, $2}'; "
# Диск: процент занятого на /
"df -m / | awk 'NR==2{printf \"DISK %.1f\\n\", $3*100/$2}'; "
# Load average 1min
"awk '{printf \"LOAD %s\\n\", $1}' /proc/loadavg; "
# Сеть: суммарные байты rx/tx всех интерфейсов
"awk '/: /{rx+=$2; tx+=$10} END{printf \"NET %d %d\\n\", rx, tx}' /proc/net/dev"
)
async def collect(c: SSHClient, server_id: int, prev_net: Optional[tuple] = None) -> MetricSnapshot:
r = await c.run(_METRICS_CMD, timeout=30)
cpu = mem_used = mem_total = disk = load = 0.0
rx = tx = 0
for line in r.stdout.splitlines():
p = line.split()
if len(p) < 2:
continue
if p[0] == "CPU":
cpu = float(p[1])
elif p[0] == "MEM":
mem_used, mem_total = int(p[1]), int(p[2])
elif p[0] == "DISK":
disk = float(p[1])
elif p[0] == "LOAD":
load = float(p[1])
elif p[0] == "NET":
rx, tx = int(p[1]), int(p[2])
rx_kbs = tx_kbs = 0.0
if prev_net:
dt = 60.0 # интервал опроса
rx_kbs = max(0.0, (rx - prev_net[0]) / 1024 / dt)
tx_kbs = max(0.0, (tx - prev_net[1]) / 1024 / dt)
snap = MetricSnapshot(
server_id=server_id,
cpu_percent=round(cpu, 1),
mem_percent=round(mem_used * 100 / mem_total, 1) if mem_total else 0,
mem_used_mb=mem_used,
mem_total_mb=mem_total,
disk_percent=disk,
load1=load,
net_rx_kbs=round(rx_kbs, 1),
net_tx_kbs=round(tx_kbs, 1),
)
with get_session() as s:
s.add(snap)
s.commit()
return snap
def latest(server_id: int) -> Optional[MetricSnapshot]:
with get_session() as s:
return s.exec(
select(MetricSnapshot)
.where(MetricSnapshot.server_id == server_id)
.order_by(MetricSnapshot.id.desc())
.limit(1)
).first()
def history(server_id: int, limit: int = 60) -> list[MetricSnapshot]:
with get_session() as s:
rows = s.exec(
select(MetricSnapshot)
.where(MetricSnapshot.server_id == server_id)
.order_by(MetricSnapshot.id.desc())
.limit(limit)
).all()
return list(reversed(rows))
+91
View File
@@ -0,0 +1,91 @@
"""Telegram-уведомления с антиспамом."""
from __future__ import annotations
import time
from typing import Optional
import httpx
from app.db.database import get_session
from app.db.models import Setting
_antispam: dict[str, float] = {}
THROTTLE_S = 600 # не чаще 1 раза в 10 мин на ключ
def _get(key: str) -> str:
with get_session() as s:
row = s.get(Setting, key)
return row.value if row else ""
def _set(key: str, value: str) -> None:
with get_session() as s:
row = s.get(Setting, key) or Setting(key=key)
row.value = value
s.add(row)
s.commit()
def get_config() -> dict:
return {
"token": _get("tg_token"),
"chat_id": _get("tg_chat_id"),
"enabled": _get("tg_enabled") == "1",
"on_alert": _get("tg_on_alert") == "1",
"on_task_done": _get("tg_on_task_done") == "1",
}
def save_config(token: str, chat_id: str, enabled: bool,
on_alert: bool, on_task_done: bool) -> None:
_set("tg_token", token)
_set("tg_chat_id", chat_id)
_set("tg_enabled", "1" if enabled else "0")
_set("tg_on_alert", "1" if on_alert else "0")
_set("tg_on_task_done", "1" if on_task_done else "0")
async def send(text: str, key: Optional[str] = None, force: bool = False) -> bool:
"""Отправить сообщение. key — для антиспама (например 'disk:server1')."""
cfg = get_config()
if not cfg["enabled"] or not cfg["token"] or not cfg["chat_id"]:
return False
if key and not force:
last = _antispam.get(key, 0)
if time.time() - last < THROTTLE_S:
return False
_antispam[key] = time.time()
url = f"https://api.telegram.org/bot{cfg['token']}/sendMessage"
try:
async with httpx.AsyncClient(timeout=15) as cl:
r = await cl.post(url, json={
"chat_id": cfg["chat_id"],
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
})
return r.status_code == 200
except Exception:
return False
async def test() -> tuple[bool, str]:
cfg = get_config()
if not cfg["token"] or not cfg["chat_id"]:
return False, "заполните token и chat_id"
ok = await send("✅ Debian Manager: тестовое уведомление", force=True)
return ok, "отправлено" if ok else "ошибка отправки (проверьте token/chat_id)"
async def alert(server_name: str, text: str) -> None:
cfg = get_config()
if cfg["enabled"] and cfg["on_alert"]:
await send(f"⚠️ <b>{server_name}</b>: {text}", key=f"alert:{server_name}:{text[:30]}")
async def task_done(server_name: str, title: str, status: str) -> None:
cfg = get_config()
if cfg["enabled"] and cfg["on_task_done"]:
icon = "✅" if status == "success" else "❌"
await send(f"{icon} <b>{server_name}</b>: {title} — {status}", force=True)
+101
View File
@@ -0,0 +1,101 @@
"""APT: update/upgrade, списки пакетов, установка/удаление."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
from app.core.ssh import SSHClient
SYSTEM_PRIORITIES = {"required", "important"}
@dataclass
class Package:
name: str
version: str
priority: str
manual: bool
upgradable: bool = False
candidate: str = ""
@property
def is_system(self) -> bool:
return self.priority in SYSTEM_PRIORITIES
async def apt_update(c: SSHClient) -> str:
r = await c.sudo("DEBIAN_FRONTEND=noninteractive apt-get update", timeout=600)
return r.stdout + r.stderr
async def list_upgradable(c: SSHClient) -> List[dict]:
r = await c.run("apt list --upgradable 2>/dev/null | tail -n +2")
out = []
for line in r.stdout.splitlines():
if not line.strip():
continue
# nginx/stable 1.22.1-9+deb12u1 amd64 [upgradable from: 1.22.1-9]
parts = line.split()
if len(parts) >= 2:
name = parts[0].split("/")[0]
out.append({"name": name, "candidate": parts[1], "raw": line})
return out
async def list_packages(c: SSHClient) -> List[Package]:
"""Все установленные пакеты с приоритетом и признаком manual."""
r = await c.run(
"dpkg-query -W -f='${Package}\\t${Version}\\t${Priority}\\n'", timeout=180
)
manual_r = await c.run("apt-mark showmanual", timeout=120)
manual = set(manual_r.stdout.split())
pkgs: List[Package] = []
for line in r.stdout.splitlines():
parts = line.split("\t")
if len(parts) == 3:
name, version, prio = parts
pkgs.append(Package(name, version, prio, name in manual))
return pkgs
def split_packages(pkgs: List[Package]) -> Dict[str, List[Package]]:
"""Разбивка на системные / пользовательские / зависимости."""
system = [p for p in pkgs if p.is_system]
user = [p for p in pkgs if not p.is_system and p.manual]
deps = [p for p in pkgs if not p.is_system and not p.manual]
return {"system": system, "user": user, "deps": deps}
async def apt_upgrade(c: SSHClient, dist: bool = False):
"""Стриминг upgrade. Возвращает async-итератор строк."""
cmd = "DEBIAN_FRONTEND=noninteractive apt-get {} -y".format(
"dist-upgrade" if dist else "upgrade"
)
return c.run_stream(cmd, use_sudo=True)
async def install(c: SSHClient, names: List[str]):
cmd = "DEBIAN_FRONTEND=noninteractive apt-get install -y " + " ".join(names)
return c.run_stream(cmd, use_sudo=True)
async def remove(c: SSHClient, names: List[str], purge: bool = False):
action = "purge" if purge else "remove"
cmd = f"DEBIAN_FRONTEND=noninteractive apt-get {action} -y " + " ".join(names)
return c.run_stream(cmd, use_sudo=True)
async def autoremove(c: SSHClient):
return c.run_stream(
"DEBIAN_FRONTEND=noninteractive apt-get autoremove -y", use_sudo=True
)
async def apt_show(c: SSHClient, name: str) -> str:
r = await c.run(f"apt-cache show {name} 2>/dev/null | head -40")
return r.stdout
async def apt_search(c: SSHClient, query: str) -> List[str]:
r = await c.run(f"apt-cache search {query} 2>/dev/null | head -50")
return [l for l in r.stdout.splitlines() if l.strip()]
+188
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
"""Proxmox VE: API через proxmoxer, ноды, VM/LXC, управление питанием."""
from __future__ import annotations
from typing import List, Optional
from app.db.database import get_session
from app.db.models import Setting
def _get(key: str) -> str:
with get_session() as s:
row = s.get(Setting, key)
return row.value if row else ""
def _set(key: str, value: str) -> None:
with get_session() as s:
row = s.get(Setting, key) or Setting(key=key)
row.value = value
s.add(row)
s.commit()
def get_config() -> dict:
return {
"host": _get("pve_host"),
"user": _get("pve_user") or "root@pam",
"token_name": _get("pve_token_name"),
"token_value": _get("pve_token_value"),
"verify_ssl": _get("pve_verify_ssl") == "1",
}
def save_config(host: str, user: str, token_name: str, token_value: str,
verify_ssl: bool) -> None:
_set("pve_host", host.rstrip("/"))
_set("pve_user", user)
_set("pve_token_name", token_name)
_set("pve_token_value", token_value)
_set("pve_verify_ssl", "1" if verify_ssl else "0")
def _client():
"""Создать proxmoxer-клиент. Бросает если не настроено."""
from proxmoxer import ProxmoxAPI
cfg = get_config()
if not cfg["host"] or not cfg["token_name"] or not cfg["token_value"]:
raise RuntimeError("Proxmox не настроен — заполните хост и API-токен")
# убрать http:// или https:// если пользователь ввёл
host = cfg["host"].replace("http://", "").replace("https://", "").rstrip("/")
return ProxmoxAPI(
host, user=cfg["user"],
token_name=cfg["token_name"], token_value=cfg["token_value"],
verify_ssl=cfg["verify_ssl"], timeout=15,
)
def test_connection() -> tuple[bool, str]:
try:
pve = _client()
version = pve.version.get()
return True, f"Proxmox VE {version.get('version', '?')}"
except Exception as e:
return False, str(e)
def nodes() -> List[dict]:
return _client().nodes.get()
def node_status(node: str) -> dict:
return _client().nodes(node).status.get()
def vms(node: str) -> List[dict]:
"""Все VM и LXC на ноде."""
pve = _client()
qemu = [{"type": "qemu", **x} for x in pve.nodes(node).qemu.get()]
lxc = [{"type": "lxc", **x} for x in pve.nodes(node).lxc.get()]
return qemu + lxc
def vm_status(node: str, vmid: int, vm_type: str) -> dict:
pve = _client()
if vm_type == "qemu":
return pve.nodes(node).qemu(vmid).status.current.get()
return pve.nodes(node).lxc(vmid).status.current.get()
def vm_action(node: str, vmid: int, vm_type: str, action: str) -> str:
"""start/stop/shutdown/reboot/reset. Возвращает UPID задачи."""
assert action in ("start", "stop", "shutdown", "reboot", "reset")
pve = _client()
api = pve.nodes(node).qemu(vmid) if vm_type == "qemu" else pve.nodes(node).lxc(vmid)
return getattr(api.status, action).post()
def vm_config(node: str, vmid: int, vm_type: str) -> dict:
pve = _client()
if vm_type == "qemu":
return pve.nodes(node).qemu(vmid).config.get()
return pve.nodes(node).lxc(vmid).config.get()
def vm_rrd(node: str, vmid: int, vm_type: str, timeframe: str = "hour") -> list:
"""Метрики из RRD (cpu, mem, netin, netout...)."""
pve = _client()
api = pve.nodes(node).qemu(vmid) if vm_type == "qemu" else pve.nodes(node).lxc(vmid)
return api.rrd.get(timeframe=timeframe, cf="AVERAGE")
def get_vm_ip(node: str, vmid: int, vm_type: str) -> Optional[str]:
"""IP из QEMU Guest Agent или из конфига LXC."""
try:
pve = _client()
if vm_type == "qemu":
data = pve.nodes(node).qemu(vmid).agent("network-get-interfaces").get()
for iface in data.get("result", []):
for addr in iface.get("ip-addresses", []):
ip = addr.get("ip-address", "")
if addr.get("ip-address-type") == "ipv4" and not ip.startswith("127."):
return ip
else:
cfg = pve.nodes(node).lxc(vmid).config.get()
net0 = cfg.get("net0", "")
# "name=eth0,bridge=vmbr0,ip=192.168.1.10/24,gw=..."
for part in net0.split(","):
if part.startswith("ip=") and part != "ip=dhcp":
return part[3:].split("/")[0]
except Exception:
pass
return None
+58
View File
@@ -0,0 +1,58 @@
"""Экспорт отчётов: CSV по серверам, метрикам, задачам."""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from typing import Optional
from sqlmodel import select
from app.core.ssh import check_server
from app.db.database import get_session
from app.db.models import MetricSnapshot, Server, TaskRecord
REPORTS_DIR = Path.home() / ".debian-manager" / "reports"
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
async def generate() -> str:
"""Сводный CSV-отчёт. Возвращает путь к файлу."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = REPORTS_DIR / f"report_{ts}.csv"
with open(path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["=== ОТЧЁТ ПО СЕРВЕРАМ", datetime.now().isoformat(), "==="])
w.writerow([])
w.writerow(["Сервер", "Хост", "Порт", "Пользователь", "Теги",
"Онлайн", "ОС", "Uptime", "CPU%", "RAM%", "Диск%"])
with get_session() as s:
servers = list(s.exec(select(Server)).all())
for srv in servers:
info = await check_server(srv)
cpu = ram = disk = ""
if info.get("online"):
with get_session() as s:
m = s.exec(select(MetricSnapshot)
.where(MetricSnapshot.server_id == srv.id)
.order_by(MetricSnapshot.id.desc()).limit(1)).first()
if m:
cpu, ram, disk = m.cpu_percent, m.mem_percent, m.disk_percent
w.writerow([srv.name, srv.host, srv.port, srv.username, srv.tags,
"да" if info.get("online") else "нет",
info.get("os", ""), info.get("uptime", ""), cpu, ram, disk])
w.writerow([])
w.writerow(["=== ПОСЛЕДНИЕ ЗАДАЧИ ==="])
w.writerow(["Время", "Сервер", "Тип", "Название", "Статус"])
with get_session() as s:
tasks = list(s.exec(select(TaskRecord)
.order_by(TaskRecord.id.desc()).limit(100)).all())
srv_names = {x.id: x.name for x in servers}
for t in tasks:
w.writerow([t.created_at, srv_names.get(t.server_id, "?"),
t.kind, t.title, t.status])
return str(path)
def list_reports() -> list[Path]:
return sorted(REPORTS_DIR.glob("report_*.csv"), reverse=True)
+193
View File
@@ -0,0 +1,193 @@
"""Планировщик: запуск задач по cron-расписанию внутри приложения."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Optional
from croniter import croniter
from sqlmodel import select
from app.db.database import get_session
from app.db.models import ScheduledTask, Server
CHECK_INTERVAL = 30 # секунд между проверками
_next_run: dict[int, datetime] = {}
_loop_task: Optional[asyncio.Task] = None
# ---------- CRUD ----------
def list_tasks() -> list[ScheduledTask]:
with get_session() as s:
return list(s.exec(select(ScheduledTask).order_by(ScheduledTask.id)).all())
def add_task(**fields) -> ScheduledTask:
with get_session() as s:
t = ScheduledTask(**fields)
s.add(t)
s.commit()
s.refresh(t)
return t
def update_task(task_id: int, **fields) -> None:
with get_session() as s:
t = s.get(ScheduledTask, task_id)
if t:
for k, v in fields.items():
setattr(t, k, v)
s.add(t)
s.commit()
_next_run.pop(task_id, None) # пересчитать расписание
def delete_task(task_id: int) -> None:
with get_session() as s:
t = s.get(ScheduledTask, task_id)
if t:
s.delete(t)
s.commit()
_next_run.pop(task_id, None)
# ---------- выполнение ----------
def _target_servers(target: str) -> list[Server]:
with get_session() as s:
servers = list(s.exec(select(Server)).all())
if target == "all":
return servers
return [srv for srv in servers if target in (srv.tags or "").split(",")
or target.strip() in [t.strip() for t in (srv.tags or "").split(",")]]
async def execute_task(task_id: int) -> str:
"""Выполнить задачу немедленно. Возвращает статус."""
from app.core import servers as servers_core
from app.core.group_ops import op_apt_update, op_upgrade, op_shell, run_group
from app.core.tasks import tasks as tm
with get_session() as s:
t = s.get(ScheduledTask, task_id)
if not t:
return "error: задача не найдена"
action, target, params, name = t.action, t.target, t.params, t.name
t.last_run = datetime.now(timezone.utc)
t.last_status = "running"
s.add(t)
s.commit()
status = "success"
log_lines: list[str] = []
try:
if action == "cleanup_metrics":
from app.db.models import MetricSnapshot
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
with get_session() as s:
old = s.exec(select(MetricSnapshot).where(MetricSnapshot.ts < cutoff)).all()
for m in old:
s.delete(m)
s.commit()
log_lines.append(f"удалено {len(old)} снапшотов")
elif action == "report":
from app.core import reports
path = await reports.generate()
log_lines.append(f"отчёт: {path}")
else:
servers = _target_servers(target)
if not servers:
raise RuntimeError(f"нет серверов для target='{target}'")
if action == "apt_update":
op = op_apt_update()
elif action == "upgrade":
op = op_upgrade(False)
elif action == "dist_upgrade":
op = op_upgrade(True)
elif action == "shell":
op = op_shell(params)
elif action == "backup":
from app.core.backup import run_backup
for srv in servers:
rec = await run_backup(srv, params or "/etc")
log_lines.append(f"{srv.name}: {rec.status}")
op = None
elif action == "playbook":
from app.core import playbooks as pb
from app.core.ssh import SSHClient
data = pb.load(params)
for srv in servers:
async with SSHClient(srv) as c:
await pb.run_playbook(c, srv, data, log_lines.append)
op = None
else:
raise RuntimeError(f"неизвестное действие: {action}")
if op is not None:
res = await run_group(servers, op)
log_lines.append(f"ok={res.ok_count} err={res.err_count}")
for r in res.results.values():
if r.status == "error":
status = "error"
log_lines.append(f"{r.name}: {r.log[-300:]}")
except Exception as e:
status = "error"
log_lines.append(f"[ОШИБКА] {e}")
with get_session() as s:
t = s.get(ScheduledTask, task_id)
if t:
t.last_status = status
s.add(t)
s.commit()
# Telegram-уведомление
try:
from app.core import notify
await notify.task_done(f"планировщик", name, status)
except Exception:
pass
return status
# ---------- цикл ----------
async def _loop():
while True:
try:
now = datetime.now(timezone.utc)
for t in list_tasks():
if not t.enabled:
continue
nxt = _next_run.get(t.id)
if nxt is None:
nxt = croniter(t.cron, now).get_next(datetime)
_next_run[t.id] = nxt
if now >= nxt:
_next_run[t.id] = croniter(t.cron, now).get_next(datetime)
asyncio.ensure_future(execute_task(t.id))
except Exception:
pass
await asyncio.sleep(CHECK_INTERVAL)
def start() -> None:
global _loop_task
if _loop_task is None or _loop_task.done():
_loop_task = asyncio.ensure_future(_loop())
# ---------- пресеты ----------
PRESETS = [
("Ежедневный apt update (03:00)", "0 3 * * *", "apt_update", "all", ""),
("Еженедельный upgrade (вс 04:00)", "0 4 * * 0", "upgrade", "all", ""),
("Ежедневный бэкап /etc (02:00)", "0 2 * * *", "backup", "all", "/etc"),
("Бэкап /etc и /home (02:30)", "30 2 * * *", "backup", "all", "/etc /home"),
("Очистка старых метрик (ежечасно)", "7 * * * *", "cleanup_metrics", "all", ""),
("Ежедневный отчёт (08:00)", "0 8 * * *", "report", "all", ""),
("Docker prune (вс 05:00)", "0 5 * * 0", "shell", "all", "docker system prune -f"),
("Проверка диска (каждые 6 ч)", "13 */6 * * *", "shell", "all", "df -h"),
]
+47
View File
@@ -0,0 +1,47 @@
"""CRUD серверов."""
from __future__ import annotations
from typing import List, Optional
from sqlmodel import select
from app.db.database import get_session
from app.db.models import Server
def list_servers() -> List[Server]:
with get_session() as s:
return list(s.exec(select(Server).order_by(Server.name)).all())
def get_server(server_id: int) -> Optional[Server]:
with get_session() as s:
return s.get(Server, server_id)
def add_server(**fields) -> Server:
with get_session() as s:
srv = Server(**fields)
s.add(srv)
s.commit()
s.refresh(srv)
return srv
def update_server(server_id: int, **fields) -> None:
with get_session() as s:
srv = s.get(Server, server_id)
if not srv:
return
for k, v in fields.items():
setattr(srv, k, v)
s.add(srv)
s.commit()
def delete_server(server_id: int) -> None:
with get_session() as s:
srv = s.get(Server, server_id)
if srv:
s.delete(srv)
s.commit()
+130
View File
@@ -0,0 +1,130 @@
"""SSH-слой: подключения, выполнение команд, sudo, потоковый вывод."""
from __future__ import annotations
import asyncio
import shlex
from dataclasses import dataclass
from typing import AsyncIterator, Optional
import asyncssh
from app.db.models import Server
CONNECT_TIMEOUT = 15
@dataclass
class CmdResult:
stdout: str
stderr: str
returncode: int
@property
def ok(self) -> bool:
return self.returncode == 0
class SSHClient:
"""Одно SSH-подключение к серверу с помощниками sudo."""
def __init__(self, server: Server):
self.server = server
self._conn: Optional[asyncssh.SSHClientConnection] = None
async def __aenter__(self) -> "SSHClient":
await self.connect()
return self
async def __aexit__(self, *exc) -> None:
self.close()
async def connect(self) -> None:
kwargs: dict = dict(
host=self.server.host,
port=self.server.port,
username=self.server.username,
known_hosts=None,
connect_timeout=CONNECT_TIMEOUT,
)
if self.server.auth_type == "key" and self.server.key_path:
kwargs["client_keys"] = [self.server.key_path]
if self.server.password:
kwargs["passphrase"] = self.server.password
else:
kwargs["password"] = self.server.password
self._conn = await asyncssh.connect(**kwargs)
def close(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
@property
def conn(self) -> asyncssh.SSHClientConnection:
if not self._conn:
raise RuntimeError("SSH not connected")
return self._conn
# ---------- выполнение ----------
async def run(self, cmd: str, timeout: int = 120) -> CmdResult:
r = await self.conn.run(cmd, timeout=timeout)
return CmdResult(r.stdout or "", r.stderr or "", r.returncode)
def sudo_wrap(self, cmd: str) -> str:
"""Обернуть команду в sudo -S с передачей пароля по stdin."""
pw = self.server.sudo_password or self.server.password or ""
if self.server.username == "root":
return cmd
return f"echo {shlex.quote(pw)} | sudo -S -p '' bash -c {shlex.quote(cmd)}"
async def sudo(self, cmd: str, timeout: int = 600) -> CmdResult:
return await self.run(self.sudo_wrap(cmd), timeout=timeout)
async def run_stream(self, cmd: str, use_sudo: bool = False) -> AsyncIterator[str]:
"""Строки вывода по мере поступления (для лог-панели)."""
full = self.sudo_wrap(cmd) if use_sudo else cmd
async with self.conn.create_process(full, term_type="dumb") as proc:
async for line in proc.stdout:
yield line.rstrip("\n")
await proc.wait()
async def is_alive(self) -> bool:
try:
r = await self.run("echo ok", timeout=10)
return r.ok
except Exception:
return False
async def check_server(server: Server) -> dict:
"""Быстрый health-check: онлайн, ОС, uptime."""
try:
async with SSHClient(server) as c:
r = await c.run(
"hostname; . /etc/os-release && echo $PRETTY_NAME; "
"uptime -p; nproc; free -m | awk 'NR==2{print $2}'",
timeout=20,
)
lines = (r.stdout or "").splitlines()
return {
"online": True,
"hostname": lines[0] if len(lines) > 0 else "",
"os": lines[1] if len(lines) > 1 else "",
"uptime": lines[2] if len(lines) > 2 else "",
"cpu_cores": lines[3] if len(lines) > 3 else "?",
"mem_mb": lines[4] if len(lines) > 4 else "?",
}
except Exception as e:
return {"online": False, "error": str(e)}
async def wait_online(server: Server, timeout_s: int = 300, interval: int = 5) -> bool:
"""Ждать возврата сервера после reboot."""
deadline = asyncio.get_event_loop().time() + timeout_s
while asyncio.get_event_loop().time() < deadline:
info = await check_server(server)
if info.get("online"):
return True
await asyncio.sleep(interval)
return False
+73
View File
@@ -0,0 +1,73 @@
"""systemd: юниты, действия, journalctl."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from app.core.ssh import SSHClient
CRITICAL_UNITS = {"ssh.service", "sshd.service", "ssh.socket", "docker.service",
"networking.service", "systemd-networkd.service", "dbus.service"}
@dataclass
class Unit:
name: str
load: str
active: str
sub: str
description: str
enabled: str = ""
async def list_units(c: SSHClient) -> List[Unit]:
r = await c.sudo(
"systemctl list-units --type=service --all --no-legend --no-pager "
"--plain | awk '{$1=$1};1'", timeout=60
)
enabled_r = await c.sudo(
"systemctl list-unit-files --type=service --no-legend --no-pager", timeout=60
)
enabled_map = {}
for line in enabled_r.stdout.splitlines():
p = line.split()
if len(p) >= 2:
enabled_map[p[0]] = p[1]
units: List[Unit] = []
for line in r.stdout.splitlines():
p = line.split(None, 4)
if len(p) >= 4:
units.append(Unit(
name=p[0], load=p[1], active=p[2], sub=p[3],
description=p[4] if len(p) > 4 else "",
enabled=enabled_map.get(p[0], ""),
))
return units
async def failed_units(c: SSHClient) -> List[str]:
r = await c.sudo("systemctl --failed --no-legend --no-pager --plain", timeout=30)
return [l.split()[0] for l in r.stdout.splitlines() if l.strip() and not l.startswith("0 ")]
async def action(c: SSHClient, unit: str, act: str) -> str:
assert act in ("start", "stop", "restart", "reload", "enable", "disable", "mask", "unmask")
r = await c.sudo(f"systemctl {act} {unit}", timeout=120)
if not r.ok:
raise RuntimeError(r.stderr.strip() or f"rc={r.returncode}")
return r.stdout
async def logs(c: SSHClient, unit: str, lines: int = 200) -> str:
r = await c.sudo(f"journalctl -u {unit} -n {lines} --no-pager", timeout=60)
return r.stdout
async def cat_unit(c: SSHClient, unit: str) -> str:
r = await c.sudo(f"systemctl cat {unit}", timeout=30)
return r.stdout
async def daemon_reload(c: SSHClient) -> None:
await c.sudo("systemctl daemon-reload")
+81
View File
@@ -0,0 +1,81 @@
"""Очередь длинных задач с журналом."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Awaitable, Callable, Coroutine, List, Optional
from app.db.database import get_session
from app.db.models import TaskRecord
LogCallback = Callable[[str], None]
class TaskManager:
def __init__(self):
self._running: dict[int, asyncio.Task] = {}
def start(self, kind: str, title: str, server_id: Optional[int],
coro_factory: Callable[[LogCallback], Coroutine],
on_log: Optional[LogCallback] = None,
on_done: Optional[Callable[[TaskRecord], None]] = None) -> int:
"""Запустить задачу. coro_factory(log) — корутина, пишущая строки в log."""
with get_session() as s:
rec = TaskRecord(kind=kind, title=title, server_id=server_id, status="running")
s.add(rec)
s.commit()
s.refresh(rec)
task_id = rec.id
def log(line: str) -> None:
with get_session() as s:
r = s.get(TaskRecord, task_id)
if r:
r.log += line + "\n"
s.add(r)
s.commit()
if on_log:
on_log(line)
async def runner():
status = "success"
try:
await coro_factory(log)
except asyncio.CancelledError:
status = "cancelled"
log("[отменено пользователем]")
except Exception as e:
status = "error"
log(f"[ОШИБКА] {e}")
with get_session() as s:
r = s.get(TaskRecord, task_id)
if r:
r.status = status
r.finished_at = datetime.now(timezone.utc)
s.add(r)
s.commit()
s.refresh(r)
if on_done:
on_done(r)
self._running[task_id] = asyncio.ensure_future(runner())
return task_id
def cancel(self, task_id: int) -> None:
t = self._running.get(task_id)
if t and not t.done():
t.cancel()
def get(self, task_id: int) -> Optional[TaskRecord]:
with get_session() as s:
return s.get(TaskRecord, task_id)
def list_recent(self, limit: int = 50) -> List[TaskRecord]:
from sqlmodel import select
with get_session() as s:
return list(s.exec(
select(TaskRecord).order_by(TaskRecord.id.desc()).limit(limit)
).all())
tasks = TaskManager()
+69
View File
@@ -0,0 +1,69 @@
"""Интерактивный терминал: PTY-сессия по SSH с потоковым вводом/выводом."""
from __future__ import annotations
import asyncio
from typing import Callable, Optional
import asyncssh
from app.db.models import Server
class TerminalSession:
"""PTY-терминал к серверу. output_cb(str) вызывается для каждого куска вывода."""
def __init__(self, server: Server, output_cb: Callable[[str], None],
cols: int = 120, rows: int = 30):
self.server = server
self.output_cb = output_cb
self.cols = cols
self.rows = rows
self._conn: Optional[asyncssh.SSHClientConnection] = None
self._proc: Optional[asyncssh.SSHClientProcess] = None
self._closed = False
async def open(self) -> None:
kwargs: dict = dict(
host=self.server.host, port=self.server.port,
username=self.server.username, known_hosts=None, connect_timeout=15,
)
if self.server.auth_type == "key" and self.server.key_path:
kwargs["client_keys"] = [self.server.key_path]
if self.server.password:
kwargs["passphrase"] = self.server.password
else:
kwargs["password"] = self.server.password
self._conn = await asyncssh.connect(**kwargs)
self._proc = await self._conn.create_process(
term_type="xterm-256color",
term_size=(self.cols, self.rows),
)
asyncio.ensure_future(self._reader())
async def _reader(self) -> None:
try:
while not self._closed and self._proc:
data = await self._proc.stdout.read(4096)
if not data:
break
self.output_cb(data)
except Exception:
pass
def write(self, data: str) -> None:
if self._proc:
self._proc.stdin.write(data)
def resize(self, cols: int, rows: int) -> None:
if self._proc:
self._proc.change_terminal_size(cols, rows)
def close(self) -> None:
self._closed = True
try:
if self._proc:
self._proc.terminate()
if self._conn:
self._conn.close()
except Exception:
pass
+111
View File
@@ -0,0 +1,111 @@
"""Webhook-сервер для автодеплоя из Gitea (push → redeploy)."""
from __future__ import annotations
import asyncio
import json
from typing import Optional
from sqlmodel import select
from app.core import servers as servers_core
from app.db.database import get_session
from app.db.models import WebhookBinding
_server = None
def list_bindings() -> list[WebhookBinding]:
with get_session() as s:
return list(s.exec(select(WebhookBinding)).all())
def add_binding(**fields) -> WebhookBinding:
with get_session() as s:
b = WebhookBinding(**fields)
s.add(b)
s.commit()
s.refresh(b)
return b
def delete_binding(bid: int) -> None:
with get_session() as s:
b = s.get(WebhookBinding, bid)
if b:
s.delete(b)
s.commit()
async def _handler(reader, writer):
"""Минимальный HTTP-сервер для /webhooks/gitea."""
try:
req = await reader.readuntil(b"\r\n\r\n")
head = req.decode(errors="replace")
first = head.splitlines()[0] if head else ""
# тело
length = 0
for line in head.splitlines():
if line.lower().startswith("content-length:"):
length = int(line.split(":", 1)[1].strip())
body = await reader.readexactly(length) if length else b""
status, text = 200, "ok"
if first.startswith("POST /webhooks/gitea"):
try:
payload = json.loads(body or b"{}")
repo = payload.get("repository", {}).get("full_name", "")
ref = payload.get("ref", "") # refs/heads/main
branch = ref.rsplit("/", 1)[-1]
asyncio.ensure_future(_deploy(repo, branch))
text = f"deploy scheduled: {repo}@{branch}"
except Exception as e:
status, text = 400, f"bad payload: {e}"
else:
status, text = 404, "not found"
writer.write(f"HTTP/1.1 {status} OK\r\nContent-Length: {len(text)}\r\n"
f"Connection: close\r\n\r\n{text}".encode())
await writer.drain()
except Exception:
pass
finally:
try:
writer.close()
except Exception:
pass
async def _deploy(repo: str, branch: str) -> None:
with get_session() as s:
bindings = list(s.exec(select(WebhookBinding)
.where(WebhookBinding.repo == repo,
WebhookBinding.branch == branch,
WebhookBinding.enabled == True)).all()) # noqa: E712
for b in bindings:
srv = servers_core.get_server(b.server_id)
if not srv:
continue
try:
from app.core import gitea as g
from app.core.ssh import SSHClient
from app.core.tasks import tasks
async def job(log, srv=srv, b=b):
async with SSHClient(srv) as c:
cfg = g.get_config()
clone_url = f"{cfg['url']}/{repo}.git"
await g.deploy(c, clone_url, repo.split("/")[-1],
b.branch, b.compose_path, b.env or None, log)
tasks.start("webhook", f"auto-deploy {repo}@{branch}", srv.id, job)
except Exception:
pass
async def start(port: int = 9100) -> None:
global _server
if _server:
return
_server = await asyncio.start_server(_handler, "0.0.0.0", port)
def is_running() -> bool:
return _server is not None