@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,571 @@
1
+ """Fault-injection, real signal/file tests, and optional native-host CI-run verification."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import io
6
+ import json
7
+ import os
8
+ import re
9
+ import select
10
+ import shlex
11
+ from pathlib import Path
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import tarfile
16
+ import tempfile
17
+ import time
18
+ from types import SimpleNamespace
19
+ import unittest
20
+ import uuid
21
+ from unittest.mock import patch
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ sys.path.insert(0, str(ROOT))
25
+ import raft # noqa: E402
26
+ import raft_files # noqa: E402
27
+ import raft_run # noqa: E402
28
+
29
+
30
+ class Transport:
31
+ """Explicit orchestration fault fixture; not a native Incus verification."""
32
+
33
+ def __init__(self, fault="", status=0):
34
+ self.fault, self.status = fault, status
35
+ self.events = []
36
+
37
+ def new(self, args, *, run_id, on_allocate):
38
+ self.events.append("allocate")
39
+ assert args.disposable is True and args.ttl == args.timeout + 120
40
+ self.token = run_id
41
+ on_allocate("lab", "rf-fixture")
42
+ if self.fault == "create":
43
+ raise RuntimeError("uncertain SSH create outcome")
44
+ self.wait("create")
45
+ return "lab:rf-fixture"
46
+
47
+ def wait(self, phase):
48
+ if self.fault == "timeout-" + phase:
49
+ time.sleep(2)
50
+ if self.fault == "signal-" + phase:
51
+ os.kill(os.getpid(), signal.SIGTERM)
52
+
53
+ def execute(self, _location, _name, command, **_kwargs):
54
+ if command == ["true"]:
55
+ self.events.append("ready")
56
+ return SimpleNamespace(returncode=0)
57
+ self.events.append("payload")
58
+ self.wait("payload")
59
+ return SimpleNamespace(returncode=self.status)
60
+
61
+ def transfer_tree(self, args, _location, _name):
62
+ self.events.append(args.action + ":" + str(args.source))
63
+ self.wait(args.action)
64
+ if self.fault == args.action or (self.fault == "first-pull" and args.source == "/first"):
65
+ raise RuntimeError(args.action + " failed")
66
+
67
+ def transaction(self, _location, _script, name, token, creation, code):
68
+ self.events.append("cleanup")
69
+ assert creation == (
70
+ "pending"
71
+ if self.fault in {"create", "timeout-create", "signal-create"}
72
+ else "acknowledged"
73
+ )
74
+ assert name == "rf-fixture" and token == self.token and code == raft_run.CLEANUP
75
+ self.wait("cleanup")
76
+ if self.fault == "cleanup":
77
+ raise RuntimeError("cleanup SSH failed")
78
+
79
+
80
+ class RunTests(unittest.TestCase):
81
+ def invoke(self, transport, *options, timeout=0.1):
82
+ args = raft.parser().parse_args(["run", *options, "--", "echo", "hello"])
83
+ args.timeout = timeout
84
+ old = {
85
+ number: signal.getsignal(number)
86
+ for number in (signal.SIGINT, signal.SIGTERM, signal.SIGALRM)
87
+ }
88
+ with contextlib.redirect_stderr(io.StringIO()), patch.object(raft_run, "GRACE", 0.1):
89
+ result = raft_run.run(args, transport)
90
+ for number, handler in old.items():
91
+ self.assertEqual(signal.getsignal(number), handler)
92
+ self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0, 0))
93
+ return result
94
+
95
+ def test_receive_deadline_after_stdout_eof_kills_child(self):
96
+ class ArmOnWrite(io.BytesIO):
97
+ def write(self, data):
98
+ signal.setitimer(signal.ITIMER_REAL, 0.1)
99
+ return super().write(data)
100
+
101
+ previous = signal.getsignal(signal.SIGALRM)
102
+
103
+ def expired(_number, _frame):
104
+ raise raft_run.Stopped(124, "test deadline")
105
+
106
+ began = time.monotonic()
107
+ try:
108
+ signal.signal(signal.SIGALRM, expired)
109
+ with self.assertRaises(raft_run.Stopped):
110
+ raft.receive(
111
+ [
112
+ sys.executable,
113
+ "-c",
114
+ "import os,time; print('ready', flush=True); os.close(1); time.sleep(5)",
115
+ ],
116
+ ArmOnWrite(),
117
+ )
118
+ finally:
119
+ signal.setitimer(signal.ITIMER_REAL, 0)
120
+ signal.signal(signal.SIGALRM, previous)
121
+ self.assertLess(time.monotonic() - began, 2, "EOF-before-exit must not evade the deadline")
122
+
123
+ def test_success_and_payload_status(self):
124
+ for status, expected in [(0, 0), (23, 23), (-signal.SIGTERM, 143)]:
125
+ api = Transport(status=status)
126
+ self.assertEqual(self.invoke(api), expected)
127
+ self.assertEqual(api.events, ["allocate", "ready", "payload", "cleanup"])
128
+
129
+ def test_uncertain_creation_and_upload_cleanup(self):
130
+ with tempfile.TemporaryDirectory() as directory:
131
+ for fault in ["create", "upload"]:
132
+ api = Transport(fault)
133
+ self.assertEqual(self.invoke(api, "--push-all", directory), 1)
134
+ self.assertEqual(api.events[-1], "cleanup")
135
+ self.assertNotIn("payload", api.events)
136
+
137
+ def test_signal_and_timeout_cleanup(self):
138
+ with tempfile.TemporaryDirectory() as directory:
139
+ for phase in ["create", "upload", "payload"]:
140
+ for prefix, expected in [("timeout-", 124), ("signal-", 143)]:
141
+ api = Transport(prefix + phase)
142
+ self.assertEqual(self.invoke(api, "--push-all", directory), expected)
143
+ self.assertEqual(api.events[-1], "cleanup")
144
+
145
+ def test_artifacts_and_error_precedence(self):
146
+ with tempfile.TemporaryDirectory() as directory:
147
+ options = [
148
+ "--pull",
149
+ "/first",
150
+ directory + "/one",
151
+ "--pull",
152
+ "/second",
153
+ directory + "/two",
154
+ ]
155
+ for failure in [
156
+ "first-pull",
157
+ "download",
158
+ "timeout-download",
159
+ "cleanup",
160
+ "timeout-cleanup",
161
+ ]:
162
+ for payload, expected in [(0, 1), (23, 23)]:
163
+ api = Transport(failure, status=payload)
164
+ self.assertEqual(self.invoke(api, *options), expected)
165
+ self.assertEqual(api.events[-1], "cleanup")
166
+ if failure == "first-pull":
167
+ self.assertIn("download:/second", api.events)
168
+
169
+ def test_invalid_paths_and_command_do_not_allocate(self):
170
+ with tempfile.TemporaryDirectory() as directory:
171
+ for extra in [
172
+ ["--pull", "/x", directory],
173
+ ["--cwd", "relative"],
174
+ ["--pull", "/x", directory + "/new", "--pull", "/y", directory + "/new"],
175
+ ]:
176
+ api = Transport()
177
+ with self.assertRaises(ValueError):
178
+ self.invoke(api, *extra)
179
+ self.assertFalse(api.events)
180
+ link = Path(directory) / "link"
181
+ link.symlink_to(Path(directory) / "absent")
182
+ with self.assertRaises(ValueError):
183
+ self.invoke(Transport(), "--pull", "/x", str(link))
184
+ args = raft.parser().parse_args(["run"])
185
+ with self.assertRaises(ValueError):
186
+ raft_run.run(args, Transport())
187
+ for value in ["0", "2592000", "1.5"]:
188
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
189
+ raft.parser().parse_args(["run", "--timeout", value, "--", "true"])
190
+
191
+ def test_creation_plan_sets_ownership_and_expiry_before_start(self):
192
+ args = raft.parser().parse_args(["run", "--location", "lab", "--", "true"])
193
+ args.ttl, args.disposable = 720, True
194
+ calls = []
195
+ config = {"ssh": "fixture", "image": "a" * 64}
196
+ with (
197
+ patch.object(raft, "configured_defaults", return_value={}),
198
+ patch.object(raft, "configuration", return_value={"lab": config}),
199
+ patch.object(raft, "settings", return_value=config),
200
+ patch.object(raft, "require_note"),
201
+ patch.object(raft, "fits_host", return_value=""),
202
+ patch.object(raft, "transaction", side_effect=lambda *args: calls.append(args)),
203
+ ):
204
+ allocated = []
205
+ handle = raft.new(
206
+ args, run_id="1" * 32, on_allocate=lambda *args: allocated.append(args)
207
+ )
208
+ self.assertEqual(handle, ":".join(allocated[0]))
209
+ script = calls[0][1]
210
+ self.assertLess(
211
+ script.index('user.raft.expires="$(( '), script.index("incus --project raft start")
212
+ )
213
+ self.assertEqual(script.count("user.raft.expires="), 1)
214
+ self.assertTrue(script.startswith(raft_run.creation_guard("1" * 32)))
215
+ self.assertIn("user.raft.run=" + "1" * 32, calls[0])
216
+ self.assertIn("user.raft.disposable=true", calls[0])
217
+
218
+
219
+ class FileTests(unittest.TestCase):
220
+ def test_git_filter_and_pinned_directory_packing(self):
221
+ with tempfile.TemporaryDirectory() as directory:
222
+ root = Path(directory)
223
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
224
+ (root / ".gitignore").write_text(".env\nignored/\n")
225
+ (root / ".env").write_text("not-for-upload")
226
+ (root / "tracked").write_text("tracked")
227
+ (root / "ignored").mkdir()
228
+ (root / "ignored/kept").write_text("tracked even though ignored")
229
+ (root / "ignored/secret").write_text("not-for-upload")
230
+ strange = "line\nbreak-" + os.fsdecode(b"\xff")
231
+ (root / strange).write_text("untracked, not ignored")
232
+ subprocess.run(["git", "-C", str(root), "add", ".gitignore", "tracked"], check=True)
233
+ subprocess.run(["git", "-C", str(root), "add", "-f", "ignored/kept"], check=True)
234
+ selected = raft_run.git_files(root)
235
+ self.assertIn(strange, selected)
236
+ self.assertIn("ignored/kept", selected)
237
+ self.assertNotIn(".env", selected)
238
+ self.assertNotIn("ignored/secret", selected)
239
+ stream = io.BytesIO()
240
+ raft_files.pack_directory(root, stream, include=selected)
241
+ stream.seek(0)
242
+ with tarfile.open(fileobj=stream) as archive:
243
+ names = {name.removeprefix("./") for name in archive.getnames()}
244
+ self.assertIn(strange, names)
245
+ self.assertIn("ignored/kept", names)
246
+ self.assertNotIn("ignored/secret", names)
247
+ self.assertNotIn(".env", names)
248
+ self.assertFalse(any(".git" in Path(name).parts for name in names))
249
+ for bad in [["../outside"], ["/absolute"], ["nested/.git/secret"]]:
250
+ with self.assertRaises(ValueError):
251
+ raft_files.pack_directory(root, io.BytesIO(), include=bad)
252
+ subprocess.run(
253
+ [
254
+ "git",
255
+ "-C",
256
+ str(root),
257
+ "update-index",
258
+ "--add",
259
+ "--cacheinfo",
260
+ "160000," + "1" * 40 + ",module",
261
+ ],
262
+ check=True,
263
+ )
264
+ with self.assertRaisesRegex(ValueError, "submodules"):
265
+ raft_run.git_files(root)
266
+ with tempfile.TemporaryDirectory() as directory:
267
+ with self.assertRaises(ValueError):
268
+ raft_run.git_files(Path(directory))
269
+
270
+ def test_cleanup_program_checks_ownership_and_lookup_failures(self):
271
+ with tempfile.TemporaryDirectory() as directory:
272
+ root = Path(directory)
273
+ shim = root / "incus"
274
+ shim.write_text(
275
+ "#!/usr/bin/env python3\n"
276
+ + """import json, os, pathlib, sys
277
+ path = pathlib.Path(os.environ['RUN_STATE'])
278
+ state = json.loads(path.read_text())
279
+ if state.get('fail'):
280
+ sys.exit(7)
281
+ if sys.argv[3] == 'list':
282
+ print(json.dumps(state['boxes']))
283
+ elif sys.argv[3] == 'delete':
284
+ state['boxes'] = []; state['deleted'] = True
285
+ path.write_text(json.dumps(state))
286
+ else:
287
+ sys.exit(8)
288
+ """
289
+ )
290
+ shim.chmod(0o755)
291
+ state = root / "state.json"
292
+ environment = {
293
+ **os.environ,
294
+ "PATH": str(root) + os.pathsep + os.environ["PATH"],
295
+ "RUN_STATE": str(state),
296
+ }
297
+ for boxes, fail, expected, deleted in [
298
+ ([], False, 0, False),
299
+ (
300
+ [
301
+ {
302
+ "name": "rf-fixture",
303
+ "config": {"user.raft": "true", "user.raft.run": "token"},
304
+ }
305
+ ],
306
+ False,
307
+ 0,
308
+ True,
309
+ ),
310
+ (
311
+ [
312
+ {
313
+ "name": "rf-fixture",
314
+ "config": {"user.raft": "true", "user.raft.run": "other"},
315
+ }
316
+ ],
317
+ False,
318
+ 1,
319
+ False,
320
+ ),
321
+ ([], True, 1, False),
322
+ ]:
323
+ state.write_text(json.dumps({"boxes": boxes, "fail": fail}))
324
+ result = subprocess.run(
325
+ [sys.executable, "-c", raft_run.CLEANUP, "rf-fixture", "token", "acknowledged"],
326
+ env=environment,
327
+ capture_output=True,
328
+ )
329
+ self.assertEqual(bool(result.returncode), bool(expected), result.stderr)
330
+ self.assertEqual(json.loads(state.read_text()).get("deleted", False), deleted)
331
+
332
+ isolated = raft_run.CLEANUP.replace(
333
+ '"/run/raft-ci-cancelled"', repr(str(root / "cancelled"))
334
+ )
335
+ for fail, token in [(False, "a" * 32), (True, "b" * 32)]:
336
+ state.write_text(json.dumps({"boxes": [], "fail": fail}))
337
+ result = subprocess.run(
338
+ [sys.executable, "-c", isolated, "rf-fixture", token, "pending"],
339
+ env=environment,
340
+ capture_output=True,
341
+ )
342
+ self.assertEqual(bool(result.returncode), fail, result.stderr)
343
+ self.assertTrue((root / "cancelled" / token).exists())
344
+
345
+ def test_cancelled_creation_cannot_start_later(self):
346
+ with tempfile.TemporaryDirectory() as directory:
347
+ root = Path(directory)
348
+ markers = root / "cancellations"
349
+ token = "a" * 32
350
+ common = raft_run.CANCELLATIONS.replace('"/run/raft-ci-cancelled"', repr(str(markers)))
351
+ # Isolated runtime state; the actual CLEANUP pending path is tested above.
352
+ cleanup = common + "\ncancellation(sys.argv[1], record=True)\n"
353
+ guard_command = shlex.split(raft_run.creation_guard(token))
354
+ guard_command[2] = guard_command[2].replace(
355
+ '"/run/raft-ci-cancelled"', repr(str(markers))
356
+ )
357
+ guard = guard_command[2]
358
+ subprocess.run([sys.executable, "-c", cleanup, token], check=True)
359
+ self.assertEqual(markers.stat().st_mode & 0o777, 0o700)
360
+ self.assertTrue((markers / token).exists())
361
+ created = root / "late-created"
362
+ late = (
363
+ "set -e\n" + shlex.join(guard_command) + "\n" + shlex.join(["touch", str(created)])
364
+ )
365
+ result = subprocess.run(
366
+ ["flock", str(root / "lifecycle.lock"), "sh", "-c", late], capture_output=True
367
+ )
368
+ self.assertNotEqual(result.returncode, 0)
369
+ self.assertFalse(created.exists(), "A queued creation must honor earlier cancellation")
370
+ self.assertEqual(subprocess.run([sys.executable, "-c", guard, "b" * 32]).returncode, 0)
371
+ # Do not follow or truncate a marker symlink, even in tampered private state.
372
+ victim = root / "victim"
373
+ victim.write_text("preserve")
374
+ (markers / ("c" * 32)).symlink_to(victim)
375
+ subprocess.run([sys.executable, "-c", cleanup, "c" * 32], check=True)
376
+ self.assertEqual(victim.read_text(), "preserve")
377
+ markers.chmod(0o755)
378
+ refused = subprocess.run([sys.executable, "-c", cleanup, "d" * 32], capture_output=True)
379
+ self.assertNotEqual(refused.returncode, 0)
380
+ self.assertFalse((markers / ("d" * 32)).exists())
381
+ markers.chmod(0o700)
382
+ link = root / "linked"
383
+ link.symlink_to(markers, target_is_directory=True)
384
+ unsafe = cleanup.replace(repr(str(markers)), repr(str(link)))
385
+ refused = subprocess.run([sys.executable, "-c", unsafe, "e" * 32], capture_output=True)
386
+ self.assertNotEqual(refused.returncode, 0)
387
+ self.assertFalse((markers / ("e" * 32)).exists())
388
+
389
+
390
+ def native(location):
391
+ """Real CLI/SSH/Incus transfers, templates and cancellation on a selected fixture host."""
392
+ owner = "verify-run-" + uuid.uuid4().hex
393
+ before = {box["name"] for box in raft.inventory(location)}
394
+ owned = set()
395
+ with tempfile.TemporaryDirectory() as directory:
396
+ root = Path(directory)
397
+ config = root / ".config/raft"
398
+ config.mkdir(parents=True)
399
+ (config / "incus.json").write_text(json.dumps({location: raft.settings(location)}))
400
+ environment = {**os.environ, "HOME": directory, "RAFT_NO_UPDATE_CHECK": "1"}
401
+ command = [sys.executable, str(ROOT / "raft.py")]
402
+ options = [
403
+ "--location",
404
+ location,
405
+ "--purpose",
406
+ "Disposable CI verification",
407
+ "--owner",
408
+ owner,
409
+ "--next",
410
+ "Delete after verification",
411
+ ]
412
+
413
+ def remember(stderr):
414
+ for handle in re.findall(r"^raft run: allocated (.+)$", stderr, re.MULTILINE):
415
+ if raft.parse_handle(handle)[0] == location:
416
+ owned.add(handle)
417
+
418
+ def cli(*args):
419
+ result = subprocess.run(
420
+ [*command, *args], env=environment, capture_output=True, text=True, timeout=150
421
+ )
422
+ remember(result.stderr)
423
+ return result
424
+
425
+ source = root / "source"
426
+ source.mkdir()
427
+ (source / "input.txt").write_text("source arrived")
428
+ try:
429
+ for status in [0, 23]:
430
+ output = root / f"output-{status}"
431
+ result = cli(
432
+ "run",
433
+ *options,
434
+ "--timeout",
435
+ "60",
436
+ "--push-all",
437
+ str(source),
438
+ "--pull",
439
+ "/workspace/project/out",
440
+ str(output),
441
+ "--",
442
+ "bash",
443
+ "-lc",
444
+ f"mkdir out; cp input.txt out/result.txt; printf payload-output; exit {status}",
445
+ )
446
+ assert result.returncode == status, result.stderr
447
+ assert result.stdout == "payload-output", result.stdout
448
+ assert (output / "result.txt").read_text() == "source arrived"
449
+ assert "cleanup confirmed" in result.stderr, result.stderr
450
+ subprocess.run(["git", "init", "-q", str(source)], check=True)
451
+ (source / ".gitignore").write_text(".env\n")
452
+ (source / ".env").write_text("fixture-not-a-real-secret")
453
+ result = cli(
454
+ "run",
455
+ *options,
456
+ "--timeout",
457
+ "60",
458
+ "--push",
459
+ str(source),
460
+ "--",
461
+ "bash",
462
+ "-lc",
463
+ "set -e; test ! -e .git; test ! -e .env; cat input.txt",
464
+ )
465
+ assert result.returncode == 0 and result.stdout == "source arrived", result.stderr
466
+ result = cli(
467
+ "run", *options, "--timeout", "10", "--", "bash", "-lc", "echo started; sleep 60"
468
+ )
469
+ assert result.returncode == 124 and "started" in result.stdout, (
470
+ result.returncode,
471
+ result.stderr,
472
+ )
473
+ assert "cleanup confirmed" in result.stderr, result.stderr
474
+ for cancellation in [signal.SIGINT, signal.SIGTERM]:
475
+ with tempfile.TemporaryFile(mode="w+") as errors:
476
+ with subprocess.Popen(
477
+ [
478
+ *command,
479
+ "run",
480
+ *options,
481
+ "--timeout",
482
+ "60",
483
+ "--",
484
+ "bash",
485
+ "-lc",
486
+ "echo started; sleep 60",
487
+ ],
488
+ env=environment,
489
+ stdout=subprocess.PIPE,
490
+ stderr=errors,
491
+ text=True,
492
+ ) as process:
493
+ try:
494
+ assert select.select([process.stdout], [], [], 30)[0], (
495
+ "No payload-ready marker"
496
+ )
497
+ assert process.stdout.readline().strip() == "started"
498
+ process.send_signal(cancellation)
499
+ process.communicate(timeout=70)
500
+ assert process.returncode == 128 + cancellation, process.returncode
501
+ finally:
502
+ if process.poll() is None:
503
+ process.kill()
504
+ process.wait()
505
+ errors.seek(0)
506
+ diagnostic = errors.read()
507
+ remember(diagnostic)
508
+ assert "cleanup confirmed" in diagnostic, diagnostic
509
+ created = cli(
510
+ "new", *options, "--persistent", "--ttl", "600", "--cpu", "1", "--memory", "2GiB"
511
+ )
512
+ assert created.returncode == 0, created.stderr
513
+ base = created.stdout.strip()
514
+ owned.add(base)
515
+ prepared = cli(
516
+ "exec",
517
+ base,
518
+ "--",
519
+ "bash",
520
+ "-lc",
521
+ "mkdir -p /opt/ci-cache; echo warm >/opt/ci-cache/proof",
522
+ )
523
+ assert prepared.returncode == 0, prepared.stderr
524
+ assert cli("stop", base).returncode == 0
525
+ assert cli("snapshot", base, "ci-base").returncode == 0
526
+ for _ in range(2):
527
+ result = cli(
528
+ "run",
529
+ *options,
530
+ "--from",
531
+ base + "/ci-base",
532
+ "--timeout",
533
+ "60",
534
+ "--",
535
+ "bash",
536
+ "-lc",
537
+ 'set -e; test "$(cat /opt/ci-cache/proof)" = warm; '
538
+ "test ! -e /opt/ci-cache/job; touch /opt/ci-cache/job; cat /sys/fs/cgroup/memory.max",
539
+ )
540
+ assert result.returncode == 0 and result.stdout.strip() == str(2 * 1024**3), (
541
+ result.stderr
542
+ )
543
+ assert cli("info", base).returncode == 0, "Template source must remain"
544
+ finally:
545
+ # Only recorded handles whose unique note still belongs to this invocation.
546
+ for handle in owned:
547
+ name = raft.parse_handle(handle)[1]
548
+ found = next((box for box in raft.inventory(location) if box["name"] == name), None)
549
+ if (
550
+ found
551
+ and json.loads(found["config"].get("user.raft.note") or "{}").get("owner")
552
+ == owner
553
+ ):
554
+ removed = cli("destroy", handle)
555
+ assert removed.returncode == 0, removed.stderr
556
+ after = {box["name"] for box in raft.inventory(location)}
557
+ assert before == after, f"CI run leaked boxes: {after - before}"
558
+ print(f"{location}: native transfers, status, signals, timeout and template isolation passed")
559
+
560
+
561
+ if __name__ == "__main__":
562
+ parser = argparse.ArgumentParser(description=__doc__)
563
+ parser.add_argument("--location")
564
+ args = parser.parse_args()
565
+ result = unittest.TextTestRunner(verbosity=2).run(
566
+ unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__])
567
+ )
568
+ if not result.wasSuccessful():
569
+ sys.exit(1)
570
+ if args.location:
571
+ native(args.location)
@@ -90,7 +90,15 @@ def next_release(work):
90
90
  """Build the current sources as release NEXT."""
91
91
  source = work / "next-source"
92
92
  source.mkdir()
93
- for name in ["raft_files.py", "pyproject.toml", "README.md", "LICENSE"]:
93
+ for name in [
94
+ "raft_files.py",
95
+ "raft_run.py",
96
+ "raft_jobs.py",
97
+ "raft_output.py",
98
+ "pyproject.toml",
99
+ "README.md",
100
+ "LICENSE",
101
+ ]:
94
102
  shutil.copy(ROOT / name, source)
95
103
  code = (ROOT / "raft.py").read_text()
96
104
  (source / "raft.py").write_text(code.replace(f'"{CURRENT}"', f'"{NEXT}"', 1))