feat(discord-bridge): comanda /infra cu confirmare prin butoane

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KddsXCqEbKMhdFJDYbAsx8
This commit is contained in:
Claude Agent
2026-09-13 10:18:12 +00:00
parent 56027e2e6c
commit c87fa06b55
3 changed files with 398 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ from dataclasses import dataclass, field
import commands_slash import commands_slash
import config import config
import infra_actions
import limits as limits_mod import limits as limits_mod
import render import render
import runner as runner_mod import runner as runner_mod
@@ -355,7 +356,7 @@ class Command:
return " ".join(self.args) return " ".join(self.args)
COMMANDS = ("new", "cd", "model", "status", "stop", "cleanup", "permisiuni", "help") COMMANDS = ("new", "cd", "model", "status", "stop", "cleanup", "permisiuni", "help", "infra")
# Optiunile slash traduse in argumentele pe care cmd_* le stiu deja. # Optiunile slash traduse in argumentele pe care cmd_* le stiu deja.
SLASH_FLAGS = { SLASH_FLAGS = {
@@ -367,6 +368,9 @@ SLASH_FLAGS = {
def slash_args(name: str, opts: dict) -> list[str]: def slash_args(name: str, opts: dict) -> list[str]:
"""`/new fork:True` -> `["--fork"]`, `/cd cale:/x` -> `["/x"]`.""" """`/new fork:True` -> `["--fork"]`, `/cd cale:/x` -> `["/x"]`."""
if name == "infra":
s = opts.get("simulare")
return [opts.get("actiune", "")] + (["simulare"] if s is True else ["real"] if s is False else [])
flag = SLASH_FLAGS.get(name) flag = SLASH_FLAGS.get(name)
if flag is not None: if flag is not None:
return [flag[1]] if opts.get(flag[0]) else [] return [flag[1]] if opts.get(flag[0]) else []
@@ -444,6 +448,60 @@ def _reset_text(resets_at: int) -> str:
return "?" return "?"
def infra_status_text(st: dict) -> str:
"""Rezumatul `/infra stare`: cvorum, noduri, HA, replicare, UPS, backup, SSL."""
quorum = st.get("quorum", {})
guests = st.get("guests", {})
ha = st.get("ha", [])
repl = st.get("replication", {})
ups = st.get("ups")
backup = st.get("backup")
ssl = st.get("ssl")
lines = [
"**Stare cluster**",
"```",
f"cvorum : {'da' if quorum.get('quorate') else 'NU'} ({quorum.get('votes', 0)}/{quorum.get('expected', 0)})",
f"guest-uri: {guests.get('running', 0)}/{guests.get('total', 0)} pornite",
f"HA : {len(ha)} servicii" + (
f", {sum(1 for h in ha if h.get('state') != 'started')} nu sunt 'started'" if ha else ""
),
f"replicare: {repl.get('ok', 0)}/{repl.get('total', 0)} ok" + (
f", esuate: {', '.join(repl.get('failed', []))}" if repl.get("failed") else ""
),
]
if ups:
lines.append(f"UPS : {ups.get('status', '?')}, incarcare {ups.get('charge', 0):.0f}%")
if backup:
full, cum = backup.get("full_age_h"), backup.get("cum_age_h")
lines.append(
"backup : FULL " + (f"{full:.1f}h" if full is not None else "?")
+ ", CUM " + (f"{cum:.1f}h" if cum is not None else "?")
)
if ssl:
lines.append(f"SSL : {ssl.get('domain')} expira in {ssl.get('min_days')} zile")
for n in st.get("nodes", []):
lines.append(f" {n['name']:<9}: {'online' if n['online'] else 'OFFLINE'}, {n.get('guests', 0)} guest-uri")
lines.append("```")
if st.get("errors"):
lines.append("⚠️ erori: " + ", ".join(f"{h}: {e}" for h, e in st["errors"].items()))
return "\n".join(lines)
def infra_result_text(label: str, result: dict) -> str:
"""Mesajul din `run()`/`stop()` al infra_actions, gata de trimis in fir."""
if not result.get("ok"):
return f"⛔ {label} a esuat (cod {result.get('code')}): {result.get('error', '?')}"
parts = [f"✅ {label} pornit."]
if result.get("log"):
parts.append(f"Jurnal: `{result['log']}`")
if result.get("output"):
parts.append(f"```\n{result['output'][:1500]}\n```")
if result.get("warning"):
parts.append(f"⚠️ {result['warning']}")
return "\n".join(parts)
def _tool_line(ev) -> str: def _tool_line(ev) -> str:
if isinstance(ev, stream_mod.ToolUse): if isinstance(ev, stream_mod.ToolUse):
inp = ev.input or {} inp = ev.input or {}
@@ -493,6 +551,7 @@ class Bridge:
# (abonament, nu API), fereastra de utilizare e singura limita reala. # (abonament, nu API), fereastra de utilizare e singura limita reala.
self.rate_limit = None self.rate_limit = None
self._rate_limit_warned: str | None = None self._rate_limit_warned: str | None = None
self._infra_boot_task: asyncio.Task | None = None
# ------------------------------------------------------------ iesire # ------------------------------------------------------------ iesire
async def _edit(self, target, text: str) -> None: async def _edit(self, target, text: str) -> None:
@@ -541,9 +600,16 @@ class Bridge:
await self.say(channel, f"⚠️ {item.get('warning')}") await self.say(channel, f"⚠️ {item.get('warning')}")
self.runner.start_reaper() self.runner.start_reaper()
self.wire_approvals() self.wire_approvals()
with contextlib.suppress(Exception):
self._infra_boot_task = asyncio.create_task(self.infra_boot_check())
return lost return lost
async def shutdown(self) -> None: async def shutdown(self) -> None:
if self._infra_boot_task is not None:
self._infra_boot_task.cancel()
with contextlib.suppress(Exception, asyncio.CancelledError):
await self._infra_boot_task
self._infra_boot_task = None
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await self.render.stop_all() await self.render.stop_all()
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -724,6 +790,7 @@ class Bridge:
"`/status` starea firului · `/stop` opreste turul curent\n" "`/status` starea firului · `/stop` opreste turul curent\n"
"`/cleanup [force:True]` procese lasate in urma\n" "`/cleanup [force:True]` procese lasate in urma\n"
"`/permisiuni [revoca:True]` ce s-a aprobat pentru tot firul\n" "`/permisiuni [revoca:True]` ce s-a aprobat pentru tot firul\n"
"`/infra actiune:<...>` infrastructura: stare, oprire/pornire cluster, UPS, DR\n"
"Orice alt mesaj din canal pleaca la Claude ca prompt.", "Orice alt mesaj din canal pleaca la Claude ca prompt.",
) )
@@ -863,6 +930,171 @@ class Bridge:
lines += ["```", "`/permisiuni revoca` le sterge pe toate."] lines += ["```", "`/permisiuni revoca` le sterge pe toate."]
return "\n".join(lines) return "\n".join(lines)
# ---------------------------------------------------------------- infra
async def cmd_infra(self, message, tid: str, cmd: Command) -> None:
if not cmd.args:
await self.say(message.channel, "Foloseste `/infra actiune:<...>`.")
return
action_id = cmd.args[0]
spec = infra_actions.ACTIONS.get(action_id)
if spec is None:
await self.say(message.channel, f"⛔ Actiune necunoscuta: `{action_id}`.")
return
if action_id == "stare":
st = await asyncio.to_thread(infra_actions.status, True)
out = infra_status_text(st)
await self.say(message.channel, out)
return
if action_id == "sarcini":
st = await asyncio.to_thread(infra_actions.status, True)
await self.say(message.channel, await self.infra_jobs_text(st))
return
dry = ("real" not in cmd.args) if spec["dry"] else False
st = await asyncio.to_thread(infra_actions.status, True)
ok, reason = infra_actions.check(action_id, st, dry)
if not ok:
await self.say(message.channel, f"⛔ {spec['label']}: {reason}")
return
if spec["confirm"] and not dry:
text = (
f"⚠️ **{spec['label']}** — confirmi?\n{spec['desc']}\n"
f"Precondiție: {spec['pre']}"
)
if action_id == "oprire":
text += "\nInstructiunile de pornire se fixeaza in canal la finalul opririi."
view = self.infra_view(action_id, dry)
if view is not None:
await message.channel.send(text, view=view)
else:
await self.say(message.channel, text + "\n(fara discord.py, nu pot arata butoane de confirmare)")
return
result = await asyncio.to_thread(infra_actions.run, action_id, dry)
await self.say(message.channel, infra_result_text(spec["label"], result))
async def infra_jobs_text(self, st: dict) -> str:
jobs = [j for j in st.get("jobs", []) if j.get("active")]
if not jobs:
return "Niciun job activ."
parts = []
for j in jobs:
tail = await asyncio.to_thread(infra_actions.log_tail, j["id"], 15)
lines = "\n".join(tail.get("lines", []))
parts.append(f"**{j['id']}** ({j['host']})\n```\n{lines}\n```")
text = "\n".join(parts)
return text if len(text) <= 1800 else text[:1800] + "\n… (trunchiat)"
def infra_view(self, action_id: str, dry: bool):
"""View cu Confirma / Anuleaza pentru o actiune infra. Fara discord.py: None."""
if discord is None:
return None
spec = infra_actions.ACTIONS[action_id]
view = discord.ui.View(timeout=300)
async def _confirm_cb(interaction): # pragma: no cover - are nevoie de Discord real
if str(interaction.user.id) not in user_ids():
with contextlib.suppress(Exception):
await interaction.response.send_message("⛔ Nu esti autorizat.", ephemeral=True)
return
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=f"⏳ {spec['label']}…", view=None)
st = await asyncio.to_thread(infra_actions.status, True)
ok, reason = infra_actions.check(action_id, st, dry)
if not ok:
with contextlib.suppress(Exception):
await interaction.edit_original_response(content=f"⛔ {spec['label']}: {reason}")
return
result = await asyncio.to_thread(infra_actions.run, action_id, dry)
with contextlib.suppress(Exception):
await interaction.edit_original_response(content=infra_result_text(spec["label"], result))
async def _cancel_cb(interaction): # pragma: no cover - are nevoie de Discord real
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=f"Anulat: {spec['label']}.", view=None)
confirm = discord.ui.Button(label="Confirma", style=discord.ButtonStyle.danger)
cancel = discord.ui.Button(label="Anuleaza", style=discord.ButtonStyle.secondary)
confirm.callback = _confirm_cb
cancel.callback = _cancel_cb
view.add_item(confirm)
view.add_item(cancel)
return view
def infra_boot_view(self):
"""View pentru reamintirea de pornire la boot: Porneste / Simulare / Nu acum."""
if discord is None:
return None
view = discord.ui.View(timeout=None)
label = infra_actions.ACTIONS["pornire"]["label"]
async def _run(interaction, dry: bool): # pragma: no cover - are nevoie de Discord real
if str(interaction.user.id) not in user_ids():
with contextlib.suppress(Exception):
await interaction.response.send_message("⛔ Nu esti autorizat.", ephemeral=True)
return
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=f"⏳ {label}…", view=None)
result = await asyncio.to_thread(infra_actions.run, "pornire", dry)
with contextlib.suppress(Exception):
await interaction.edit_original_response(content=infra_result_text(label, result))
async def _skip_cb(interaction): # pragma: no cover - are nevoie de Discord real
with contextlib.suppress(Exception):
await interaction.response.edit_message(view=None)
start = discord.ui.Button(label="Porneste guest-urile", style=discord.ButtonStyle.success)
sim = discord.ui.Button(label="Simulare", style=discord.ButtonStyle.secondary)
skip = discord.ui.Button(label="Nu acum", style=discord.ButtonStyle.secondary)
start.callback = lambda interaction: _run(interaction, False)
sim.callback = lambda interaction: _run(interaction, True)
skip.callback = _skip_cb
view.add_item(start)
view.add_item(sim)
view.add_item(skip)
return view
async def infra_boot_check(self) -> None:
"""La pornire: daca clusterul e deja sus dar inca in mentenanta (repornit
manual, crontab-ul inca poarta #MENTENANTA), aminteste sa se dea /infra
pornire. Nu are voie sa doboare botul: orice eroare ramane doar in log."""
try:
for attempt in range(20):
try:
st = await asyncio.to_thread(infra_actions.status, True)
except Exception:
log.exception("infra_boot_check: status() a esuat")
st = None
if st is not None:
if attempt == 0 and not st.get("maint", 0):
return
if infra_actions.maintenance_pending(st):
await self._infra_post_maint_reminder(st)
return
await asyncio.sleep(30)
except Exception: # pragma: no cover - plasa de siguranta
log.exception("infra_boot_check a esuat")
async def _infra_post_maint_reminder(self, st: dict) -> None:
chans = config.get_list("DISCORD_CHANNEL_IDS") or config.get_list("DISCORD_CHANNEL_ID")
if not chans:
return
channel = self.channel_of(chans[0])
if channel is None:
return
guests = st.get("guests", {})
n = max(0, int(guests.get("total", 0)) - int(guests.get("running", 0)))
text = f"🟢 Cluster sus — 3/3 noduri · {n} guest-uri de pornit."
view = self.infra_boot_view()
with contextlib.suppress(Exception):
if view is not None:
await channel.send(text, view=view)
else:
await channel.send(text)
async def cmd_stop(self, message, tid: str, cmd: Command) -> None: async def cmd_stop(self, message, tid: str, cmd: Command) -> None:
proc = self.runner.procs.get(tid) proc = self.runner.procs.get(tid)
if proc is None or not proc.alive: if proc is None or not proc.alive:

View File

@@ -12,6 +12,8 @@ from __future__ import annotations
import logging import logging
import infra_actions
log = logging.getLogger("discord-bridge.slash") log = logging.getLogger("discord-bridge.slash")
try: # pragma: no cover - depinde de mediu try: # pragma: no cover - depinde de mediu
@@ -82,6 +84,14 @@ def build_tree(client, bridge):
async def _help(interaction): async def _help(interaction):
await bridge.handle_slash(interaction, "help") await bridge.handle_slash(interaction, "help")
@tree.command(name="infra", description="Infrastructura: stare, oprire/pornire cluster, UPS, DR")
@app_commands.describe(actiune="Ce faci", simulare="Doar simulare (implicit da unde exista)")
@app_commands.choices(actiune=[
app_commands.Choice(name=a["label"][:100], value=k) for k, a in infra_actions.ACTIONS.items()
])
async def _infra(interaction, actiune: app_commands.Choice[str], simulare: bool | None = None):
await bridge.handle_slash(interaction, "infra", actiune=actiune.value, simulare=simulare)
return tree return tree

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import pytest import pytest
import bot import bot
@@ -345,3 +347,156 @@ async def test_sync_fara_arbore_sau_fara_guilduri_nu_arunca():
assert await commands_slash.sync_guilds(None, ["100"]) == {} assert await commands_slash.sync_guilds(None, ["100"]) == {}
assert await commands_slash.sync_guilds(_FakeTree(), []) == {} assert await commands_slash.sync_guilds(_FakeTree(), []) == {}
# --------------------------------------------------------------------- /infra
import infra_actions # noqa: E402
def _st(**over) -> dict:
base = {
"quorum": {"quorate": True, "votes": 3, "expected": 3},
"guests": {"running": 2, "total": 3},
"ha": [], "replication": {"ok": 0, "total": 0, "last": 0, "failed": []},
"ups": None, "backup": None, "ssl": None, "vm109": None,
"dr_failover_active": None, "maint": 0, "jobs": [], "errors": {},
"nodes": [{"name": "pvemini", "ip": "10.0.20.201", "online": True,
"cpu": 0.0, "mem": 0, "maxmem": 0, "uptime": 0, "guests": 2}],
}
base.update(over)
return base
def test_slash_args_infra():
assert bot.slash_args("infra", {"actiune": "oprire", "simulare": None}) == ["oprire"]
assert bot.slash_args("infra", {"actiune": "oprire", "simulare": False}) == ["oprire", "real"]
assert bot.slash_args("infra", {"actiune": "oprire", "simulare": True}) == ["oprire", "simulare"]
async def test_infra_actiune_necunoscuta_fara_subprocess(bridge, monkeypatch):
import subprocess
monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("nu trebuia sa ruleze nimic"))
_, ch, _ = await slash(bridge, "infra", actiune="nu-exista", simulare=None)
assert "necunoscuta" in ch.all_text
async def test_infra_fara_actiune(bridge):
_, ch, _ = await slash(bridge, "infra", actiune="", simulare=None)
assert "necunoscuta" in ch.all_text or "Foloseste" in ch.all_text
async def test_infra_stare(bridge, monkeypatch):
st = _st(ups={"status": "OL", "charge": 100.0, "runtime_s": 1200})
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: st)
_, ch, _ = await slash(bridge, "infra", actiune="stare", simulare=None)
text = ch.all_text
assert "cvorum" in text and "3/3" in text and "guest-uri" in text and "pvemini" in text
assert "UPS" in text and "OL" in text
async def test_infra_sarcini_fara_joburi(bridge, monkeypatch):
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st(jobs=[]))
_, ch, _ = await slash(bridge, "infra", actiune="sarcini", simulare=None)
assert "Niciun job activ" in ch.all_text
async def test_infra_sarcini_cu_job_activ(bridge, monkeypatch):
jobs = [{"id": "oprire", "host": "pvemini", "active": True, "log": "/var/log/x.log"}]
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st(jobs=jobs))
monkeypatch.setattr(infra_actions, "log_tail", lambda aid, n=60: {"file": "/x", "lines": ["linia 1"]})
_, ch, _ = await slash(bridge, "infra", actiune="sarcini", simulare=None)
assert "oprire" in ch.all_text and "linia 1" in ch.all_text
async def test_infra_precondition_esuata(bridge, monkeypatch):
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
monkeypatch.setattr(infra_actions, "check", lambda aid, st, dry: (False, "motiv de test"))
apelat = []
monkeypatch.setattr(infra_actions, "run", lambda *a, **k: apelat.append(a) or {"ok": True, "code": 200})
_, ch, _ = await slash(bridge, "infra", actiune="wol", simulare=None)
assert "⛔" in ch.all_text and "motiv de test" in ch.all_text
assert apelat == []
async def test_infra_fara_confirmare_ruleaza_direct(bridge, monkeypatch):
"""`wol` nu cere confirmare: ruleaza imediat daca precondiția trece."""
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
monkeypatch.setattr(infra_actions, "check", lambda aid, st, dry: (True, ""))
apelat = []
def _run(aid, dry):
apelat.append((aid, dry))
return {"ok": True, "code": 200, "host": "oracle-prod-admin"}
monkeypatch.setattr(infra_actions, "run", _run)
_, ch, _ = await slash(bridge, "infra", actiune="wol", simulare=None)
assert apelat == [("wol", False)] # "wol" nu are simulare -> dry mereu False
assert "pornit" in ch.all_text
async def test_infra_cu_confirmare_arata_intrebarea_si_nu_ruleaza(bridge, monkeypatch):
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
monkeypatch.setattr(infra_actions, "check", lambda aid, st, dry: (True, ""))
apelat = []
monkeypatch.setattr(infra_actions, "run", lambda *a, **k: apelat.append(a) or {"ok": True, "code": 200})
_, ch, _ = await slash(bridge, "infra", actiune="oprire", simulare=False) # "real" -> dry=False
assert "confirmi" in ch.all_text.lower()
assert apelat == [] # butonul nu a fost apasat, comanda nu a rulat nimic
async def test_infra_actiune_de_simulare_are_dry_true(bridge, monkeypatch):
vazute = []
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
def _check(aid, st, dry):
vazute.append(dry)
return (True, "")
monkeypatch.setattr(infra_actions, "check", _check)
monkeypatch.setattr(infra_actions, "run", lambda *a, **k: {"ok": True, "code": 200})
await slash(bridge, "infra", actiune="oprire", simulare=None) # implicit: simulare
await slash(bridge, "infra", actiune="oprire", simulare=False) # explicit: real
assert vazute == [True, False]
def test_infra_view_e_none_fara_discordpy(bridge):
if bot.discord is None:
assert bridge.infra_view("oprire", True) is None
assert bridge.infra_boot_view() is None
async def test_infra_boot_check_anunta_mentenanta_apoi_se_opreste(bridge, monkeypatch):
ch = FakeChannel()
monkeypatch.setenv("DISCORD_CHANNEL_IDS", str(ch.id))
bridge.get_channel = lambda cid: ch if str(cid) == str(ch.id) else None
st = _st(maint=1)
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: st)
monkeypatch.setattr(infra_actions, "maintenance_pending", lambda s: True)
await bridge.infra_boot_check()
assert "guest-uri de pornit" in ch.all_text
async def test_infra_boot_check_iese_daca_nu_e_in_mentenanta(bridge, monkeypatch):
st = _st(maint=0)
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: st)
apelat = []
monkeypatch.setattr(infra_actions, "maintenance_pending", lambda s: apelat.append(s) or False)
await bridge.infra_boot_check()
assert apelat == [] # a iesit la prima citire (maint == 0), fara sa mai verifice
async def test_infra_boot_check_nu_doboara_botul_la_eroare(bridge, monkeypatch):
"""`status()` care arunca nu are voie sa opreasca bucla sau botul."""
def _boom(fresh=False):
raise RuntimeError("retea picata")
async def _fast_sleep(_):
return None
monkeypatch.setattr(infra_actions, "status", _boom)
monkeypatch.setattr(asyncio, "sleep", _fast_sleep)
await bridge.infra_boot_check() # 20 incercari esuate, nici o exceptie in afara