Research-Stack/4-Infrastructure/auto/infra_controller.py
2026-05-25 16:24:21 -05:00

420 lines
16 KiB
Python
Executable file

#!/usr/bin/env python3
# PTOS: LAYER=INFRA / DOMAIN=AUTOMATION / CONDITION=ALPHA
"""
infra_controller.py — Full-loop infrastructure health orchestration.
Runs on 361395-1 (Netcup VPS) as the control plane. Probes all nodes via SSH,
collects health status, makes decisions, dispatches alerts. qfox-1 is treated
as opportunistic — its absence is logged but not alerted.
Observe → Decide → Act → Emit receipt cycle. Systemd timer fires every 5 min.
Usage:
python3 4-Infrastructure/auto/infra_controller.py [options]
Options:
--once Run one cycle and exit (default)
--probe-only Observe only, emit receipt, no actions
--dry-run Show what would happen, don't execute
--loop N Run every N seconds (default: 300)
--dump Print full receipt to stdout instead of JSONL
"""
from __future__ import annotations
import sys
import os
import time
from pathlib import Path
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import argparse
# Set up paths so we can import from auto.lib regardless of cwd
AUTO_DIR = Path(__file__).resolve().parent # 4-Infrastructure/auto/
INFRA_DIR = AUTO_DIR.parent # 4-Infrastructure/
sys.path.insert(0, str(INFRA_DIR))
from auto.lib.config import load_config
def _record_hoxel(obs: Observation, status: str) -> None:
"""Record node health as a hoxel transition to the rs-surface API."""
import json
import urllib.request
endpoint = os.environ.get(
"RS_HOXEL_ENDPOINT",
"http://100.101.247.127:8444/v1/hoxels/record",
)
payload = json.dumps({
"obj_key": "infra-controller/cycle",
"bucket": "research-stack",
"to_tier": "infra",
"spectral_mode": "health",
"thermal_score": len(obs.critical_down) / max(len(obs.probes), 1),
"residual": len(obs.optional_unavailable) / max(len(obs.probes), 1),
"payload_bytes": len(obs.probes),
"density": 1.0,
"confidence": 1.0,
"semantic_load": 0.0,
}).encode()
try:
req = urllib.request.Request(
endpoint, data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
except Exception:
pass # hoxel recording is advisory, never block the probe loop
from auto.lib.probe import run_probe
from auto.lib.receipt import build_receipt, write_receipt, read_chain
from auto.lib.alerting import Alerter
# ── helpers ────────────────────────────────────────────────────────────────────
def _ts() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%H:%M:%S")
def _log(msg: str) -> None:
print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True)
# ── OBSERVE ────────────────────────────────────────────────────────────────────
class Observation:
def __init__(self) -> None:
self.probes: dict[str, Any] = {} # node_name → list of probe results
self.backup_status: dict[str, Any] = {}
self.garage_status: dict[str, Any] = {}
self.optional_unavailable: list[str] = []
self.critical_down: list[str] = []
def to_dict(self) -> dict[str, Any]:
return {
"probes": {
node: [r.to_dict() for r in results]
for node, results in self.probes.items()
},
"backup_status": self.backup_status,
"garage_status": self.garage_status,
"optional_unavailable": self.optional_unavailable,
"critical_down": self.critical_down,
}
def observe(config: dict) -> tuple[Observation, Alerter]:
obs = Observation()
alerter = Alerter(config)
nodes_cfg = config.get("nodes", {})
tier_1 = set(config.get("tier_1_required", []))
# Probe all nodes in parallel
futures: dict = {}
with ThreadPoolExecutor(max_workers=8) as pool:
for node_name, node_cfg in nodes_cfg.items():
ip = node_cfg["ip"]
ssh_target = node_cfg.get("ssh_target", f"root@{ip}")
optional = node_cfg.get("optional", False)
probe_names = node_cfg.get("probes", [])
for pname in probe_names:
fut = pool.submit(run_probe, node_name, ip, ssh_target, pname, optional)
futures[fut] = (node_name, pname, optional)
for fut in as_completed(futures):
node_name, pname, optional = futures[fut]
try:
result = fut.result()
except Exception as exc:
result = type("Pr", (), {})()
result.__dict__ = {
"node": node_name, "ip": "",
"optional": optional, "reachable": False,
"error": str(exc), "data": {}, "elapsed_ms": 0,
}
obs.probes.setdefault(node_name, []).append(result)
if not result.reachable and not optional:
obs.critical_down.append(node_name)
node_role = nodes_cfg[node_name].get("roles", [])
alerter.alert(f"node DOWN: {node_name} ({', '.join(node_role)}) is unreachable")
elif not result.reachable and optional:
obs.optional_unavailable.append(node_name)
alerter.note(f"optional node unavailable: {node_name}")
# Parse probe-specific data into structured fields
_parse_probe_data(obs, node_name, pname, result, alerter, config)
# Aggregate garage status across all nodes that probed it
_aggregate_garage_status(obs, alerter, config)
# Deduplicate unavailable lists (each node may have multiple probes)
obs.critical_down = sorted(set(obs.critical_down))
obs.optional_unavailable = sorted(set(obs.optional_unavailable))
# Aggregate backup status
_aggregate_backup_status(obs, alerter, config)
return obs, alerter
def _parse_probe_data(obs, node_name, pname, result, alerter, config):
thresh = config.get("thresholds", {})
data = result.data if result.reachable else {}
if pname == "system":
disk_pct = int(data.get("disk", {}).get("used_pct", 0) or 0)
mem_pct = int(data.get("memory", {}).get("used_pct", 0) or 0)
if disk_pct >= thresh.get("disk_warn_pct", 90):
alerter.warn(f"high disk usage: {node_name} at {disk_pct}%")
if mem_pct >= thresh.get("memory_warn_pct", 90):
alerter.warn(f"high memory usage: {node_name} at {mem_pct}%")
elif pname == "caddy":
cert_days = int(data.get("cert_days_remaining", 999) or 999)
if cert_days < thresh.get("cert_min_days", 30):
alerter.alert(f"SSL certificate expiring: {node_name} has {cert_days} days remaining")
if not data.get("caddy_running", True):
alerter.alert(f"caddy not running on {node_name}")
elif pname == "restic":
if data.get("restic_reachable"):
hours = int(data.get("latest_age_hours", 0) or 0)
max_age = thresh.get("restic_max_age_hours", 28)
if hours > max_age:
alerter.warn(f"restic snapshot age: {node_name} last snap {hours}h ago (limit {max_age}h)")
if not data.get("backup_log_ok", True):
alerter.warn(f"restic backup log shows no successful backup on {node_name}")
elif data.get("restic_installed"):
alerter.warn(f"restic repository unreachable on {node_name}")
elif pname == "k3s":
total = int(data.get("total_pods", 0) or 0)
running = int(data.get("running_pods", 0) or 0)
if total > 0 and running < total:
alerter.warn(f"k3s pods degraded: {node_name} has {running}/{total} running")
def _aggregate_garage_status(obs, alerter, config):
"""Combine garage probe data from all nodes that run the probe."""
seen_nodes: set = set()
garage_nodes = []
total_healthy = 0
thresh = config.get("thresholds", {})
for node_name, results in obs.probes.items():
for r in results:
if not r.reachable:
continue
data = r.data
if data.get("garage_reachable"):
total_h = int(data.get("healthy_nodes", 0) or 0)
nodes = data.get("nodes", [])
if nodes:
for n in nodes:
nid = n.get("id", "")
if nid and nid not in seen_nodes:
seen_nodes.add(nid)
garage_nodes.append({"probed_from": node_name, **n})
alerter.note(
f"garage via {node_name}: {total_h} healthy nodes"
)
total_healthy = max(total_healthy, total_h)
min_nodes = thresh.get("garage_nodes_min", 3)
if total_healthy < min_nodes:
alerter.warn(f"garage cluster degraded: {total_healthy}/{min_nodes} healthy nodes")
obs.garage_status = {
"healthy_nodes": total_healthy,
"nodes_seen": garage_nodes,
}
# Bucket probe data
bucket_list = []
for node_name, results in obs.probes.items():
for r in results:
if r.reachable and "buckets" in r.data:
buckets = r.data.get("buckets", [])
if buckets:
bucket_list = buckets # take the first non-empty result
break
obs.garage_status["buckets"] = bucket_list
def _aggregate_backup_status(obs, alerter, config):
"""Aggregate restic + cold copy probe data."""
backup = {"snapshot_count": 0, "latest_age_hours": 0, "backup_log_ok": False,
"last_cold_copy_hours": 0, "total_size": 0, "file_size": 0}
thresh = config.get("thresholds", {})
for node_name, results in obs.probes.items():
for r in results:
if not r.reachable:
continue
data = r.data
if data.get("restic_reachable"):
backup["snapshot_count"] = int(data.get("snapshot_count", 0) or 0)
backup["latest_age_hours"] = int(data.get("latest_age_hours", 0) or 0)
backup["backup_log_ok"] = data.get("backup_log_ok", False)
backup["total_size"] = int(data.get("total_size", 0) or 0)
backup["file_size"] = int(data.get("file_size", 0) or 0)
if "hours_since_copy" in data:
backup["last_cold_copy_hours"] = int(data.get("hours_since_copy", 0) or 0)
# Only warn about backup state if at least one restic probe responded
has_restic_data = False
for node_name, results in obs.probes.items():
for r in results:
if r.reachable and r.data.get("restic_reachable") or r.data.get("restic_installed"):
has_restic_data = True
break
if not has_restic_data:
if any(n for n in obs.optional_unavailable if "qfox-1" in n):
alerter.note("restic/backup host (qfox-1) is offline — backup status unknown")
obs.backup_status = backup
return
if backup["snapshot_count"] == 0:
alerter.warn("no restic snapshots found")
elif backup["latest_age_hours"] > thresh.get("restic_max_age_hours", 28):
alerter.warn(
f"restic snapshots are {backup['latest_age_hours']}h old "
f"(limit {thresh['restic_max_age_hours']}h)"
)
backup["cold_copy_stale"] = backup["last_cold_copy_hours"] > 14 * 24 # 14 days
if backup["cold_copy_stale"]:
alerter.warn(
f"cold copy to gdrive is stale: {backup['last_cold_copy_hours']}h since last copy"
)
obs.backup_status = backup
# ── DECIDE ─────────────────────────────────────────────────────────────────────
class Decision:
def __init__(self) -> None:
self.actions: list[str] = []
def to_dict(self) -> dict:
return {"actions": self.actions}
def decide(obs: Observation) -> Decision:
d = Decision()
# Decisions are made in the observe phase via the alerter.
# The controller is primarily an observer + alerter; actions
# like restarting services require manual intervention via alerts
# (or future automation scripts on the target nodes).
#
# The decision surface tracks whether any actionable alerts were
# generated — the act phase handles dispatching them.
has_alerts = bool(obs.critical_down or obs.backup_status.get("snapshot_count", 0) == 0
or obs.garage_status.get("healthy_nodes", 0) < 3)
if has_alerts:
d.actions.append("dispatch_alerts")
return d
# ── ACT ────────────────────────────────────────────────────────────────────────
def act(alerter: Alerter, dry_run: bool = False) -> dict[str, Any]:
if dry_run:
return {"alerts": alerter.alerts, "warnings": alerter.warnings, "info": alerter.info}
return alerter.send()
# ── MAIN CYCLE ─────────────────────────────────────────────────────────────────
def run_cycle(
config: dict,
tick: int,
parent_hash: str,
probe_only: bool = False,
dry_run: bool = False,
) -> str:
_log(f"tick={tick} probing {len(config.get('nodes',{}))} nodes...")
obs, alerter = observe(config)
dec = decide(obs)
result = act(alerter, dry_run=dry_run)
receipt = build_receipt(
tick=tick,
parent_hash=parent_hash,
observation=obs.to_dict(),
decision=dec.to_dict(),
action_result=result,
)
write_receipt(receipt)
status = "OK"
_record_hoxel(obs, status)
if obs.critical_down:
status = "DEGRADED"
elif alerter.alerts:
status = "ALERTS"
_log(
f"tick={tick} done — status={status} "
f"critical_down={len(obs.critical_down)} "
f"optional_unavailable={len(obs.optional_unavailable)} "
f"alerts={len(alerter.alerts)} warnings={len(alerter.warnings)} "
f"hash={receipt['receipt_hash'][:16]}..."
)
return receipt["receipt_hash"]
def main() -> None:
p = argparse.ArgumentParser(description="infra_controller.py — infrastructure health daemon")
p.add_argument("--once", action="store_true", default=True, help="Run one cycle")
p.add_argument("--probe-only", action="store_true", help="Observe only")
p.add_argument("--dry-run", action="store_true", help="Show what would happen")
p.add_argument("--loop", type=float, default=0, help="Run every N seconds")
p.add_argument("--dump", action="store_true", help="Print receipt to stdout")
args = p.parse_args()
# Load config
config = load_config()
tick, parent_hash = read_chain()
tick += 1
interval = args.loop if args.loop > 0 else config.get("schedule", {}).get("node_probes", 300)
if args.loop:
_log(f"daemon mode, interval={interval}s, resuming at tick={tick}")
while True:
try:
parent_hash = run_cycle(config, tick, parent_hash,
probe_only=args.probe_only,
dry_run=args.dry_run)
tick += 1
except Exception as exc:
_log(f"cycle error (tick={tick}): {exc}")
import traceback
traceback.print_exc(file=sys.stderr)
time.sleep(interval)
else:
parent_hash = run_cycle(config, tick, parent_hash,
probe_only=args.probe_only,
dry_run=args.dry_run)
if args.dump:
import json
last_tick, last_hash = read_chain()
log_path = Path.home() / ".cache" / "infra-controller.jsonl"
if log_path.exists():
with open(log_path) as fh:
lines = fh.readlines()
if lines:
print(lines[-1].strip())
if __name__ == "__main__":
main()