SysDeck/bridge/fester.py

282 lines
9.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Fester Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
REAL INTEGRATION against the vendored fester service (the same one
the Web Edition runs): web/mini-services/fester, a distributed DAG
build orchestrator speaking REST + WebSocket on 127.0.0.1:3010.
This helper is a thin stdlib-only REST client (urllib.request + json,
4s timeout — no requests library, no curl dependency). Every
subcommand prints the service's JSON response; `status` additionally
enriches the health payload with the local connection facts. HTTP
error bodies (409 cancel conflicts, 404 unknown builds, 400
validation) are JSON on this service and are surfaced verbatim.
Connection failures are graceful, like the other bridge helpers:
{"ok": false, "error": ...} with a remediation hint, exit code 0.
Subcommands:
status GET /api/health (+ port / transport / base_url)
metrics GET /api/metrics
builds GET /api/builds (live builds + history)
build <id> GET /api/builds/<id>
nodes GET /api/nodes
targets GET /api/targets (project catalog)
timeline <id> GET /api/timeline/<id>
sessions GET /api/sessions
start-build --project <p> --targets <csv>
[--no-cache] [--retries <0-3>] [--fail-action <a>]
POST /api/build → build_id
cancel <id> POST /api/builds/<id>/cancel
replay <buildId> [--label <l>]
POST /api/sessions → session
Usage:
python3 /usr/lib/sysdeck/bridge/fester.py status
python3 /usr/lib/sysdeck/bridge/fester.py builds
python3 /usr/lib/sysdeck/bridge/fester.py start-build \
--project linux-tool --targets debian --no-cache --retries 2
FESTER_URL=http://10.0.0.5:3010 \\
python3 /usr/lib/sysdeck/bridge/fester.py status
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
# The vendored fester service binds REST+WS here by default. Override
# with FESTER_URL when it lives elsewhere.
FESTER_URL = os.environ.get("FESTER_URL", "http://127.0.0.1:3010").rstrip("/")
# Strict 4s timeout — the panel polls every 5s, so a hung request must
# never outlive one refresh cycle.
FESTER_TIMEOUT = 4 # seconds
# Connection-level failure messages (callers print this and exit 0 —
# graceful, same contract as the other bridge helpers).
UNREACHABLE_MSG = (
"fester service unreachable at {url} — start it with "
"`make fester-start` or `bun run dev` in web/mini-services/fester, "
"or set FESTER_URL"
)
def _unreachable() -> dict:
"""Return the graceful offline response (remediation hint included)."""
return {"ok": False, "error": UNREACHABLE_MSG.format(url=FESTER_URL)}
def _base_port() -> int:
"""Port of the base URL (3010 for the default vendored service)."""
try:
return urllib.parse.urlparse(FESTER_URL).port or 3010
except ValueError:
return 3010
def _quote(value: str) -> str:
"""URL-path-encode a path segment (build/session ids)."""
return urllib.parse.quote(str(value), safe="")
def _request(path: str, method: str = "GET", body: dict | None = None) -> dict:
"""One HTTP call against the fester service. Returns parsed JSON.
HTTPError bodies are JSON on this service — surface them instead of
crashing. Connection-level failures raise URLError/OSError; the
_get/_post wrappers translate those into the graceful offline
response.
"""
url = FESTER_URL + path
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=FESTER_TIMEOUT) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
# 400/404/409 responses carry a JSON body — return it verbatim.
try:
raw = exc.read().decode("utf-8", errors="replace")
if raw.strip():
return json.loads(raw)
except (OSError, ValueError):
pass
return {"ok": False, "error": f"HTTP {exc.code}: {exc.reason}"}
try:
return json.loads(raw) if raw.strip() else {"ok": False, "error": f"empty response from {url}"}
except json.JSONDecodeError:
return {"ok": False, "error": f"non-JSON response from {url}"}
def _get(path: str) -> dict:
"""GET <path> with graceful offline handling."""
try:
return _request(path)
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
def _post(path: str, body: dict) -> dict:
"""POST <path> with a JSON body, graceful offline handling."""
try:
return _request(path, method="POST", body=body)
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
# ── subcommands ──────────────────────────────────────────────────────
def cmd_status(_args: list[str]) -> dict:
"""GET /api/health, enriched with the local connection facts."""
try:
data = _request("/api/health")
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
out = dict(data)
out["port"] = _base_port()
out["transport"] = "rest+ws"
out["base_url"] = FESTER_URL
return out
def cmd_build(args: list[str]) -> dict:
"""GET /api/builds/<id> — one build (live state or stored record)."""
if not args:
return {"ok": False, "error": "build id required: build <id>"}
return _get(f"/api/builds/{_quote(args[0])}")
def cmd_timeline(args: list[str]) -> dict:
"""GET /api/timeline/<id> — the event journal for one build."""
if not args:
return {"ok": False, "error": "build id required: timeline <id>"}
return _get(f"/api/timeline/{_quote(args[0])}")
def cmd_start_build(args: list[str]) -> dict:
"""POST /api/build — start a build, print the build_id response."""
project = None
targets: list[str] = []
no_cache = False
retries = 0
retries_given = False
fail_action = None
i = 0
while i < len(args):
arg = args[i]
if arg == "--project" and i + 1 < len(args):
project = args[i + 1]
i += 2
elif arg == "--targets" and i + 1 < len(args):
targets = [t for t in args[i + 1].split(",") if t]
i += 2
elif arg == "--no-cache":
no_cache = True
i += 1
elif arg == "--retries" and i + 1 < len(args):
raw = args[i + 1]
i += 2
try:
retries = int(raw)
except ValueError:
return {"ok": False, "error": f"retries must be an integer between 0 and 3: {raw!r}"}
if not 0 <= retries <= 3:
return {"ok": False, "error": f"retries must be an integer between 0 and 3: {retries}"}
retries_given = True
elif arg == "--fail-action" and i + 1 < len(args):
fail_action = args[i + 1]
i += 2
else:
return {"ok": False, "error": f"unknown argument: {arg}"}
if not project:
return {"ok": False, "error": "--project <p> is required"}
if not targets:
return {"ok": False, "error": "--targets <csv> is required (at least one target)"}
body: dict = {"project": project, "targets": targets}
if no_cache:
body["noCache"] = True
if retries_given:
body["retries"] = retries
if fail_action:
body["failAction"] = fail_action
return _post("/api/build", body)
def cmd_cancel(args: list[str]) -> dict:
"""POST /api/builds/<id>/cancel — 409-style JSON on non-running builds."""
if not args:
return {"ok": False, "error": "build id required: cancel <id>"}
return _post(f"/api/builds/{_quote(args[0])}/cancel", {})
def cmd_replay(args: list[str]) -> dict:
"""POST /api/sessions — create a replay session for a finished build."""
build_id = None
label = None
i = 0
while i < len(args):
arg = args[i]
if arg == "--label" and i + 1 < len(args):
label = args[i + 1]
i += 2
elif build_id is None:
build_id = arg
i += 1
else:
return {"ok": False, "error": f"unknown argument: {arg}"}
if not build_id:
return {"ok": False, "error": "build id required: replay <buildId> [--label <l>]"}
body: dict = {"buildId": build_id}
if label is not None:
body["label"] = label
return _post("/api/sessions", body)
# ── dispatch table ───────────────────────────────────────────────────
COMMANDS = {
"status": lambda _args: cmd_status(_args),
"metrics": lambda _args: _get("/api/metrics"),
"builds": lambda _args: _get("/api/builds"),
"build": cmd_build,
"nodes": lambda _args: _get("/api/nodes"),
"targets": lambda _args: _get("/api/targets"),
"timeline": cmd_timeline,
"sessions": lambda _args: _get("/api/sessions"),
"start-build": cmd_start_build,
"cancel": cmd_cancel,
"replay": cmd_replay,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))