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.
@@ -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