Fix Copy for AI: materializer fetches from control-plane API instead of Redis
services/agent-system/runtime-materializer/materializer.py: - Add materialize_from_api() that fetches all world-state endpoints from the control-plane HTTP API (CONTROL_PLANE_URL env var) - When CONTROL_PLANE_URL is set, use API as source of truth instead of Redis - Redis path preserved as fallback for backward compat hosts/piha/runtime/agent-system/docker-compose.override.yml (new): - Inject CONTROL_PLANE_URL=http://100.95.58.48:18180 for runtime-materializer - piha webui /snapshot now mirrors VPS observer output (clean, ghost-free) Root cause: materializer read from Redis which held 80 stale service entries with hash-prefixed ghost keys (e.g. 0ccb8a88e079_control-plane-supervisor). Redis is never updated by the current observer pipeline; the control-plane API is the single authoritative world-state source. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b40b832159
commit
7277bdc27f
|
|
@ -0,0 +1,8 @@
|
||||||
|
services:
|
||||||
|
runtime-materializer:
|
||||||
|
environment:
|
||||||
|
# Pull world state from the VPS control-plane API instead of local Redis.
|
||||||
|
# The observer on VPS is the authoritative writer; mirroring its API output
|
||||||
|
# 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"
|
||||||
|
|
@ -3,6 +3,8 @@ import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import argparse
|
import argparse
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Configuration from environment variables
|
# Configuration from environment variables
|
||||||
|
|
@ -10,6 +12,15 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
|
||||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
|
REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
|
||||||
WORLD_DIR = os.environ.get("WORLD_DIR", "/opt/homelab/world")
|
WORLD_DIR = os.environ.get("WORLD_DIR", "/opt/homelab/world")
|
||||||
|
|
||||||
|
# When set, materialize from the control-plane HTTP API instead of Redis.
|
||||||
|
# This is the authoritative source of truth: the observer writes clean world
|
||||||
|
# state to the control-plane API, which the materializer mirrors locally so
|
||||||
|
# the webui's /snapshot (and all other endpoints) reflect the same data.
|
||||||
|
#
|
||||||
|
# Example: CONTROL_PLANE_URL=http://100.95.58.48:18180
|
||||||
|
CONTROL_PLANE_URL = os.environ.get("CONTROL_PLANE_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def get_redis_client():
|
def get_redis_client():
|
||||||
"""Returns a Redis client with decoding enabled."""
|
"""Returns a Redis client with decoding enabled."""
|
||||||
return redis.Redis(
|
return redis.Redis(
|
||||||
|
|
@ -41,6 +52,61 @@ def normalize_health(health):
|
||||||
return "degraded"
|
return "degraded"
|
||||||
return "error"
|
return "error"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(url):
|
||||||
|
"""Fetch JSON from a URL, returning parsed data or None on error."""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{datetime.now().isoformat()}] Error fetching {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(filename, data):
|
||||||
|
path = os.path.join(WORLD_DIR, filename)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_from_api():
|
||||||
|
"""Mirror world state from the control-plane API to local world files.
|
||||||
|
|
||||||
|
The control-plane observer on VPS is the single authoritative writer of
|
||||||
|
world state. By fetching from its HTTP API we get the same clean, pruned
|
||||||
|
data that the /summary endpoint serves — no stale Redis artefacts.
|
||||||
|
|
||||||
|
Returns True if all fetches succeeded and files were written, False otherwise.
|
||||||
|
"""
|
||||||
|
print(f"[{datetime.now().isoformat()}] Materializing from control-plane API: {CONTROL_PLANE_URL}")
|
||||||
|
|
||||||
|
endpoints = {
|
||||||
|
"nodes.json": f"{CONTROL_PLANE_URL}/nodes",
|
||||||
|
"services.json": f"{CONTROL_PLANE_URL}/services",
|
||||||
|
"incidents.json": f"{CONTROL_PLANE_URL}/incidents",
|
||||||
|
"deployments.json": f"{CONTROL_PLANE_URL}/deployments",
|
||||||
|
"recommendations.json":f"{CONTROL_PLANE_URL}/recommendations",
|
||||||
|
"runtime-summary.json":f"{CONTROL_PLANE_URL}/summary",
|
||||||
|
"events.json": f"{CONTROL_PLANE_URL}/events",
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched = {}
|
||||||
|
for filename, url in endpoints.items():
|
||||||
|
data = _fetch_json(url)
|
||||||
|
if data is None:
|
||||||
|
print(f"[{datetime.now().isoformat()}] Aborting: failed to fetch {url}")
|
||||||
|
return False
|
||||||
|
fetched[filename] = data
|
||||||
|
|
||||||
|
os.makedirs(WORLD_DIR, exist_ok=True)
|
||||||
|
for filename, data in fetched.items():
|
||||||
|
write_json(filename, data)
|
||||||
|
|
||||||
|
svc_count = len(fetched.get("services.json") or [])
|
||||||
|
print(f"[{datetime.now().isoformat()}] Materialized from API: {svc_count} services → {WORLD_DIR}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def materialize():
|
def materialize():
|
||||||
"""Reads state from Redis and writes JSON files to the world directory."""
|
"""Reads state from Redis and writes JSON files to the world directory."""
|
||||||
print(f"[{datetime.now().isoformat()}] Materializing world state...")
|
print(f"[{datetime.now().isoformat()}] Materializing world state...")
|
||||||
|
|
@ -146,11 +212,6 @@ def materialize():
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
os.makedirs(WORLD_DIR, exist_ok=True)
|
os.makedirs(WORLD_DIR, exist_ok=True)
|
||||||
|
|
||||||
def write_json(filename, data):
|
|
||||||
path = os.path.join(WORLD_DIR, filename)
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
|
|
||||||
write_json("runtime-summary.json", summary)
|
write_json("runtime-summary.json", summary)
|
||||||
write_json("nodes.json", nodes)
|
write_json("nodes.json", nodes)
|
||||||
write_json("services.json", services)
|
write_json("services.json", services)
|
||||||
|
|
@ -172,10 +233,19 @@ if __name__ == "__main__":
|
||||||
parser.add_argument("--interval", type=int, default=30, help="Sleep interval between runs (seconds)")
|
parser.add_argument("--interval", type=int, default=30, help="Sleep interval between runs (seconds)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.once:
|
if CONTROL_PLANE_URL:
|
||||||
materialize()
|
print(f"Mode: control-plane API ({CONTROL_PLANE_URL})")
|
||||||
|
run_fn = materialize_from_api
|
||||||
else:
|
else:
|
||||||
print(f"Starting materializer loop (interval: {args.interval}s)...")
|
print(f"Mode: Redis ({REDIS_HOST}:{REDIS_PORT})")
|
||||||
|
run_fn = materialize
|
||||||
|
|
||||||
|
interval = int(os.environ.get("MATERIALIZE_INTERVAL", args.interval))
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
run_fn()
|
||||||
|
else:
|
||||||
|
print(f"Starting materializer loop (interval: {interval}s)...")
|
||||||
while True:
|
while True:
|
||||||
materialize()
|
run_fn()
|
||||||
time.sleep(args.interval)
|
time.sleep(interval)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue