131 lines
4.4 KiB
Python
131 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""RSI cron + ZTE auto-patch. Detects degraded skills, snapshots, issues signed /deploy actions, rolls back on failure."""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
SKILLS_DIR = Path(os.environ.get("HERMES_HOME", "/opt/data")) / "skills"
|
|
SNAPSHOT_DIR = Path("/tmp/rsi-snapshots")
|
|
DEPLOY_URL = os.environ.get("DEPLOY_URL", "http://localhost:8099/deploy")
|
|
DEPLOY_TOKEN = os.environ.get("DEPLOY_TOKEN", "factory-deploy-token-2026")
|
|
FAIL_THRESHOLD = 0.8
|
|
|
|
|
|
def run(cmd, timeout=30):
|
|
return subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
def signed_payload(action, target, actor="skill_health", payload_hash="none"):
|
|
data = {
|
|
"timestamp": int(time.time()),
|
|
"nonce": hashlib.sha256(f"{time.time()}:{target}:{action}".encode()).hexdigest()[:16],
|
|
"actor": actor,
|
|
"action": action,
|
|
"target": target,
|
|
"payload_hash": payload_hash,
|
|
}
|
|
body = json.dumps({k: data[k] for k in ("timestamp", "nonce", "actor", "action", "target", "payload_hash")}, sort_keys=True, separators=(",", ":")).encode()
|
|
data["signature"] = hmac.new(DEPLOY_TOKEN.encode(), body, hashlib.sha256).hexdigest()
|
|
return data
|
|
|
|
|
|
def post_deploy(payload, timeout=15):
|
|
req = urllib.request.Request(
|
|
DEPLOY_URL,
|
|
data=json.dumps(payload).encode(),
|
|
headers={"Authorization": f"Bearer {DEPLOY_TOKEN}", "Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return r.read().decode()
|
|
except urllib.error.HTTPError as e:
|
|
return e.read().decode()
|
|
|
|
|
|
def check_skill(name):
|
|
d = SKILLS_DIR / name
|
|
t = d / "test.sh"
|
|
if not t.exists():
|
|
return None
|
|
r = run(["bash", str(t)])
|
|
if r.returncode == 0:
|
|
Path("/opt/data/rsi-known-good").mkdir(exist_ok=True)
|
|
(Path("/opt/data/rsi-known-good") / f"{name}.sig").write_text(hashlib.sha256(open(t, "rb").read()).hexdigest()[:16])
|
|
return {"skill": name, "passed": r.returncode == 0}
|
|
|
|
|
|
def snapshot():
|
|
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
tag = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
shutil.copytree(SKILLS_DIR, SNAPSHOT_DIR / tag, dirs_exist_ok=True)
|
|
(SNAPSHOT_DIR / "latest").write_text(tag)
|
|
return tag
|
|
|
|
|
|
def rollback():
|
|
lf = SNAPSHOT_DIR / "latest"
|
|
if not lf.exists():
|
|
return False
|
|
snap = SNAPSHOT_DIR / lf.read_text().strip()
|
|
if not snap.exists():
|
|
return False
|
|
shutil.rmtree(SKILLS_DIR)
|
|
shutil.copytree(snap, SKILLS_DIR, dirs_exist_ok=True)
|
|
return True
|
|
|
|
|
|
def auto_patch(failed):
|
|
"""ZTE: snapshot, request safe skill action, verify, rollback if still failing."""
|
|
print(f"🔄 ZTE: auto-patching {len(failed)} degraded skills")
|
|
snap = snapshot()
|
|
print(f"📸 Snap: {snap}")
|
|
for f in failed:
|
|
skill = f["skill"]
|
|
print(f" Fixing {skill}...")
|
|
payload_hash = hashlib.sha256(f"{snap}:{skill}".encode()).hexdigest()[:16]
|
|
result = post_deploy(signed_payload("patch_skill_from_pr", skill, payload_hash=payload_hash))
|
|
print(f" Result: {result[:100]}")
|
|
verify = [check_skill(f["skill"]) for f in failed]
|
|
still_failing = [v for v in verify if v and not v["passed"]]
|
|
if still_failing:
|
|
print(f"⚠️ {len(still_failing)} still failing, rolling back...")
|
|
rollback()
|
|
print("✅ Rolled back")
|
|
else:
|
|
print("✅ All patched successfully")
|
|
|
|
|
|
def main():
|
|
do_patch = "--auto-patch" in sys.argv
|
|
results = [check_skill(d.name) for d in sorted(SKILLS_DIR.iterdir()) if d.is_dir() and (d / "test.sh").exists()]
|
|
results = [r for r in results if r]
|
|
passed = sum(1 for r in results if r["passed"])
|
|
total = len(results)
|
|
score = round(passed / total, 2) if total else 0
|
|
failed = [r for r in results if not r["passed"]]
|
|
for r in results:
|
|
print(f"{'✅' if r['passed'] else '❌'} {r['skill']}")
|
|
print(f"\nRSI: {passed}/{total} ({score:.0%})")
|
|
if failed:
|
|
print(f"Degraded: {[f['skill'] for f in failed]}")
|
|
if do_patch:
|
|
auto_patch(failed)
|
|
else:
|
|
print("Reported (no auto-patch)")
|
|
else:
|
|
print("All healthy — ZTE idle")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|