#!/usr/bin/env python3 """Discord bot for csrx.ru infrastructure management.""" import os import asyncio import logging from datetime import datetime, timezone import discord from discord import app_commands import docker import httpx # --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) log = logging.getLogger(__name__) DISCORD_TOKEN = os.environ["DISCORD_TOKEN"] FORGEJO_TOKEN = os.environ["FORGEJO_TOKEN"] FORGEJO_URL = os.environ.get("FORGEJO_URL", "https://git.csrx.ru") FORGEJO_REPO = os.environ.get("FORGEJO_REPO", "jack/infra") PROMETHEUS_URL = os.environ.get("PROMETHEUS_URL", "http://prometheus:9090") # --------------------------------------------------------------------------- intents = discord.Intents.default() client = discord.Client(intents=intents) tree = app_commands.CommandTree(client) docker_client = docker.DockerClient(base_url="unix:///var/run/docker.sock") # --------------------------------------------------------------------------- # Helpers def _icon(status: str) -> str: if status == "running": return "🟢" if status == "paused": return "🟡" return "🔴" def _trunc(text: str, limit: int = 1900) -> str: text = text.strip() if len(text) <= limit: return text return "…\n" + text[-limit:] def _gb(b) -> str: return f"{b / 1e9:.1f} GB" if b else "?" # --------------------------------------------------------------------------- # /status — list all containers @tree.command(name="status", description="Статус всех Docker-контейнеров") async def cmd_status(interaction: discord.Interaction): await interaction.response.defer() try: containers = sorted(docker_client.containers.list(all=True), key=lambda c: c.name) lines = [f"{_icon(c.status)} `{c.name}` — {c.status}" for c in containers] embed = discord.Embed( title="🖥️ Контейнеры", description="\n".join(lines) or "Нет контейнеров", color=0x5865F2, timestamp=datetime.now(timezone.utc), ) await interaction.followup.send(embed=embed) except Exception as e: await interaction.followup.send(f"❌ Ошибка: `{e}`") # --------------------------------------------------------------------------- # /logs — last 40 lines of container logs @tree.command(name="logs", description="Последние логи контейнера") @app_commands.describe(service="Имя контейнера (forgejo, plane-web, traefik, …)") async def cmd_logs(interaction: discord.Interaction, service: str): await interaction.response.defer() try: c = docker_client.containers.get(service) raw = c.logs(tail=40, timestamps=False).decode("utf-8", errors="replace") await interaction.followup.send( f"**Логи `{service}` (последние 40 строк):**\n```\n{_trunc(raw)}\n```" ) except docker.errors.NotFound: await interaction.followup.send(f"❌ Контейнер `{service}` не найден") except Exception as e: await interaction.followup.send(f"❌ Ошибка: `{e}`") # --------------------------------------------------------------------------- # /restart — restart a container @tree.command(name="restart", description="Перезапустить контейнер") @app_commands.describe(service="Имя контейнера") async def cmd_restart(interaction: discord.Interaction, service: str): await interaction.response.defer() try: c = docker_client.containers.get(service) await interaction.followup.send(f"🔄 Перезапускаю `{service}`…") c.restart(timeout=30) await interaction.channel.send(f"✅ `{service}` перезапущен") except docker.errors.NotFound: await interaction.followup.send(f"❌ Контейнер `{service}` не найден") except Exception as e: await interaction.followup.send(f"❌ Ошибка: `{e}`") # --------------------------------------------------------------------------- # /deploy — trigger Forgejo CI pipeline via workflow_dispatch @tree.command(name="deploy", description="Запустить деплой через Forgejo CI") async def cmd_deploy(interaction: discord.Interaction): await interaction.response.defer() try: async with httpx.AsyncClient(timeout=15) as http: r = await http.post( f"{FORGEJO_URL}/api/v1/repos/{FORGEJO_REPO}/actions/workflows/deploy.yml/dispatches", headers={"Authorization": f"token {FORGEJO_TOKEN}"}, json={"ref": "master"}, ) if r.status_code in (200, 204): repo_url = f"{FORGEJO_URL}/{FORGEJO_REPO}/actions" await interaction.followup.send( f"🚀 **Деплой запущен!**\nПрогресс: {repo_url}" ) else: await interaction.followup.send( f"⚠️ Forgejo ответил `{r.status_code}`:\n```{r.text[:300]}```" ) except Exception as e: await interaction.followup.send(f"❌ Ошибка: `{e}`") # --------------------------------------------------------------------------- # /metrics — CPU / RAM / disk from Prometheus @tree.command(name="metrics", description="Метрики сервера (CPU / RAM / диск)") async def cmd_metrics(interaction: discord.Interaction): await interaction.response.defer() try: info = docker_client.info() running = info.get("ContainersRunning", "?") stopped = info.get("ContainersStopped", "?") async with httpx.AsyncClient(timeout=6) as http: async def prom(q: str): try: r = await http.get( f"{PROMETHEUS_URL}/api/v1/query", params={"query": q} ) d = r.json() if d["status"] == "success" and d["data"]["result"]: return float(d["data"]["result"][0]["value"][1]) except Exception: pass return None cpu = await prom('100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)') mem_total = await prom("node_memory_MemTotal_bytes") mem_avail = await prom("node_memory_MemAvailable_bytes") disk_total = await prom('node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}') disk_free = await prom('node_filesystem_free_bytes{mountpoint="/",fstype!="tmpfs"}') mem_pct = (mem_total - mem_avail) / mem_total * 100 if mem_total and mem_avail else None disk_pct = (disk_total - disk_free) / disk_total * 100 if disk_total and disk_free else None embed = discord.Embed( title="📊 Метрики сервера", color=0x2ecc71, timestamp=datetime.now(timezone.utc), ) embed.add_field(name="CPU", value=f"`{cpu:.1f}%`" if cpu is not None else "—", inline=True) embed.add_field( name="RAM", value=f"`{mem_pct:.1f}%` ({_gb(mem_total - mem_avail if mem_total and mem_avail else None)} / {_gb(mem_total)})" if mem_pct is not None else "—", inline=True, ) embed.add_field( name="Диск /", value=f"`{disk_pct:.1f}%` ({_gb(disk_total - disk_free if disk_total and disk_free else None)} / {_gb(disk_total)})" if disk_pct is not None else "—", inline=True, ) embed.add_field( name="Контейнеры", value=f"🟢 {running} running / 🔴 {stopped} stopped", inline=False, ) await interaction.followup.send(embed=embed) except Exception as e: await interaction.followup.send(f"❌ Ошибка: `{e}`") # --------------------------------------------------------------------------- # /backup — last backup container logs @tree.command(name="backup", description="Логи последнего бэкапа") async def cmd_backup(interaction: discord.Interaction): await interaction.response.defer() try: containers = docker_client.containers.list( all=True, filters={"name": "backup"} ) if not containers: await interaction.followup.send( "ℹ️ Контейнер `backup` не найден (запускается по расписанию каждые 6 часов)" ) return c = containers[0] raw = c.logs(tail=30).decode("utf-8", errors="replace") lower = raw.lower() icon = "🟢" if any(w in lower for w in ("uploaded", "complete", "done")) else \ "🔴" if any(w in lower for w in ("error", "fail", "traceback")) else "🟡" await interaction.followup.send( f"{icon} **Логи бэкапа** (`{c.name}`, статус: `{c.status}`):\n" f"```\n{_trunc(raw, 1500)}\n```" ) except Exception as e: await interaction.followup.send(f"❌ Ошибка: `{e}`") # --------------------------------------------------------------------------- @client.event async def on_ready(): await tree.sync() log.info("Logged in as %s (ID: %s). Slash commands synced.", client.user, client.user.id) client.run(DISCORD_TOKEN, log_handler=None)