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,249 @@
|
|
|
1
|
+
"""CLI node subcommands — list/info/busy/idle/clear-status/status (T-117 split).
|
|
2
|
+
|
|
3
|
+
Handler signatures are plain ``(client, args) -> int``; the
|
|
4
|
+
``node_cli.with_client`` decorator is applied at parser-registration time
|
|
5
|
+
in the facade. ``log`` is referenced lazily via ``node_cli.log`` (the
|
|
6
|
+
facade logger) so this module does not need its own logger instance and
|
|
7
|
+
stays decoupled.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import nodes.common.node_cli as _cli
|
|
17
|
+
from nodes.common.node_config import load_active_profile, load_active_status, write_active_status
|
|
18
|
+
from nodes.common.node_utils import load_meta
|
|
19
|
+
from nodes.common.relay_client import RelayClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cmd_node_list(client: RelayClient, args) -> int:
|
|
23
|
+
"""List all nodes registered on the relay server."""
|
|
24
|
+
try:
|
|
25
|
+
resp = client._get_with_retry("/relay/v2/discovery/nodes")
|
|
26
|
+
resp.raise_for_status()
|
|
27
|
+
data = resp.json()
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
print(f"failed to query nodes: {exc}", file=sys.stderr)
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
nodes = data.get("nodes", [])
|
|
33
|
+
if args.json:
|
|
34
|
+
print(json.dumps(nodes, default=str))
|
|
35
|
+
return 0
|
|
36
|
+
if not nodes:
|
|
37
|
+
print("(no nodes registered on the server)")
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
print(f"Nodes ({len(nodes)} total):\n")
|
|
41
|
+
for n in nodes:
|
|
42
|
+
nid = n.get("node_id", "?")
|
|
43
|
+
name = n.get("node_name", "?")
|
|
44
|
+
status = n.get("status", "?")
|
|
45
|
+
avail = "✅" if n.get("available", False) else "❌"
|
|
46
|
+
endpoint = n.get("endpoint", "-")
|
|
47
|
+
role = n.get("role", "-")
|
|
48
|
+
caps_raw = n.get("capabilities", "")
|
|
49
|
+
caps = ", ".join(c.get("name", "?") for c in caps_raw) if isinstance(caps_raw, list) else str(caps_raw)[:60]
|
|
50
|
+
last_seen = n.get("last_seen", "?")[:19] if n.get("last_seen") else "?"
|
|
51
|
+
print(f" {avail} {name:20} ID={nid}")
|
|
52
|
+
print(f" Status: {status:10} Role: {role}")
|
|
53
|
+
print(f" Endpoint: {endpoint}")
|
|
54
|
+
print(f" Last: {last_seen}")
|
|
55
|
+
print(f" Caps: {caps}")
|
|
56
|
+
# T-072: show node-level description (truncated for the list view).
|
|
57
|
+
desc = n.get("description", "")
|
|
58
|
+
if desc:
|
|
59
|
+
print(f" Desc: {desc[:60]}{'...' if len(desc) > 60 else ''}")
|
|
60
|
+
print()
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_node_info(client: RelayClient, args) -> int:
|
|
65
|
+
"""Show detailed info for a single node."""
|
|
66
|
+
try:
|
|
67
|
+
resp = client._get_with_retry("/relay/v2/discovery/nodes?status=all")
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
data = resp.json()
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
print(f"failed to query nodes: {exc}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
nodes = data.get("nodes", [])
|
|
75
|
+
# Some older server versions treat ``status=all`` as a literal status filter
|
|
76
|
+
# (returning an empty list). Fall back to the unfiltered endpoint so the
|
|
77
|
+
# command still works for offline/pending nodes.
|
|
78
|
+
if not nodes:
|
|
79
|
+
try:
|
|
80
|
+
resp = client._get_with_retry("/relay/v2/discovery/nodes")
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
nodes = resp.json().get("nodes", [])
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
node = None
|
|
86
|
+
for n in nodes:
|
|
87
|
+
if n.get("node_id") == args.node_id:
|
|
88
|
+
node = n
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
if not node:
|
|
92
|
+
print(f"Node '{args.node_id}' not found.")
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
if args.json:
|
|
96
|
+
print(json.dumps(node, default=str))
|
|
97
|
+
return 0
|
|
98
|
+
print(f"Node: {node.get('node_name', '?')}")
|
|
99
|
+
print(f"ID: {node.get('node_id', '?')}")
|
|
100
|
+
print(f"Status: {node.get('status', '?')}")
|
|
101
|
+
print(f"Role: {node.get('role', '-')}")
|
|
102
|
+
print(f"Available: {'yes' if node.get('available', False) else 'no'}")
|
|
103
|
+
print(f"Endpoint: {node.get('endpoint', '-')}")
|
|
104
|
+
print(f"Load: {node.get('load', 0):.1f}")
|
|
105
|
+
print(f"Queue Depth: {node.get('queue_depth', 0)}")
|
|
106
|
+
print(f"Last Seen: {node.get('last_seen', '?')}")
|
|
107
|
+
print(f"Registered: {node.get('registered_at', '?')}")
|
|
108
|
+
# T-072: show the full node-level description.
|
|
109
|
+
desc = node.get("description", "")
|
|
110
|
+
if desc:
|
|
111
|
+
print(f"Description: {desc}")
|
|
112
|
+
|
|
113
|
+
caps_raw = node.get("capabilities", "")
|
|
114
|
+
if isinstance(caps_raw, list) and caps_raw:
|
|
115
|
+
print(f"\nCapabilities ({len(caps_raw)}):")
|
|
116
|
+
for c in caps_raw:
|
|
117
|
+
cname = c.get("name", "?")
|
|
118
|
+
cver = c.get("version", "?")
|
|
119
|
+
cavail = "✅" if c.get("available", False) else "❌"
|
|
120
|
+
print(f" {cavail} {cname:25} v{cver}")
|
|
121
|
+
elif caps_raw:
|
|
122
|
+
print(f"\nCapabilities: {caps_raw}")
|
|
123
|
+
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# node busy / idle / status (T-084) — status stored in active YAML profile
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _save_requested_status(status: str) -> None:
|
|
133
|
+
"""Persist the operator-requested node status into the active YAML profile.
|
|
134
|
+
|
|
135
|
+
The heartbeat loop reads the YAML ``status`` field and forwards it
|
|
136
|
+
to the server on the next heartbeat, where the transition is validated
|
|
137
|
+
against the central registry. The YAML file is the only source for the
|
|
138
|
+
daemon so the requested status survives restarts.
|
|
139
|
+
"""
|
|
140
|
+
write_active_status(status)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _clear_requested_status() -> None:
|
|
144
|
+
"""Remove the operator-requested status so the node returns to auto."""
|
|
145
|
+
write_active_status(None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cmd_node_busy(client: RelayClient, args) -> int:
|
|
149
|
+
"""node-cli node busy — mark this node as busy.
|
|
150
|
+
|
|
151
|
+
Persists ``status: busy`` into the meta file so the next heartbeat
|
|
152
|
+
forwards it to the server. The server validates the transition
|
|
153
|
+
(online/idle → busy) via the central status registry; an invalid
|
|
154
|
+
transition is silently ignored on the server side.
|
|
155
|
+
|
|
156
|
+
With ``--once`` the daemon is not affected and a single heartbeat
|
|
157
|
+
carrying the new status is sent immediately.
|
|
158
|
+
"""
|
|
159
|
+
_save_requested_status("busy")
|
|
160
|
+
if args.once:
|
|
161
|
+
caps = load_active_profile()
|
|
162
|
+
client.heartbeat(caps, {})
|
|
163
|
+
if args.json:
|
|
164
|
+
print(json.dumps({"status": "busy", "persisted": True, "once": bool(args.once)}))
|
|
165
|
+
return 0
|
|
166
|
+
print("✅ Node marked busy — next heartbeat will forward the new status.")
|
|
167
|
+
if not args.once:
|
|
168
|
+
print(" Send SIGHUP or restart the daemon to pick up the change immediately.")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _cmd_node_idle(client: RelayClient, args) -> int:
|
|
173
|
+
"""node-cli node idle — mark this node as idle (available for claims)."""
|
|
174
|
+
_save_requested_status("idle")
|
|
175
|
+
if args.once:
|
|
176
|
+
caps = load_active_profile()
|
|
177
|
+
client.heartbeat(caps, {})
|
|
178
|
+
if args.json:
|
|
179
|
+
print(json.dumps({"status": "idle", "persisted": True, "once": bool(args.once)}))
|
|
180
|
+
return 0
|
|
181
|
+
print("✅ Node marked idle — next heartbeat will forward the new status.")
|
|
182
|
+
if not args.once:
|
|
183
|
+
print(" Send SIGHUP or restart the daemon to pick up the change immediately.")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cmd_node_clear_status(client: RelayClient, args) -> int:
|
|
188
|
+
"""node-cli node clear-status — remove an explicit status request.
|
|
189
|
+
|
|
190
|
+
After this the node returns to automatic status handling (the
|
|
191
|
+
server sets it to ``online`` on the next heartbeat and may later
|
|
192
|
+
flip it to ``busy`` via the auto-busy load tracking).
|
|
193
|
+
"""
|
|
194
|
+
_clear_requested_status()
|
|
195
|
+
if args.once:
|
|
196
|
+
caps = load_active_profile()
|
|
197
|
+
client.heartbeat(caps, {})
|
|
198
|
+
if args.json:
|
|
199
|
+
print(json.dumps({"status": None, "persisted": True, "once": bool(args.once)}))
|
|
200
|
+
return 0
|
|
201
|
+
print("✅ Explicit node status cleared — auto handling restored.")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cmd_node_status(client: RelayClient, args) -> int:
|
|
206
|
+
"""node-cli node status — show the local + server-side node status.
|
|
207
|
+
|
|
208
|
+
Reports the operator-requested status from the active YAML profile
|
|
209
|
+
(if any) and queries the server for the authoritative current
|
|
210
|
+
status of this node.
|
|
211
|
+
"""
|
|
212
|
+
meta = load_meta()
|
|
213
|
+
requested = load_active_status()
|
|
214
|
+
server_status: Optional[str] = None
|
|
215
|
+
server_load = None
|
|
216
|
+
server_queue = None
|
|
217
|
+
try:
|
|
218
|
+
resp = client._get_with_retry("/relay/v2/discovery/nodes?status=all")
|
|
219
|
+
if resp.status_code == 200:
|
|
220
|
+
nodes = resp.json().get("nodes", [])
|
|
221
|
+
if not nodes:
|
|
222
|
+
resp = client._get_with_retry("/relay/v2/discovery/nodes")
|
|
223
|
+
nodes = resp.json().get("nodes", []) if resp.status_code == 200 else []
|
|
224
|
+
for n in nodes:
|
|
225
|
+
if n.get("node_id") == meta.get("node_id"):
|
|
226
|
+
server_status = n.get("status")
|
|
227
|
+
server_load = n.get("load")
|
|
228
|
+
server_queue = n.get("queue_depth")
|
|
229
|
+
break
|
|
230
|
+
except Exception as exc: # noqa: BLE001
|
|
231
|
+
_cli.log.warning("could not query server-side node status: %s", exc)
|
|
232
|
+
|
|
233
|
+
if args.json:
|
|
234
|
+
print(json.dumps({
|
|
235
|
+
"node_id": meta.get("node_id"),
|
|
236
|
+
"requested_status": requested,
|
|
237
|
+
"server_status": server_status,
|
|
238
|
+
"load": server_load,
|
|
239
|
+
"queue_depth": server_queue,
|
|
240
|
+
}, default=str))
|
|
241
|
+
return 0
|
|
242
|
+
print(f"Node: {meta.get('node_name', meta.get('node_id', '?'))}")
|
|
243
|
+
print(f"Requested status: {requested or '(auto)'}")
|
|
244
|
+
print(f"Server status: {server_status or '-'}")
|
|
245
|
+
if server_load is not None:
|
|
246
|
+
print(f"Server load: {server_load}")
|
|
247
|
+
if server_queue is not None:
|
|
248
|
+
print(f"Server queue: {server_queue}")
|
|
249
|
+
return 0
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""CLI route subcommands — register/unregister/list temp bridge routes (T-136).
|
|
2
|
+
|
|
3
|
+
Handler signatures are plain ``(client, args) -> int``; the
|
|
4
|
+
``node_cli.with_client`` decorator is applied at parser-registration time
|
|
5
|
+
in the facade. Errors are printed to stderr and the command returns 1 so
|
|
6
|
+
the caller (operator or script) sees a clear failure without a logger
|
|
7
|
+
dependency.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from nodes.common.relay_client import RelayClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cmd_route_register(client: RelayClient, args) -> int:
|
|
19
|
+
"""node-cli route register — open a temporary bridge route (T-136)."""
|
|
20
|
+
try:
|
|
21
|
+
result = client.register_temp_route(
|
|
22
|
+
path=args.path,
|
|
23
|
+
method=args.method,
|
|
24
|
+
upstream=args.upstream,
|
|
25
|
+
ttl_seconds=args.ttl,
|
|
26
|
+
channel_id=args.channel,
|
|
27
|
+
description=args.description or "",
|
|
28
|
+
)
|
|
29
|
+
except Exception as exc: # noqa: BLE001
|
|
30
|
+
print(f"failed to register route: {exc}", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
if args.json:
|
|
34
|
+
print(json.dumps(result, default=str))
|
|
35
|
+
return 0
|
|
36
|
+
print("✅ Temp route registered")
|
|
37
|
+
print(f" Path: {result.get('path')}")
|
|
38
|
+
print(f" Method: {result.get('method')}")
|
|
39
|
+
print(f" Channel: {result.get('channel_id')}")
|
|
40
|
+
print(f" Expires at: {result.get('expires_at')}")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cmd_route_unregister(client: RelayClient, args) -> int:
|
|
45
|
+
"""node-cli route unregister — revoke a temp route before its TTL (T-136)."""
|
|
46
|
+
try:
|
|
47
|
+
client.unregister_temp_route(path=args.path, method=args.method)
|
|
48
|
+
except Exception as exc: # noqa: BLE001
|
|
49
|
+
print(f"failed to unregister route: {exc}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
if args.json:
|
|
53
|
+
print(json.dumps({"status": "deleted", "path": args.path, "method": args.method}))
|
|
54
|
+
return 0
|
|
55
|
+
print("✅ Temp route deleted")
|
|
56
|
+
print(f" Path: {args.path}")
|
|
57
|
+
print(f" Method: {args.method}")
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_route_list(client: RelayClient, args) -> int:
|
|
62
|
+
"""node-cli route list — list this node's own routes (T-136)."""
|
|
63
|
+
try:
|
|
64
|
+
routes = client.list_temp_routes()
|
|
65
|
+
except Exception as exc: # noqa: BLE001
|
|
66
|
+
print(f"failed to list routes: {exc}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
if args.json:
|
|
70
|
+
print(json.dumps(routes, default=str))
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
if not routes:
|
|
74
|
+
print("(no routes registered for this node)")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
print(f"Routes ({len(routes)} total):\n")
|
|
78
|
+
for r in routes:
|
|
79
|
+
path = r.get("path", "?")
|
|
80
|
+
method = r.get("method", "?")
|
|
81
|
+
auth = r.get("auth", "?")
|
|
82
|
+
upstream = r.get("upstream", "-")
|
|
83
|
+
expires = r.get("expires_at") or "permanent"
|
|
84
|
+
channel = r.get("channel_id") or "-"
|
|
85
|
+
kind = "temp" if r.get("expires_at") else "perm "
|
|
86
|
+
print(f" [{kind}] {method:6} {path}")
|
|
87
|
+
print(f" auth: {auth}")
|
|
88
|
+
print(f" upstream: {upstream}")
|
|
89
|
+
print(f" channel: {channel}")
|
|
90
|
+
print(f" expires: {expires}")
|
|
91
|
+
print()
|
|
92
|
+
return 0
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""T-178: first-class node registration + unauthenticated server status CLI.
|
|
2
|
+
|
|
3
|
+
``_cmd_node_register`` needs no RelayClient (registration happens *before*
|
|
4
|
+
any state files exist); the server status commands reuse the T-177
|
|
5
|
+
``probe_server()`` logic at module level so they work without meta/token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
import nodes.common.node_cli as _cli
|
|
17
|
+
from nodes.common import node_utils
|
|
18
|
+
from nodes.common.node_utils import save_meta, save_token
|
|
19
|
+
from nodes.common.relay_client import _parse_prometheus_gauges
|
|
20
|
+
|
|
21
|
+
REGISTER_PATH = "/relay/v2/auth/register"
|
|
22
|
+
DEFAULT_PORT = 8788
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_base_url(server: str) -> str:
|
|
26
|
+
"""Normalize a server argument to a usable base URL.
|
|
27
|
+
|
|
28
|
+
``192.0.2.60`` → ``http://192.0.2.60:8788``; a ``host:port`` keeps
|
|
29
|
+
its port; a full URL is kept as-is (trailing slash stripped).
|
|
30
|
+
"""
|
|
31
|
+
server = server.strip().rstrip("/")
|
|
32
|
+
if server.startswith(("http://", "https://")):
|
|
33
|
+
return server
|
|
34
|
+
host, sep, port = server.rpartition(":")
|
|
35
|
+
if sep and port.isdigit():
|
|
36
|
+
return f"http://{host}:{port}"
|
|
37
|
+
return f"http://{server}:{DEFAULT_PORT}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# node register (T-178)
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cmd_node_register(args) -> int:
|
|
46
|
+
"""node-cli node register <server> — register this node and persist state.
|
|
47
|
+
|
|
48
|
+
Chicken-and-egg-free: runs without meta/token files and creates them.
|
|
49
|
+
Never writes anything on failure.
|
|
50
|
+
"""
|
|
51
|
+
import socket
|
|
52
|
+
|
|
53
|
+
base_url = normalize_base_url(args.server)
|
|
54
|
+
name = args.name or socket.gethostname()
|
|
55
|
+
|
|
56
|
+
# Guard: refuse to clobber an existing node identity unless --force.
|
|
57
|
+
meta_path = node_utils.META_PATH
|
|
58
|
+
if meta_path.exists() and not args.force:
|
|
59
|
+
print(
|
|
60
|
+
f"node state already exists at {meta_path} "
|
|
61
|
+
f"(node already registered?). Use --force to re-register.",
|
|
62
|
+
file=sys.stderr,
|
|
63
|
+
)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
body = {
|
|
67
|
+
"node_name": name,
|
|
68
|
+
"endpoint": None,
|
|
69
|
+
"role": "node",
|
|
70
|
+
"capabilities": [],
|
|
71
|
+
}
|
|
72
|
+
try:
|
|
73
|
+
resp = httpx.post(f"{base_url}{REGISTER_PATH}", json=body, timeout=args.timeout)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
data = resp.json()
|
|
76
|
+
except httpx.HTTPStatusError as exc:
|
|
77
|
+
print(
|
|
78
|
+
f"registration failed: HTTP {exc.response.status_code}: {exc.response.text[:200]}",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
return 1
|
|
82
|
+
except Exception as exc: # noqa: BLE001
|
|
83
|
+
print(f"registration failed: {exc}", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
node_id = data.get("node_id")
|
|
87
|
+
token = data.get("token")
|
|
88
|
+
if not node_id or not token:
|
|
89
|
+
print(f"unexpected registration response: {json.dumps(data)[:200]}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
meta = {
|
|
93
|
+
"node_id": node_id,
|
|
94
|
+
"node_name": name,
|
|
95
|
+
"endpoint": None,
|
|
96
|
+
"registration_secret": data.get("registration_secret"),
|
|
97
|
+
"capabilities": [],
|
|
98
|
+
"base_url": base_url,
|
|
99
|
+
}
|
|
100
|
+
save_meta(meta)
|
|
101
|
+
save_token(token, data.get("expires_at"))
|
|
102
|
+
|
|
103
|
+
if args.json:
|
|
104
|
+
print(json.dumps({"ok": True, **meta, "status": data.get("status", "pending")}, default=str))
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
print(f"✅ Node registered: {name} (ID={node_id})")
|
|
108
|
+
print(f" Server: {base_url}")
|
|
109
|
+
print(f" Status: {data.get('status', 'pending')}")
|
|
110
|
+
if data.get("expires_at"):
|
|
111
|
+
print(f" Token: temporary tp_-token, expires {data['expires_at']}")
|
|
112
|
+
print()
|
|
113
|
+
print(" ⏳ Node is pending — an admin must approve it (dashboard or admin API)")
|
|
114
|
+
print(" before it can claim work.")
|
|
115
|
+
print(" Next: node-cli capabilities publish <profile> && node-daemon")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# server health / metrics (T-178; probe logic from T-177)
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def probe_server_endpoint(
|
|
125
|
+
base_url: str,
|
|
126
|
+
*,
|
|
127
|
+
timeout: float = 10.0,
|
|
128
|
+
verify: str | bool = True,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""GET /health + /ready + /metrics without client state.
|
|
131
|
+
|
|
132
|
+
Module-level mirror of ``RelayClient.probe_server()`` (T-177) so the CLI
|
|
133
|
+
can probe a server *before* the node has meta/token files. Never raises;
|
|
134
|
+
returns ``{"ok": False, "error": ...}`` on failure.
|
|
135
|
+
"""
|
|
136
|
+
result: dict[str, Any] = {"ok": False, "error": ""}
|
|
137
|
+
try:
|
|
138
|
+
r = httpx.get(f"{base_url}/health", timeout=timeout, verify=verify)
|
|
139
|
+
r.raise_for_status()
|
|
140
|
+
health = r.json()
|
|
141
|
+
result["version"] = health.get("version")
|
|
142
|
+
result["mode"] = health.get("mode")
|
|
143
|
+
except Exception as exc: # noqa: BLE001 — probe must never throw
|
|
144
|
+
result["error"] = f"health: {exc}"
|
|
145
|
+
return result
|
|
146
|
+
try:
|
|
147
|
+
r = httpx.get(f"{base_url}/ready", timeout=timeout, verify=verify)
|
|
148
|
+
r.raise_for_status()
|
|
149
|
+
ready = r.json()
|
|
150
|
+
result["database"] = ready.get("database")
|
|
151
|
+
result["scheduler"] = ready.get("scheduler")
|
|
152
|
+
except Exception as exc: # noqa: BLE001
|
|
153
|
+
# /health OK but /ready down → server is up but degraded.
|
|
154
|
+
result["error"] = f"ready: {exc}"
|
|
155
|
+
try:
|
|
156
|
+
r = httpx.get(f"{base_url}/metrics", timeout=timeout, verify=verify)
|
|
157
|
+
r.raise_for_status()
|
|
158
|
+
result.update(_parse_prometheus_gauges(r.text))
|
|
159
|
+
except Exception as exc: # noqa: BLE001
|
|
160
|
+
result["error"] = f"metrics: {exc}"
|
|
161
|
+
result["ok"] = not result["error"]
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_server(server: str | None, as_json: bool = False) -> str | None:
|
|
166
|
+
"""Resolve the server argument: arg → config file / node state → error."""
|
|
167
|
+
if server:
|
|
168
|
+
return normalize_base_url(server)
|
|
169
|
+
cfg = _cli.load_json(node_utils.CONFIG_PATH, default={}) or {}
|
|
170
|
+
base = cfg.get("base_url")
|
|
171
|
+
if not base and node_utils.META_PATH.exists():
|
|
172
|
+
meta = _cli.load_json(node_utils.META_PATH, default={}) or {}
|
|
173
|
+
base = meta.get("base_url")
|
|
174
|
+
if base:
|
|
175
|
+
return str(base).rstrip("/")
|
|
176
|
+
msg = "no server given and no base_url in relay_config.json or node state"
|
|
177
|
+
if as_json:
|
|
178
|
+
print(json.dumps({"ok": False, "error": msg}))
|
|
179
|
+
else:
|
|
180
|
+
print(msg, file=sys.stderr)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fmt_num(val: Any) -> str:
|
|
185
|
+
"""Display helper: 4.0 → '4', 12.87 → '12.87'."""
|
|
186
|
+
if isinstance(val, float) and val.is_integer():
|
|
187
|
+
return str(int(val))
|
|
188
|
+
return str(val)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _cmd_server_health(args) -> int:
|
|
192
|
+
"""node-cli server health [server] — one-shot server status probe."""
|
|
193
|
+
base_url = resolve_server(args.server, getattr(args, "json", False))
|
|
194
|
+
if base_url is None:
|
|
195
|
+
return 1
|
|
196
|
+
data = probe_server_endpoint(base_url)
|
|
197
|
+
if args.json:
|
|
198
|
+
print(json.dumps(data, default=str))
|
|
199
|
+
return 0 if data["ok"] else 1
|
|
200
|
+
if not data["ok"]:
|
|
201
|
+
print(f"❌ Server unreachable: {data['error']}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
print(f"✅ {base_url} — IOWAP {data.get('version', '?')} ({data.get('mode', '?')})")
|
|
204
|
+
print(f" Database: {data.get('database', '?')}")
|
|
205
|
+
print(f" Scheduler: {data.get('scheduler', '?')}")
|
|
206
|
+
total = data.get("nodes_total")
|
|
207
|
+
online = data.get("nodes_online")
|
|
208
|
+
if total is not None:
|
|
209
|
+
print(f" Nodes: {_fmt_num(online)}/{_fmt_num(total)} online")
|
|
210
|
+
if data.get("queue_depth") is not None:
|
|
211
|
+
print(f" Queue: {_fmt_num(data.get('queue_depth'))} tasks waiting")
|
|
212
|
+
completed = data.get("tasks_completed")
|
|
213
|
+
failed = data.get("tasks_failed")
|
|
214
|
+
if completed is not None:
|
|
215
|
+
print(f" Tasks: {_fmt_num(completed)} completed, {_fmt_num(failed)} failed")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _cmd_server_metrics(args) -> int:
|
|
220
|
+
"""node-cli server metrics [server] — merged probe dict, Prometheus fields."""
|
|
221
|
+
base_url = resolve_server(args.server, getattr(args, "json", False))
|
|
222
|
+
if base_url is None:
|
|
223
|
+
return 1
|
|
224
|
+
data = probe_server_endpoint(base_url)
|
|
225
|
+
if not data["ok"]:
|
|
226
|
+
msg = f"server unreachable: {data['error']}"
|
|
227
|
+
if args.json:
|
|
228
|
+
print(json.dumps({"ok": False, "error": msg}))
|
|
229
|
+
else:
|
|
230
|
+
print(f"❌ {msg}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
if args.json:
|
|
233
|
+
print(json.dumps(data, default=str))
|
|
234
|
+
return 0
|
|
235
|
+
print(f"Server metrics ({base_url}):")
|
|
236
|
+
for key in sorted(data):
|
|
237
|
+
if key in ("ok", "error"):
|
|
238
|
+
continue
|
|
239
|
+
val = data[key]
|
|
240
|
+
if isinstance(val, dict):
|
|
241
|
+
print(f" {key}:")
|
|
242
|
+
for sub, sv in sorted(val.items()):
|
|
243
|
+
print(f" {sub:24} {sv}")
|
|
244
|
+
elif isinstance(val, float) and val.is_integer():
|
|
245
|
+
print(f" {key:28} {int(val)}")
|
|
246
|
+
else:
|
|
247
|
+
print(f" {key:28} {val}")
|
|
248
|
+
return 0
|