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,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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user