@microck/raft 0.5.0 → 0.7.0

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.
Files changed (63) hide show
  1. package/README.md +27 -15
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/raft-expire.py +109 -31
  4. package/deploy/verify-controls.py +6 -6
  5. package/deploy/verify-features.py +329 -19
  6. package/deploy/verify-jobs.py +264 -0
  7. package/deploy/verify-limits.py +22 -3
  8. package/deploy/verify-live.py +31 -6
  9. package/deploy/verify-output.py +208 -0
  10. package/deploy/verify-release.py +33 -4
  11. package/deploy/verify-run.py +571 -0
  12. package/deploy/verify-self-update.py +9 -1
  13. package/deploy/verify-sync.py +122 -0
  14. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  15. package/docs/boat-capabilities.md +60 -7
  16. package/docs/boat-parity-design.md +1 -0
  17. package/docs/boot-performance.mdx +21 -0
  18. package/docs/contract.md +88 -13
  19. package/docs/first-box.mdx +22 -19
  20. package/docs/guides/background-jobs.mdx +38 -31
  21. package/docs/guides/backup-recovery.mdx +4 -3
  22. package/docs/guides/ci-jobs.mdx +209 -0
  23. package/docs/guides/config-policy.mdx +109 -35
  24. package/docs/guides/files.mdx +2 -2
  25. package/docs/guides/index.mdx +31 -0
  26. package/docs/guides/long-running-services.mdx +9 -3
  27. package/docs/guides/meta.json +7 -2
  28. package/docs/guides/project-setup.mdx +1 -1
  29. package/docs/guides/scripting-ci.mdx +36 -14
  30. package/docs/guides/snapshots-forks.mdx +4 -2
  31. package/docs/guides/troubleshooting.mdx +19 -2
  32. package/docs/guides/upgrades.mdx +11 -9
  33. package/docs/index.mdx +26 -28
  34. package/docs/maintainers/index.mdx +18 -0
  35. package/docs/maintainers/meta.json +12 -0
  36. package/docs/meta.json +5 -9
  37. package/docs/operations.mdx +17 -10
  38. package/docs/publishing.md +14 -4
  39. package/docs/reference/capacity.mdx +2 -1
  40. package/docs/reference/cli.md +135 -43
  41. package/docs/reference/configuration.mdx +10 -6
  42. package/docs/reference/meta.json +3 -1
  43. package/docs/releases/0.2.0.md +1 -0
  44. package/docs/releases/0.2.1.md +1 -0
  45. package/docs/releases/0.3.0.md +1 -0
  46. package/docs/releases/0.4.0.md +1 -0
  47. package/docs/releases/0.5.0.md +1 -0
  48. package/docs/releases/0.6.0.md +17 -0
  49. package/docs/releases/0.7.0.md +23 -0
  50. package/docs/releases/index.mdx +21 -0
  51. package/docs/releases/meta.json +15 -0
  52. package/docs/requirements.mdx +1 -1
  53. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  54. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  55. package/docs/site-development.md +1 -0
  56. package/docs/verification.mdx +22 -4
  57. package/package.json +3 -1
  58. package/raft.py +526 -193
  59. package/raft_files.py +13 -1
  60. package/raft_jobs.py +390 -0
  61. package/raft_output.py +166 -0
  62. package/raft_run.py +330 -0
  63. package/skills/raft-cli/SKILL.md +78 -182
@@ -0,0 +1,264 @@
1
+ """Real process/store checks and optional disposable-host retained-job verification."""
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import json
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+
12
+ ROOT = Path(__file__).resolve().parent.parent
13
+ sys.path.insert(0, str(ROOT))
14
+ import raft_jobs as jobs # noqa: E402
15
+
16
+
17
+ def stored(job, *, state="completed", finished=None):
18
+ return {
19
+ "job": job,
20
+ "box": "rf-" + "a" * 16,
21
+ "state": state,
22
+ "exitCode": 7,
23
+ "finishedAt": int(time.time()) if finished is None else finished,
24
+ "bootId": "old-boot",
25
+ }
26
+
27
+
28
+ def verify():
29
+ with tempfile.TemporaryDirectory(prefix="raft-jobs-") as directory:
30
+ root = Path(directory)
31
+ with jobs.private_store(root):
32
+ pass
33
+ status, dropped = jobs.capture(
34
+ root,
35
+ [
36
+ sys.executable,
37
+ "-c",
38
+ "import os,sys; os.write(1,b'x'*(2*1024*1024)); os.write(2,b'err'); sys.exit(7)",
39
+ ],
40
+ )
41
+ assert status == 7 and dropped == jobs.LOG_LIMIT + 3
42
+ assert (root / "output").read_bytes() == b"x" * jobs.LOG_LIMIT
43
+ (root / "output").unlink()
44
+ # A real file-size limit fails the first record write before any service launch.
45
+ rejected = subprocess.run(
46
+ [
47
+ sys.executable,
48
+ "-c",
49
+ "import resource,signal,sys; from pathlib import Path; import raft_jobs; "
50
+ "signal.signal(signal.SIGXFSZ, signal.SIG_IGN); "
51
+ "resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)); "
52
+ "raft_jobs.start(Path(sys.argv[1]), 'rf-'+'a'*16, 'rfcmd-'+'d'*32, ['true'], '')",
53
+ str(root),
54
+ ],
55
+ cwd=ROOT,
56
+ capture_output=True,
57
+ text=True,
58
+ )
59
+ assert rejected.returncode != 0 and "File too large" in rejected.stderr
60
+ assert not list(root.iterdir()), "Failed allocation consumed a record slot"
61
+ old = root / ("rfcmd-" + "a" * 32)
62
+ old.mkdir()
63
+ jobs.save(old, stored(old.name, finished=int(time.time()) - jobs.RETENTION - 1))
64
+ current = root / ("rfcmd-" + "b" * 32)
65
+ current.mkdir()
66
+ jobs.save(current, stored(current.name))
67
+ interrupted = root / ("rfcmd-" + "c" * 32)
68
+ interrupted.mkdir()
69
+ jobs.save(interrupted, stored(interrupted.name, state="running"))
70
+ incomplete = root / ("rfcmd-" + "e" * 32)
71
+ incomplete.mkdir()
72
+ damaged = root / ("rfcmd-" + "f" * 32)
73
+ damaged.mkdir()
74
+ (damaged / "result.json").write_text('{"state":')
75
+ (damaged / "output").write_bytes(b"retained evidence")
76
+ jobs.prune(root)
77
+ assert incomplete.exists() and (damaged / "output").read_bytes() == b"retained evidence"
78
+ for unreadable in (incomplete, damaged):
79
+ try:
80
+ jobs.load(unreadable)
81
+ except ValueError as error:
82
+ assert str(unreadable / "result.json") in str(error)
83
+ else:
84
+ raise AssertionError("Unreadable record accepted")
85
+ inspected = subprocess.run(
86
+ [
87
+ sys.executable,
88
+ "-c",
89
+ "from pathlib import Path; import sys,raft_jobs; "
90
+ "root=Path(sys.argv.pop(1)); raft_jobs.main(root)",
91
+ str(root),
92
+ "status",
93
+ "rf-" + "a" * 16,
94
+ current.name,
95
+ ],
96
+ cwd=ROOT,
97
+ capture_output=True,
98
+ text=True,
99
+ check=True,
100
+ )
101
+ assert json.loads(inspected.stdout)["exitCode"] == 7
102
+ for malformed in (None, {}, {**stored(damaged.name), "finishedAt": "invalid"}):
103
+ (damaged / "result.json").write_text(json.dumps(malformed))
104
+ jobs.prune(root)
105
+ assert jobs.load(current)["exitCode"] == 7
106
+ try:
107
+ jobs.load(damaged)
108
+ except ValueError:
109
+ pass
110
+ else:
111
+ raise AssertionError("Malformed record accepted")
112
+ assert not old.exists() and current.exists()
113
+ assert jobs.load(interrupted)["state"] == "interrupted"
114
+ assert jobs.load(interrupted)["exitCode"] is None
115
+ root.chmod(0o755)
116
+ try:
117
+ with jobs.private_store(root):
118
+ raise AssertionError("World-readable job store accepted")
119
+ except ValueError:
120
+ pass
121
+ finally:
122
+ root.chmod(0o700)
123
+ for index in range(jobs.RECORD_LIMIT - len(list(root.iterdir()))):
124
+ (root / f"slot-{index}").mkdir()
125
+ try:
126
+ jobs.start(root, "rf-" + "a" * 16, "rfcmd-" + "d" * 32, ["true"], "")
127
+ except ValueError as error:
128
+ assert "full" in str(error)
129
+ else:
130
+ raise AssertionError("Full store accepted a new job")
131
+ print(
132
+ "Real process capture, damaged-record isolation, retention, reboot interruption and store limits passed"
133
+ )
134
+
135
+
136
+ def native(location):
137
+ spec = importlib.util.spec_from_file_location("live", ROOT / "deploy/verify-live.py")
138
+ live = importlib.util.module_from_spec(spec)
139
+ spec.loader.exec_module(live)
140
+ box = live.raft(
141
+ "new",
142
+ "--location",
143
+ location,
144
+ "--persistent",
145
+ "--ttl",
146
+ "600",
147
+ "--purpose",
148
+ "Retained job verification",
149
+ "--owner",
150
+ "verify-jobs",
151
+ ).stdout.strip()
152
+ destroyed = False
153
+ try:
154
+ deadline = time.monotonic() + 40
155
+ while live.raft(
156
+ "exec", box, "--", "systemctl", "show", "-p", "Version", check=False
157
+ ).returncode:
158
+ assert time.monotonic() < deadline, "Guest systemd did not become ready"
159
+ time.sleep(0.2)
160
+ completed = live.raft(
161
+ "exec", box, "--detach", "--", "sh", "-c", "printf retained-output; exit 23"
162
+ ).stdout.strip()
163
+ record = live.wait_job(box, completed)
164
+ output = live.raft("logs", box, completed).stdout
165
+ if record["exitCode"] != 23 or record["state"] != "completed":
166
+ unit = live.raft("exec", box, "--", "systemctl", "show", completed, check=False)
167
+ raise AssertionError(
168
+ {"record": record, "output": output, "unit": unit.stdout, "error": unit.stderr}
169
+ )
170
+ assert output == "retained-output"
171
+ for signum in (1, 2, 13, 15):
172
+ signalled = live.raft(
173
+ "exec",
174
+ box,
175
+ "--detach",
176
+ "--",
177
+ "python3",
178
+ "-c",
179
+ f"import os,signal; signal.signal({signum},signal.SIG_DFL); "
180
+ f"os.kill(os.getpid(),{signum})",
181
+ ).stdout.strip()
182
+ record = live.wait_job(box, signalled)
183
+ assert record["state"] == "completed" and record["exitCode"] == 128 + signum, (
184
+ record,
185
+ live.raft("logs", box, signalled).stdout,
186
+ )
187
+ active = live.raft(
188
+ "exec", box, "--detach", "--", "sh", "-c", "echo ready; sleep 600"
189
+ ).stdout.strip()
190
+ deadline = time.monotonic() + 20
191
+ while "ready" not in live.raft("logs", box, active).stdout:
192
+ assert time.monotonic() < deadline
193
+ time.sleep(0.2)
194
+ live.raft("cancel", box, active)
195
+ record = live.wait_job(box, active)
196
+ assert record["state"] == "cancelled", (record, live.raft("logs", box, active).stdout)
197
+ orphan = live.raft(
198
+ "exec", box, "--detach", "--", "sh", "-c", "echo orphan-ready; exec sleep 600"
199
+ ).stdout.strip()
200
+ deadline = time.monotonic() + 20
201
+ while "orphan-ready" not in live.raft("logs", box, orphan).stdout:
202
+ assert time.monotonic() < deadline, "Orphan test payload did not start"
203
+ time.sleep(0.2)
204
+ live.transaction(location, 'systemctl kill --signal=SIGKILL "$1"', "raft-" + orphan)
205
+ record = live.wait_job(box, orphan)
206
+ assert record["state"] == "interrupted", record
207
+ assert int(live.execute(box, "systemctl", "show", orphan, "-p", "MainPID", "--value")) > 0
208
+ live.raft("cancel", box, orphan)
209
+ record = json.loads(live.raft("status", box, orphan).stdout)
210
+ assert record["state"] == "cancelled" and record["exitCode"] is None, record
211
+ assert record["finishedAt"] is not None
212
+ # ExecMainPID retains the historical PID after exit; MainPID tracks the live process.
213
+ assert int(live.execute(box, "systemctl", "show", orphan, "-p", "MainPID", "--value")) == 0
214
+ live.raft("cancel", box, orphan)
215
+ assert json.loads(live.raft("status", box, orphan).stdout) == record
216
+ interrupted = live.raft("exec", box, "--detach", "--", "sleep", "600").stdout.strip()
217
+ zero_on_stop = live.raft(
218
+ "exec",
219
+ box,
220
+ "--detach",
221
+ "--",
222
+ "python3",
223
+ "-u",
224
+ "-c",
225
+ "import signal,sys,time; signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)); "
226
+ "print('shutdown-handler-ready'); time.sleep(600)",
227
+ ).stdout.strip()
228
+ deadline = time.monotonic() + 20
229
+ while "shutdown-handler-ready" not in live.raft("logs", box, zero_on_stop).stdout:
230
+ assert time.monotonic() < deadline, "Shutdown handler did not start"
231
+ time.sleep(0.2)
232
+ live.raft("stop", box)
233
+ for stopped_job in (interrupted, zero_on_stop):
234
+ record = live.wait_job(box, stopped_job)
235
+ assert record["state"] == "interrupted" and record["exitCode"] is None, (
236
+ record,
237
+ live.raft("logs", box, stopped_job).stdout,
238
+ )
239
+ assert live.raft("logs", box, completed).stdout == "retained-output"
240
+ count_records = "find /var/lib/raft-jobs -mindepth 1 -maxdepth 1 -type d | wc -l"
241
+ before = live.transaction(location, count_records)
242
+ refused = live.raft("exec", box, "--detach", "--", "true", check=False)
243
+ assert refused.returncode == 1 and not refused.stdout, refused
244
+ assert "no job was created" in refused.stderr, refused.stderr
245
+ assert live.transaction(location, count_records) == before
246
+ live.raft("destroy", box)
247
+ destroyed = True
248
+ assert json.loads(live.raft("status", box, completed).stdout)["exitCode"] == 23
249
+ assert live.raft("logs", box, completed).stdout == "retained-output"
250
+ wrong = location + ":rf-" + "0" * 16
251
+ assert live.raft("logs", wrong, completed, check=False).returncode == 1
252
+ print(f"{location}: completed, cancelled and interrupted results survive stop and deletion")
253
+ finally:
254
+ if not destroyed:
255
+ live.raft("destroy", box)
256
+
257
+
258
+ if __name__ == "__main__":
259
+ cli = argparse.ArgumentParser(description=__doc__)
260
+ cli.add_argument("--location")
261
+ args = cli.parse_args()
262
+ verify()
263
+ if args.location:
264
+ native(args.location)
@@ -108,7 +108,8 @@ def policy_checks():
108
108
 
109
109
 
110
110
  def command(*args):
111
- return subprocess.check_output([*CLI, *args], text=True).strip()
111
+ # The private controller HOME keeps this controller's defaults for new out.
112
+ return subprocess.check_output([*CLI, *args], text=True, env=live.CONTROLLER_ENV).strip()
112
113
 
113
114
 
114
115
  def report(location):
@@ -298,8 +299,26 @@ def disk_checks(location):
298
299
  time.sleep(2)
299
300
  assert disk_size(box)[0] == total, disk_size(box)
300
301
  # Removing the container frees the space; restart Docker in case it lost state.
301
- live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
302
- live.execute(box, "systemctl", "restart", "docker")
302
+ removed = live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
303
+ restarted = live.raft("exec", box, "--", "systemctl", "restart", "docker", check=False)
304
+ if restarted.returncode:
305
+ print(
306
+ "Docker removal after disk exhaustion:", removed.stdout, removed.stderr, flush=True
307
+ )
308
+ journal = live.raft(
309
+ "exec",
310
+ box,
311
+ "--",
312
+ "journalctl",
313
+ "--no-pager",
314
+ "-n",
315
+ "100",
316
+ "-u",
317
+ "docker",
318
+ check=False,
319
+ )
320
+ print(journal.stdout, journal.stderr, flush=True)
321
+ raise RuntimeError("Docker restart after disk exhaustion failed: " + restarted.stderr)
303
322
  live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
304
323
  assert disk_size(box)[1] > GIB, disk_size(box)
305
324
  assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
@@ -7,6 +7,7 @@ import os
7
7
  import pty
8
8
  import select
9
9
  import shlex
10
+ import shutil
10
11
  from pathlib import Path
11
12
  import socket
12
13
  import subprocess
@@ -19,11 +20,24 @@ ROOT = Path(__file__).resolve().parent.parent
19
20
  CLI = [sys.executable, str(ROOT / "raft.py")]
20
21
  USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
21
22
  sys.path.insert(0, str(ROOT))
22
- from raft import configuration, incus, remote, settings, transaction # noqa: E402
23
+ from raft import CONFIG, configuration, incus, remote, settings, transaction # noqa: E402
24
+
25
+ # Suites run the CLI in a private HOME that holds only the location inventory, so
26
+ # this controller's defaults for new (config.json) never reach them: a disposable
27
+ # default would delete boxes a suite stops and expects to keep, and size defaults
28
+ # would change asserted limits. OpenSSH reads keys and aliases from the passwd
29
+ # home, not HOME. The inventory is copied once; suites that edit it use their own.
30
+ CONTROLLER_HOME = tempfile.TemporaryDirectory(prefix="raft-verify-home-")
31
+ if CONFIG.exists():
32
+ (Path(CONTROLLER_HOME.name) / ".config/raft").mkdir(parents=True)
33
+ shutil.copy2(CONFIG, Path(CONTROLLER_HOME.name) / ".config/raft/incus.json")
34
+ CONTROLLER_ENV = {**os.environ, "HOME": CONTROLLER_HOME.name}
23
35
 
24
36
 
25
37
  def raft(*arguments, check=True, env=None):
26
- response = subprocess.run([*CLI, *arguments], capture_output=True, text=True, env=env)
38
+ response = subprocess.run(
39
+ [*CLI, *arguments], capture_output=True, text=True, env=env or CONTROLLER_ENV
40
+ )
27
41
  if check and response.returncode:
28
42
  raise RuntimeError(f"Raft {arguments!r} failed: {response.stderr.strip()}")
29
43
  return response
@@ -43,6 +57,16 @@ def wait_running(box):
43
57
  raise RuntimeError("Guest Docker and SSH did not become ready")
44
58
 
45
59
 
60
+ def wait_job(box, job):
61
+ deadline = time.monotonic() + 30
62
+ while time.monotonic() < deadline:
63
+ record = json.loads(raft("status", box, job).stdout)
64
+ if record["state"] in {"completed", "cancelled", "interrupted", "failed"}:
65
+ return record
66
+ time.sleep(0.2)
67
+ raise RuntimeError("Detached job did not finish: " + job)
68
+
69
+
46
70
  def terminal_session(box):
47
71
  master, slave = pty.openpty()
48
72
  process = subprocess.Popen([*CLI, "ssh", box], stdin=slave, stdout=slave, stderr=slave)
@@ -352,7 +376,9 @@ def browser_session(box):
352
376
  assert.equal(response.result.errorText, undefined);
353
377
  socket.send(JSON.stringify(viewerProof));
354
378
  } else if (response.id === 4) {
355
- const viewer = JSON.parse(response.result.result.value);
379
+ // While noVNC is still loading, the evaluation can return no value.
380
+ const value = response.result.result.value;
381
+ const viewer = typeof value === 'string' ? JSON.parse(value) : {connected: false};
356
382
  if (!viewer.connected) {
357
383
  setTimeout(() => socket.send(JSON.stringify(viewerProof)), 200); return;
358
384
  }
@@ -780,8 +806,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
780
806
  job = raft(
781
807
  "exec", box, "--detach", "--", "bash", "-lc", "echo detached-ok; exit 23"
782
808
  ).stdout.strip()
783
- time.sleep(2)
784
- assert "ExecMainStatus=23" in raft("status", box, job).stdout
809
+ assert wait_job(box, job)["exitCode"] == 23
785
810
  assert "detached-ok" in raft("logs", box, job).stdout
786
811
  execute(box, "bash", "-lc", "echo saved > /workspace/persistent")
787
812
  raft("stop", box)
@@ -808,7 +833,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
808
833
  fetch_through_tunnel(["forward", box, "--remote", "8080"], "/persistent").strip()
809
834
  == b"saved"
810
835
  )
811
- assert "ActiveState=active" in raft("status", box, server).stdout
836
+ assert json.loads(raft("status", box, server).stdout)["state"] == "running"
812
837
  assert b"noVNC" in fetch_through_tunnel(["desktop", box], "/vnc.html", timeout=20)
813
838
  assert b"noVNC" in fetch_through_tunnel(
814
839
  ["desktop", box], "/vnc.html", timeout=20, default_port=6080
@@ -0,0 +1,208 @@
1
+ """Verify JSONL bytes, errors, completion discovery and native command status."""
2
+
3
+ import argparse
4
+ import base64
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ sys.path.insert(0, str(ROOT))
15
+ import raft # noqa: E402
16
+ import raft_output # noqa: E402
17
+
18
+
19
+ def events(command, env):
20
+ response = subprocess.run(command, env=env, capture_output=True)
21
+ records = [json.loads(line) for line in response.stdout.splitlines()]
22
+ assert not response.stderr, response.stderr
23
+ assert records[-1] == {"type": "exit", "code": response.returncode}, records
24
+ return response, records
25
+
26
+
27
+ def stream(records, kind):
28
+ return b"".join(base64.b64decode(r["data"]) for r in records if r["type"] == kind)
29
+
30
+
31
+ def verify():
32
+ with tempfile.TemporaryDirectory(prefix="raft-output-") as directory:
33
+ env = {**os.environ, "HOME": directory, "RAFT_NO_UPDATE_CHECK": "1"}
34
+ command = [sys.executable, str(ROOT / "raft.py"), "--json"]
35
+ response, records = events(command + ["--help"], env)
36
+ assert response.returncode == 0 and b"usage:" in stream(records, "stdout")
37
+ for arguments in [["invalid"], ["new", "--ttl", "no"], ["new", "--unknown"]]:
38
+ response, records = events(command + arguments, env)
39
+ assert response.returncode == 2 and records[0]["code"] == "usage", records
40
+ response, records = events(command + ["locations"], env)
41
+ assert response.returncode == 1 and records[0]["code"] == "operation", records
42
+ response, records = events(command + ["run", "--", "true"], env)
43
+ failures = [record for record in records if record["type"] == "error"]
44
+ assert response.returncode == 1 and len(failures) == 1, records
45
+ assert failures[0]["code"] == "operation" and "incus.json" in failures[0]["message"]
46
+ assert b"raft run: finished" in stream(records, "stderr"), records
47
+ plain = subprocess.run(
48
+ [sys.executable, str(ROOT / "raft.py"), "run", "--", "true"],
49
+ env=env,
50
+ capture_output=True,
51
+ )
52
+ assert plain.returncode == 1 and not plain.stdout
53
+ assert plain.stderr.startswith(b"raft run: ") and b"incus.json" in plain.stderr
54
+ inventory = Path(directory) / ".config/raft"
55
+ inventory.mkdir(parents=True)
56
+ (inventory / "incus.json").write_text('{"fixture":{"ssh":"offline.invalid","image":""}}')
57
+ response, records = events(command + ["locations"], env)
58
+ assert records[0] == {
59
+ "type": "data",
60
+ "value": {"location": "fixture", "default": True, "note": ""},
61
+ }
62
+ # Real subprocess descriptors, including binary bytes and more than a pipe
63
+ # buffer on both channels. Reconstruction must be exact and cannot hang.
64
+ program = """
65
+ import os, subprocess, sys
66
+ import raft_output
67
+ out = raft_output.JsonOutput().start()
68
+ subprocess.run([sys.executable, '-c', 'import os; os.write(1, bytes(range(256))*512); os.write(2,b"error"*32768)'], check=True)
69
+ out.finish(7)
70
+ sys.exit(7)
71
+ """
72
+ response, records = events(
73
+ [sys.executable, "-c", program], {**env, "PYTHONPATH": str(ROOT)}
74
+ )
75
+ assert response.returncode == 7
76
+ assert stream(records, "stdout") == bytes(range(256)) * 512
77
+ assert stream(records, "stderr") == b"error" * 32768
78
+ for number in (9, 15):
79
+ # The event and shell status must agree after real child termination.
80
+ signalled = f"""
81
+ import subprocess, sys
82
+ import raft, raft_output
83
+ out = raft_output.JsonOutput().start()
84
+ child = subprocess.run([sys.executable, '-c', 'import os, signal; os.kill(os.getpid(), {number})'])
85
+ status = raft.exit_status(child.returncode)
86
+ out.finish(status)
87
+ sys.exit(status)
88
+ """
89
+ response, records = events(
90
+ [sys.executable, "-c", signalled], {**env, "PYTHONPATH": str(ROOT)}
91
+ )
92
+ assert response.returncode == 128 + number, records
93
+ parser = raft.parser()
94
+ parser.add_argument("--future-flag", action="store_true")
95
+ assert raft_output.candidates(parser, ["--future"]) == ["--future-flag"]
96
+ assert "--location" in raft_output.candidates(parser, ["new", "--lo"])
97
+ assert "set" in raft_output.candidates(parser, ["config", ""])
98
+ assert raft_output.candidates(parser, ["exec", "box", "--", ""]) == []
99
+ assert raft_output.candidates(parser, ["completions", "z"]) == ["zsh"]
100
+ for shell in ("bash", "zsh", "fish"):
101
+ script = subprocess.run(
102
+ [sys.executable, str(ROOT / "raft.py"), "completions", shell],
103
+ env=env,
104
+ capture_output=True,
105
+ check=True,
106
+ )
107
+ assert b"_complete" in script.stdout and not script.stderr
108
+ setup = raft_output.completions("bash")
109
+ completed = subprocess.run(
110
+ [
111
+ "bash",
112
+ "-c",
113
+ setup
114
+ + "\nraft() { "
115
+ + sys.executable
116
+ + " "
117
+ + str(ROOT / "raft.py")
118
+ + ' "$@"; }\nCOMP_WORDS=(raft config ""); COMP_CWORD=2; _raft_complete; printf "%s\\n" "${COMPREPLY[@]}"',
119
+ ],
120
+ env=env,
121
+ capture_output=True,
122
+ check=True,
123
+ )
124
+ assert "set" in completed.stdout.decode().splitlines(), completed
125
+ print("JSONL bytes, subprocess streams, structured errors, reports and Bash completion passed")
126
+
127
+
128
+ def native(location):
129
+ spec = importlib.util.spec_from_file_location("live", ROOT / "deploy/verify-live.py")
130
+ live = importlib.util.module_from_spec(spec)
131
+ spec.loader.exec_module(live)
132
+ box = live.raft(
133
+ "new",
134
+ "--location",
135
+ location,
136
+ "--ttl",
137
+ "600",
138
+ "--disposable",
139
+ "--purpose",
140
+ "JSON output verification",
141
+ "--owner",
142
+ "verify-output",
143
+ ).stdout.strip()
144
+ try:
145
+ live.wait_running(box)
146
+ response, records = events(
147
+ [
148
+ sys.executable,
149
+ str(ROOT / "raft.py"),
150
+ "--json",
151
+ "exec",
152
+ box,
153
+ "--",
154
+ "sh",
155
+ "-c",
156
+ "printf hello; printf error >&2; exit 7",
157
+ ],
158
+ live.CONTROLLER_ENV,
159
+ )
160
+ assert response.returncode == 7
161
+ assert stream(records, "stdout") == b"hello" and stream(records, "stderr") == b"error", (
162
+ records
163
+ )
164
+ response, records = events(
165
+ [sys.executable, str(ROOT / "raft.py"), "--json", "info", box], live.CONTROLLER_ENV
166
+ )
167
+ assert records[0]["value"]["name"] == box.partition(":")[2], records
168
+ with tempfile.TemporaryDirectory(prefix="raft-output-artifacts-") as artifacts:
169
+ response, records = events(
170
+ [
171
+ sys.executable,
172
+ str(ROOT / "raft.py"),
173
+ "--json",
174
+ "run",
175
+ "--location",
176
+ location,
177
+ "--purpose",
178
+ "JSON artifact failure verification",
179
+ "--owner",
180
+ "verify-output",
181
+ "--pull",
182
+ "/missing-raft-artifacts",
183
+ str(Path(artifacts) / "collected"),
184
+ "--",
185
+ "true",
186
+ ],
187
+ live.CONTROLLER_ENV,
188
+ )
189
+ assert response.returncode == 1, records
190
+ assert any(
191
+ record["type"] == "error"
192
+ and record["code"] == "operation"
193
+ and "could not collect /missing-raft-artifacts" in record["message"]
194
+ for record in records
195
+ ), records
196
+ assert b"cleanup confirmed" in stream(records, "stderr"), records
197
+ print(f"{location}: JSON exec, reports, run artifact failure and cleanup passed")
198
+ finally:
199
+ live.raft("destroy", box)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ cli = argparse.ArgumentParser(description=__doc__)
204
+ cli.add_argument("--location")
205
+ args = cli.parse_args()
206
+ verify()
207
+ if args.location:
208
+ native(args.location)
@@ -68,12 +68,21 @@ def verify_npm():
68
68
  content = archive.extractfile(member).read()
69
69
  inspect(member.name, content)
70
70
  names.add(member.name)
71
- if member.name in {"package/raft.py", "package/raft_files.py"}:
71
+ if member.name in {
72
+ "package/raft.py",
73
+ "package/raft_files.py",
74
+ "package/raft_run.py",
75
+ "package/raft_jobs.py",
76
+ "package/raft_output.py",
77
+ }:
72
78
  if content != (ROOT / PurePosixPath(member.name).name).read_bytes():
73
79
  raise ValueError("npm runtime differs from canonical Python source")
74
80
  required = {
75
81
  "package/raft.py",
76
82
  "package/raft_files.py",
83
+ "package/raft_run.py",
84
+ "package/raft_jobs.py",
85
+ "package/raft_output.py",
77
86
  "package/LICENSE",
78
87
  "package/deploy/deploy.py",
79
88
  "package/images/incus/build.sh",
@@ -105,12 +114,28 @@ def main():
105
114
  raise ValueError("Unexpected non-file member: " + member.name)
106
115
  inspect(member.name, archive.extractfile(member).read())
107
116
  names = {PurePosixPath(member.name).parts[1] for member in members}
108
- if not {"raft.py", "deploy", "docs", "images", "skills", "LICENSE"} <= names:
117
+ if (
118
+ not {
119
+ "raft.py",
120
+ "raft_files.py",
121
+ "raft_run.py",
122
+ "raft_jobs.py",
123
+ "raft_output.py",
124
+ "deploy",
125
+ "docs",
126
+ "images",
127
+ "skills",
128
+ "LICENSE",
129
+ }
130
+ <= names
131
+ ):
109
132
  raise ValueError("Source archive missing installation resources")
110
133
  with zipfile.ZipFile(wheels[0]) as archive:
111
134
  for name in archive.namelist():
112
135
  inspect(name, archive.read(name))
113
- if not {"raft.py", "raft_files.py"} <= set(archive.namelist()):
136
+ if not {"raft.py", "raft_files.py", "raft_run.py", "raft_jobs.py", "raft_output.py"} <= set(
137
+ archive.namelist()
138
+ ):
114
139
  raise ValueError("Wheel missing CLI module")
115
140
  # A new environment avoids both editable checkouts and uv's cached same-version
116
141
  # tool environments. Run the installed entry point outside the source tree.
@@ -123,7 +148,11 @@ def main():
123
148
  check=True,
124
149
  )
125
150
  verify_cli(environment / "bin/raft", work)
126
- subprocess.run([str(python), "-I", "-c", "import raft, raft_files"], cwd=work, check=True)
151
+ subprocess.run(
152
+ [str(python), "-I", "-c", "import raft, raft_files, raft_run, raft_jobs, raft_output"],
153
+ cwd=work,
154
+ check=True,
155
+ )
127
156
  verify_npm()
128
157
  print("Release archives and isolated wheel installation passed")
129
158