From d2fe7f93c8431d78ef7fe46cfc5b569abeee2193 Mon Sep 17 00:00:00 2001 From: oskar Date: Mon, 20 Jul 2026 19:55:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(actions):=20mirror=20pending=20z=20VPS=20na?= =?UTF-8?q?=20panel=20PIHA=20+=20mutate=20proxy=20do=20VPS=20=E2=80=94=20A?= =?UTF-8?q?ction=20Queue=20byla=20slepa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-system/docker-compose.override.yml | 7 + services/agent-system/docker-compose.yml | 6 + .../runtime-materializer/materializer.py | 4 + .../runtime-materializer/tests/__init__.py | 0 .../runtime-materializer/tests/conftest.py | 33 +++++ .../tests/test_materializer.py | 68 ++++++++++ services/agent-system/webui/tests/__init__.py | 0 services/agent-system/webui/tests/test_web.py | 128 ++++++++++++++++++ services/agent-system/webui/web.py | 52 ++++++- 9 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 services/agent-system/runtime-materializer/tests/__init__.py create mode 100644 services/agent-system/runtime-materializer/tests/conftest.py create mode 100644 services/agent-system/runtime-materializer/tests/test_materializer.py create mode 100644 services/agent-system/webui/tests/__init__.py create mode 100644 services/agent-system/webui/tests/test_web.py diff --git a/hosts/piha/runtime/agent-system/docker-compose.override.yml b/hosts/piha/runtime/agent-system/docker-compose.override.yml index 58ae42a..9ab849c 100644 --- a/hosts/piha/runtime/agent-system/docker-compose.override.yml +++ b/hosts/piha/runtime/agent-system/docker-compose.override.yml @@ -6,3 +6,10 @@ services: # here ensures the webui /snapshot matches the clean 97-service state that # the control-plane /summary endpoint serves. CONTROL_PLANE_URL: "http://100.95.58.48:18180" + + webui: + environment: + # Same VPS control plane as runtime-materializer above: makes the panel + # read the mirrored actions.json and proxy approve/reject there instead + # of its own always-empty local ACTIONS_DIR (Action Queue mirror fix). + CONTROL_PLANE_URL: "http://100.95.58.48:18180" diff --git a/services/agent-system/docker-compose.yml b/services/agent-system/docker-compose.yml index dcb0e77..fd152ad 100644 --- a/services/agent-system/docker-compose.yml +++ b/services/agent-system/docker-compose.yml @@ -11,6 +11,12 @@ services: container_name: agent-system-webui ports: - "18180:8080" + environment: + # Empty by default (standalone/dev mode: reads/mutates local ACTIONS_DIR). + # Set on mirror nodes (see hosts//runtime/agent-system/) to make + # this panel read actions from world/actions.json and proxy approve/ + # reject to the VPS control-plane instead. + CONTROL_PLANE_URL: ${CONTROL_PLANE_URL:-} volumes: - /opt/homelab:/opt/homelab # Shared liveness logic — single source of truth, bind-mounted read-only diff --git a/services/agent-system/runtime-materializer/materializer.py b/services/agent-system/runtime-materializer/materializer.py index 8b42b91..f215f4b 100644 --- a/services/agent-system/runtime-materializer/materializer.py +++ b/services/agent-system/runtime-materializer/materializer.py @@ -88,6 +88,10 @@ def materialize_from_api(): "recommendations.json":f"{CONTROL_PLANE_URL}/recommendations", "runtime-summary.json":f"{CONTROL_PLANE_URL}/summary", "events.json": f"{CONTROL_PLANE_URL}/events", + # Actions live only on VPS (supervisor writes pending/, executor polls + # approved/ there). Mirroring them read-only lets the webui panel show + # the real queue instead of its own always-empty local ACTIONS_DIR. + "actions.json": f"{CONTROL_PLANE_URL}/actions", } fetched = {} diff --git a/services/agent-system/runtime-materializer/tests/__init__.py b/services/agent-system/runtime-materializer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/agent-system/runtime-materializer/tests/conftest.py b/services/agent-system/runtime-materializer/tests/conftest.py new file mode 100644 index 0000000..a480d8f --- /dev/null +++ b/services/agent-system/runtime-materializer/tests/conftest.py @@ -0,0 +1,33 @@ +"""Stub the redis package so materializer.py can be imported without it installed. + +materialize_from_api() (exercised by these tests) never touches redis at all — +only materialize() (the legacy direct-Redis path) does — but the module-level +`import redis` runs regardless, so a stub must be in place before import. +""" +from __future__ import annotations + +import sys +import types + + +def _make_redis_stub() -> types.ModuleType: + mod = types.ModuleType("redis") + + class _ConnectionError(Exception): + pass + + class _ResponseError(Exception): + pass + + exceptions_mod = types.ModuleType("redis.exceptions") + exceptions_mod.ConnectionError = _ConnectionError + exceptions_mod.ResponseError = _ResponseError + mod.exceptions = exceptions_mod + mod.Redis = object + return mod + + +if "redis" not in sys.modules: + stub = _make_redis_stub() + sys.modules["redis"] = stub + sys.modules["redis.exceptions"] = stub.exceptions diff --git a/services/agent-system/runtime-materializer/tests/test_materializer.py b/services/agent-system/runtime-materializer/tests/test_materializer.py new file mode 100644 index 0000000..f64cfce --- /dev/null +++ b/services/agent-system/runtime-materializer/tests/test_materializer.py @@ -0,0 +1,68 @@ +"""Tests for materialize_from_api's actions mirror. + +Covers the fix where actions/ (pending/approved/...) were never mirrored from +the VPS control-plane API, leaving a PIHA-side webui panel reading its own +always-empty local ACTIONS_DIR. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import materializer as materializer_module + + +FAKE_ACTIONS = { + "pending": [{"id": "a1", "status": "pending", "type": "redeploy"}], + "approved": [], "running": [], "completed": [], "failed": [], "rejected": [], +} + +ALL_ENDPOINTS_DATA = { + "/nodes": [], "/services": [], "/incidents": [], "/deployments": [], + "/recommendations": [], "/summary": {}, "/events": [], + "/actions": FAKE_ACTIONS, +} + + +def test_materialize_from_api_fetches_and_writes_actions_json(tmp_path, monkeypatch): + monkeypatch.setattr(materializer_module, "CONTROL_PLANE_URL", "http://cp.example") + monkeypatch.setattr(materializer_module, "WORLD_DIR", str(tmp_path)) + + requested = [] + + def fake_fetch(url): + requested.append(url) + path = url[len("http://cp.example"):] + return ALL_ENDPOINTS_DATA[path] + + monkeypatch.setattr(materializer_module, "_fetch_json", fake_fetch) + + ok = materializer_module.materialize_from_api() + + assert ok is True + assert "http://cp.example/actions" in requested + + written = json.loads((tmp_path / "actions.json").read_text()) + assert written == FAKE_ACTIONS + + +def test_materialize_from_api_aborts_if_actions_endpoint_fails(tmp_path, monkeypatch): + monkeypatch.setattr(materializer_module, "CONTROL_PLANE_URL", "http://cp.example") + monkeypatch.setattr(materializer_module, "WORLD_DIR", str(tmp_path)) + + def fake_fetch(url): + if url.endswith("/actions"): + return None + path = url[len("http://cp.example"):] + return ALL_ENDPOINTS_DATA[path] + + monkeypatch.setattr(materializer_module, "_fetch_json", fake_fetch) + + ok = materializer_module.materialize_from_api() + + assert ok is False + assert not (tmp_path / "actions.json").exists() diff --git a/services/agent-system/webui/tests/__init__.py b/services/agent-system/webui/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/agent-system/webui/tests/test_web.py b/services/agent-system/webui/tests/test_web.py new file mode 100644 index 0000000..0d52807 --- /dev/null +++ b/services/agent-system/webui/tests/test_web.py @@ -0,0 +1,128 @@ +"""Tests for the agent-system webui's actions mirror + mutate proxy. + +Covers the fix for the Action Queue panel always showing 0 pending: when +CONTROL_PLANE_URL is set (this webui is a read-only mirror of VPS, e.g. on +PIHA), current_actions() must read the materializer's mirrored +world/actions.json instead of the local (always-empty) ACTIONS_DIR, and +mutate_action() must proxy approve/reject to the VPS control-plane instead +of writing to the local ACTIONS_DIR, which the real executor never reads. +""" +from __future__ import annotations + +import json +import sys +import urllib.error +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import web as web_module + + +class _FakeResponse: + def __init__(self, status): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _setup_dirs(tmp_path, monkeypatch): + world = tmp_path / "world" + actions = tmp_path / "actions" + world.mkdir() + actions.mkdir() + monkeypatch.setattr(web_module, "WORLD_DIR", world) + monkeypatch.setattr(web_module, "ACTIONS_DIR", actions) + return world, actions + + +def test_current_actions_reads_mirror_when_control_plane_url_set(tmp_path, monkeypatch): + world, actions = _setup_dirs(tmp_path, monkeypatch) + monkeypatch.setattr(web_module, "CONTROL_PLANE_URL", "http://100.95.58.48:18180") + + mirrored = {"pending": [{"id": "a1", "status": "pending", "type": "redeploy"}], + "approved": [], "running": [], "completed": [], "failed": [], "rejected": []} + (world / "actions.json").write_text(json.dumps(mirrored)) + + # A stray local pending file must NOT leak into the mirrored result — VPS + # is the sole source of truth once mirroring is on. + local_pending = actions / "pending" + local_pending.mkdir() + (local_pending / "local-only.json").write_text(json.dumps({"type": "alert_only"})) + + result = web_module.current_actions() + + assert result == mirrored + assert len(result["pending"]) == 1 + assert result["pending"][0]["id"] == "a1" + + +def test_current_actions_falls_back_to_local_scan_without_control_plane_url(tmp_path, monkeypatch): + world, actions = _setup_dirs(tmp_path, monkeypatch) + monkeypatch.setattr(web_module, "CONTROL_PLANE_URL", "") + + pending_dir = actions / "pending" + pending_dir.mkdir() + (pending_dir / "act-1.json").write_text(json.dumps({"type": "redeploy"})) + + result = web_module.current_actions() + + assert len(result["pending"]) == 1 + assert result["pending"][0]["id"] == "act-1" + assert result["pending"][0]["status"] == "pending" + + +def test_mutate_action_proxies_to_control_plane_when_set(tmp_path, monkeypatch): + _setup_dirs(tmp_path, monkeypatch) + monkeypatch.setattr(web_module, "CONTROL_PLANE_URL", "http://100.95.58.48:18180") + + captured = {} + + def fake_urlopen(req, timeout=10): + captured["url"] = req.full_url + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(200) + + monkeypatch.setattr(web_module.urllib.request, "urlopen", fake_urlopen) + + success, msg = web_module.mutate_action("a1", "approved") + + assert success is True + assert captured["url"] == "http://100.95.58.48:18180/action/mutate" + assert captured["body"] == {"id": "a1", "status": "approved"} + + +def test_mutate_action_proxy_failure_reports_error(tmp_path, monkeypatch): + _setup_dirs(tmp_path, monkeypatch) + monkeypatch.setattr(web_module, "CONTROL_PLANE_URL", "http://100.95.58.48:18180") + + def fake_urlopen(req, timeout=10): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(web_module.urllib.request, "urlopen", fake_urlopen) + + success, msg = web_module.mutate_action("a1", "approved") + + assert success is False + assert "100.95.58.48:18180" in msg + + +def test_mutate_action_local_when_no_control_plane_url(tmp_path, monkeypatch): + world, actions = _setup_dirs(tmp_path, monkeypatch) + monkeypatch.setattr(web_module, "CONTROL_PLANE_URL", "") + + pending_dir = actions / "pending" + pending_dir.mkdir() + (pending_dir / "act-1.json").write_text(json.dumps({"status": "pending"})) + + success, msg = web_module.mutate_action("act-1", "approved") + + assert success is True + assert not (pending_dir / "act-1.json").exists() + approved_data = json.loads((actions / "approved" / "act-1.json").read_text()) + assert approved_data["status"] == "approved" diff --git a/services/agent-system/webui/web.py b/services/agent-system/webui/web.py index a380f6f..233f2c1 100644 --- a/services/agent-system/webui/web.py +++ b/services/agent-system/webui/web.py @@ -1,6 +1,8 @@ import json import os import time +import urllib.error +import urllib.request from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -24,6 +26,14 @@ WORLD_DIR = Path(os.getenv("HOMELAB_WORLD_ROOT", "/opt/homelab/world")) ACTIONS_DIR = Path(os.getenv("HOMELAB_ACTIONS_ROOT", "/opt/homelab/actions")) CONFIG_DIR = Path(os.getenv("HOMELAB_CONFIG_ROOT", "/opt/homelab/config")) +# When set, this panel is a read-only mirror of a control-plane running +# elsewhere (VPS): actions are mirrored into world/actions.json by the +# runtime-materializer (same as nodes/services), and mutations (approve/ +# reject) are proxied there instead of touching the local ACTIONS_DIR, which +# the real executor never reads. Same variable the materializer uses to pick +# its mode — keeps both components' "am I a mirror?" decision in sync. +CONTROL_PLANE_URL = os.environ.get("CONTROL_PLANE_URL", "").rstrip("/") + STATIC_DIR = Path(__file__).parent DEFAULT_CONFIG = { @@ -170,6 +180,19 @@ def current_events(): def current_actions(): + """Actions shown to the operator. + + In mirror mode (CONTROL_PLANE_URL set) VPS is the single source of truth + for actions, same as nodes/services: the materializer fetches VPS's + /actions and writes world/actions.json, and we just read it back here. + The local ACTIONS_DIR scan is only for standalone/dev use where this + webui runs next to its own supervisor+executor (no CONTROL_PLANE_URL). + """ + if CONTROL_PLANE_URL: + mirrored = read_json_file(WORLD_DIR / "actions.json", default={}) + if isinstance(mirrored, dict): + return mirrored + actions = {} statuses = ["pending", "approved", "running", "completed", "failed", "rejected"] for status in statuses: @@ -186,11 +209,38 @@ def current_actions(): return actions +def _proxy_mutate(action_id, target_status): + """Forward an approve/reject to the VPS control-plane API. + + The executor only ever polls ACTIONS_DIR/approved on VPS, so a mutation + applied to this panel's local (mirrored, otherwise-unused) ACTIONS_DIR + would silently vanish — the approval would look successful here but the + executor would never see it. Proxying is the only way an approval made + on this mirror panel actually reaches the executor. + """ + url = f"{CONTROL_PLANE_URL}/action/mutate" + body = json.dumps({"id": action_id, "status": target_status}).encode("utf-8") + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status == 200: + return True, "Success" + return False, f"Control plane returned HTTP {resp.status}" + except urllib.error.HTTPError as e: + return False, f"Control plane returned HTTP {e.code}: {e.reason}" + except Exception as e: + return False, f"Failed to reach control plane at {url}: {e}" + + def mutate_action(action_id, target_status): statuses = ["pending", "approved", "running", "completed", "failed", "rejected"] if target_status not in statuses: return False, f"Invalid target status: {target_status}" + if CONTROL_PLANE_URL: + return _proxy_mutate(action_id, target_status) + # Find where the action is source_path = None current_status = None @@ -212,7 +262,7 @@ def mutate_action(action_id, target_status): data = json.loads(source_path.read_text()) data["status"] = target_status data["updated_at"] = time.time() - + # Keep history of transitions history = data.get("transition_history", []) history.append({