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 config
import infra_actions
import limits as limits_mod
import render
import runner as runner_mod
@@ -355,7 +356,7 @@ class Command:
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.
SLASH_FLAGS = {
@@ -367,6 +368,9 @@ SLASH_FLAGS = {
def slash_args(name: str, opts: dict) -> list[str]:
"""`/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)
if flag is not None:
return [flag[1]] if opts.get(flag[0]) else []
@@ -444,6 +448,60 @@ def _reset_text(resets_at: int) -> str:
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:
if isinstance(ev, stream_mod.ToolUse):
inp = ev.input or {}
@@ -493,6 +551,7 @@ class Bridge:
# (abonament, nu API), fereastra de utilizare e singura limita reala.
self.rate_limit = None
self._rate_limit_warned: str | None = None
self._infra_boot_task: asyncio.Task | None = None
# ------------------------------------------------------------ iesire
async def _edit(self, target, text: str) -> None:
@@ -541,9 +600,16 @@ class Bridge:
await self.say(channel, f"⚠️ {item.get('warning')}")
self.runner.start_reaper()
self.wire_approvals()
with contextlib.suppress(Exception):
self._infra_boot_task = asyncio.create_task(self.infra_boot_check())
return lost
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):
await self.render.stop_all()
with contextlib.suppress(Exception):
@@ -724,6 +790,7 @@ class Bridge:
"`/status` starea firului · `/stop` opreste turul curent\n"
"`/cleanup [force:True]` procese lasate in urma\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.",
)
@@ -863,6 +930,171 @@ class Bridge:
lines += ["```", "`/permisiuni revoca` le sterge pe toate."]
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:
proc = self.runner.procs.get(tid)
if proc is None or not proc.alive: