fix(actions): mirror pending z VPS na panel PIHA + mutate proxy do VPS — Action Queue byla slepa

This commit is contained in:
oskar 2026-07-20 19:55:54 +02:00
parent 9b0532afae
commit d2fe7f93c8
9 changed files with 297 additions and 1 deletions

View file

@ -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"

View file

@ -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/<node>/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

View file

@ -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 = {}

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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({