svrnty-hermes-webui-plugin/scripts/boot-smoke.py
2026-05-28 21:44:02 -04:00

160 lines
6.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""boot-smoke.py — start hermes-webui + plugin, curl every plugin endpoint.
Exit 0 if every endpoint returns its expected status, 1 otherwise. Used by
upstream-sync.py and as a one-shot manual check after install.
Usage:
python3 boot-smoke.py # start webui + smoke + stop
python3 boot-smoke.py --no-start # webui already running; just smoke
python3 boot-smoke.py --base http://... # smoke against custom base URL
"""
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError
PLUGIN_REPO = Path(__file__).resolve().parent.parent
# Endpoints we expect after the plugin is loaded. Status codes and content
# checks are minimal — this is "did it boot", not "is it correct".
SMOKE = [
{"path": "/health", "expect": [200], "kind": "vanilla"},
{"path": "/api/vault/status", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/api/canvas/status", "expect": [200, 503, 401, 403], "kind": "plugin"},
{"path": "/api/canvas/tools", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/api/canvas/proxy?path=/api/v1/capabilities", "expect": [200, 401, 403, 502], "kind": "plugin"},
{"path": "/api/canvas/command", "method": "POST", "body": b"{}", "expect": [400, 401, 403], "kind": "plugin"},
{"path": "/api/canvas/design-context", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/api/canvas/events", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/api/umbrella", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/api/umbrella/doc?path=sot/README.md", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/plugins/svrnty/app.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/app.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/canvas.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/canvas.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/canvas.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/umbrella.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/umbrella.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/umbrella.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
]
def _free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _wait_for(url, timeout=20):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urlopen(url, timeout=1) as r:
if r.status < 500:
return True
except URLError:
pass
except Exception:
pass
time.sleep(0.3)
return False
def _hit(base, spec):
path = spec["path"]
url = base.rstrip("/") + path
method = spec.get("method", "GET")
body = spec.get("body")
try:
req = Request(url, data=body, method=method)
if body is not None:
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=5) as r:
return r.status, r.read()[:200]
except URLError as e:
if hasattr(e, "code"):
return e.code, b""
return None, str(e).encode()
except Exception as e:
return None, str(e).encode()
def smoke(base):
rows = []
failed = 0
for s in SMOKE:
status, _body = _hit(base, s)
ok = status in s["expect"]
rows.append({"path": s["path"], "status": status, "kind": s["kind"], "ok": ok})
if not ok:
failed += 1
return rows, failed
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--no-start", action="store_true",
help="Assume hermes-webui is already running; just curl")
ap.add_argument("--base", default=None,
help="Base URL (default http://127.0.0.1:<port>)")
ap.add_argument("--webui-dir",
default=str(PLUGIN_REPO.parent / "hermes-webui"),
help="Path to hermes-webui repo")
args = ap.parse_args()
proc = None
base = args.base or "http://127.0.0.1:8787"
if not args.no_start:
port = _free_port()
base = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env["HERMES_WEBUI_PYTHON_PLUGIN"] = "svrnty_hermes_webui_plugin"
env["HERMES_WEBUI_PORT"] = str(port)
env["HERMES_REPO_ROOT"] = str(PLUGIN_REPO.parent)
env["PYTHONPATH"] = (
str(PLUGIN_REPO)
if not env.get("PYTHONPATH")
else f"{PLUGIN_REPO}{os.pathsep}{env['PYTHONPATH']}"
)
# Best-effort: start under the agent venv if it exists; else system python.
py = Path(args.webui_dir) / "venv" / "bin" / "python"
cmd = [str(py) if py.exists() else "python3", "bootstrap.py", str(port), "--foreground"]
proc = subprocess.Popen(
cmd, cwd=args.webui_dir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
if not _wait_for(f"{base}/health", timeout=30):
print(f"FAIL: webui did not respond at {base}/health within 30s",
file=sys.stderr)
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
sys.exit(1)
try:
rows, failed = smoke(base)
finally:
if proc is not None:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
print(json.dumps({"base": base, "rows": rows, "failed": failed}, indent=2))
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()