191 lines
7.2 KiB
Python
Executable File
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)
|