iowap-node 2.3.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
"""Shared Relay API client.
|
|
2
|
+
|
|
3
|
+
The ``RelayClient`` here is the common HTTP client that both the
|
|
4
|
+
``node-cli`` Swiss-army-knife command and the ``node-daemon`` realtime
|
|
5
|
+
daemon use to talk to an AI Relay. Extracted out of ``node_cli.py`` so
|
|
6
|
+
the daemon no longer depends on the whole CLI monolith (T-112).
|
|
7
|
+
|
|
8
|
+
Also carries the small helpers that built the client's config/logging:
|
|
9
|
+
``_setup_logging``, ``_effective_config``, ``_base_url`` and
|
|
10
|
+
``_filename_from_response``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from nodes.common.node_config import load_active_status
|
|
28
|
+
from nodes.common.node_utils import load_config, load_token, save_meta, save_token
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("relay-client")
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# T-210: load measurement with environment-appropriate sources
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# T-210: previous state of /sys/fs/cgroup/cpu.stat (cgroup v2) resp.
|
|
37
|
+
# cpuacct.usage (v1). CPU consumption is a *rate*, so we diff against
|
|
38
|
+
# the last sample; on the very first call there is no previous state
|
|
39
|
+
# and the cgroup rung yields nothing (falls through to loadavg).
|
|
40
|
+
_CGROUP_PREV: dict[str, tuple[float, float]] = {} # path -> (monotonic, usage)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_cgroup_cpu_usage() -> tuple[str, float] | None:
|
|
44
|
+
"""Return (rung, cpu-seconds-used) from the process's own cgroup.
|
|
45
|
+
|
|
46
|
+
cgroup v2 first (/sys/fs/cgroup/cpu.stat), then v1 (cpuacct.usage).
|
|
47
|
+
Both report CPU time consumed by the cgroup, which inside an LXC
|
|
48
|
+
container is exactly the CT's own usage (the container's cgroup
|
|
49
|
+
root). On bare metal it equals the host's usage — also correct.
|
|
50
|
+
Returns None when neither file is readable.
|
|
51
|
+
"""
|
|
52
|
+
# cgroup v2
|
|
53
|
+
try:
|
|
54
|
+
with open("/sys/fs/cgroup/cpu.stat") as f:
|
|
55
|
+
for line in f:
|
|
56
|
+
if line.startswith("usage_usec"):
|
|
57
|
+
return "cgroup2", int(line.split()[1]) / 1_000_000.0
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
pass
|
|
60
|
+
# cgroup v1 (cpuacct.usage is in NANOSECONDS, like v2's usage_usec*1000)
|
|
61
|
+
for path in ("/sys/fs/cgroup/cpuacct/cpuacct.usage", "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage"):
|
|
62
|
+
try:
|
|
63
|
+
with open(path) as f:
|
|
64
|
+
return "cgroup", int(f.read().strip()) / 1_000_000_000.0
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
continue
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _measure_load_pct(cpu_count: int) -> tuple[float, str]:
|
|
71
|
+
"""Load as percent (0-100) from the best available source.
|
|
72
|
+
|
|
73
|
+
Rungs:
|
|
74
|
+
1. cgroup CPU consumption (diffed against the previous heartbeat)
|
|
75
|
+
— container-scoped, correct inside LXC/Docker.
|
|
76
|
+
2. os.getloadavg() — host loadavg; correct on bare metal and
|
|
77
|
+
macOS, but inside LXC it reports the HOST's load (all CTs
|
|
78
|
+
share /proc/loadavg). Kept as fallback because it is the only
|
|
79
|
+
source on macOS (NovaForge).
|
|
80
|
+
"""
|
|
81
|
+
now = time.monotonic()
|
|
82
|
+
sample = _read_cgroup_cpu_usage()
|
|
83
|
+
if sample is not None:
|
|
84
|
+
source, usage = sample
|
|
85
|
+
prev = _CGROUP_PREV.get("current")
|
|
86
|
+
_CGROUP_PREV["current"] = (now, usage)
|
|
87
|
+
if prev is not None and now > prev[0]:
|
|
88
|
+
dt = now - prev[0]
|
|
89
|
+
cpu_used = max(usage - prev[1], 0.0)
|
|
90
|
+
# utilization = used CPU-seconds / (elapsed * cores)
|
|
91
|
+
pct = (cpu_used / (dt * max(cpu_count, 1))) * 100.0
|
|
92
|
+
if 0.0 <= pct <= 1000.0: # sanity: CPU can't exceed cores*100
|
|
93
|
+
return min(pct, 100.0), source
|
|
94
|
+
# rung 2: loadavg
|
|
95
|
+
try:
|
|
96
|
+
load_avg = os.getloadavg()[0]
|
|
97
|
+
return min((load_avg / max(cpu_count, 1)) * 100.0, 100.0), "loadavg"
|
|
98
|
+
except (OSError, AttributeError):
|
|
99
|
+
return 0.0, "loadavg"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _setup_logging(level: str | None = None) -> None:
|
|
103
|
+
if level is None:
|
|
104
|
+
level = os.environ.get("RELAY_LOG_LEVEL", "INFO")
|
|
105
|
+
numeric = getattr(logging, str(level).upper(), logging.INFO)
|
|
106
|
+
logging.basicConfig(
|
|
107
|
+
level=numeric,
|
|
108
|
+
format="%(asctime)s | %(levelname)-7s | node-cli | %(message)s",
|
|
109
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _utcnow_str() -> str:
|
|
114
|
+
return datetime.now(UTC).isoformat()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Config helpers (env-var aware)
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _effective_config() -> dict[str, Any]:
|
|
122
|
+
"""Return relay_config.json merged with env-var overrides."""
|
|
123
|
+
cfg = load_config()
|
|
124
|
+
base_url = os.environ.get("RELAY_BASE_URL")
|
|
125
|
+
if base_url:
|
|
126
|
+
cfg["base_url"] = base_url
|
|
127
|
+
hb = os.environ.get("RELAY_HEARTBEAT_INTERVAL")
|
|
128
|
+
if hb is not None:
|
|
129
|
+
try:
|
|
130
|
+
cfg["heartbeat_interval"] = int(hb)
|
|
131
|
+
except ValueError:
|
|
132
|
+
log.warning("ignoring invalid RELAY_HEARTBEAT_INTERVAL=%r", hb)
|
|
133
|
+
ci = os.environ.get("RELAY_CLAIM_INTERVAL")
|
|
134
|
+
if ci is not None:
|
|
135
|
+
try:
|
|
136
|
+
cfg["claim_interval"] = int(ci)
|
|
137
|
+
except ValueError:
|
|
138
|
+
log.warning("ignoring invalid RELAY_CLAIM_INTERVAL=%r", ci)
|
|
139
|
+
mr = os.environ.get("RELAY_MAX_RETRIES")
|
|
140
|
+
if mr is not None:
|
|
141
|
+
try:
|
|
142
|
+
cfg["max_retries"] = int(mr)
|
|
143
|
+
except ValueError:
|
|
144
|
+
log.warning("ignoring invalid RELAY_MAX_RETRIES=%r", mr)
|
|
145
|
+
# T-182: fixed maintenance intervals. rs refreshes on every start + once
|
|
146
|
+
# per day; rt refreshes every 6 days. Both TTLs are 7 days, so both stay
|
|
147
|
+
# comfortably ahead of expiry without any expiry math.
|
|
148
|
+
rs_iv = os.environ.get("RELAY_RS_REFRESH_INTERVAL")
|
|
149
|
+
if rs_iv is not None:
|
|
150
|
+
try:
|
|
151
|
+
cfg["rs_refresh_interval_seconds"] = int(rs_iv)
|
|
152
|
+
except ValueError:
|
|
153
|
+
log.warning("ignoring invalid RELAY_RS_REFRESH_INTERVAL=%r", rs_iv)
|
|
154
|
+
rt_iv = os.environ.get("RELAY_RT_REFRESH_INTERVAL")
|
|
155
|
+
if rt_iv is not None:
|
|
156
|
+
try:
|
|
157
|
+
cfg["rt_refresh_interval_seconds"] = int(rt_iv)
|
|
158
|
+
except ValueError:
|
|
159
|
+
log.warning("ignoring invalid RELAY_RT_REFRESH_INTERVAL=%r", rt_iv)
|
|
160
|
+
return cfg
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _base_url(meta: dict[str, Any], cfg: dict[str, Any]) -> str:
|
|
164
|
+
url = cfg.get("base_url") or meta.get("base_url")
|
|
165
|
+
if not url:
|
|
166
|
+
# T-187: filtered mDNS fallback — accept ONLY the relay service
|
|
167
|
+
# name (configurable, default "IOWAP Relay Service"); never the
|
|
168
|
+
# first-best _http._tcp hit (QNAP/Brother false positives).
|
|
169
|
+
service_name = _mdns_service_name(cfg)
|
|
170
|
+
discovered = discover_relay(service_name=service_name)
|
|
171
|
+
if discovered:
|
|
172
|
+
log.info("mDNS: discovered relay at %s", discovered)
|
|
173
|
+
url = discovered
|
|
174
|
+
if not url:
|
|
175
|
+
raise SystemExit(
|
|
176
|
+
"no base_url configured (set base_url in relay_config.json, RELAY_BASE_URL, "
|
|
177
|
+
"run `node-cli relay discover`, or let the node discover the relay via mDNS)"
|
|
178
|
+
)
|
|
179
|
+
return url.rstrip("/")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# T-187: filtered mDNS discovery
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
DEFAULT_SERVICE_NAME = "IOWAP Relay Service"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mdns_service_name(cfg: dict[str, Any]) -> str:
|
|
190
|
+
"""Resolve the expected mDNS service name: env > config > default."""
|
|
191
|
+
env = os.environ.get("RELAY_MDNS_SERVICE_NAME")
|
|
192
|
+
if env:
|
|
193
|
+
return env
|
|
194
|
+
from_cfg = cfg.get("mdns_service_name")
|
|
195
|
+
if from_cfg:
|
|
196
|
+
return str(from_cfg)
|
|
197
|
+
return DEFAULT_SERVICE_NAME
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _probe_mdns_services(timeout: float = 2.0) -> dict[str, str]:
|
|
201
|
+
"""Browse ``_http._tcp`` and return ``{base_url: service_name}``.
|
|
202
|
+
|
|
203
|
+
Returns ALL neighbours (QNAP, printer, relay, ...); the name filter
|
|
204
|
+
lives in :func:`discover_relay`. Kept as a separate seam so tests can
|
|
205
|
+
stub the browse without any network.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
from zeroconf import ServiceBrowser, Zeroconf
|
|
209
|
+
except ImportError:
|
|
210
|
+
log.warning("mDNS discovery unavailable (zeroconf not installed)")
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
found: dict[str, str] = {}
|
|
214
|
+
|
|
215
|
+
class _Listener:
|
|
216
|
+
def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
217
|
+
info = zc.get_service_info(type_, name)
|
|
218
|
+
if info is None:
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
addr = info.parsed_addresses()[0] if info.parsed_addresses() else None
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
addr = None
|
|
224
|
+
if not addr:
|
|
225
|
+
return
|
|
226
|
+
port = info.port or 8788
|
|
227
|
+
found[f"http://{addr}:{port}"] = name
|
|
228
|
+
|
|
229
|
+
def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
zc = Zeroconf()
|
|
236
|
+
try:
|
|
237
|
+
browser = ServiceBrowser(zc, "_http._tcp.local.", _Listener())
|
|
238
|
+
import time
|
|
239
|
+
|
|
240
|
+
deadline = time.monotonic() + timeout
|
|
241
|
+
while time.monotonic() < deadline:
|
|
242
|
+
time.sleep(0.1)
|
|
243
|
+
browser.cancel()
|
|
244
|
+
finally:
|
|
245
|
+
zc.close()
|
|
246
|
+
return found
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def discover_relay(
|
|
250
|
+
service_name: str | None = None, timeout: float = 2.0
|
|
251
|
+
) -> str | None:
|
|
252
|
+
"""Discover the relay via mDNS, accepting ONLY the relay service name.
|
|
253
|
+
|
|
254
|
+
``service_name`` defaults to env ``RELAY_MDNS_SERVICE_NAME``, then
|
|
255
|
+
``mdns_service_name`` from relay_config.json, then
|
|
256
|
+
``DEFAULT_SERVICE_NAME`` ("IOWAP Relay Service"). Other ``_http._tcp``
|
|
257
|
+
neighbours are ignored — the connection partner is never chosen by
|
|
258
|
+
first-best-win.
|
|
259
|
+
"""
|
|
260
|
+
if not service_name:
|
|
261
|
+
from nodes.common.node_utils import load_config
|
|
262
|
+
|
|
263
|
+
service_name = _mdns_service_name(load_config())
|
|
264
|
+
candidates = _probe_mdns_services(timeout=timeout)
|
|
265
|
+
for url, name in candidates.items():
|
|
266
|
+
# Instance names may arrive escaped (avahi-style \032 for spaces).
|
|
267
|
+
label = name.removesuffix("._http._tcp.local.")
|
|
268
|
+
if label.replace("\\032", " ") == service_name:
|
|
269
|
+
return url
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _discover_relay_mdns(timeout: float = 2.0) -> str | None:
|
|
274
|
+
"""Backward-compatible alias for the filtered discovery."""
|
|
275
|
+
return discover_relay(timeout=timeout)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _cmd_relay_set(args: Any) -> int:
|
|
279
|
+
"""node-cli relay set — pin the relay URL or re-enable discovery."""
|
|
280
|
+
from nodes.common import node_utils
|
|
281
|
+
|
|
282
|
+
server_url = getattr(args, "server_url", None)
|
|
283
|
+
discover = getattr(args, "discover", False)
|
|
284
|
+
if server_url and discover:
|
|
285
|
+
print("--server-url and --discover are mutually exclusive")
|
|
286
|
+
raise SystemExit(2)
|
|
287
|
+
if not server_url and not discover:
|
|
288
|
+
print("nothing to do: pass --server-url <url> or --discover")
|
|
289
|
+
raise SystemExit(2)
|
|
290
|
+
cfg = load_config()
|
|
291
|
+
if server_url:
|
|
292
|
+
cfg["base_url"] = server_url
|
|
293
|
+
node_utils.write_json_atomic(node_utils.CONFIG_PATH, cfg)
|
|
294
|
+
print(f"pinned relay base_url: {server_url}")
|
|
295
|
+
print(f" written to {node_utils.CONFIG_PATH} (discovery disabled)")
|
|
296
|
+
else:
|
|
297
|
+
cfg.pop("base_url", None)
|
|
298
|
+
node_utils.write_json_atomic(node_utils.CONFIG_PATH, cfg)
|
|
299
|
+
# T-198: `node register` persists base_url in the node state file
|
|
300
|
+
# and _base_url() resolves cfg.base_url or meta.base_url BEFORE the
|
|
301
|
+
# mDNS fallback — leaving the state-file pin in place would silently
|
|
302
|
+
# keep the pin alive and discovery would never engage. Unpin the
|
|
303
|
+
# state file too (a node without meta — never registered — is fine).
|
|
304
|
+
try:
|
|
305
|
+
meta = node_utils.load_meta()
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
meta = None
|
|
308
|
+
if meta is not None and "base_url" in meta:
|
|
309
|
+
del meta["base_url"]
|
|
310
|
+
node_utils.save_meta(meta)
|
|
311
|
+
print(f" removed state-file pin from {node_utils.META_PATH}")
|
|
312
|
+
print("base_url pin removed — discovery enabled (targeted mDNS by service name)")
|
|
313
|
+
print(f" written to {node_utils.CONFIG_PATH}")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _cmd_relay_discover(args: Any) -> int:
|
|
318
|
+
"""node-cli relay discover — targeted mDNS lookup, prints the URL."""
|
|
319
|
+
service_name = getattr(args, "name", None)
|
|
320
|
+
timeout = float(getattr(args, "timeout", 2.0) or 2.0)
|
|
321
|
+
url = discover_relay(service_name=service_name, timeout=timeout)
|
|
322
|
+
if url is None:
|
|
323
|
+
expected = service_name or _mdns_service_name(load_config())
|
|
324
|
+
print(
|
|
325
|
+
f"relay not found: no mDNS service named {expected!r} "
|
|
326
|
+
f"(_http._tcp, watched {timeout}s)"
|
|
327
|
+
)
|
|
328
|
+
return 1
|
|
329
|
+
print(url)
|
|
330
|
+
if not service_name:
|
|
331
|
+
print(f" service: {_mdns_service_name(load_config())} (_http._tcp)")
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# Auth helpers (thin wrappers around poller functions)
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
class RelayClient:
|
|
340
|
+
"""Minimal HTTP client wrapping heartbeat / claim / complete / submit.
|
|
341
|
+
|
|
342
|
+
Encapsulates token handling so the CLI subcommands and the daemon
|
|
343
|
+
can share a single implementation. On 401/403 it attempts a token
|
|
344
|
+
refresh exactly once.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
def __init__(self, meta: dict[str, Any], cfg: dict[str, Any]) -> None:
|
|
348
|
+
self.meta = meta
|
|
349
|
+
self.cfg = cfg
|
|
350
|
+
self.base_url = _base_url(meta, cfg)
|
|
351
|
+
# TLS (T-111): optional CA cert path for nodes connecting over https
|
|
352
|
+
# to a relay using a private/self-signed CA. When set, httpx verifies
|
|
353
|
+
# against it instead of the system trust store. Default True = system
|
|
354
|
+
# trust store (public CA / Let's Encrypt).
|
|
355
|
+
self._verify: str | bool = cfg.get("tls_ca_cert") or True
|
|
356
|
+
data = load_token()
|
|
357
|
+
self.token = data["token"] if data else None
|
|
358
|
+
# T-088: track the token expiry so the daemon can refresh
|
|
359
|
+
# proactively before it expires. ``None`` means unknown (e.g.
|
|
360
|
+
# a migrated legacy token or a server that omits expires_at).
|
|
361
|
+
self.token_expires_at: str | None = data.get("expires_at") if data else None
|
|
362
|
+
# T-108: auth-failure streak for exponential backoff. After
|
|
363
|
+
# repeated 401/403 failures the daemon increases the
|
|
364
|
+
# heartbeat/claim spacing instead of hammering in a tight loop.
|
|
365
|
+
self._auth_fail_streak = 0
|
|
366
|
+
# T-182: fixed-interval credential maintenance. The daemons call
|
|
367
|
+
# maybe_refresh_token() every heartbeat tick; the client tracks
|
|
368
|
+
# per-credential timers here.
|
|
369
|
+
# T-185 (Ronny's correction): rt is NOT due at start just because
|
|
370
|
+
# the daemon (re)started — the 6-day cadence counts from the LAST
|
|
371
|
+
# REFRESH, persisted as ``refreshed_at`` in the token envelope so
|
|
372
|
+
# it survives restarts. A missing/unparseable stamp (legacy
|
|
373
|
+
# envelope, fresh install) counts as due: the next maintenance
|
|
374
|
+
# window rotates rt once and re-anchors it. rs IS due at start
|
|
375
|
+
# (``_rs_last_refresh is None``): the daemons consume it
|
|
376
|
+
# synchronously BEFORE any connection starts (quiescence model),
|
|
377
|
+
# and a failed startup rotation stays due so the next maintenance
|
|
378
|
+
# window retries it.
|
|
379
|
+
self._rs_last_refresh: float | None = None
|
|
380
|
+
self._rt_refreshed_at: str | None = (
|
|
381
|
+
data.get("refreshed_at") if data else None
|
|
382
|
+
)
|
|
383
|
+
# T-183: claim pause during credential maintenance. The gate is
|
|
384
|
+
# SET = claims allowed (open). During run_credential_maintenance()
|
|
385
|
+
# it is cleared: claim() skips without HTTP and the 401 fallback
|
|
386
|
+
# waits for the gate instead of refreshing against a token the
|
|
387
|
+
# maintenance just invalidated (2026-09-13 restart race).
|
|
388
|
+
self._maintenance_gate = threading.Event()
|
|
389
|
+
# T-184: which thread owns the gate right now. The maintenance
|
|
390
|
+
# thread itself must neither skip (maybe_refresh_token guard) nor
|
|
391
|
+
# wait ( _refresh_token gate wait) on the gate it just closed —
|
|
392
|
+
# both were silent no-ops/self-deadlocks before.
|
|
393
|
+
self._maintenance_owner: int | None = None
|
|
394
|
+
self._maintenance_gate.set()
|
|
395
|
+
if not self.token:
|
|
396
|
+
print(
|
|
397
|
+
"no runtime token found, attempting recovery with registration secret",
|
|
398
|
+
file=sys.stderr,
|
|
399
|
+
)
|
|
400
|
+
self.token = self._recover_runtime_token()
|
|
401
|
+
if not self.token:
|
|
402
|
+
raise SystemExit("no runtime token available and recovery failed")
|
|
403
|
+
|
|
404
|
+
# -- T-108: backoff + self-healing -------------------------------------
|
|
405
|
+
|
|
406
|
+
# Backoff begins after this many consecutive auth failures.
|
|
407
|
+
_BACKOFF_THRESHOLD = 3
|
|
408
|
+
# Base delay (seconds) once the threshold is reached.
|
|
409
|
+
_BACKOFF_BASE = 10
|
|
410
|
+
# Hard cap (seconds) so the backoff never grows unbounded.
|
|
411
|
+
_BACKOFF_MAX = 300
|
|
412
|
+
# T-183: max seconds a 401 fallback waits for the credential
|
|
413
|
+
# maintenance gate before proceeding with its own refresh.
|
|
414
|
+
_MAINTENANCE_WAIT_TIMEOUT = 120.0
|
|
415
|
+
|
|
416
|
+
def _register_backoff_failure(self) -> None:
|
|
417
|
+
"""Record one consecutive auth failure (401/403)."""
|
|
418
|
+
self._auth_fail_streak += 1
|
|
419
|
+
|
|
420
|
+
def _register_backoff_success(self) -> None:
|
|
421
|
+
"""Reset the auth-failure streak after a successful auth/refresh."""
|
|
422
|
+
self._auth_fail_streak = 0
|
|
423
|
+
|
|
424
|
+
def _current_backoff(self) -> float:
|
|
425
|
+
"""Return the current auth-failure backoff in seconds (0 = none)."""
|
|
426
|
+
if self._auth_fail_streak < self._BACKOFF_THRESHOLD:
|
|
427
|
+
return 0.0
|
|
428
|
+
# Exponential: 10s, 20s, 40s, 80s, 160s — capped at _BACKOFF_MAX.
|
|
429
|
+
exp = min(self._auth_fail_streak - self._BACKOFF_THRESHOLD + 1, 5)
|
|
430
|
+
return float(min(self._BACKOFF_BASE * (2 ** (exp - 1)), self._BACKOFF_MAX))
|
|
431
|
+
|
|
432
|
+
def _reload_token_from_disk(self) -> None:
|
|
433
|
+
"""Re-read the token file. Helps when an external process (or a
|
|
434
|
+
manual intervention) corrected the token after the daemon cached
|
|
435
|
+
an invalid value. Reads only — overwrites nothing on disk.
|
|
436
|
+
T-185: adopts the persisted ``refreshed_at`` cadence anchor too,
|
|
437
|
+
so the rt timer follows whatever is actually on disk.
|
|
438
|
+
"""
|
|
439
|
+
data = load_token()
|
|
440
|
+
if data and data.get("token"):
|
|
441
|
+
self.token = data["token"]
|
|
442
|
+
self.token_expires_at = data.get("expires_at")
|
|
443
|
+
self._rt_refreshed_at = data.get("refreshed_at")
|
|
444
|
+
|
|
445
|
+
# -- low level ----------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def _post(
|
|
448
|
+
self, path: str, body: dict[str, Any] | None = None, *, timeout: float | None = None
|
|
449
|
+
) -> httpx.Response:
|
|
450
|
+
return httpx.post(
|
|
451
|
+
f"{self.base_url}{path}",
|
|
452
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
453
|
+
json=body or {},
|
|
454
|
+
timeout=timeout or self.cfg["request_timeout"],
|
|
455
|
+
verify=self._verify,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def _get(
|
|
459
|
+
self, path: str, *, timeout: float | None = None
|
|
460
|
+
) -> httpx.Response:
|
|
461
|
+
return httpx.get(
|
|
462
|
+
f"{self.base_url}{path}",
|
|
463
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
464
|
+
timeout=timeout or self.cfg["request_timeout"],
|
|
465
|
+
verify=self._verify,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _get_with_retry(
|
|
469
|
+
self, path: str, *, timeout: float | None = None
|
|
470
|
+
) -> httpx.Response:
|
|
471
|
+
r = self._get(path, timeout=timeout)
|
|
472
|
+
if r.status_code in (401, 403):
|
|
473
|
+
log.warning("auth error %s on %s, refreshing token", r.status_code, path)
|
|
474
|
+
if self._refresh_token():
|
|
475
|
+
r = self._get(path, timeout=timeout)
|
|
476
|
+
return r
|
|
477
|
+
|
|
478
|
+
def _post_with_retry(
|
|
479
|
+
self, path: str, body: dict[str, Any] | None = None, *, timeout: float | None = None
|
|
480
|
+
) -> httpx.Response:
|
|
481
|
+
r = self._post(path, body, timeout=timeout)
|
|
482
|
+
if r.status_code in (401, 403):
|
|
483
|
+
log.warning("auth error %s on %s, refreshing token", r.status_code, path)
|
|
484
|
+
if self._refresh_token():
|
|
485
|
+
r = self._post(path, body, timeout=timeout)
|
|
486
|
+
return r
|
|
487
|
+
|
|
488
|
+
# -- token refresh -------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def _refresh_token(self) -> bool:
|
|
491
|
+
# T-183: a 401 fallback that raced the maintenance start waits for
|
|
492
|
+
# the maintenance to finish, then adopts the token it persisted.
|
|
493
|
+
# Refreshing against the old token would fail: the server already
|
|
494
|
+
# invalidated it (2026-09-13 restart race).
|
|
495
|
+
gate_was_closed = not self._maintenance_gate.is_set()
|
|
496
|
+
if gate_was_closed and self._maintenance_owner != threading.get_ident():
|
|
497
|
+
if not self._maintenance_gate.wait(timeout=self._MAINTENANCE_WAIT_TIMEOUT):
|
|
498
|
+
log.warning(
|
|
499
|
+
"credential maintenance still running after %.0fs; refreshing anyway",
|
|
500
|
+
self._MAINTENANCE_WAIT_TIMEOUT,
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
# Maintenance finished while we waited: adopt its fresh token.
|
|
504
|
+
self._reload_token_from_disk()
|
|
505
|
+
try:
|
|
506
|
+
r = httpx.post(
|
|
507
|
+
f"{self.base_url}/relay/v2/auth/refresh",
|
|
508
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
509
|
+
json={"requested_credential": "runtime_token"},
|
|
510
|
+
timeout=self.cfg["request_timeout"],
|
|
511
|
+
verify=self._verify,
|
|
512
|
+
)
|
|
513
|
+
if r.status_code == 200:
|
|
514
|
+
data = r.json()
|
|
515
|
+
new = data.get("token")
|
|
516
|
+
expires_at = data.get("expires_at")
|
|
517
|
+
if new:
|
|
518
|
+
# T-185: re-anchor the persisted cadence stamp — the
|
|
519
|
+
# 6-day interval counts from THIS refresh.
|
|
520
|
+
self._rt_refreshed_at = _utcnow_str()
|
|
521
|
+
save_token(
|
|
522
|
+
new, expires_at=expires_at,
|
|
523
|
+
refreshed_at=self._rt_refreshed_at,
|
|
524
|
+
)
|
|
525
|
+
self.token = new
|
|
526
|
+
self.token_expires_at = expires_at
|
|
527
|
+
# T-182: if the server rotates the rs alongside, keep it.
|
|
528
|
+
self._persist_rotated_secret(data)
|
|
529
|
+
self._register_backoff_success()
|
|
530
|
+
return True
|
|
531
|
+
except Exception as exc:
|
|
532
|
+
log.warning("runtime-token refresh failed: %s", exc)
|
|
533
|
+
recovered = self._recover_runtime_token()
|
|
534
|
+
if recovered is not None:
|
|
535
|
+
self._register_backoff_success()
|
|
536
|
+
return True
|
|
537
|
+
# Refresh + recovery failed: re-read the file from disk — an
|
|
538
|
+
# external process/manual intervention might have corrected the
|
|
539
|
+
# token in the meantime (T-108 task 1). Increment the backoff
|
|
540
|
+
# streak so the daemon does not stay in a tight 401 loop (T-108
|
|
541
|
+
# task 2).
|
|
542
|
+
self._reload_token_from_disk()
|
|
543
|
+
self._register_backoff_failure()
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
def _recover_runtime_token(self) -> str | None:
|
|
547
|
+
try:
|
|
548
|
+
r = httpx.post(
|
|
549
|
+
f"{self.base_url}/relay/v2/auth/refresh",
|
|
550
|
+
json={
|
|
551
|
+
"node_id": self.meta["node_id"],
|
|
552
|
+
"requested_credential": "runtime_token",
|
|
553
|
+
"registration_secret": self.meta.get("registration_secret"),
|
|
554
|
+
},
|
|
555
|
+
timeout=self.cfg["request_timeout"],
|
|
556
|
+
verify=self._verify,
|
|
557
|
+
)
|
|
558
|
+
r.raise_for_status()
|
|
559
|
+
data = r.json()
|
|
560
|
+
new = data.get("token")
|
|
561
|
+
expires_at = data.get("expires_at")
|
|
562
|
+
if new:
|
|
563
|
+
# T-185: the recovered token is brand-new — re-anchor the
|
|
564
|
+
# persisted cadence stamp; the 6-day interval counts from
|
|
565
|
+
# THIS recovery.
|
|
566
|
+
self._rt_refreshed_at = _utcnow_str()
|
|
567
|
+
save_token(
|
|
568
|
+
new, expires_at=expires_at,
|
|
569
|
+
refreshed_at=self._rt_refreshed_at,
|
|
570
|
+
)
|
|
571
|
+
self.token = new
|
|
572
|
+
self.token_expires_at = expires_at
|
|
573
|
+
# T-182 (Bug 4): the server rotates the rs on recovery and
|
|
574
|
+
# returns it. Persisting it is the whole point — a node that
|
|
575
|
+
# drops the rotated rs cannot recover the NEXT time.
|
|
576
|
+
self._persist_rotated_secret(data)
|
|
577
|
+
return new
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
log.error("registration-secret recovery failed: %s", exc)
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
# -- proactive refresh (T-118) + credential maintenance (T-182) ----------
|
|
583
|
+
|
|
584
|
+
def _persist_rotated_secret(self, data: dict[str, Any]) -> None:
|
|
585
|
+
"""Keep a rotated registration secret from any refresh response.
|
|
586
|
+
|
|
587
|
+
The 2026-09-13 death spiral happened because the server attached a
|
|
588
|
+
rotated rs to the recovery response but the client threw it away
|
|
589
|
+
(relay_client.py:316-323 pre-T-182). A node whose meta file holds a
|
|
590
|
+
stale rs cannot recover once its rt dies — persist whatever the
|
|
591
|
+
server hands us, in both response shapes.
|
|
592
|
+
"""
|
|
593
|
+
new_secret = data.get("registration_secret")
|
|
594
|
+
if not new_secret or new_secret == self.meta.get("registration_secret"):
|
|
595
|
+
return
|
|
596
|
+
self.meta["registration_secret"] = new_secret
|
|
597
|
+
save_meta(self.meta)
|
|
598
|
+
log.info("persisted rotated registration secret from refresh response")
|
|
599
|
+
|
|
600
|
+
def _maybe_rotate_rs(self) -> None:
|
|
601
|
+
"""Rotate the registration secret on start + every rs interval (T-182).
|
|
602
|
+
|
|
603
|
+
Due when ``_rs_last_refresh is None`` (never done since daemon
|
|
604
|
+
start — T-185: the startup wrapper consumes this synchronously
|
|
605
|
+
before any connection) or the 24h interval has elapsed. Uses the
|
|
606
|
+
valid runtime token (server auth.py Case 3). A failed rotation is
|
|
607
|
+
non-fatal: keep the old secret and try again next tick.
|
|
608
|
+
"""
|
|
609
|
+
interval = float(self.cfg.get("rs_refresh_interval_seconds", 86400))
|
|
610
|
+
due = self._rs_last_refresh is None or (
|
|
611
|
+
time.monotonic() - self._rs_last_refresh
|
|
612
|
+
) >= interval
|
|
613
|
+
if not due:
|
|
614
|
+
return
|
|
615
|
+
if not self.token:
|
|
616
|
+
return
|
|
617
|
+
try:
|
|
618
|
+
r = httpx.post(
|
|
619
|
+
f"{self.base_url}/relay/v2/auth/refresh",
|
|
620
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
621
|
+
json={"requested_credential": "registration_secret"},
|
|
622
|
+
timeout=self.cfg["request_timeout"],
|
|
623
|
+
verify=self._verify,
|
|
624
|
+
)
|
|
625
|
+
if r.status_code == 200:
|
|
626
|
+
data = r.json()
|
|
627
|
+
new = data.get("token")
|
|
628
|
+
if new:
|
|
629
|
+
self.meta["registration_secret"] = new
|
|
630
|
+
save_meta(self.meta)
|
|
631
|
+
self._rs_last_refresh = time.monotonic()
|
|
632
|
+
log.info("registration secret rotated proactively")
|
|
633
|
+
else:
|
|
634
|
+
log.warning("rs rotation response without token; retrying next tick")
|
|
635
|
+
else:
|
|
636
|
+
log.warning(
|
|
637
|
+
"rs rotation failed (http %s); retrying next tick", r.status_code
|
|
638
|
+
)
|
|
639
|
+
except Exception as exc: # noqa: BLE001 — maintenance must not kill the daemon
|
|
640
|
+
log.warning("rs rotation error: %s", exc)
|
|
641
|
+
|
|
642
|
+
# -- T-185: rt cadence anchored at the persisted last refresh -----------
|
|
643
|
+
|
|
644
|
+
@staticmethod
|
|
645
|
+
def _parse_iso_ts(value: Any) -> datetime | None:
|
|
646
|
+
"""Parse an ISO-8601 timestamp; returns None when unusable."""
|
|
647
|
+
if not isinstance(value, str) or not value:
|
|
648
|
+
return None
|
|
649
|
+
try:
|
|
650
|
+
ts = datetime.fromisoformat(value)
|
|
651
|
+
except ValueError:
|
|
652
|
+
return None
|
|
653
|
+
return ts if ts.tzinfo else ts.replace(tzinfo=UTC)
|
|
654
|
+
|
|
655
|
+
def _rt_due(self) -> bool:
|
|
656
|
+
"""Whether the rt refresh interval has elapsed since the LAST
|
|
657
|
+
REFRESH (persisted ``refreshed_at`` in the token envelope).
|
|
658
|
+
|
|
659
|
+
T-185 correction (Ronny): the 6-day cadence must NOT count from
|
|
660
|
+
daemon start — a restart would reset the timer of an aging token
|
|
661
|
+
and it could silently run past its 7-day TTL. The stamp lives on
|
|
662
|
+
disk, so restarts preserve it. Unknown/unparseable stamp → due
|
|
663
|
+
(self-healing: the next window rotates once and re-anchors).
|
|
664
|
+
|
|
665
|
+
Stamping: ``save_token()`` anchors ``refreshed_at`` to NOW on
|
|
666
|
+
every token write, so the refresh/recovery paths re-anchor the
|
|
667
|
+
cadence implicitly; only the RAM mirror is updated here.
|
|
668
|
+
"""
|
|
669
|
+
interval = float(self.cfg.get("rt_refresh_interval_seconds", 6 * 86400))
|
|
670
|
+
ts = self._parse_iso_ts(self._rt_refreshed_at)
|
|
671
|
+
if ts is None:
|
|
672
|
+
return True
|
|
673
|
+
return (datetime.now(UTC) - ts).total_seconds() >= interval
|
|
674
|
+
|
|
675
|
+
def maybe_refresh_token(self) -> None:
|
|
676
|
+
"""Fixed-interval credential maintenance (T-182).
|
|
677
|
+
|
|
678
|
+
Replaces the T-118 expiry-margin logic. No expiry math anymore —
|
|
679
|
+
the node acts on its own schedule:
|
|
680
|
+
- rs: rotated on daemon start + every ``rs_refresh_interval_seconds``
|
|
681
|
+
(default 24h)
|
|
682
|
+
- rt: refreshed every ``rt_refresh_interval_seconds`` (default 6 days)
|
|
683
|
+
Both credentials carry a 7-day TTL server-side, so fixed intervals
|
|
684
|
+
keep them permanently fresh. A recovery that ran in ``__init__``
|
|
685
|
+
counts as a fresh rt refresh (the recovered token is brand-new).
|
|
686
|
+
|
|
687
|
+
T-183: claims must not run concurrently with the rotation — the
|
|
688
|
+
daemons use :meth:`run_credential_maintenance`, which pauses the
|
|
689
|
+
claim loop for the duration. This method only guards against a
|
|
690
|
+
direct call while another maintenance is in flight.
|
|
691
|
+
|
|
692
|
+
T-185 correction (Ronny): the rt cadence counts from the LAST
|
|
693
|
+
REFRESH, persisted as ``refreshed_at`` in the token envelope —
|
|
694
|
+
never from daemon start, so a restart cannot reset the timer of
|
|
695
|
+
an aging token. A missing/unparseable stamp counts as due: the
|
|
696
|
+
next window rotates rt once and re-anchors the stamp (legacy
|
|
697
|
+
envelopes bootstrap cleanly on the rs-start rotation).
|
|
698
|
+
"""
|
|
699
|
+
if self._maintenance_owner == threading.get_ident():
|
|
700
|
+
# This thread IS the maintenance (T-184): never skip your own
|
|
701
|
+
# rotation — the guard is only for other threads.
|
|
702
|
+
pass
|
|
703
|
+
elif not self._maintenance_gate.is_set():
|
|
704
|
+
# Another thread is inside run_credential_maintenance().
|
|
705
|
+
return
|
|
706
|
+
rt_due = self._rt_due()
|
|
707
|
+
if rt_due:
|
|
708
|
+
self._refresh_token()
|
|
709
|
+
self._maybe_rotate_rs()
|
|
710
|
+
|
|
711
|
+
def run_credential_maintenance(self) -> None:
|
|
712
|
+
"""Run one maintenance tick with claims paused (T-183).
|
|
713
|
+
|
|
714
|
+
Sequence model instead of locking: pause claims, rotate rt/rs,
|
|
715
|
+
resume claims — which now inherit the fresh tokens. The ``finally``
|
|
716
|
+
guarantees the gate reopens even if the maintenance raises (server
|
|
717
|
+
down), so a wedged claim loop can never outlive one bad tick.
|
|
718
|
+
"""
|
|
719
|
+
self._maintenance_gate.clear()
|
|
720
|
+
self._maintenance_owner = threading.get_ident()
|
|
721
|
+
try:
|
|
722
|
+
self.maybe_refresh_token()
|
|
723
|
+
finally:
|
|
724
|
+
self._maintenance_owner = None
|
|
725
|
+
self._maintenance_gate.set()
|
|
726
|
+
|
|
727
|
+
def maintenance_due(self) -> bool:
|
|
728
|
+
"""Whether a rotation is due right now (T-185).
|
|
729
|
+
|
|
730
|
+
The heartbeat loop opens the quiescence window ONLY when this is
|
|
731
|
+
true — the window drops SSE/claims/heartbeat, so it must never run
|
|
732
|
+
on a plain 8 s tick without a pending rotation.
|
|
733
|
+
|
|
734
|
+
rt: counted from the persisted last refresh (T-185 correction) —
|
|
735
|
+
see :meth:`_rt_due`. rs: start tick outstanding or 24h elapsed.
|
|
736
|
+
"""
|
|
737
|
+
rt_due = self._rt_due()
|
|
738
|
+
rs_interval = float(self.cfg.get("rs_refresh_interval_seconds", 86400))
|
|
739
|
+
rs_due = self._rs_last_refresh is None or (
|
|
740
|
+
time.monotonic() - self._rs_last_refresh
|
|
741
|
+
) >= rs_interval
|
|
742
|
+
return rt_due or rs_due
|
|
743
|
+
|
|
744
|
+
def refresh_registration_secret(self) -> None:
|
|
745
|
+
"""Synchronous rs rotation for daemon startup (T-185).
|
|
746
|
+
|
|
747
|
+
The daemons call this BEFORE starting any connection thread, so the
|
|
748
|
+
very first heartbeat/SSE request presents fresh credentials. A
|
|
749
|
+
failure is non-fatal: rs stays due (``_rs_last_refresh is None``)
|
|
750
|
+
and the next maintenance window retries (same semantics as
|
|
751
|
+
``_maybe_rotate_rs``).
|
|
752
|
+
"""
|
|
753
|
+
self._maybe_rotate_rs()
|
|
754
|
+
|
|
755
|
+
# -- public API ----------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
def _build_heartbeat_payload(
|
|
758
|
+
self, caps: list[dict[str, Any]], in_flight: dict[str, int]
|
|
759
|
+
) -> dict[str, Any]:
|
|
760
|
+
# T-210: load_source chain. os.getloadavg() reports the HOST
|
|
761
|
+
# loadavg inside LXC containers (shared /proc/loadavg), so all
|
|
762
|
+
# CTs on one Proxmox host reported the same number. Prefer
|
|
763
|
+
# container-scoped sources, fall back to loadavg (correct on
|
|
764
|
+
# bare metal / macOS). Each rung sets load_source so the server
|
|
765
|
+
# (and dashboard) can tell which measurement it got.
|
|
766
|
+
cpu_count = os.cpu_count() or 1
|
|
767
|
+
load_pct, load_source = _measure_load_pct(cpu_count)
|
|
768
|
+
load_cap = float(self.cfg.get("load_cap", cpu_count * 100.0))
|
|
769
|
+
load = min(load_pct, load_cap, 100.0)
|
|
770
|
+
|
|
771
|
+
cap_status: list[dict[str, Any]] = []
|
|
772
|
+
for cap in caps:
|
|
773
|
+
if not cap.get("auto_publish", True):
|
|
774
|
+
continue
|
|
775
|
+
name = cap["name"]
|
|
776
|
+
inflight = in_flight.get(name, 0)
|
|
777
|
+
entry: dict[str, Any] = {
|
|
778
|
+
"name": name,
|
|
779
|
+
"version": cap.get("version", "1.0.0"),
|
|
780
|
+
"available": inflight < cap.get("max_parallel", 1),
|
|
781
|
+
}
|
|
782
|
+
# T-053: forward capability metadata so the server can
|
|
783
|
+
# populate node_capabilities.{description,input_schema}
|
|
784
|
+
# and resolve capability_details on claim/task-view
|
|
785
|
+
# without an extra discovery round-trip. Omit fields that
|
|
786
|
+
# are absent or falsy to keep the heartbeat payload small.
|
|
787
|
+
if cap.get("type"):
|
|
788
|
+
entry["type"] = cap.get("type")
|
|
789
|
+
if cap.get("description"):
|
|
790
|
+
entry["description"] = cap.get("description")
|
|
791
|
+
if cap.get("input_schema"):
|
|
792
|
+
entry["input_schema"] = cap.get("input_schema")
|
|
793
|
+
# T-164: forward upload_modes (inline/artifact/bridge) to the
|
|
794
|
+
# server so the node-capabilities table stores them and file
|
|
795
|
+
# send/file get can use them for mode selection.
|
|
796
|
+
if cap.get("upload_modes"):
|
|
797
|
+
entry["upload_modes"] = cap.get("upload_modes")
|
|
798
|
+
# T-004 (iowap-flow): forward result_path_hints to the server
|
|
799
|
+
# so the flow planner knows valid ${ref.result.path} paths.
|
|
800
|
+
if cap.get("result_path_hints"):
|
|
801
|
+
entry["result_path_hints"] = cap.get("result_path_hints")
|
|
802
|
+
cap_status.append(entry)
|
|
803
|
+
|
|
804
|
+
queue_depth = sum(in_flight.values())
|
|
805
|
+
body: dict[str, Any] = {
|
|
806
|
+
"node_id": self.meta["node_id"],
|
|
807
|
+
"status": "online",
|
|
808
|
+
"available": True,
|
|
809
|
+
"load": load,
|
|
810
|
+
"queue_depth": queue_depth,
|
|
811
|
+
"capabilities": cap_status,
|
|
812
|
+
}
|
|
813
|
+
# T-210: which rung of the load chain produced the value
|
|
814
|
+
# (cgroup2/cgroup/loadavg), so the dashboard can show it.
|
|
815
|
+
body["load_source"] = load_source
|
|
816
|
+
|
|
817
|
+
# T-072: forward node-level node_name + description from the
|
|
818
|
+
# meta file (iowap-agent.json) so the server can store and
|
|
819
|
+
# surface them via `node list` / `node info`.
|
|
820
|
+
node_name = self.meta.get("node_name")
|
|
821
|
+
if node_name:
|
|
822
|
+
body["node_name"] = node_name
|
|
823
|
+
description = self.meta.get("description")
|
|
824
|
+
if description:
|
|
825
|
+
body["description"] = description
|
|
826
|
+
|
|
827
|
+
# T-081: forward the node's requested status (busy/idle) from the
|
|
828
|
+
# active YAML profile. The value is written into the YAML by
|
|
829
|
+
# `node-cli node busy`/`idle` and persists until explicitly
|
|
830
|
+
# changed. When no explicit status is set we send "online" so
|
|
831
|
+
# the server can transition the node from approved/offline to
|
|
832
|
+
# online.
|
|
833
|
+
requested_status = load_active_status()
|
|
834
|
+
if requested_status:
|
|
835
|
+
body["status"] = requested_status
|
|
836
|
+
# T-081: forward the per-node load cap so the server can run
|
|
837
|
+
# its auto-busy logic against the operator-configured ceiling
|
|
838
|
+
# rather than a server-wide default.
|
|
839
|
+
load_cap = self.cfg.get("load_cap")
|
|
840
|
+
if load_cap is not None:
|
|
841
|
+
body["load_cap"] = float(load_cap)
|
|
842
|
+
|
|
843
|
+
return body
|
|
844
|
+
|
|
845
|
+
def heartbeat(self, caps: list[dict[str, Any]], in_flight: dict[str, int]) -> dict[str, Any]:
|
|
846
|
+
body = self._build_heartbeat_payload(caps, in_flight)
|
|
847
|
+
r = self._post_with_retry(
|
|
848
|
+
# T-176: post to /worker-heartbeat (replace mode). The regular
|
|
849
|
+
# /heartbeat endpoint merges capabilities union-only on the
|
|
850
|
+
# server (core/discovery.py), so capabilities removed from the
|
|
851
|
+
# active profile would linger as ghosts on the relay forever.
|
|
852
|
+
# /worker-heartbeat hardcodes replace_capabilities=True, i.e.
|
|
853
|
+
# the server REPLACES the stored set with this heartbeat's
|
|
854
|
+
# list — removals propagate. Server-side this endpoint has
|
|
855
|
+
# existed and shipped since T-081 (api/v2/discovery.py).
|
|
856
|
+
"/relay/v2/discovery/worker-heartbeat",
|
|
857
|
+
body,
|
|
858
|
+
)
|
|
859
|
+
r.raise_for_status()
|
|
860
|
+
return r.json()
|
|
861
|
+
|
|
862
|
+
def claim(self, capability: str) -> dict[str, Any] | None:
|
|
863
|
+
# T-183: during credential maintenance the claim thread pauses —
|
|
864
|
+
# decided locally, no HTTP against a token that is about to rotate.
|
|
865
|
+
if not self._maintenance_gate.is_set():
|
|
866
|
+
return None
|
|
867
|
+
r = self._post_with_retry(
|
|
868
|
+
"/relay/v2/scheduler/claim",
|
|
869
|
+
{"capability": capability},
|
|
870
|
+
)
|
|
871
|
+
if r.status_code == 204:
|
|
872
|
+
return None
|
|
873
|
+
r.raise_for_status()
|
|
874
|
+
data = r.json()
|
|
875
|
+
if not data.get("claimed") or not data.get("stage"):
|
|
876
|
+
return None
|
|
877
|
+
return data["stage"]
|
|
878
|
+
|
|
879
|
+
def complete(self, task_id: str, stage_id: str, result: dict[str, Any]) -> dict[str, Any]:
|
|
880
|
+
r = self._post_with_retry(
|
|
881
|
+
f"/relay/v2/scheduler/stages/{stage_id}/complete",
|
|
882
|
+
{"node_id": self.meta["node_id"], "task_id": task_id, "result": result},
|
|
883
|
+
timeout=self.cfg.get("task_timeout", 600),
|
|
884
|
+
)
|
|
885
|
+
r.raise_for_status()
|
|
886
|
+
return r.json()
|
|
887
|
+
|
|
888
|
+
def submit_simple_task(
|
|
889
|
+
self,
|
|
890
|
+
capability: str,
|
|
891
|
+
payload: dict[str, Any],
|
|
892
|
+
*,
|
|
893
|
+
name: str = "",
|
|
894
|
+
priority: int = 0,
|
|
895
|
+
owner_node_id: str | None = None,
|
|
896
|
+
) -> dict[str, Any]:
|
|
897
|
+
body = {
|
|
898
|
+
"capability": capability,
|
|
899
|
+
"payload": payload,
|
|
900
|
+
"name": name,
|
|
901
|
+
"priority": priority,
|
|
902
|
+
}
|
|
903
|
+
if owner_node_id:
|
|
904
|
+
body["owner_node_id"] = owner_node_id
|
|
905
|
+
r = self._post_with_retry("/relay/v2/scheduler/task-simple", body)
|
|
906
|
+
r.raise_for_status()
|
|
907
|
+
return r.json()
|
|
908
|
+
|
|
909
|
+
def get_task(self, task_id: str) -> dict[str, Any]:
|
|
910
|
+
"""Fetch task details including stages, artifacts, and notes."""
|
|
911
|
+
r = self._get_with_retry(f"/relay/v2/scheduler/tasks/{task_id}")
|
|
912
|
+
if r.status_code == 404:
|
|
913
|
+
return {"error": "not found", "task_id": task_id}
|
|
914
|
+
r.raise_for_status()
|
|
915
|
+
return r.json()
|
|
916
|
+
|
|
917
|
+
# -- T-164: transfer ladder --------------------------------------------
|
|
918
|
+
|
|
919
|
+
def get_transfer_config(self) -> dict[str, Any]:
|
|
920
|
+
"""Fetch the server's transfer-ladder config (T-164).
|
|
921
|
+
|
|
922
|
+
``GET /relay/v2/discovery/transfer-config`` returns
|
|
923
|
+
``{max_inline_bytes, max_artifact_bytes, max_payload_bytes}``.
|
|
924
|
+
"""
|
|
925
|
+
r = self._get_with_retry("/relay/v2/discovery/transfer-config")
|
|
926
|
+
r.raise_for_status()
|
|
927
|
+
return r.json()
|
|
928
|
+
|
|
929
|
+
def get_capability_detail(self, name: str) -> dict[str, Any]:
|
|
930
|
+
"""Fetch a single capability's details incl. ``upload_modes`` (T-164).
|
|
931
|
+
|
|
932
|
+
``GET /relay/v2/discovery/capabilities/{name}`` returns the
|
|
933
|
+
capability with ``input_schema`` and ``upload_modes`` so the
|
|
934
|
+
``file send``/``file get`` handler can pick the transfer mode.
|
|
935
|
+
"""
|
|
936
|
+
r = self._get_with_retry(f"/relay/v2/discovery/capabilities/{name}")
|
|
937
|
+
r.raise_for_status()
|
|
938
|
+
return r.json()
|
|
939
|
+
|
|
940
|
+
def add_task_note(self, task_id: str, message: str, kind: str = "info") -> dict[str, Any]:
|
|
941
|
+
"""Append a note to a task (T-052 mini-chat; T-154 kind drives Long-Run)."""
|
|
942
|
+
r = self._post_with_retry(
|
|
943
|
+
f"/relay/v2/scheduler/tasks/{task_id}/notes",
|
|
944
|
+
{"message": message, "kind": kind},
|
|
945
|
+
)
|
|
946
|
+
r.raise_for_status()
|
|
947
|
+
return r.json()
|
|
948
|
+
|
|
949
|
+
# -- T-126: temporary bridge routes -------------------------------------
|
|
950
|
+
|
|
951
|
+
def register_temp_route(
|
|
952
|
+
self,
|
|
953
|
+
path: str,
|
|
954
|
+
method: str,
|
|
955
|
+
upstream: str,
|
|
956
|
+
*,
|
|
957
|
+
ttl_seconds: int,
|
|
958
|
+
channel_id: str,
|
|
959
|
+
description: str = "",
|
|
960
|
+
) -> dict[str, Any]:
|
|
961
|
+
"""Register a temporary bridge route on the server (T-124/T-126).
|
|
962
|
+
|
|
963
|
+
The route is owned by this node (the node_id comes from the
|
|
964
|
+
Bearer token), lives for ``ttl_seconds`` and is tied to
|
|
965
|
+
``channel_id`` so the caller can revoke it later. Use this for
|
|
966
|
+
large-file handoff (storage upload/download channels) where the
|
|
967
|
+
regular heartbeat routes would be replaced too eagerly.
|
|
968
|
+
|
|
969
|
+
Returns the server response dict containing ``expires_at``.
|
|
970
|
+
"""
|
|
971
|
+
body = {
|
|
972
|
+
"path": path,
|
|
973
|
+
"method": method,
|
|
974
|
+
"upstream": upstream,
|
|
975
|
+
"ttl_seconds": ttl_seconds,
|
|
976
|
+
"channel_id": channel_id,
|
|
977
|
+
"description": description,
|
|
978
|
+
}
|
|
979
|
+
r = self._post_with_retry(
|
|
980
|
+
"/relay/v2/dashboard/api/node-routes/register", body
|
|
981
|
+
)
|
|
982
|
+
r.raise_for_status()
|
|
983
|
+
return r.json()
|
|
984
|
+
|
|
985
|
+
def unregister_temp_route(self, path: str, method: str = "GET") -> None:
|
|
986
|
+
"""Delete a route owned by this node before its TTL expires (T-126).
|
|
987
|
+
|
|
988
|
+
``DELETE /api/node-routes/{node_id}/{path}?method=...``. The node
|
|
989
|
+
is resolved from the Bearer token on the server side, so this
|
|
990
|
+
client only needs the path + method it registered earlier. A 404
|
|
991
|
+
(route already expired/reaped) is swallowed.
|
|
992
|
+
"""
|
|
993
|
+
import urllib.parse
|
|
994
|
+
|
|
995
|
+
# The path is matched verbatim by the server; keep the leading
|
|
996
|
+
# slash the server expects and URL-encode any segment so a path
|
|
997
|
+
# with special characters survives the routing layer.
|
|
998
|
+
sub = path if path.startswith("/") else "/" + path
|
|
999
|
+
url_path = urllib.parse.quote(sub, safe="/")
|
|
1000
|
+
r = httpx.delete(
|
|
1001
|
+
f"{self.base_url}/relay/v2/dashboard/api/node-routes/{self.meta['node_id']}{url_path}",
|
|
1002
|
+
params={"method": method},
|
|
1003
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
1004
|
+
timeout=self.cfg["request_timeout"],
|
|
1005
|
+
verify=self._verify,
|
|
1006
|
+
)
|
|
1007
|
+
if r.status_code in (401, 403):
|
|
1008
|
+
if self._refresh_token():
|
|
1009
|
+
r = httpx.delete(
|
|
1010
|
+
f"{self.base_url}/relay/v2/dashboard/api/node-routes/{self.meta['node_id']}{url_path}",
|
|
1011
|
+
params={"method": method},
|
|
1012
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
1013
|
+
timeout=self.cfg["request_timeout"],
|
|
1014
|
+
verify=self._verify,
|
|
1015
|
+
)
|
|
1016
|
+
# 404 means the route already expired/reaped — fine.
|
|
1017
|
+
if r.status_code not in (200, 404):
|
|
1018
|
+
r.raise_for_status()
|
|
1019
|
+
|
|
1020
|
+
def list_temp_routes(self) -> list[dict[str, Any]]:
|
|
1021
|
+
"""List this node's own temp routes from the server (T-136).
|
|
1022
|
+
|
|
1023
|
+
``GET /api/node-routes?node_id=<own>`` (rt-Token). The server
|
|
1024
|
+
resolves the caller's node_id from the Bearer token and returns
|
|
1025
|
+
only routes owned by this node, including ``expires_at`` and
|
|
1026
|
+
``channel_id`` for each row.
|
|
1027
|
+
"""
|
|
1028
|
+
r = self._get_with_retry("/relay/v2/dashboard/api/node-routes")
|
|
1029
|
+
r.raise_for_status()
|
|
1030
|
+
data = r.json()
|
|
1031
|
+
# Server returns ``{"routes": [...]}``.
|
|
1032
|
+
if isinstance(data, list):
|
|
1033
|
+
return data
|
|
1034
|
+
return data.get("routes", [])
|
|
1035
|
+
|
|
1036
|
+
# -- artifact download ---------------------------------------------------
|
|
1037
|
+
|
|
1038
|
+
def download_artifact(
|
|
1039
|
+
self,
|
|
1040
|
+
artifact_id: str,
|
|
1041
|
+
output_path: Path | None = None,
|
|
1042
|
+
*,
|
|
1043
|
+
chunk_size: int = 64 * 1024,
|
|
1044
|
+
) -> Path:
|
|
1045
|
+
"""Download an artifact by id, streaming it to disk chunkwise.
|
|
1046
|
+
|
|
1047
|
+
Falls back to a token refresh on a 401/403, then retries once. The
|
|
1048
|
+
output filename is derived from the Content-Disposition header when
|
|
1049
|
+
no ``output_path`` is supplied.
|
|
1050
|
+
"""
|
|
1051
|
+
url = f"{self.base_url}/relay/v2/storage/files/{artifact_id}"
|
|
1052
|
+
timeout = self.cfg.get("request_timeout", 30)
|
|
1053
|
+
|
|
1054
|
+
cm = httpx.stream(
|
|
1055
|
+
"GET",
|
|
1056
|
+
url,
|
|
1057
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
1058
|
+
follow_redirects=True,
|
|
1059
|
+
timeout=timeout,
|
|
1060
|
+
verify=self._verify, # T-190 (F3): verify like every other call
|
|
1061
|
+
)
|
|
1062
|
+
resp = cm.__enter__()
|
|
1063
|
+
try:
|
|
1064
|
+
if resp.status_code in (401, 403):
|
|
1065
|
+
# Close this attempt and retry once after refreshing the token.
|
|
1066
|
+
cm.__exit__(None, None, None)
|
|
1067
|
+
refreshed = self._refresh_token()
|
|
1068
|
+
cm = httpx.stream(
|
|
1069
|
+
"GET",
|
|
1070
|
+
url,
|
|
1071
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
1072
|
+
follow_redirects=True,
|
|
1073
|
+
timeout=timeout,
|
|
1074
|
+
verify=self._verify, # T-190 (F3)
|
|
1075
|
+
)
|
|
1076
|
+
resp = cm.__enter__()
|
|
1077
|
+
if not refreshed:
|
|
1078
|
+
resp.raise_for_status() # surface the auth error
|
|
1079
|
+
resp.raise_for_status()
|
|
1080
|
+
|
|
1081
|
+
target = output_path or Path(_filename_from_response(resp, artifact_id))
|
|
1082
|
+
with target.open("wb") as f:
|
|
1083
|
+
for chunk in resp.iter_bytes(chunk_size=chunk_size):
|
|
1084
|
+
f.write(chunk)
|
|
1085
|
+
return target
|
|
1086
|
+
finally:
|
|
1087
|
+
cm.__exit__(None, None, None)
|
|
1088
|
+
|
|
1089
|
+
# -- artifact upload -----------------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
def upload_artifact(
|
|
1092
|
+
self,
|
|
1093
|
+
file_path: Path,
|
|
1094
|
+
*,
|
|
1095
|
+
name: str | None = None,
|
|
1096
|
+
task_id: str | None = None,
|
|
1097
|
+
stage_id: str | None = None,
|
|
1098
|
+
) -> dict[str, Any]:
|
|
1099
|
+
"""Upload a local file to the relay as an artifact.
|
|
1100
|
+
|
|
1101
|
+
Returns the server response dict containing ``artifact_id``,
|
|
1102
|
+
``name``, ``size_bytes``, etc. Falls back to a token refresh
|
|
1103
|
+
on a 401/403, then retries once.
|
|
1104
|
+
"""
|
|
1105
|
+
url = f"{self.base_url}/relay/v2/storage/upload"
|
|
1106
|
+
params: dict[str, str] = {}
|
|
1107
|
+
if task_id:
|
|
1108
|
+
params["task_id"] = task_id
|
|
1109
|
+
if stage_id:
|
|
1110
|
+
params["stage_id"] = stage_id
|
|
1111
|
+
|
|
1112
|
+
file_path = Path(file_path)
|
|
1113
|
+
upload_name = name or file_path.name
|
|
1114
|
+
|
|
1115
|
+
def _do_upload() -> httpx.Response:
|
|
1116
|
+
with file_path.open("rb") as f:
|
|
1117
|
+
return httpx.post(
|
|
1118
|
+
url,
|
|
1119
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
1120
|
+
files={"file": (upload_name, f, "application/octet-stream")},
|
|
1121
|
+
params=params or None,
|
|
1122
|
+
timeout=self.cfg.get("request_timeout", 30),
|
|
1123
|
+
verify=self._verify,
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
resp = _do_upload()
|
|
1127
|
+
if resp.status_code in (401, 403):
|
|
1128
|
+
self._refresh_token()
|
|
1129
|
+
resp = _do_upload()
|
|
1130
|
+
resp.raise_for_status()
|
|
1131
|
+
return resp.json()
|
|
1132
|
+
|
|
1133
|
+
# -- T-177: unauthenticated server-status probe ---------------------------
|
|
1134
|
+
|
|
1135
|
+
def probe_server(self) -> dict[str, Any]:
|
|
1136
|
+
"""GET /health, /ready and /metrics and reduce them to a dict.
|
|
1137
|
+
|
|
1138
|
+
All three endpoints are public (no token) — this probe is read-only
|
|
1139
|
+
monitoring for dashboards/telemetry. Never raises: on any failure
|
|
1140
|
+
the probe degrades to ``{"ok": False, "error": ...}`` so callers
|
|
1141
|
+
(daemon probe thread, status file, telemetry push) can treat the
|
|
1142
|
+
server as unreachable without their own try/except.
|
|
1143
|
+
|
|
1144
|
+
Shape::
|
|
1145
|
+
|
|
1146
|
+
{"ok": True, "version": "2.0.0", "mode": "core",
|
|
1147
|
+
"database": "ok", "scheduler": "ok",
|
|
1148
|
+
"maintenance_age_seconds": 29.0,
|
|
1149
|
+
"nodes_total": 6, "nodes_online": 4,
|
|
1150
|
+
"queue_depth": 0,
|
|
1151
|
+
"tasks_completed": 311, "tasks_failed": 24,
|
|
1152
|
+
"tasks_cancelled": 7,
|
|
1153
|
+
"stages_total": 311, "stages_retry_ratio": 0.0322,
|
|
1154
|
+
"tasks_created_5m": 0, "tasks_completed_5m": 0,
|
|
1155
|
+
"node_load": {"E4W3CBWQ": 12.87, ...}}
|
|
1156
|
+
"""
|
|
1157
|
+
result: dict[str, Any] = {"ok": False, "error": ""}
|
|
1158
|
+
timeout = self.cfg.get("request_timeout", 10)
|
|
1159
|
+
try:
|
|
1160
|
+
r = httpx.get(
|
|
1161
|
+
f"{self.base_url}/health",
|
|
1162
|
+
timeout=timeout,
|
|
1163
|
+
verify=self._verify,
|
|
1164
|
+
)
|
|
1165
|
+
r.raise_for_status()
|
|
1166
|
+
health = r.json()
|
|
1167
|
+
result["version"] = health.get("version")
|
|
1168
|
+
result["mode"] = health.get("mode")
|
|
1169
|
+
except Exception as exc: # noqa: BLE001 — probe must never throw
|
|
1170
|
+
result["error"] = f"health: {exc}"
|
|
1171
|
+
return result
|
|
1172
|
+
|
|
1173
|
+
try:
|
|
1174
|
+
r = httpx.get(
|
|
1175
|
+
f"{self.base_url}/ready",
|
|
1176
|
+
timeout=timeout,
|
|
1177
|
+
verify=self._verify,
|
|
1178
|
+
)
|
|
1179
|
+
r.raise_for_status()
|
|
1180
|
+
ready = r.json()
|
|
1181
|
+
result["database"] = ready.get("database")
|
|
1182
|
+
result["scheduler"] = ready.get("scheduler")
|
|
1183
|
+
except Exception as exc: # noqa: BLE001
|
|
1184
|
+
result["error"] = f"ready: {exc}"
|
|
1185
|
+
# /health OK but /ready down → server is up but degraded.
|
|
1186
|
+
|
|
1187
|
+
try:
|
|
1188
|
+
r = httpx.get(
|
|
1189
|
+
f"{self.base_url}/metrics",
|
|
1190
|
+
timeout=timeout,
|
|
1191
|
+
verify=self._verify,
|
|
1192
|
+
)
|
|
1193
|
+
r.raise_for_status()
|
|
1194
|
+
result.update(_parse_prometheus_gauges(r.text))
|
|
1195
|
+
except Exception as exc: # noqa: BLE001
|
|
1196
|
+
result["error"] = f"metrics: {exc}"
|
|
1197
|
+
|
|
1198
|
+
result["ok"] = not result["error"]
|
|
1199
|
+
return result
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _parse_prometheus_gauges(text: str) -> dict[str, Any]:
|
|
1203
|
+
"""Pull the interesting gauges out of a Prometheus exposition dump.
|
|
1204
|
+
|
|
1205
|
+
Only simple gauges (``name value``) are collected — histograms are
|
|
1206
|
+
skipped except for the pre-aggregated *_sum/_count lines. Labelled
|
|
1207
|
+
series are mapped: relay_tasks{status=…} → tasks_<status>,
|
|
1208
|
+
relay_node_load{node_id=…} → node_load[node_id].
|
|
1209
|
+
"""
|
|
1210
|
+
gauges: dict[str, Any] = {}
|
|
1211
|
+
node_load: dict[str, float] = {}
|
|
1212
|
+
for line in text.splitlines():
|
|
1213
|
+
line = line.strip()
|
|
1214
|
+
if not line or line.startswith("#"):
|
|
1215
|
+
continue
|
|
1216
|
+
name, _, rest = line.partition(" ")
|
|
1217
|
+
value_str = rest.strip()
|
|
1218
|
+
try:
|
|
1219
|
+
value = float(value_str)
|
|
1220
|
+
except ValueError:
|
|
1221
|
+
continue
|
|
1222
|
+
# Labels? → map into structured fields, skip the raw name.
|
|
1223
|
+
if "{" in name:
|
|
1224
|
+
labels = dict(re.findall(r'(\w+)="([^"]*)"', name))
|
|
1225
|
+
bare = name[: name.index("{")]
|
|
1226
|
+
if bare == "relay_tasks":
|
|
1227
|
+
gauges[f"tasks_{labels.get('status', 'unknown')}"] = value
|
|
1228
|
+
elif bare == "relay_stages":
|
|
1229
|
+
gauges[f"stages_{labels.get('status', 'unknown')}"] = value
|
|
1230
|
+
elif bare == "relay_node_load":
|
|
1231
|
+
node_id = labels.get("node_id")
|
|
1232
|
+
if node_id:
|
|
1233
|
+
node_load[node_id] = value
|
|
1234
|
+
continue
|
|
1235
|
+
name = name.removeprefix("relay_")
|
|
1236
|
+
gauges[name] = value
|
|
1237
|
+
if node_load:
|
|
1238
|
+
gauges["node_load"] = node_load
|
|
1239
|
+
return gauges
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _filename_from_response(response: httpx.Response, fallback: str) -> str:
|
|
1243
|
+
"""Extract a filename from Content-Disposition, falling back to the id."""
|
|
1244
|
+
cd = response.headers.get("content-disposition", "")
|
|
1245
|
+
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
|
|
1246
|
+
return m.group(1) if m else fallback
|