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,546 @@
|
|
|
1
|
+
"""T-179: handler primitives — ``node-cli hp put/get``.
|
|
2
|
+
|
|
3
|
+
Scripts talk exclusively to the local node-cli (subprocess contract,
|
|
4
|
+
board decision ①): token, ladder negotiation and the transfer formats
|
|
5
|
+
stay inside the CLI process. Scripts see only a path in / a path out.
|
|
6
|
+
|
|
7
|
+
FROZEN CONTRACT (Phase 1, T-179)
|
|
8
|
+
================================
|
|
9
|
+
Every public signature in this module is immutable across Phase 2
|
|
10
|
+
(scaffold: test stubs + parser boilerplate) and Phase 3 (integrate:
|
|
11
|
+
implementation). Specifically FROZEN:
|
|
12
|
+
|
|
13
|
+
* parameter names + order (the Phase 2/3 tests call them positionally
|
|
14
|
+
and by keyword — both must remain stable),
|
|
15
|
+
* return types (``int`` exit codes; ``0`` ok / ``1`` error / ``2`` usage),
|
|
16
|
+
* stdout contracts (exactly ONE compact JSON line each, see cmd_put/cmd_get),
|
|
17
|
+
* the error message strings of ``decide_mode`` (Task 2b needs them
|
|
18
|
+
byte-identical to ``cli_file._choose_mode``).
|
|
19
|
+
|
|
20
|
+
House convention (like cli_file/cli_task, T-117 split): CLI handlers are
|
|
21
|
+
``(client, args) -> int`` and get the ``with_client(...)`` decorator at
|
|
22
|
+
parser registration time in ``node_cli.py``. This module does NOT import
|
|
23
|
+
``node_cli`` (no circular import; the RelayClient monkeypatch stays
|
|
24
|
+
functional).
|
|
25
|
+
|
|
26
|
+
Phase 1 delivers only signatures + contract documentation; the bodies
|
|
27
|
+
are implemented in Phase 3 (Phase 2 produces ``NotImplementedError``
|
|
28
|
+
test stubs that are already prepared here).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import UTC, datetime, timedelta
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, TextIO
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
from nodes.common import file_serve
|
|
44
|
+
from nodes.common.cli.cli_file import _load_capability_modes
|
|
45
|
+
from nodes.common.envelope import ( # noqa: F401
|
|
46
|
+
ENVELOPE_KEY,
|
|
47
|
+
EnvelopeError,
|
|
48
|
+
dump,
|
|
49
|
+
is_envelope,
|
|
50
|
+
make_envelope,
|
|
51
|
+
parse_envelope,
|
|
52
|
+
)
|
|
53
|
+
from nodes.common.relay_client import RelayClient
|
|
54
|
+
|
|
55
|
+
# Order = preference; the first allowed rung whose threshold fits.
|
|
56
|
+
# FROZEN: the order is part of the contract (inline < artifact < bridge).
|
|
57
|
+
_LADDER = ("inline", "artifact", "bridge")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decide_mode(
|
|
61
|
+
size_bytes: int,
|
|
62
|
+
upload_modes: list[str],
|
|
63
|
+
thresholds: dict[str, int] | None = None,
|
|
64
|
+
force: str | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Pick the ladder rung for a file — the shared decision point.
|
|
67
|
+
|
|
68
|
+
FROZEN SIGNATURE (Phase 1) — Task 2b routes ``cli_file._choose_mode``
|
|
69
|
+
through this function; that is why the error messages are FROZEN
|
|
70
|
+
too (see below).
|
|
71
|
+
|
|
72
|
+
Semantics
|
|
73
|
+
---------
|
|
74
|
+
* Ladder preference: the first rung of ``_LADDER`` that (a) is
|
|
75
|
+
allowed in ``upload_modes`` and (b) whose threshold carries the
|
|
76
|
+
size.
|
|
77
|
+
* ``thresholds``: keys ``max_inline_bytes`` / ``max_artifact_bytes``
|
|
78
|
+
(from the server, ``RelayClient.get_transfer_config()``).
|
|
79
|
+
- Missing key or ``None`` → the rung counts as **unlimited**
|
|
80
|
+
(the safety net is the server payload limit, not a silent
|
|
81
|
+
failure).
|
|
82
|
+
- Explicit ``0`` → rung **forbidden** (cli_file semantics when
|
|
83
|
+
the server response is missing).
|
|
84
|
+
- ``thresholds=None`` → all rungs unlimited → pure preference
|
|
85
|
+
choice according to ``upload_modes``.
|
|
86
|
+
* ``bridge`` has no threshold key (the server defines none) →
|
|
87
|
+
unlimited; chosen as soon as inline/artifact do not fit.
|
|
88
|
+
* ``force``: overrides the choice but must be in ``upload_modes``.
|
|
89
|
+
Checked BEFORE the size logic (like cli_file).
|
|
90
|
+
|
|
91
|
+
Errors (FROZEN, byte-identical to ``cli_file._choose_mode``)
|
|
92
|
+
------------------------------------------------------------
|
|
93
|
+
Raise ``ValueError`` (library level; the CLI adapter converts it
|
|
94
|
+
to SystemExit/exit code):
|
|
95
|
+
|
|
96
|
+
* force not allowed::
|
|
97
|
+
|
|
98
|
+
--force {force!r} not supported by capability (upload_modes={upload_modes})
|
|
99
|
+
|
|
100
|
+
* no rung fits::
|
|
101
|
+
|
|
102
|
+
file too big: {size_bytes} bytes, capability supports only {upload_modes} (server ladder: inline<={max_inline}, artifact<={max_artifact})
|
|
103
|
+
|
|
104
|
+
with ``max_inline = (thresholds or {}).get("max_inline_bytes", 0)`` etc.
|
|
105
|
+
|
|
106
|
+
Task-2b mapping (FROZEN, behavior-identical) in ``cli_file.py``::
|
|
107
|
+
|
|
108
|
+
thresholds = {
|
|
109
|
+
"max_inline_bytes": int(transfer_cfg.get("max_inline_bytes", 0)),
|
|
110
|
+
"max_artifact_bytes": int(transfer_cfg.get("max_artifact_bytes", 0)),
|
|
111
|
+
}
|
|
112
|
+
try:
|
|
113
|
+
return decide_mode(size, upload_modes, thresholds, force=force)
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise SystemExit(str(exc))
|
|
116
|
+
|
|
117
|
+
(cli_file materializes the 0-defaults explicitly — this keeps the
|
|
118
|
+
"missing key = unlimited" behavior of decide_mode without any
|
|
119
|
+
effect on the cli_file path.)
|
|
120
|
+
"""
|
|
121
|
+
if force is not None:
|
|
122
|
+
if force not in upload_modes:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"--force {force!r} not supported by capability "
|
|
125
|
+
f"(upload_modes={upload_modes})"
|
|
126
|
+
)
|
|
127
|
+
return force
|
|
128
|
+
|
|
129
|
+
th = thresholds or {}
|
|
130
|
+
max_inline = th.get("max_inline_bytes")
|
|
131
|
+
max_artifact = th.get("max_artifact_bytes")
|
|
132
|
+
|
|
133
|
+
if "inline" in upload_modes and (max_inline is None or size_bytes <= max_inline):
|
|
134
|
+
return "inline"
|
|
135
|
+
if "artifact" in upload_modes and (
|
|
136
|
+
max_artifact is None or size_bytes <= max_artifact
|
|
137
|
+
):
|
|
138
|
+
return "artifact"
|
|
139
|
+
if "bridge" in upload_modes:
|
|
140
|
+
return "bridge"
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"file too big: {size_bytes} bytes, capability supports only {upload_modes} "
|
|
143
|
+
f"(server ladder: inline<={(th or {}).get('max_inline_bytes', 0)}, "
|
|
144
|
+
f"artifact<={(th or {}).get('max_artifact_bytes', 0)})"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _serve_count_from_env() -> int:
|
|
149
|
+
"""``--serve-count`` equivalent from ``IOWAP_SERVE_COUNT`` (1–10, F6).
|
|
150
|
+
|
|
151
|
+
The FROZEN ``cmd_put`` signature (F9) has no ``serve_count`` param —
|
|
152
|
+
the F6 multi-download runs through the env (0/invalid/out-of-range →
|
|
153
|
+
1, no WARNING spam for the normal case). A value ``< 1`` would fail
|
|
154
|
+
fast in ``stage_file`` anyway — here we normalize cleanly to 1.
|
|
155
|
+
"""
|
|
156
|
+
raw = os.environ.get("IOWAP_SERVE_COUNT")
|
|
157
|
+
if not raw:
|
|
158
|
+
return 1
|
|
159
|
+
try:
|
|
160
|
+
value = int(raw)
|
|
161
|
+
except ValueError:
|
|
162
|
+
return 1
|
|
163
|
+
return value if 1 <= value <= 10 else 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_put(
|
|
167
|
+
client: RelayClient,
|
|
168
|
+
*,
|
|
169
|
+
cap: str,
|
|
170
|
+
path: Path,
|
|
171
|
+
name: str | None = None,
|
|
172
|
+
out: TextIO | None = None,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""``node-cli hp put <path> --cap <cap>`` → envelope JSON on stdout.
|
|
175
|
+
|
|
176
|
+
FROZEN SIGNATURE (Phase 1).
|
|
177
|
+
|
|
178
|
+
Contract
|
|
179
|
+
--------
|
|
180
|
+
* Loads the capability details via ``_load_capability_modes(client,
|
|
181
|
+
cap)`` (``upload_modes``; its ``SystemExit`` on HTTP/lookup errors
|
|
182
|
+
propagates — ``main()`` turns it into the exit code).
|
|
183
|
+
* Thresholds via ``client.get_transfer_config()``; extract defensively
|
|
184
|
+
(take only real ints, anything else = omit the key → the rung is
|
|
185
|
+
unlimited). HTTP errors here → ``return 1`` with a clear message.
|
|
186
|
+
* ``mode = decide_mode(size, upload_modes, thresholds)`` with
|
|
187
|
+
``size = path.stat().st_size`` (do NOT read the entire file first —
|
|
188
|
+
artifact/bridge are meant to handle big files without a RAM peak;
|
|
189
|
+
inline reads the bytes only after the decision). ``ValueError``
|
|
190
|
+
from decide_mode → stderr + ``return 1``.
|
|
191
|
+
* inline → ``make_envelope(src="inline", filename=name or path.name,
|
|
192
|
+
data=<bytes>)``.
|
|
193
|
+
* artifact → ``client.upload_artifact(path, name=name or path.name)``;
|
|
194
|
+
a missing ``artifact_id`` in the response → stderr + ``return 1``;
|
|
195
|
+
otherwise ``make_envelope(src="artifact", ..., artifact_id=aid)``.
|
|
196
|
+
* bridge → ``raise NotImplementedError("bridge-put follows in Task
|
|
197
|
+
3")`` (deliberate MVP boundary — the envelope can carry bridge, the
|
|
198
|
+
channel-open flow cannot; see plan T-179).
|
|
199
|
+
* stdout: EXACTLY ONE line — compact JSON of the envelope
|
|
200
|
+
(``envelope.dump``) + ``"\n"``. ``out=None`` → resolve
|
|
201
|
+
``sys.stdout`` at CALL time (not import time!) so that pytest-capsys
|
|
202
|
+
works. No other stdout output; errors → stderr.
|
|
203
|
+
* Exit codes: ``0`` ok, ``1`` transfer/protocol error,
|
|
204
|
+
``2`` usage (file missing — checked in dispatch_put).
|
|
205
|
+
|
|
206
|
+
``name`` overrides the filename inside the envelope (default:
|
|
207
|
+
path.name).
|
|
208
|
+
"""
|
|
209
|
+
if not path.is_file():
|
|
210
|
+
print(f"file not found: {path}", file=sys.stderr)
|
|
211
|
+
return 2
|
|
212
|
+
|
|
213
|
+
# 1. Capability details (upload_modes); SystemExit on lookup errors
|
|
214
|
+
# propagates — main() turns it into the exit code.
|
|
215
|
+
cap_detail = _load_capability_modes(client, cap)
|
|
216
|
+
upload_modes = list(
|
|
217
|
+
cap_detail.get("upload_modes") or ["inline", "artifact", "bridge"]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 2. Load the server ladder config; defensively: take only real ints,
|
|
221
|
+
# anything else = omit the key → rung unlimited (F2.5 semantics).
|
|
222
|
+
try:
|
|
223
|
+
transfer_cfg = client.get_transfer_config()
|
|
224
|
+
except httpx.HTTPError as exc:
|
|
225
|
+
print(f"failed to load transfer config: {exc}", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
thresholds: dict[str, int] = {}
|
|
228
|
+
if isinstance(transfer_cfg, dict):
|
|
229
|
+
for key in ("max_inline_bytes", "max_artifact_bytes"):
|
|
230
|
+
value = transfer_cfg.get(key)
|
|
231
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
232
|
+
thresholds[key] = value
|
|
233
|
+
|
|
234
|
+
# 3. Size via stat (F2.9) — read the bytes only after the decision.
|
|
235
|
+
size = path.stat().st_size
|
|
236
|
+
try:
|
|
237
|
+
mode = decide_mode(size, upload_modes, thresholds)
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
print(str(exc), file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
|
|
242
|
+
# 4. Build the envelope per mode.
|
|
243
|
+
if mode == "inline":
|
|
244
|
+
envelope = make_envelope(
|
|
245
|
+
src="inline", filename=name or path.name, data=path.read_bytes()
|
|
246
|
+
)
|
|
247
|
+
elif mode == "artifact":
|
|
248
|
+
try:
|
|
249
|
+
resp = client.upload_artifact(path, name=name or path.name)
|
|
250
|
+
except httpx.HTTPError as exc:
|
|
251
|
+
print(f"artifact upload failed: {exc}", file=sys.stderr)
|
|
252
|
+
return 1
|
|
253
|
+
artifact_id = resp.get("artifact_id") if isinstance(resp, dict) else None
|
|
254
|
+
if not artifact_id:
|
|
255
|
+
print(
|
|
256
|
+
"artifact upload response without artifact_id", file=sys.stderr
|
|
257
|
+
)
|
|
258
|
+
return 1
|
|
259
|
+
envelope = make_envelope(
|
|
260
|
+
src="artifact",
|
|
261
|
+
filename=name or path.name,
|
|
262
|
+
artifact_id=str(artifact_id),
|
|
263
|
+
size_bytes=size,
|
|
264
|
+
)
|
|
265
|
+
else: # bridge — ephemeral serve handoff (T-166, F1/F5/F6/F8)
|
|
266
|
+
manifest = file_serve.read_manifest()
|
|
267
|
+
if manifest is None or not file_serve.probe_serve(
|
|
268
|
+
manifest["port"], host=manifest.get("host")
|
|
269
|
+
):
|
|
270
|
+
print(
|
|
271
|
+
"hp put: ephemeral serve not reachable (is the node daemon "
|
|
272
|
+
"running?) — use artifact fallback",
|
|
273
|
+
file=sys.stderr,
|
|
274
|
+
)
|
|
275
|
+
return 1
|
|
276
|
+
transfer_id, _staged, sha_hex = file_serve.stage_file(
|
|
277
|
+
path, max_downloads=_serve_count_from_env()
|
|
278
|
+
)
|
|
279
|
+
route_path = f"/download/{transfer_id}"
|
|
280
|
+
# D8: advertise host from the manifest (the daemon writes the
|
|
281
|
+
# serve_host() address) — the proxy dials the address VERBATIM,
|
|
282
|
+
# so it must be resolvable from the relay machine, not only from
|
|
283
|
+
# here (live: LXC 903 ↔ Hermes host).
|
|
284
|
+
upstream = (
|
|
285
|
+
f"http://{manifest.get('host', file_serve.SERVE_HOST)}:{manifest['port']}"
|
|
286
|
+
f"/transfer/{transfer_id}"
|
|
287
|
+
)
|
|
288
|
+
node_id = str((client.meta or {}).get("node_id") or "unknown")
|
|
289
|
+
expires = (
|
|
290
|
+
datetime.now(UTC) + timedelta(seconds=3600)
|
|
291
|
+
).isoformat()
|
|
292
|
+
try:
|
|
293
|
+
client.register_temp_route(
|
|
294
|
+
route_path,
|
|
295
|
+
"POST",
|
|
296
|
+
upstream,
|
|
297
|
+
ttl_seconds=3600,
|
|
298
|
+
channel_id=transfer_id,
|
|
299
|
+
description="iowap ephemeral file serve (T-166)",
|
|
300
|
+
)
|
|
301
|
+
except httpx.HTTPError as exc:
|
|
302
|
+
# No stale left-over without a route (F6): staged file + manifest gone.
|
|
303
|
+
file_serve.discard_staged(transfer_id)
|
|
304
|
+
print(f"hp put: route registration failed: {exc}", file=sys.stderr)
|
|
305
|
+
return 1
|
|
306
|
+
envelope = make_envelope(
|
|
307
|
+
src="bridge",
|
|
308
|
+
filename=name or path.name,
|
|
309
|
+
storage_ref=file_serve.build_storage_ref(node_id, route_path, expires),
|
|
310
|
+
sha256=sha_hex,
|
|
311
|
+
size_bytes=size, # read before stage_file — move semantics (F5)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# 5. stdout: EXACTLY ONE compact JSON line; out=None → sys.stdout at
|
|
315
|
+
# CALL time (capsys-compatible).
|
|
316
|
+
stream = out if out is not None else sys.stdout
|
|
317
|
+
stream.write(dump(envelope) + "\n")
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_get(
|
|
322
|
+
client: RelayClient,
|
|
323
|
+
*,
|
|
324
|
+
envelope: dict[str, Any],
|
|
325
|
+
out_dir: Path,
|
|
326
|
+
output: Path | None = None,
|
|
327
|
+
out: TextIO | None = None,
|
|
328
|
+
) -> int:
|
|
329
|
+
"""``node-cli hp get`` → resolve the envelope, store the file locally.
|
|
330
|
+
|
|
331
|
+
FROZEN SIGNATURE (Phase 1).
|
|
332
|
+
|
|
333
|
+
Contract
|
|
334
|
+
--------
|
|
335
|
+
* ``parse_envelope(envelope)`` → ``(src, ref)``; ``EnvelopeError`` →
|
|
336
|
+
``"hp get: {exc}"`` on stderr + ``return 1``.
|
|
337
|
+
* Filename: ``envelope["__iowap_ref__"].get("filename") or
|
|
338
|
+
"download"`` (the filename from the envelope is NOT trustworthy
|
|
339
|
+
as a path — do not accept ``..``/absolute paths, sanitize).
|
|
340
|
+
* ``out_dir`` is created when needed (``mkdir(parents=True,
|
|
341
|
+
exist_ok=True)``) — also for the ``output`` override, so the
|
|
342
|
+
default-path branch stays consistent.
|
|
343
|
+
* inline → ``target = output or out_dir / filename``;
|
|
344
|
+
``target.write_bytes(ref)``.
|
|
345
|
+
* artifact → ``target = client.download_artifact(ref, output or
|
|
346
|
+
out_dir / filename)`` (RelayClient takes care of the streaming).
|
|
347
|
+
* bridge → stderr ``"hp get: bridge resolution not supported by
|
|
348
|
+
this node-cli version"`` + ``return 1`` (MVP boundary; the exact
|
|
349
|
+
message string is FROZEN — plan T-179 Task 3).
|
|
350
|
+
* sha256 verification when the envelope carries one (inline always
|
|
351
|
+
does): mismatch → stderr
|
|
352
|
+
``"hp get: sha256 mismatch ({digest} != {expected})"`` + ``return 1``.
|
|
353
|
+
* stdout: EXACTLY ONE line — compact JSON
|
|
354
|
+
``{"path": str(target), "size_bytes": target.stat().st_size,
|
|
355
|
+
"src": <mode>}`` + ``"\n"`` (keys FROZEN). ``out=None`` →
|
|
356
|
+
``sys.stdout`` at CALL time (capsys-compatible).
|
|
357
|
+
* Exit codes: ``0`` ok, ``1`` resolution/verification error,
|
|
358
|
+
``2`` usage.
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
src, ref = parse_envelope(envelope)
|
|
362
|
+
except EnvelopeError as exc:
|
|
363
|
+
print(f"hp get: {exc}", file=sys.stderr)
|
|
364
|
+
return 1
|
|
365
|
+
|
|
366
|
+
# The filename from the envelope is NOT trustworthy (no
|
|
367
|
+
# ../absolute paths) — sanitize.
|
|
368
|
+
raw_name = (envelope.get(ENVELOPE_KEY) or {}).get("filename")
|
|
369
|
+
if not isinstance(raw_name, str) or not raw_name:
|
|
370
|
+
raw_name = "download"
|
|
371
|
+
safe_name = Path(raw_name).name
|
|
372
|
+
if safe_name in ("", ".", ".."):
|
|
373
|
+
safe_name = "download"
|
|
374
|
+
|
|
375
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
|
|
377
|
+
if src == "bridge":
|
|
378
|
+
serve_ref = ref if isinstance(ref, dict) else {}
|
|
379
|
+
if (
|
|
380
|
+
serve_ref.get("type") != "node_serve"
|
|
381
|
+
or not serve_ref.get("node_id")
|
|
382
|
+
or not serve_ref.get("path")
|
|
383
|
+
):
|
|
384
|
+
print(
|
|
385
|
+
"hp get: bridge storage_ref missing node_serve fields "
|
|
386
|
+
"(node_id/path)",
|
|
387
|
+
file=sys.stderr,
|
|
388
|
+
)
|
|
389
|
+
return 1
|
|
390
|
+
url = file_serve.proxy_url(
|
|
391
|
+
client.base_url, str(serve_ref["node_id"]), str(serve_ref["path"])
|
|
392
|
+
)
|
|
393
|
+
target = output or out_dir / safe_name
|
|
394
|
+
headers: dict[str, str] = {}
|
|
395
|
+
if client.token:
|
|
396
|
+
headers["Authorization"] = f"Bearer {client.token}"
|
|
397
|
+
try:
|
|
398
|
+
# POST with an empty body — the proxy matches the registered
|
|
399
|
+
# method exactly (route_registry.py:194-200); GET on a POST
|
|
400
|
+
# route would be 404. The serve handler ignores the body.
|
|
401
|
+
with httpx.stream(
|
|
402
|
+
"POST",
|
|
403
|
+
url,
|
|
404
|
+
headers=headers,
|
|
405
|
+
content=b"",
|
|
406
|
+
timeout=httpx.Timeout(30.0, read=None),
|
|
407
|
+
) as r:
|
|
408
|
+
r.raise_for_status()
|
|
409
|
+
with target.open("wb") as f:
|
|
410
|
+
for chunk in r.iter_bytes(chunk_size=64 * 1024):
|
|
411
|
+
f.write(chunk)
|
|
412
|
+
except httpx.HTTPError as exc:
|
|
413
|
+
print(f"hp get: bridge pull failed: {exc}", file=sys.stderr)
|
|
414
|
+
try:
|
|
415
|
+
target.unlink(missing_ok=True)
|
|
416
|
+
except OSError:
|
|
417
|
+
pass
|
|
418
|
+
return 1
|
|
419
|
+
# F7: the receiver NEVER unregisters (it is not the owner) —
|
|
420
|
+
# ephemerality/TTL is the sender daemon's job.
|
|
421
|
+
|
|
422
|
+
elif src == "inline":
|
|
423
|
+
target = output or out_dir / safe_name
|
|
424
|
+
target.write_bytes(ref)
|
|
425
|
+
else: # artifact — RelayClient takes care of the streaming.
|
|
426
|
+
try:
|
|
427
|
+
target = client.download_artifact(ref, output or out_dir / safe_name)
|
|
428
|
+
except httpx.HTTPError as exc:
|
|
429
|
+
print(f"hp get: artifact download failed: {exc}", file=sys.stderr)
|
|
430
|
+
return 1
|
|
431
|
+
|
|
432
|
+
# sha256 verification when the envelope carries one (inline always does).
|
|
433
|
+
expected = (envelope.get(ENVELOPE_KEY) or {}).get("sha256")
|
|
434
|
+
if expected:
|
|
435
|
+
# T-192 (F5): hash chunkwise (64 KiB) — no RAM peak on the
|
|
436
|
+
# big bridge/artifact path, which the rest of the flow streams
|
|
437
|
+
# deliberately.
|
|
438
|
+
digest = file_serve._sha256_file(target)
|
|
439
|
+
if digest != expected:
|
|
440
|
+
print(
|
|
441
|
+
f"hp get: sha256 mismatch ({digest} != {expected})",
|
|
442
|
+
file=sys.stderr,
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
target.unlink()
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
return 1
|
|
449
|
+
|
|
450
|
+
stream = out if out is not None else sys.stdout
|
|
451
|
+
stream.write(
|
|
452
|
+
json.dumps(
|
|
453
|
+
{
|
|
454
|
+
"path": str(target),
|
|
455
|
+
"size_bytes": target.stat().st_size,
|
|
456
|
+
"src": src,
|
|
457
|
+
},
|
|
458
|
+
separators=(",", ":"),
|
|
459
|
+
)
|
|
460
|
+
+ "\n"
|
|
461
|
+
)
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def default_out_dir() -> Path:
|
|
466
|
+
"""Default drop directory for ``hp get``: ``~/.relay/tmp/<task_id>/``.
|
|
467
|
+
|
|
468
|
+
FROZEN SIGNATURE (Phase 1). Task ID from ``RELAY_TASK_ID`` (assigned
|
|
469
|
+
by the handler_runner to every handler, see
|
|
470
|
+
``handler_runner.HANDLER_ENV_KEYS``); without handler context →
|
|
471
|
+
``adhoc``. Mirrors ``node_config.BASE_DIR`` (``~/.relay``) — the same
|
|
472
|
+
root as profile/meta, no new root folder.
|
|
473
|
+
"""
|
|
474
|
+
task_id = __import__("os").environ.get("RELAY_TASK_ID") or "adhoc"
|
|
475
|
+
return Path.home() / ".relay" / "tmp" / task_id
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def dispatch_put(client: RelayClient, args: argparse.Namespace) -> int:
|
|
479
|
+
"""Argparse adapter: ``node-cli hp put <path> --cap <cap> [--name N]``.
|
|
480
|
+
|
|
481
|
+
FROZEN SIGNATURE (Phase 1). House convention ``(client, args) -> int``;
|
|
482
|
+
registration in ``node_cli.py`` (Phase 2)::
|
|
483
|
+
|
|
484
|
+
p_hp_put.set_defaults(func=with_client(cli_hp.dispatch_put))
|
|
485
|
+
|
|
486
|
+
Namespace attributes (FROZEN, Phase 2 registers exactly these):
|
|
487
|
+
|
|
488
|
+
* ``args.path`` (str, positional) — file, existence-checked here:
|
|
489
|
+
missing → stderr + ``return 2`` (like cli_file._cmd_file_send).
|
|
490
|
+
* ``args.cap`` (str, --cap, required)
|
|
491
|
+
* ``args.name`` (str | None, --name, default None)
|
|
492
|
+
|
|
493
|
+
Delegates to ``cmd_put(client, cap=args.cap, path=Path(args.path),
|
|
494
|
+
name=args.name)``.
|
|
495
|
+
"""
|
|
496
|
+
path = Path(args.path)
|
|
497
|
+
if not path.is_file():
|
|
498
|
+
print(f"file not found: {path}", file=sys.stderr)
|
|
499
|
+
return 2
|
|
500
|
+
return cmd_put(client, cap=args.cap, path=path, name=args.name)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def dispatch_get(client: RelayClient, args: argparse.Namespace) -> int:
|
|
504
|
+
"""Argparse adapter: ``node-cli hp get [--file F] [--output P]``.
|
|
505
|
+
|
|
506
|
+
FROZEN SIGNATURE (Phase 1). House convention ``(client, args) -> int``;
|
|
507
|
+
registration in ``node_cli.py`` (Phase 2)::
|
|
508
|
+
|
|
509
|
+
p_hp_get.set_defaults(func=with_client(cli_hp.dispatch_get))
|
|
510
|
+
|
|
511
|
+
Namespace attributes (FROZEN, Phase 2 registers exactly these):
|
|
512
|
+
|
|
513
|
+
* ``args.file`` (str | None, --file, default None) — envelope JSON
|
|
514
|
+
as a file; without --file stdin is read (``sys.stdin``, call time).
|
|
515
|
+
* ``args.output`` (Path | None, --output/-o, default None)
|
|
516
|
+
* ``args.out_dir`` (Path | None, --out-dir, default None) — override,
|
|
517
|
+
otherwise ``default_out_dir()``.
|
|
518
|
+
|
|
519
|
+
The input must be a JSON object with ``__iowap_ref__``; invalid
|
|
520
|
+
JSON → stderr + ``return 2`` (usage). Delegates to ``cmd_get``.
|
|
521
|
+
"""
|
|
522
|
+
if args.file:
|
|
523
|
+
try:
|
|
524
|
+
raw = Path(args.file).read_text(encoding="utf-8")
|
|
525
|
+
except OSError as exc:
|
|
526
|
+
print(f"hp get: cannot read {args.file}: {exc}", file=sys.stderr)
|
|
527
|
+
return 2
|
|
528
|
+
else:
|
|
529
|
+
raw = sys.stdin.read()
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
envelope = json.loads(raw)
|
|
533
|
+
except json.JSONDecodeError as exc:
|
|
534
|
+
print(f"hp get: invalid JSON: {exc}", file=sys.stderr)
|
|
535
|
+
return 2
|
|
536
|
+
if not isinstance(envelope, dict) or ENVELOPE_KEY not in envelope:
|
|
537
|
+
print(
|
|
538
|
+
f"hp get: no {ENVELOPE_KEY} key — not an envelope",
|
|
539
|
+
file=sys.stderr,
|
|
540
|
+
)
|
|
541
|
+
return 2
|
|
542
|
+
|
|
543
|
+
out_dir = args.out_dir if args.out_dir is not None else default_out_dir()
|
|
544
|
+
return cmd_get(
|
|
545
|
+
client, envelope=envelope, out_dir=out_dir, output=args.output
|
|
546
|
+
)
|