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 <noreply@anthropic.com>
This commit is contained in:
commit
e036908edb
5 changed files with 298 additions and 0 deletions
46
.forgejo/workflows/deploy.yml
Normal file
46
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -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
|
||||||
|
"
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
234
bot.py
Normal file
234
bot.py
Normal file
|
|
@ -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 <service> — 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 <service> — 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)
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
discord.py==2.4.0
|
||||||
|
docker==7.1.0
|
||||||
|
httpx==0.28.1
|
||||||
Loading…
Reference in a new issue