diff --git a/proxmox/lxc171-claude-agent/discord-bridge/bot.py b/proxmox/lxc171-claude-agent/discord-bridge/bot.py index b88f5d0..be2aff8 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/bot.py @@ -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: diff --git a/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py b/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py index e872de3..e602c9b 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py @@ -12,6 +12,8 @@ from __future__ import annotations import logging +import infra_actions + log = logging.getLogger("discord-bridge.slash") try: # pragma: no cover - depinde de mediu @@ -82,6 +84,14 @@ def build_tree(client, bridge): async def _help(interaction): 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 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py index 0662757..df5cfaf 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import pytest 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(_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