fix(actions): mirror pending z VPS na panel PIHA + mutate proxy do VPS — Action Queue byla slepa
This commit is contained in:
parent
9b0532afae
commit
d2fe7f93c8
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
33
services/agent-system/runtime-materializer/tests/conftest.py
Normal file
33
services/agent-system/runtime-materializer/tests/conftest.py
Normal 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
|
||||
|
|
@ -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()
|
||||
0
services/agent-system/webui/tests/__init__.py
Normal file
0
services/agent-system/webui/tests/__init__.py
Normal file
128
services/agent-system/webui/tests/test_web.py
Normal file
128
services/agent-system/webui/tests/test_web.py
Normal 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"
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue