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,497 @@
1
+ #!/usr/bin/env python3
2
+ """Handler runner: executes a capability handler as a subprocess.
3
+
4
+ A *handler* is an external executable (script, binary, or shell command)
5
+ that performs the actual work for a claimed stage. The runner sets up a
6
+ well-defined environment, feeds the stage ``payload`` as JSON on stdin,
7
+ captures stdout/stderr, enforces a timeout, and returns a result dict
8
+ ready to be POSTed to the ``/complete`` endpoint.
9
+
10
+ Contract (see NODE_CLI_SPEC.md §4 and docs/node/capabilities.md):
11
+
12
+ * Environment variables set before execution::
13
+
14
+ RELAY_STAGE_ID Stage ID from claim
15
+ RELAY_TASK_ID Task ID from claim
16
+ RELAY_CAPABILITY Capability name
17
+ RELAY_NODE_ID Assigned node ID
18
+ RELAY_BASE_URL Relay server URL
19
+ RELAY_TOKEN_FILE Path to runtime token file
20
+
21
+ * Stdin: Request Envelope as a JSON string (T-005b Envelope-Contract):
22
+ ``{"task_id": ..., "capability": ..., "input": {...payload...}}``.
23
+ Since T-005d (strict phase) the envelope is sent alone; a legacy
24
+ top-level mirror of the payload keys can be re-enabled per capability
25
+ via ``config.envelope_request_mirror: true`` (rollback lever).
26
+ * Stdout: on exit 0 MUST be valid JSON — normalized to the Response
27
+ Envelope (T-005b): a conforming ``{"status": "completed", "result":
28
+ {...}, "error": null}`` passes through verbatim, ``status: "error"``
29
+ fails the stage fail-fast via its ``error`` message, and bare result
30
+ dicts are wrapped into the envelope (without an ``error`` key — the
31
+ daemon counts failures via ``"error" in result`` key presence).
32
+ * Stderr: captured and included in the error result on non-zero exit.
33
+ On exit 0 it is attached to the result under ``_handler.stderr`` for
34
+ debugging but is never interpreted as the result.
35
+ * Exit codes:
36
+ 0 -> stdout parsed as result dict
37
+ non-zero -> {"error": "handler exited with code N", "stderr": ...}
38
+ Handlers MUST exit non-zero whenever they could not produce a valid
39
+ result. Exit 0 with non-JSON or an ``error``-keyed payload completes
40
+ the stage silently and skips the scheduler's retry budget, losing the
41
+ failed work.
42
+ * Timeout: kills the whole handler process tree (T-188) and returns
43
+ ``{"error": "handler timeout after Ns"}``.
44
+ * Output cap (T-191): stdout/stderr are buffered up to
45
+ ``MAX_HANDLER_STDOUT_BYTES``/``MAX_HANDLER_STDERR_BYTES``; exceeding the
46
+ cap fails the stage instead of letting a runaway handler OOM the daemon.
47
+
48
+ Retry behaviour (T-060): non-zero exit, timeout, invalid-JSON-on-exit-0
49
+ and complete-endpoint failures all increment the daemon-side per-task
50
+ failure counter (and the server-side ``retry_count`` via the released
51
+ claim). Once ``max_retries`` (default 2 → 3 attempts total) is exceeded
52
+ the scheduler marks the stage as ``failed`` permanently.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import os
59
+ import signal
60
+ import subprocess
61
+ import threading
62
+ import time
63
+ from typing import Any
64
+
65
+ from nodes.common import node_config
66
+
67
+ # Environment variables passed to every handler.
68
+ HANDLER_ENV_KEYS = (
69
+ "RELAY_STAGE_ID",
70
+ "RELAY_TASK_ID",
71
+ "RELAY_CAPABILITY",
72
+ "RELAY_NODE_ID",
73
+ "RELAY_BASE_URL",
74
+ "RELAY_TOKEN_FILE",
75
+ )
76
+
77
+ # T-191 (F4): the handler shares the daemon process, so an unbounded stdout
78
+ # buffer is an OOM vector — a handler that accidentally dumps a huge file to
79
+ # stdout would take down the process holding the agent, file-serve and
80
+ # heartbeat threads. Cap what we buffer and fail the stage loudly instead.
81
+ MAX_HANDLER_STDOUT_BYTES = 10 * 1024 * 1024
82
+ MAX_HANDLER_STDERR_BYTES = 1 * 1024 * 1024
83
+
84
+
85
+ def _kill_process_tree(proc: subprocess.Popen) -> None:
86
+ """Kill the handler and every process it spawned (T-188, F1).
87
+
88
+ The handler runs through ``shell=True`` in its own session
89
+ (``start_new_session=True`` at spawn), so the child is a process-group
90
+ leader and ``os.killpg`` reaches the whole tree. ``proc.kill()`` alone
91
+ only reaps the direct shell — the actual handler (a grandchild) would
92
+ survive a timeout and keep burning CPU on the host.
93
+ """
94
+ try:
95
+ pgid = os.getpgid(proc.pid)
96
+ except (ProcessLookupError, PermissionError):
97
+ pgid = None
98
+ if pgid is not None:
99
+ try:
100
+ os.killpg(pgid, signal.SIGKILL)
101
+ except (ProcessLookupError, PermissionError):
102
+ pass
103
+ try:
104
+ proc.kill()
105
+ except (ProcessLookupError, PermissionError):
106
+ pass
107
+
108
+
109
+ def _read_stream_bounded(stream: Any, limit: int) -> tuple[bytes, bool]:
110
+ """Read a pipe up to ``limit`` bytes; return ``(data, overflowed)``.
111
+
112
+ Stops reading as soon as the cap is hit — the caller kills the process
113
+ tree on overflow, so the truncated remainder never matters. Returns
114
+ ``overflowed=True`` exactly when more than ``limit`` bytes were offered.
115
+ """
116
+ buf = bytearray()
117
+ while True:
118
+ try:
119
+ chunk = stream.read(65536)
120
+ except (ValueError, OSError):
121
+ break
122
+ if not chunk:
123
+ break
124
+ remaining = limit - len(buf)
125
+ if len(chunk) > remaining:
126
+ buf += chunk[: max(0, remaining)]
127
+ return bytes(buf), True
128
+ buf += chunk
129
+ return bytes(buf), False
130
+
131
+
132
+
133
+ def _build_env(stage: dict[str, Any], context: dict[str, Any]) -> dict[str, str]:
134
+ """Build the environment dict for the handler subprocess.
135
+
136
+ Inherits the current process environment and overlays the relay
137
+ context variables. Stage-derived values (``RELAY_STAGE_ID``,
138
+ ``RELAY_TASK_ID``, ``RELAY_CAPABILITY``) are populated from the
139
+ claimed stage when not already supplied by ``context``; node-level
140
+ values (``RELAY_NODE_ID``, ``RELAY_BASE_URL``, ``RELAY_TOKEN_FILE``)
141
+ always come from ``context``. Missing values are set to the empty
142
+ string so the handler can rely on the keys always being present.
143
+ """
144
+ env = dict(os.environ)
145
+ # Stage-derived defaults (context may override).
146
+ stage_defaults = {
147
+ "RELAY_STAGE_ID": stage.get("stage_id"),
148
+ "RELAY_TASK_ID": stage.get("task_id"),
149
+ "RELAY_CAPABILITY": stage.get("capability"),
150
+ }
151
+ for key in HANDLER_ENV_KEYS:
152
+ if key in stage_defaults and key not in context:
153
+ value = stage_defaults[key]
154
+ else:
155
+ value = context.get(key)
156
+ env[key] = "" if value is None else str(value)
157
+ return env
158
+
159
+
160
+ def _stdin_payload(stage: dict[str, Any]) -> bytes:
161
+ """Serialize the stage payload for handler stdin.
162
+
163
+ T-005b (design.md §3.2): delegates to _build_stdin_payload with the
164
+ per-capability rollout toggle (``config.envelope_request_mirror``).
165
+ Signature kept for stability — daemon and tests call this unchanged.
166
+ T-005d strict phase: the toggle defaults to False (envelope-only
167
+ stdin); True re-enables the legacy top-level mirror as a rollback
168
+ lever for a single capability.
169
+ """
170
+ return _build_stdin_payload(
171
+ stage, mirrored=_envelope_request_mirror(stage)
172
+ )
173
+
174
+
175
+ def _envelope_request_mirror(stage: dict[str, Any]) -> bool:
176
+ """Resolve the per-capability request-mirror toggle for ``stage``.
177
+
178
+ Reads the active profile's capability ``config.envelope_request_mirror``
179
+ (T-005b design.md §3.2, D5: per-capability flip beats a global flag).
180
+
181
+ T-005d (strict phase): the default is now **False** — the fleet has
182
+ migrated to the envelope contract, so the strict envelope-only stdin
183
+ is the normal case. The config key survives as the per-capability
184
+ ROLLBACK lever: setting ``envelope_request_mirror: true`` re-enables
185
+ the legacy top-level mirror for a single capability whose handler
186
+ still reads flat keys.
187
+
188
+ Any lookup problem (profile unreadable, capability unknown, config
189
+ not a mapping) resolves to the default as well (strict) — a config
190
+ hiccup must not silently re-enable the rollout mirror; a legacy
191
+ handler reading flat keys then fails loudly instead of being kept
192
+ alive by a stale profile.
193
+ """
194
+ try:
195
+ for cap in node_config.load_active_profile():
196
+ if cap.get("name") == stage.get("capability"):
197
+ cfg = cap.get("config")
198
+ if isinstance(cfg, dict):
199
+ return bool(cfg.get("envelope_request_mirror", False))
200
+ break
201
+ except Exception: # noqa: BLE001 — resolve to strict default, no mirror
202
+ return False
203
+ return False
204
+
205
+
206
+ def _build_stdin_payload(stage: dict[str, Any], *, mirrored: bool) -> bytes:
207
+ """Build the Request Envelope for handler stdin (T-005b, design.md §3.2).
208
+
209
+ FROZEN contract: ``{"task_id": str, "capability": str, "input": dict}``
210
+ — the capability-specific payload moves under ``input``.
211
+
212
+ mirrored=True (rollout phase): payload keys are additionally placed at
213
+ the top level so legacy handlers reading flat keys keep working.
214
+ Payload keys are written FIRST and the contract keys LAST, so
215
+ ``task_id``/``capability``/``input`` always win on collision.
216
+ mirrored=False (strict phase, post-migration): envelope only.
217
+ """
218
+ payload = stage.get("payload")
219
+ if payload is None:
220
+ payload = {}
221
+ if not isinstance(payload, dict):
222
+ payload = {"payload": payload}
223
+ envelope: dict[str, Any] = {}
224
+ if mirrored:
225
+ envelope.update(payload)
226
+ envelope["task_id"] = str(stage.get("task_id") or "")
227
+ envelope["capability"] = str(stage.get("capability") or "")
228
+ envelope["input"] = payload
229
+ return json.dumps(envelope).encode("utf-8")
230
+
231
+
232
+ def _normalize_result(stdout_text: str) -> dict[str, Any]:
233
+ """Normalize handler stdout to the Response Envelope (T-005b §3.1).
234
+
235
+ Contract (FROZEN, T-005a design.md §3.1 + ## Deviations):
236
+
237
+ - Parsed dict WITH ``status == "completed"``: requires a ``result``
238
+ object; ``error`` must be None if the key exists. Passed through
239
+ verbatim (conforming handlers are never rewritten). Note the
240
+ verified live shape omits ``error`` entirely on success — the
241
+ normalize layer does NOT inject ``error: None`` because the daemon
242
+ counts failures via ``"error" in result`` (key presence).
243
+ - ``status == "error"``: pass through verbatim — the daemon's
244
+ ``"error" in result`` check then fails the stage (fail-fast,
245
+ retry budget applies, no exit-code guessing).
246
+ - Any other ``status`` value: error result.
247
+ - Bare dict (no ``status``) WITH ``error`` key: pass through
248
+ unchanged (legacy error convention, D3).
249
+ - Bare dict otherwise: wrapped as ``{"status": "completed",
250
+ "result": <d>}`` — deliberately WITHOUT an ``error`` key (daemon
251
+ failure counting is key-presence-based; D4/D10 with the verified
252
+ daemon read).
253
+ - Non-object JSON: error result (bare scalars were always ambiguous;
254
+ the contract requires an object).
255
+
256
+ Raises nothing; returns an error-result dict on normalize failures.
257
+ Idempotent by construction: envelopes are detected by the ``status``
258
+ key and passed through verbatim, so double-wrapping is impossible.
259
+ """
260
+ try:
261
+ parsed = json.loads(stdout_text)
262
+ except json.JSONDecodeError as exc:
263
+ return {"error": f"handler stdout is not valid JSON: {exc.msg}"}
264
+ if not isinstance(parsed, dict):
265
+ kind = type(parsed).__name__
266
+ return {
267
+ "error": (
268
+ "handler stdout must be a JSON object (envelope or bare "
269
+ f"result), got {kind}"
270
+ )
271
+ }
272
+ if "status" not in parsed:
273
+ # Bare result (legacy). Error-keyed bare dicts pass through
274
+ # unchanged; everything else is wrapped into the envelope.
275
+ if "error" in parsed:
276
+ return parsed
277
+ return {"status": "completed", "result": parsed}
278
+ status = parsed.get("status")
279
+ if status == "completed":
280
+ if not isinstance(parsed.get("result"), dict):
281
+ return {
282
+ "error": (
283
+ "handler envelope status 'completed' missing 'result' "
284
+ "object"
285
+ )
286
+ }
287
+ if "error" in parsed and parsed.get("error") is not None:
288
+ return {
289
+ "error": (
290
+ "handler envelope status 'completed' carries non-null "
291
+ "'error'"
292
+ )
293
+ }
294
+ return parsed
295
+ if status == "error":
296
+ if not isinstance(parsed.get("error"), str) or not parsed.get("error"):
297
+ return {
298
+ "error": "handler envelope status 'error' missing 'error' message"
299
+ }
300
+ return parsed
301
+ return {
302
+ "error": (
303
+ f"handler envelope has invalid status {status!r} "
304
+ "(expected 'completed' or 'error')"
305
+ )
306
+ }
307
+
308
+
309
+ def run_handler(
310
+ handler: str,
311
+ stage: dict[str, Any],
312
+ *,
313
+ context: dict[str, Any] | None = None,
314
+ timeout: int = 300,
315
+ ) -> dict[str, Any]:
316
+ """Execute a handler subprocess and return a result dict.
317
+
318
+ Parameters
319
+ ----------
320
+ handler:
321
+ Executable path or shell command. Always run via the shell
322
+ (``shell=True``) so profiles can use pipelines / env-aware
323
+ commands. Profiles are trusted operator-only files.
324
+ stage:
325
+ The stage dict returned by the claim endpoint. Only its
326
+ ``payload`` is forwarded to stdin; ``stage_id`` / ``task_id``
327
+ are exposed via environment variables.
328
+ context:
329
+ Mapping with at least ``RELAY_NODE_ID``, ``RELAY_BASE_URL``,
330
+ ``RELAY_TOKEN_FILE`` and (optionally) the other env vars. Any
331
+ missing key is sent to the handler as the empty string.
332
+ timeout:
333
+ Subprocess timeout in seconds. Defaults to 300.
334
+
335
+ Returns
336
+ -------
337
+ dict
338
+ On success (exit 0): the parsed JSON from stdout, augmented with
339
+ an ``_handler`` debug dict (``stderr``, ``stdout_length``,
340
+ ``exit_code``). If the stdout is not valid JSON, an error dict is
341
+ returned instead.
342
+ On failure (non-zero exit or timeout): an error dict of the
343
+ form ``{"error": "...", "stderr": "..."}``.
344
+ """
345
+ context = context or {}
346
+ env = _build_env(stage, context)
347
+ stdin_bytes = _stdin_payload(stage)
348
+
349
+ try:
350
+ proc = subprocess.Popen( # noqa: S602 — shell=True by design
351
+ handler,
352
+ shell=True,
353
+ stdin=subprocess.PIPE,
354
+ stdout=subprocess.PIPE,
355
+ stderr=subprocess.PIPE,
356
+ env=env,
357
+ start_new_session=True, # T-188: own process group → killpg reaches the tree
358
+ )
359
+ except OSError as exc:
360
+ return {"error": f"handler failed to start: {exc}", "stderr": ""}
361
+
362
+ out_holder: list[tuple[bytes, bool]] = []
363
+ err_holder: list[tuple[bytes, bool]] = []
364
+ overflow_evt = threading.Event()
365
+
366
+ def _drain(stream: Any, limit: int, holder: list[tuple[bytes, bool]]) -> None:
367
+ data, overflowed = _read_stream_bounded(stream, limit)
368
+ holder.append((data, overflowed))
369
+ if overflowed:
370
+ # Stop waiting for the handler to exit — it is blocked writing
371
+ # into the now-unread pipe. The main loop kills the tree.
372
+ overflow_evt.set()
373
+
374
+ def _feed_stdin() -> None:
375
+ stdin_pipe = proc.stdin
376
+ if stdin_pipe is None: # pragma: no cover — PIPE guarantees a handle
377
+ return
378
+ try:
379
+ stdin_pipe.write(stdin_bytes)
380
+ except (BrokenPipeError, OSError, ValueError):
381
+ pass
382
+ finally:
383
+ try:
384
+ stdin_pipe.close()
385
+ except (BrokenPipeError, OSError, ValueError):
386
+ pass
387
+
388
+ # Drain stdout/stderr on separate threads: a single-threaded read would
389
+ # deadlock the moment one pipe fills while we block on the other.
390
+ t_out = threading.Thread(
391
+ target=_drain,
392
+ args=(proc.stdout, MAX_HANDLER_STDOUT_BYTES, out_holder),
393
+ daemon=True,
394
+ )
395
+ t_err = threading.Thread(
396
+ target=_drain,
397
+ args=(proc.stderr, MAX_HANDLER_STDERR_BYTES, err_holder),
398
+ daemon=True,
399
+ )
400
+ t_in = threading.Thread(target=_feed_stdin, daemon=True)
401
+ t_out.start()
402
+ t_err.start()
403
+ t_in.start()
404
+
405
+ timed_out = False
406
+ deadline = time.monotonic() + timeout
407
+ while proc.poll() is None:
408
+ if overflow_evt.is_set():
409
+ # T-191 (F4): kill now, the handler cannot finish (blocked write).
410
+ _kill_process_tree(proc)
411
+ break
412
+ if time.monotonic() >= deadline:
413
+ # T-188 (F1): kill the whole tree, not just the direct shell.
414
+ timed_out = True
415
+ _kill_process_tree(proc)
416
+ break
417
+ time.sleep(0.05)
418
+ try:
419
+ proc.wait(timeout=5)
420
+ except subprocess.TimeoutExpired: # pragma: no cover — SIGKILL is final
421
+ pass
422
+
423
+ t_out.join(timeout=5)
424
+ t_err.join(timeout=5)
425
+ t_in.join(timeout=5)
426
+
427
+ stdout_bytes, stdout_overflowed = out_holder[0] if out_holder else (b"", False)
428
+ stderr_bytes, stderr_overflowed = err_holder[0] if err_holder else (b"", False)
429
+
430
+ stdout = _safe_decode(stdout_bytes)
431
+ stderr = _safe_decode(stderr_bytes)
432
+
433
+ if timed_out:
434
+ return {"error": f"handler timeout after {timeout}s", "stderr": stderr}
435
+
436
+ # T-191 (F4): a handler must not be able to OOM the daemon through stdout.
437
+ if stdout_overflowed or stderr_overflowed:
438
+ return {
439
+ "error": (
440
+ "handler output exceeded the size limit "
441
+ f"(stdout>{MAX_HANDLER_STDOUT_BYTES}B / "
442
+ f"stderr>{MAX_HANDLER_STDERR_BYTES}B)"
443
+ ),
444
+ "stdout": stdout,
445
+ "stderr": stderr,
446
+ }
447
+
448
+ if proc.returncode != 0:
449
+ return {
450
+ "error": f"handler exited with code {proc.returncode}",
451
+ "stderr": stderr,
452
+ "stdout": stdout,
453
+ }
454
+
455
+ # Exit 0: stdout must be valid JSON result.
456
+ if not stdout.strip():
457
+ return {"error": "handler produced no stdout output", "stderr": stderr}
458
+
459
+ try:
460
+ # Guard kept separate from normalize so the non-JSON error keeps
461
+ # its pre-existing shape (stdout/stderr attached) byte-identical.
462
+ json.loads(stdout)
463
+ except json.JSONDecodeError as exc:
464
+ return {
465
+ "error": f"handler stdout is not valid JSON: {exc.msg}",
466
+ "stdout": stdout,
467
+ "stderr": stderr,
468
+ }
469
+
470
+ # T-005b Envelope-Contract: normalize handler stdout at this single
471
+ # choke-point — conforming envelopes pass through verbatim, bare
472
+ # results are wrapped, error shapes fail the stage fail-fast. All
473
+ # normalize failures return an error-result dict (daemon failure
474
+ # accounting untouched).
475
+ parsed = _normalize_result(stdout)
476
+
477
+ # Always attach handler diagnostics so callers can debug empty
478
+ # responses without having to download artifacts. The CLI surfaces
479
+ # these in `node-cli task result` (see _print_task_result).
480
+ # T-193 (F6): a handler may ship a non-dict ``_handler`` in its own
481
+ # envelope — normalize it instead of raising a TypeError that would
482
+ # abort the stage before ``client.complete()``.
483
+ if not isinstance(parsed.get("_handler"), dict):
484
+ parsed["_handler"] = {}
485
+ parsed["_handler"]["stderr"] = stderr
486
+ parsed["_handler"]["stdout_length"] = len(stdout)
487
+ parsed["_handler"]["exit_code"] = proc.returncode
488
+ return parsed
489
+
490
+
491
+ def _safe_decode(data: bytes | None) -> str:
492
+ if not data:
493
+ return ""
494
+ try:
495
+ return data.decode("utf-8")
496
+ except UnicodeDecodeError:
497
+ return data.decode("utf-8", errors="replace")