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

601 lines
22 KiB
Python

#!/usr/bin/env python3
# PTOS: LAYER=INFRA / DOMAIN=ORCHESTRATOR / CONDITION=ALPHA
"""
unified_orchestrator.py — Unified Observe → Decide → Act → Emit loop.
Merges:
- infra_controller.py (node health probes via SSH)
- storage_agent.py (Garage/restic backup stack probes)
Both run in the same cycle. Decisions and actions are dispatched separately
but emitted as a single unified receipt. Hoxels recorded for every action.
Usage:
python3 4-Infrastructure/auto/orchestrator.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 Run in polling loop
--interval N Seconds between ticks (default: 300)
--dump Print receipt to stdout
"""
from __future__ import annotations
import sys
import os
import time
import json
import hashlib
import argparse
import shlex
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
AUTO_DIR = Path(__file__).resolve().parent
INFRA_DIR = AUTO_DIR.parent
sys.path.insert(0, str(INFRA_DIR))
from auto.lib.config import load_config
from auto.lib.receipt import read_chain, write_receipt, sha256
from auto.lib.probe import run_probe
from auto.lib.alerting import Alerter
from auto.lib.q16 import Q16_ONE
SCHEMA = "unified_orchestrator_receipt_v1"
VERSION = "1.0.0"
LOG_PATH = Path.home() / ".cache" / "orchestrator.jsonl"
BACKUP_SH = INFRA_DIR / "storage" / "restic" / "backup.sh"
CONSOLIDATE_SH = INFRA_DIR / "storage" / "garage" / "db-consolidate.sh"
# ── helpers ─────────────────────────────────────────────────────────────────────
def _ts() -> str:
return datetime.now(timezone.utc).strftime("%H:%M:%S")
def _log(msg: str) -> None:
print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True)
def _run(
args: list[str],
env_extra: dict[str, str] | None = None,
timeout: int = 120,
) -> tuple[int, str, str]:
import subprocess
env = os.environ.copy()
if env_extra:
env.update(env_extra)
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=env)
return r.returncode, r.stdout, r.stderr
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except Exception as e:
return -1, "", str(e)
def _send_alert(subject: str, body: str) -> None:
"""Send an alert email via the microvm-racknerd msmtp relay."""
import subprocess
target = os.environ.get("RS_ALERT_RELAY_SSH_TARGET", "root@100.101.247.127")
remote = (
"send-alert "
f"{shlex.quote(subject)} "
f"{shlex.quote(body)}"
)
cmd = [
"ssh", "-o", "StrictHostKeyChecking=no",
target,
remote,
]
try:
subprocess.run(cmd, capture_output=True, timeout=30)
except Exception:
pass
def _record_hoxel(action: str, outcome: str, domain: str = "infra") -> None:
import urllib.request
endpoint = os.environ.get(
"RS_HOXEL_ENDPOINT",
"http://100.101.247.127:8444/v1/hoxels/record",
)
payload = json.dumps({
"obj_key": f"orchestrator/{action}",
"bucket": "research-stack",
"to_tier": domain,
"spectral_mode": "orchestration",
"thermal_score": 0.0,
"residual": 0.0,
"payload_bytes": 0,
"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):
pass
except Exception:
pass
# ── Garage/restic credential loading (from storage_agent) ───────────────────────
def _load_garage_env() -> dict[str, str]:
env: dict[str, str] = {}
garage_env = Path("/etc/garage/garage.env")
if garage_env.exists():
try:
raw_lines = garage_env.read_text().splitlines()
except PermissionError:
raw_lines = []
for line in raw_lines:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
env[key.strip()] = val.strip()
return {
"AWS_ACCESS_KEY_ID": env.get("GARAGE_ACCESS_KEY_ID", os.environ.get("AWS_ACCESS_KEY_ID", "")),
"AWS_SECRET_ACCESS_KEY": env.get("GARAGE_SECRET_ACCESS_KEY", os.environ.get("AWS_SECRET_ACCESS_KEY", "")),
"AWS_DEFAULT_REGION": env.get("AWS_DEFAULT_REGION", os.environ.get("AWS_DEFAULT_REGION", "garage")),
"AWS_ENDPOINT_URL": env.get("AWS_ENDPOINT_URL", os.environ.get("AWS_ENDPOINT_URL", "http://localhost:3900")),
"RESTIC_REPOSITORY": os.environ.get("RESTIC_REPOSITORY", "s3:http://localhost:3900/research-stack"),
"RESTIC_PASSWORD_FILE": os.environ.get("RESTIC_PASSWORD_FILE", "/etc/garage/restic-password"),
}
# ── 1. OBSERVE ──────────────────────────────────────────────────────────────────
class StorageObservation:
def __init__(self) -> None:
self.garage_up: bool = False
self.garage_nodes_total: int = 0
self.garage_nodes_ok: int = 0
self.garage_buckets: list[str] = []
self.restic_snapshot_count: int = 0
self.restic_latest_ts: str | None = None
self.restic_latest_size_bytes: int = 0
self.restic_stored_bytes: int = 0
self.dedup_ratio_q16: int = 0
self.backup_log_last_ok: bool = False
self.cold_copy_needed: bool = False
self.errors: list[str] = []
def to_dict(self) -> dict:
return {
"garage_up": self.garage_up,
"garage_nodes_total": self.garage_nodes_total,
"garage_nodes_ok": self.garage_nodes_ok,
"garage_buckets": self.garage_buckets,
"restic": {
"snapshot_count": self.restic_snapshot_count,
"latest_ts": self.restic_latest_ts,
"latest_size_bytes": self.restic_latest_size_bytes,
"stored_bytes": self.restic_stored_bytes,
"dedup_ratio_q16": self.dedup_ratio_q16,
},
"backup_log_last_ok": self.backup_log_last_ok,
"cold_copy_needed": self.cold_copy_needed,
"errors": self.errors,
}
class InfraObservation:
def __init__(self) -> None:
self.probes: dict[str, Any] = {}
self.critical_down: list[str] = []
self.optional_unavailable: list[str] = []
self.backup_status: dict[str, Any] = {}
self.garage_status: dict[str, Any] = {}
def to_dict(self) -> dict:
return {
"probes": {
n: [r.to_dict() if hasattr(r, "to_dict") else r.__dict__ for r in rs]
for n, rs in self.probes.items()
},
"critical_down": self.critical_down,
"optional_unavailable": self.optional_unavailable,
"backup_status": self.backup_status,
"garage_status": self.garage_status,
}
def _probe_garage(obs: StorageObservation, creds: dict[str, str]) -> None:
import urllib.request
import urllib.error
try:
urllib.request.urlopen(creds["AWS_ENDPOINT_URL"], timeout=5)
obs.garage_up = True
except urllib.error.HTTPError as e:
if e.code in (403, 400):
obs.garage_up = True
else:
obs.errors.append(f"garage: HTTP {e.code}")
except Exception as exc:
obs.errors.append(f"garage: {exc}")
return
rc, out, err = _run(
["aws", "s3", "ls", "--endpoint-url", creds["AWS_ENDPOINT_URL"]],
env_extra=creds, timeout=30,
)
if rc == 0:
obs.garage_buckets = [line.split()[-1] for line in out.splitlines() if line.strip()]
def _probe_restic(obs: StorageObservation, creds: dict[str, str]) -> None:
if not obs.garage_up:
obs.errors.append("restic: skipped (Garage unreachable)")
return
rc, out, err = _run(["restic", "snapshots", "--json"], env_extra=creds, timeout=60)
if rc != 0:
obs.errors.append(f"restic snapshots: {err.strip()[:300]}")
return
try:
snapshots = json.loads(out)
except json.JSONDecodeError as e:
obs.errors.append(f"restic parse: {e}")
return
obs.restic_snapshot_count = len(snapshots)
if snapshots:
latest = sorted(snapshots, key=lambda s: s.get("time", ""), reverse=True)[0]
obs.restic_latest_ts = latest.get("time")
rc2, out2, err2 = _run(["restic", "stats", "--json"], env_extra=creds, timeout=120)
if rc2 == 0:
try:
stats = json.loads(out2)
pre = stats.get("total_file_size", 0)
post = stats.get("total_size", 0)
obs.restic_latest_size_bytes = pre
obs.restic_stored_bytes = post
if pre > 0:
ratio = max(0.0, 1.0 - (post / pre))
obs.dedup_ratio_q16 = int(ratio * Q16_ONE)
except (json.JSONDecodeError, KeyError, ZeroDivisionError) as e:
obs.errors.append(f"restic stats: {e}")
def _probe_backup_log(obs: StorageObservation) -> None:
log_path = Path.home() / ".cache" / "restic-backup.log"
if not log_path.exists():
return
try:
text = log_path.read_text(errors="replace")
obs.backup_log_last_ok = any("snapshot" in l and "saved" in l for l in text.splitlines())
except OSError as e:
obs.errors.append(f"backup log: {e}")
def _probe_cold_copy(obs: StorageObservation) -> None:
if obs.restic_snapshot_count == 0 or obs.restic_latest_ts is None:
return
try:
latest = datetime.fromisoformat(obs.restic_latest_ts.rstrip("Z").split("+")[0])
age = (datetime.now(timezone.utc).replace(tzinfo=None) - latest).total_seconds() / 3600
obs.cold_copy_needed = age > 26
except Exception as e:
obs.errors.append(f"cold copy: {e}")
def observe_storage(creds: dict[str, str]) -> StorageObservation:
obs = StorageObservation()
_probe_garage(obs, creds)
_probe_restic(obs, creds)
_probe_backup_log(obs)
_probe_cold_copy(obs)
return obs
def observe_infra(config: dict) -> tuple[InfraObservation, Alerter]:
obs = InfraObservation()
alerter = Alerter(config)
nodes = config.get("nodes", {})
tier_1 = set(config.get("tier_1_required", []))
from concurrent.futures import ThreadPoolExecutor, as_completed
futures = {}
with ThreadPoolExecutor(max_workers=8) as pool:
for name, cfg in nodes.items():
ip = cfg["ip"]
target = cfg.get("ssh_target", f"root@{ip}")
optional = cfg.get("optional", False)
for pname in cfg.get("probes", []):
f = pool.submit(run_probe, name, ip, target, pname, optional)
futures[f] = (name, pname, optional)
for f in as_completed(futures):
name, pname, optional = futures[f]
try:
result = f.result()
except Exception as e:
result = type("PR", (), {})()
result.__dict__ = {"node": name, "ip": "", "optional": optional, "reachable": False, "error": str(e), "data": {}, "elapsed_ms": 0}
obs.probes.setdefault(name, []).append(result)
if not result.reachable and not optional:
obs.critical_down.append(name)
alerter.alert(f"node DOWN: {name} unreachable")
elif not result.reachable and optional:
obs.optional_unavailable.append(name)
alerter.note(f"optional node unavailable: {name}")
obs.critical_down = sorted(set(obs.critical_down))
obs.optional_unavailable = sorted(set(obs.optional_unavailable))
return obs, alerter
# ── 2. DECIDE ───────────────────────────────────────────────────────────────────
class UnifiedDecision:
def __init__(self) -> None:
self.storage_actions: list[str] = []
self.infra_actions: list[str] = []
self.alerts: list[str] = []
def to_dict(self) -> dict:
return {
"storage_actions": self.storage_actions,
"infra_actions": self.infra_actions,
"alerts": self.alerts,
}
Q16_DEDUP_LOW = 19661
def decide(
storage: StorageObservation,
infra: InfraObservation,
alerter: Alerter,
) -> UnifiedDecision:
d = UnifiedDecision()
# ── Storage decisions ───────────────────────────────────────────────────
if not storage.garage_up:
d.storage_actions.append("restart_garage")
d.alerts.append("Garage S3 unreachable")
if storage.restic_snapshot_count == 0 and storage.garage_up:
d.storage_actions.append("snap")
if not storage.backup_log_last_ok and storage.garage_up:
d.storage_actions.append("snap")
if (
storage.dedup_ratio_q16 > 0
and storage.dedup_ratio_q16 < Q16_DEDUP_LOW
and storage.restic_snapshot_count > 5
):
d.storage_actions.append("verify")
if storage.restic_snapshot_count > 30:
d.storage_actions.append("forget")
if storage.cold_copy_needed:
d.storage_actions.append("cold_copy")
if storage.garage_up:
d.storage_actions.append("offload")
# ── Infra decisions ────────────────────────────────────────────────────
has_infra_issues = bool(
infra.critical_down
or infra.backup_status.get("snapshot_count", 0) == 0
or infra.garage_status.get("healthy_nodes", 0) < 3
)
if has_infra_issues:
d.infra_actions.append("dispatch_alerts")
d.alerts.extend(alerter.alerts)
return d
# ── 3. ACT ──────────────────────────────────────────────────────────────────────
class UnifiedActionResult:
def __init__(self) -> None:
self.storage_succeeded: list[str] = []
self.storage_failed: list[str] = []
self.infra_dispatched: list[str] = []
def to_dict(self) -> dict:
return {
"storage_succeeded": self.storage_succeeded,
"storage_failed": self.storage_failed,
"infra_dispatched": self.infra_dispatched,
}
def _act_one(
label: str,
args: list[str],
succeeded: list[str],
failed: list[str],
env_extra: dict[str, str] | None = None,
timeout: int = 600,
) -> None:
rc, out, err = _run(args, env_extra=env_extra, timeout=timeout)
if rc == 0:
succeeded.append(label)
_record_hoxel(label, "success", "storage")
else:
failed.append(label)
def act_storage(d: UnifiedDecision, creds: dict[str, str], probe_only: bool, dry_run: bool) -> tuple[list[str], list[str]]:
succeeded: list[str] = []
failed: list[str] = []
if probe_only or dry_run:
return succeeded, failed
for action in d.storage_actions:
if action == "restart_garage":
_act_one("restart_garage", ["systemctl", "--user", "restart", "garage.service"], succeeded, failed, timeout=30)
if "restart_garage" in failed:
_act_one("restart_garage_system", ["sudo", "systemctl", "restart", "garage.service"], succeeded, failed, timeout=30)
elif action == "snap":
_act_one("snap", ["bash", str(BACKUP_SH), "snap", "orchestrator-triggered"], succeeded, failed, env_extra=creds, timeout=3600)
elif action == "cold_copy":
_act_one("cold_copy", ["bash", str(BACKUP_SH), "cold-copy"], succeeded, failed, env_extra=creds, timeout=3600)
elif action == "verify":
_act_one("verify", ["bash", str(BACKUP_SH), "verify"], succeeded, failed, env_extra=creds, timeout=600)
elif action == "forget":
_act_one("forget", ["bash", str(BACKUP_SH), "forget"], succeeded, failed, env_extra=creds, timeout=600)
elif action == "offload":
_act_one("offload", ["bash", str(CONSOLIDATE_SH), "offload"], succeeded, failed, env_extra=creds, timeout=300)
return succeeded, failed
def act_infra(d: UnifiedDecision, alerter: Alerter, dry_run: bool) -> list[str]:
if dry_run:
return []
if "dispatch_alerts" in d.infra_actions:
result = alerter.send()
return list(result.keys())
return []
# ── 4. EMIT RECEIPT ─────────────────────────────────────────────────────────────
def build_receipt(
tick: int,
parent_hash: str,
storage_obs: StorageObservation,
infra_obs: InfraObservation,
decision: UnifiedDecision,
result: UnifiedActionResult,
) -> dict:
receipt = {
"schema": SCHEMA,
"version": VERSION,
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"tick": tick,
"parent_hash": parent_hash,
"observation": {
"storage": storage_obs.to_dict(),
"infra": infra_obs.to_dict(),
},
"decision": decision.to_dict(),
"action_result": result.to_dict(),
"claim_boundary": "unified-orchestrator-observe-decide-act",
}
preimage = {k: v for k, v in receipt.items() if k not in ("generated_at_utc", "receipt_hash")}
receipt["receipt_hash"] = sha256(json.dumps(preimage, sort_keys=True))
return receipt
def emit_local(receipt: dict) -> None:
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_PATH, "a") as f:
f.write(json.dumps(receipt) + "\n")
write_receipt(receipt)
# ── 5. MAIN CYCLE ───────────────────────────────────────────────────────────────
def run_cycle(
config: dict,
creds: dict[str, str],
tick: int,
parent_hash: str,
probe_only: bool = False,
dry_run: bool = False,
) -> str:
_log(f"tick={tick} starting unified cycle...")
storage_obs = observe_storage(creds)
infra_obs, alerter = observe_infra(config)
dec = decide(storage_obs, infra_obs, alerter)
storage_ok, storage_fail = act_storage(dec, creds, probe_only=probe_only, dry_run=dry_run)
infra_dispatch = act_infra(dec, alerter, dry_run=dry_run)
result = UnifiedActionResult()
result.storage_succeeded = storage_ok
result.storage_failed = storage_fail
result.infra_dispatched = infra_dispatch
receipt = build_receipt(tick, parent_hash, storage_obs, infra_obs, dec, result)
emit_local(receipt)
summary = (
f"tick={tick} "
f"garage={'UP' if storage_obs.garage_up else 'DOWN'} "
f"snapshots={storage_obs.restic_snapshot_count} "
f"nodes_down={len(infra_obs.critical_down)} "
f"storage_ok={storage_ok} "
f"storage_fail={storage_fail} "
f"hash={receipt['receipt_hash'][:16]}..."
)
_log(summary)
for alert in dec.alerts:
_log(f" ALERT: {alert}")
if not dry_run:
_send_alert("Orchestrator Alert", alert)
if infra_obs.critical_down and not dry_run:
_send_alert("Node DOWN", f"Critical nodes unreachable: {', '.join(infra_obs.critical_down)}")
for h in storage_ok:
_record_hoxel(h, "success", "storage")
for h in storage_fail:
_record_hoxel(h, "failure", "storage")
return receipt["receipt_hash"]
# ── CLI ────────────────────────────────────────────────────────────────────────
def main() -> None:
p = argparse.ArgumentParser(description="unified_orchestrator.py — unified infrastructure daemon")
p.add_argument("--once", action="store_true", default=True)
p.add_argument("--probe-only", action="store_true")
p.add_argument("--dry-run", action="store_true")
p.add_argument("--loop", action="store_true")
p.add_argument("--interval", type=int, default=300)
p.add_argument("--dump", action="store_true")
args = p.parse_args()
config = load_config()
creds = _load_garage_env()
tick, parent_hash = read_chain()
tick += 1
if args.loop:
_log(f"loop mode, interval={args.interval}s, resuming tick={tick}")
while True:
try:
parent_hash = run_cycle(config, creds, tick, parent_hash,
probe_only=args.probe_only,
dry_run=args.dry_run)
tick += 1
except Exception as e:
_log(f"cycle error: {e}")
import traceback
traceback.print_exc(file=sys.stderr)
time.sleep(args.interval)
else:
run_cycle(config, creds, tick, parent_hash,
probe_only=args.probe_only,
dry_run=args.dry_run)
if args.dump:
if LOG_PATH.exists():
with open(LOG_PATH) as f:
lines = f.readlines()
if lines:
print(lines[-1].strip())
if __name__ == "__main__":
main()