commit e036908edb4eeaf47c14c952c91e43b8b392cb09 Author: rainofdestiny Date: Thu Mar 26 05:27:51 2026 +0700 Initial commit: Discord infrastructure bot Commands: /status /logs /restart /deploy /metrics /backup CI/CD: builds Docker image, pushes to git.csrx.ru registry, deploys to server Co-Authored-By: Claude Sonnet 4.6 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..2d60326 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: CI/CD + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + # ── Build & push Docker image ──────────────────────────────────────────── + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Forgejo Container Registry + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | \ + docker login git.csrx.ru -u ${{ github.actor }} --password-stdin + + - name: Build image + run: docker build -t git.csrx.ru/jack/discord-bot:latest . + + - name: Push image + run: docker push git.csrx.ru/jack/discord-bot:latest + + # ── Deploy to server (master only) ─────────────────────────────────────── + deploy: + needs: build + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Configure SSH + run: | + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p 22 87.249.49.32 >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Pull & restart on server + run: | + ssh deploy@87.249.49.32 " + docker pull git.csrx.ru/jack/discord-bot:latest && + docker compose -f /opt/services/docker-compose.yml up -d --no-deps discord-bot + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77c9573 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies first (cached layer) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy bot code +COPY bot.py . + +CMD ["python", "-u", "bot.py"] diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..21bc228 --- /dev/null +++ b/bot.py @@ -0,0 +1,234 @@ +#!/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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a18574 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +discord.py==2.4.0 +docker==7.1.0 +httpx==0.28.1