Files
UmbraTrans/poc/umbra_stage1.py
2026-10-18 19:01:05 -07:00

191 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Stage 1: keep a heartbeat going to the UmbraTrans controller.
One `curl` is one request. It returns to the shell prompt when it is done, which is
correct behaviour for curl and the reason a node that "sent one heartbeat and stopped"
looks identical to a healthy node that never started. This loops instead.
Nothing is scheduled, backgrounded or installed. It runs in the foreground so that
closing the terminal stops it, which makes the first few minutes obvious: press
Ctrl-C and the node goes quiet, and the controller will show it offline about 45
seconds later.
The report itself comes from your existing `scripts/umbra_node_report.py --json`. This
only adds the loop and the transport, and does not change what your report contains.
python3 umbra_stage1.py --key "$UMBRA_NODE_KEY"
Then leave it running. To keep it running after you log out, put it under systemd (or
your platform's equivalent) as a service for your own account - do not run it as root,
and do not use cron: cron has no notion of "stop", so a failing request would pile up
one job per interval forever.
Every response is printed as it arrives, so what the controller said is visible rather
than inferred. The three outcomes that matter:
200 authenticated and recorded
401 no credential, or the wrong one. Nothing is stored. The key is the problem.
403 the key is valid but lacks the scope. Nothing is stored.
A 401 is not a transient error and retrying will not fix it, so the loop exits on it
rather than hammering the controller once every ten seconds for an hour.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
INTERVAL_SECONDS = 10
DEFAULT_URL = "https://umbratrans.bdmurphy.me/api/node/heartbeat"
REPORT_SCRIPT = "scripts/umbra_node_report.py"
def build_report() -> dict:
"""Your existing report, as an object.
If the script is missing or fails, an empty report is sent rather than nothing at
all: the heartbeat still proves the node is alive, and `measurements` is free-form.
A node that stops heartbeating because its report broke is worse than one
heartbeating an empty report, because the first looks identical to a dead machine.
"""
try:
completed = subprocess.run(
[sys.executable, REPORT_SCRIPT, "--json"],
capture_output=True,
text=True,
timeout=30,
check=True,
)
except FileNotFoundError:
print(f" {REPORT_SCRIPT} not found; sending an empty report", flush=True)
return {}
except subprocess.TimeoutExpired:
print(f" {REPORT_SCRIPT} took over 30s; sending an empty report", flush=True)
return {}
except subprocess.CalledProcessError as error:
print(f" {REPORT_SCRIPT} exited {error.returncode}; sending an empty report", flush=True)
return {}
try:
report = json.loads(completed.stdout)
except json.JSONDecodeError:
# A report that is not JSON is a bug on your side worth seeing, not a reason
# to stop heartbeating.
print(" report was not valid JSON; sending an empty report", flush=True)
print(f" first 200 bytes: {completed.stdout[:200]!r}", flush=True)
return {}
return report if isinstance(report, dict) else {"report": report}
def agent_version() -> str:
"""Best effort. Never fatal: an unknown version is not a reason to be silent."""
try:
completed = subprocess.run(
[sys.executable, "-c", "import umbra_audio; print(getattr(umbra_audio, '__version__', 'unknown'))"],
capture_output=True,
text=True,
timeout=10,
)
return completed.stdout.strip() or "unknown"
except Exception: # noqa: BLE001 - cosmetic only
return "unknown"
def send(url: str, key: str, payload: dict, timeout: int) -> tuple[int, str]:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as error:
return error.code, error.read().decode("utf-8", "replace")
except urllib.error.URLError as error:
# Controller unreachable. Retryable, so this is not fatal - a node on the same
# network as the controller will see this during a restart.
return 0, f"unreachable: {error.reason}"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--key",
default=os.environ.get("UMBRA_NODE_KEY", ""),
help="node credential. Prefer UMBRA_NODE_KEY so it stays out of shell history.",
)
parser.add_argument("--url", default=os.environ.get("UMBRA_NODE_URL", DEFAULT_URL))
parser.add_argument("--interval", type=int, default=INTERVAL_SECONDS)
parser.add_argument("--timeout", type=int, default=15)
parser.add_argument(
"--once",
action="store_true",
help="send a single heartbeat and exit, for testing",
)
args = parser.parse_args()
if not args.key:
print("no key given: pass --key or set UMBRA_NODE_KEY", file=sys.stderr)
return 2
print(f"heartbeating {args.url} every {args.interval}s", flush=True)
print("Ctrl-C to stop; the controller will show this node offline about 45s later", flush=True)
while True:
started = time.monotonic()
payload = {
"agent_version": agent_version(),
"active_jobs": 0,
"resident_models": [],
"devices": [],
"measurements": build_report(),
}
status, body = send(args.url, args.key, payload, args.timeout)
stamp = time.strftime("%H:%M:%S")
if status == 200:
try:
reason = json.loads(body).get("status_reason", "")
except json.JSONDecodeError:
reason = body[:120]
print(f" {stamp} 200 {reason}", flush=True)
elif status in (401, 403):
print(f" {stamp} {status} refused: {body.strip()[:200]}", flush=True)
print(" not retrying - a credential problem does not resolve itself", flush=True)
return 1
elif status == 0:
print(f" {stamp} --- {body[:120]}", flush=True)
else:
print(f" {stamp} {status} {body.strip()[:160]}", flush=True)
if args.once:
return 0 if status == 200 else 1
# Subtract the time the request took, so the interval is between sends rather
# than on top of them. Without this a slow controller pushes the heartbeat
# past the 45s window and the node declares itself offline while healthy.
time.sleep(max(1.0, args.interval - (time.monotonic() - started)))
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
print("\nstopped", flush=True)