@microck/raft 0.0.0-stage → 0.2.1

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 (67) hide show
  1. package/.github/assets/raft-logo.png +0 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -2
  4. package/deploy/benchmark-boot.py +150 -0
  5. package/deploy/build-image.py +95 -0
  6. package/deploy/deploy.py +176 -0
  7. package/deploy/incus-network.conf +4 -0
  8. package/deploy/install-incus.sh +22 -0
  9. package/deploy/raft-expire.py +62 -0
  10. package/deploy/raft-expire.service +8 -0
  11. package/deploy/raft-expire.timer +8 -0
  12. package/deploy/raft-network.service +12 -0
  13. package/deploy/raft-network.sh +40 -0
  14. package/deploy/verify-controls.py +241 -0
  15. package/deploy/verify-features.py +519 -0
  16. package/deploy/verify-fresh-host.py +301 -0
  17. package/deploy/verify-image-tests.py +139 -0
  18. package/deploy/verify-image.py +95 -0
  19. package/deploy/verify-limits.py +110 -0
  20. package/deploy/verify-live.py +903 -0
  21. package/deploy/verify-release.py +132 -0
  22. package/deploy/verify-tools.py +105 -0
  23. package/docs/benchmarks/amd64-native-ci.json +137 -0
  24. package/docs/benchmarks/arm64-baseline-a.json +108 -0
  25. package/docs/benchmarks/arm64-baseline-b.json +108 -0
  26. package/docs/benchmarks/arm64-candidate-a.json +109 -0
  27. package/docs/benchmarks/arm64-desktop-local.json +22 -0
  28. package/docs/benchmarks/arm64-native-ci.json +137 -0
  29. package/docs/boat-capabilities.md +64 -0
  30. package/docs/boat-parity-design.md +26 -0
  31. package/docs/boot-performance.mdx +102 -0
  32. package/docs/contract.md +93 -0
  33. package/docs/first-box.mdx +86 -0
  34. package/docs/guides/background-jobs.mdx +95 -0
  35. package/docs/guides/backup-recovery.mdx +111 -0
  36. package/docs/guides/desktop-services.mdx +90 -0
  37. package/docs/guides/docker.mdx +84 -0
  38. package/docs/guides/files.mdx +91 -0
  39. package/docs/guides/long-running-services.mdx +109 -0
  40. package/docs/guides/meta.json +18 -0
  41. package/docs/guides/project-setup.mdx +112 -0
  42. package/docs/guides/scripting-ci.mdx +133 -0
  43. package/docs/guides/snapshots-forks.mdx +107 -0
  44. package/docs/guides/troubleshooting.mdx +152 -0
  45. package/docs/guides/upgrades.mdx +113 -0
  46. package/docs/incus.example.json +3 -0
  47. package/docs/index.mdx +40 -0
  48. package/docs/meta.json +21 -0
  49. package/docs/operations.mdx +239 -0
  50. package/docs/publishing.md +48 -0
  51. package/docs/reference/capacity-limits.mdx +92 -0
  52. package/docs/reference/cli.md +288 -0
  53. package/docs/reference/configuration.mdx +77 -0
  54. package/docs/reference/meta.json +10 -0
  55. package/docs/releases/0.2.0.md +22 -0
  56. package/docs/releases/0.2.1.md +14 -0
  57. package/docs/requirements.mdx +47 -0
  58. package/docs/reviews/code-review-2026-10-06.md +168 -0
  59. package/docs/site-development.md +80 -0
  60. package/docs/verification.mdx +237 -0
  61. package/images/incus/build.sh +57 -0
  62. package/images/incus/desktop.sh +21 -0
  63. package/images/incus/raft-desktop.service +7 -0
  64. package/package.json +46 -4
  65. package/raft.py +961 -0
  66. package/raft_files.py +134 -0
  67. package/skills/raft-cli/SKILL.md +117 -0
@@ -0,0 +1,519 @@
1
+ """Exercise disposable lifecycle, retention, templates, resizing and tree transfers.
2
+
3
+ --isolated-worker stages the current worker under a private temporary path for
4
+ existing hosts. It never replaces their installed services. CI uses the installed
5
+ worker and verifies automatic timer deletion as well as CLI prune/gc.
6
+ """
7
+
8
+ import argparse
9
+ import importlib.util
10
+ import io
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import shlex
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import tarfile
19
+ import tempfile
20
+ import threading
21
+ import time
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ sys.path.insert(0, str(ROOT))
25
+ from raft import incus, inventory, parse_handle, remote, settings, transaction # noqa: E402
26
+ import raft_files # noqa: E402
27
+ from raft_files import pack_directory, publish, unpack_directory # noqa: E402
28
+
29
+ spec = importlib.util.spec_from_file_location("live", Path(__file__).with_name("verify-live.py"))
30
+ live = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(live)
32
+
33
+
34
+ def two_file_archive():
35
+ stream = io.BytesIO()
36
+ with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive:
37
+ for name in ["first", "second"]:
38
+ member = tarfile.TarInfo(name)
39
+ member.size = 1
40
+ archive.addfile(member, io.BytesIO(b"x"))
41
+ return stream.getvalue()
42
+
43
+
44
+ def verify_archives():
45
+ with tempfile.TemporaryDirectory() as work:
46
+ root = Path(work)
47
+ for name, kind, link in [
48
+ ("../escape", tarfile.REGTYPE, ""),
49
+ ("/absolute", tarfile.REGTYPE, ""),
50
+ ("link", tarfile.SYMTYPE, "../../escape"),
51
+ ("link", tarfile.LNKTYPE, "../escape"),
52
+ ("pipe", tarfile.FIFOTYPE, ""),
53
+ ("device", tarfile.CHRTYPE, ""),
54
+ ]:
55
+ stream = io.BytesIO()
56
+ with tarfile.open(fileobj=stream, mode="w") as archive:
57
+ member = tarfile.TarInfo(name)
58
+ member.type, member.linkname = kind, link
59
+ archive.addfile(member)
60
+ stream.seek(0)
61
+ try:
62
+ unpack_directory(stream, root / "output", len(stream.getvalue()))
63
+ except (ValueError, tarfile.FilterError):
64
+ pass
65
+ else:
66
+ raise AssertionError("Unsafe archive accepted: " + name)
67
+ assert not (root / "output").exists()
68
+ assert not list(root.glob(".raft-tree-*"))
69
+ complete = two_file_archive()
70
+ try:
71
+ unpack_directory(io.BytesIO(complete[:1024]), root / "truncated", len(complete))
72
+ except ValueError as error:
73
+ assert "size mismatch" in str(error)
74
+ else:
75
+ raise AssertionError("Truncated archive published a partial tree")
76
+ assert not (root / "truncated").exists() and not list(root.glob(".raft-tree-*"))
77
+ staged = root / "staged"
78
+ staged.mkdir()
79
+ existing = root / "existing"
80
+ existing.mkdir()
81
+ try:
82
+ publish(staged, existing)
83
+ except FileExistsError:
84
+ pass
85
+ else:
86
+ raise AssertionError("Atomic publication replaced an existing empty directory")
87
+ assert staged.exists() and existing.exists()
88
+ socket_source = root / "socket-source"
89
+ socket_source.mkdir()
90
+ with socket.socket(socket.AF_UNIX) as endpoint:
91
+ endpoint.bind(str(socket_source / "socket"))
92
+ try:
93
+ pack_directory(socket_source, io.BytesIO())
94
+ except ValueError as error:
95
+ assert "special files" in str(error)
96
+ else:
97
+ raise AssertionError("Source socket was silently omitted")
98
+ # A real writer replaces both a file and a directory with external links.
99
+ # No function interception: accepted archives must never contain the secret.
100
+ race_source = root / "race-source"
101
+ race_source.mkdir()
102
+ secret = root / "secret"
103
+ secret.write_bytes(b"raft-outside-secret")
104
+ foreign = root / "foreign"
105
+ foreign.mkdir()
106
+ (foreign / "secret").write_bytes(secret.read_bytes())
107
+ (race_source / "file").write_bytes(b"inside")
108
+ (race_source / "directory").mkdir()
109
+ (race_source / "directory" / "file").write_bytes(b"inside")
110
+ finished = threading.Event()
111
+
112
+ def replace_entries():
113
+ while not finished.is_set():
114
+ for name, target in [("file", secret), ("directory", foreign)]:
115
+ entry = race_source / name
116
+ held = root / (name + "-held")
117
+ entry.rename(held)
118
+ entry.symlink_to(target)
119
+ entry.unlink()
120
+ held.rename(entry)
121
+
122
+ writer = threading.Thread(target=replace_entries)
123
+ writer.start()
124
+ verified_files = 0
125
+ try:
126
+ for _ in range(100):
127
+ packed = io.BytesIO()
128
+ try:
129
+ pack_directory(race_source, packed)
130
+ except (OSError, ValueError, tarfile.FilterError):
131
+ continue
132
+ packed.seek(0)
133
+ with tarfile.open(fileobj=packed) as archive:
134
+ for member in archive:
135
+ if member.isfile():
136
+ assert archive.extractfile(member).read() == b"inside"
137
+ verified_files += 1
138
+ finally:
139
+ finished.set()
140
+ writer.join()
141
+ assert verified_files, "Source replacement test verified no file contents"
142
+ print(f"Source replacement test verified {verified_files} regular-file members", flush=True)
143
+ outside = root / "outside"
144
+ outside.mkdir()
145
+ link = root / "destination"
146
+ link.symlink_to(outside, target_is_directory=True)
147
+ try:
148
+ unpack_directory(io.BytesIO(), link, 0)
149
+ except FileExistsError:
150
+ pass
151
+ else:
152
+ raise AssertionError("Destination symlink was followed")
153
+ assert link.is_symlink() and not list(outside.iterdir())
154
+ print(
155
+ "Archive safety, socket rejection, complete streams and no-overwrite checks passed",
156
+ flush=True,
157
+ )
158
+
159
+
160
+ def verify_readiness(location, expect_stale=False):
161
+ # Isolate the selected host without losing the controller's verified SSH config.
162
+ with tempfile.TemporaryDirectory(prefix="raft-doctor-test-") as work:
163
+ home = Path(work)
164
+ (home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
165
+ config = home / ".config/raft/incus.json"
166
+ config.parent.mkdir(parents=True)
167
+ config.write_text(json.dumps({location: settings(location)}))
168
+ response = live.raft("doctor", check=False, env={**os.environ, "HOME": str(home)})
169
+ assert location + ": " in response.stdout, response
170
+ if expect_stale:
171
+ assert response.returncode == 1, response
172
+ assert (
173
+ f"raft: {location}: Host expiry worker differs from this CLI; "
174
+ "deploy the current Raft checkout first"
175
+ ) in response.stderr, response
176
+ else:
177
+ assert response.returncode == 0, response
178
+ assert "saved boxes; system containers (shared host kernel)" in response.stdout, response
179
+ print(f"{location}: doctor {'rejected stale worker' if expect_stale else 'passed'}", flush=True)
180
+
181
+
182
+ def verify_transaction_stdin(location):
183
+ program = """import sys, tempfile
184
+ from raft import transaction
185
+ assert transaction(sys.argv[1], 'cat') == ''
186
+ with tempfile.TemporaryFile() as output:
187
+ transaction(sys.argv[1], 'cat', stdout=output)
188
+ output.seek(0)
189
+ assert output.read() == b''
190
+ """
191
+ with subprocess.Popen(
192
+ [sys.executable, "-c", program, location],
193
+ cwd=ROOT,
194
+ stdin=subprocess.PIPE,
195
+ stdout=subprocess.PIPE,
196
+ stderr=subprocess.PIPE,
197
+ ) as process:
198
+ try:
199
+ process.stdin.write(b"must-not-reach-the-host\n")
200
+ process.stdin.flush()
201
+ process.wait(timeout=30)
202
+ finally:
203
+ # Release even a regressed remote reader before leaving this test.
204
+ process.stdin.close()
205
+ try:
206
+ process.wait(timeout=30)
207
+ except subprocess.TimeoutExpired:
208
+ process.kill()
209
+ process.wait()
210
+ raise
211
+ assert process.returncode == 0, process.stderr.read().decode()
212
+ assert transaction(location, "printf lock-released") == "lock-released"
213
+ print(f"{location}: open stdin ignored and lifecycle lock released", flush=True)
214
+
215
+
216
+ def verify(location, isolated_worker=False):
217
+ if inventory(location):
218
+ raise RuntimeError(
219
+ "Retention verification requires an empty Raft host; existing boxes are untouched"
220
+ )
221
+ if not isolated_worker:
222
+ rejected = subprocess.run(
223
+ [
224
+ sys.executable,
225
+ str(Path(__file__).resolve()),
226
+ "--location",
227
+ location,
228
+ "--isolated-worker",
229
+ ],
230
+ capture_output=True,
231
+ text=True,
232
+ )
233
+ assert rejected.returncode and "targets older deployments" in rejected.stderr
234
+ assert not inventory(location), "Rejected verification mode allocated a box"
235
+ fixtures = []
236
+ worker = None
237
+ host = settings(location)["ssh"]
238
+ if isolated_worker:
239
+ protocol = subprocess.check_output(
240
+ [
241
+ "ssh",
242
+ "-T",
243
+ host,
244
+ shlex.join(
245
+ [
246
+ "sudo",
247
+ "-n",
248
+ "python3",
249
+ "-c",
250
+ "import runpy; print(runpy.run_path('/usr/local/lib/raft/expire-worker').get('PROTOCOL'))",
251
+ ]
252
+ ),
253
+ ],
254
+ text=True,
255
+ ).strip()
256
+ if protocol == "1":
257
+ raise RuntimeError(
258
+ "--isolated-worker targets older deployments; use normal verification on a current host"
259
+ )
260
+ worker = subprocess.check_output(
261
+ ["ssh", "-T", host, "mktemp /tmp/raft-feature-worker.XXXXXXXX"], text=True
262
+ ).strip()
263
+ subprocess.run(
264
+ ["ssh", "-T", host, shlex.join(["sh", "-c", 'cat > "$1"', "raft", worker])],
265
+ input=(ROOT / "deploy/raft-expire.py").read_bytes(),
266
+ check=True,
267
+ )
268
+
269
+ def cleanup_command(*arguments):
270
+ if worker:
271
+ return transaction(location, 'exec python3 "$@"', worker, *arguments)
272
+ command = ["prune", "--location", location]
273
+ if arguments:
274
+ command += ["--older-than", arguments[1]]
275
+ if "--yes" in arguments:
276
+ command += ["--yes"]
277
+ else:
278
+ command = ["gc", "--location", location]
279
+ return live.raft(*command).stdout
280
+
281
+ def create(*arguments):
282
+ handle = live.raft(
283
+ "new", "--location", location, "--ttl", "1800", *arguments
284
+ ).stdout.strip()
285
+ fixtures.append(handle)
286
+ return handle
287
+
288
+ def metadata(handle):
289
+ return json.loads(live.raft("info", handle).stdout)
290
+
291
+ def exists(handle):
292
+ return any(box["name"] == parse_handle(handle)[1] for box in inventory(location))
293
+
294
+ try:
295
+ verify_readiness(location, expect_stale=isolated_worker)
296
+ verify_transaction_stdin(location)
297
+ box = create("--memory", "1GiB")
298
+ live.wait_running(box)
299
+ _, name = parse_handle(box)
300
+ with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
301
+ root = Path(work)
302
+ source = root / "source with spaces"
303
+ source.mkdir()
304
+ (source / "binary").write_bytes(bytes(range(256)) * 1024)
305
+ (source / "link").symlink_to("binary")
306
+ os.link(source / "binary", source / "hard")
307
+ (source / "empty").mkdir()
308
+ (source / "run").write_text("#!/bin/sh\necho tree\n")
309
+ (source / "run").chmod(0o755)
310
+ guest = "/workspace/tree with spaces"
311
+ live.raft("upload", box, str(source), guest, "--recursive")
312
+ output = root / "download"
313
+ live.raft("download", box, guest, str(output), "--recursive")
314
+ assert (output / "binary").read_bytes() == (source / "binary").read_bytes()
315
+ assert (output / "link").is_symlink() and (output / "link").readlink() == Path("binary")
316
+ assert (output / "hard").stat().st_ino == (output / "binary").stat().st_ino
317
+ assert (output / "empty").is_dir() and (output / "run").stat().st_mode & 0o777 == 0o755
318
+ assert live.raft(
319
+ "upload", box, str(source), guest, "--recursive", check=False
320
+ ).returncode
321
+ assert live.raft(
322
+ "download", box, guest, str(output), "--recursive", check=False
323
+ ).returncode
324
+ (source / "unsafe").symlink_to("../../escape")
325
+ assert live.raft(
326
+ "upload", box, str(source), "/workspace/rejected", "--recursive", check=False
327
+ ).returncode
328
+ live.execute(box, "test", "!", "-e", "/workspace/rejected")
329
+ live.execute(box, "ln", "-s", "/etc/shadow", guest + "/unsafe")
330
+ assert live.raft(
331
+ "download", box, guest, str(root / "rejected"), "--recursive", check=False
332
+ ).returncode
333
+ assert not (root / "rejected").exists()
334
+ live.execute(box, "rm", guest + "/unsafe")
335
+ complete = two_file_archive()
336
+ interrupted = root / "interrupted.tar"
337
+ interrupted.write_bytes(complete[:1024])
338
+ with interrupted.open("rb") as truncated:
339
+ rejected = remote(
340
+ location,
341
+ [
342
+ "exec",
343
+ name,
344
+ "--",
345
+ "python3",
346
+ "-c",
347
+ Path(raft_files.__file__).read_text(),
348
+ "unpack",
349
+ "/workspace/truncated",
350
+ "--size",
351
+ str(len(complete)),
352
+ ],
353
+ stdin=truncated,
354
+ )
355
+ assert rejected.returncode and b"size mismatch" in rejected.stderr
356
+ live.execute(box, "test", "!", "-e", "/workspace/truncated")
357
+ assert not live.execute(
358
+ box, "find", "/workspace", "-maxdepth", "1", "-name", ".raft-tree-*"
359
+ )
360
+ print(
361
+ f"{location}: recursive trees, permissions, links, overwrite and unsafe-link rejection passed",
362
+ flush=True,
363
+ )
364
+ live.execute(box, "bash", "-ec", "echo original > /workspace/proof")
365
+ live.raft("stop", box)
366
+ assert metadata(box)["config"].get("user.raft.stopped-at")
367
+ live.raft("snapshot", box, "prepared")
368
+ live.raft("resume", box, "--ttl", "1800", "--cpu", "2", "--memory", "4GiB")
369
+ resized = metadata(box)
370
+ assert resized["expanded_config"]["limits.cpu"] == "2"
371
+ assert resized["expanded_config"]["limits.memory"] == "4GiB"
372
+ assert not resized["config"].get("user.raft.stopped-at")
373
+ assert live.execute(box, "cat", "/sys/fs/cgroup/memory.max") == str(4 * 1024**3)
374
+ assert (
375
+ live.execute(box, "python3", "-c", "import os; print(len(os.sched_getaffinity(0)))")
376
+ == "2"
377
+ )
378
+ assert live.execute(box, "cat", "/workspace/proof") == "original"
379
+ assert live.raft("resume", box, "--ttl", "600", "--cpu", "1", check=False).returncode
380
+ assert metadata(box)["expanded_config"]["limits.cpu"] == "2"
381
+ template = live.raft("new", "--from", box + "/prepared", "--ttl", "600").stdout.strip()
382
+ fixtures.append(template)
383
+ live.wait_running(template)
384
+ assert live.execute(template, "cat", "/workspace/proof") == "original"
385
+ assert (
386
+ metadata(template)["config"]["volatile.eth0.hwaddr"]
387
+ != metadata(box)["config"]["volatile.eth0.hwaddr"]
388
+ )
389
+ assert metadata(template)["config"]["user.raft.disposable"] == "false"
390
+ assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
391
+ live.raft("snapshot-delete", box, "prepared")
392
+ assert live.execute(template, "cat", "/workspace/proof") == "original"
393
+ live.raft("destroy", template)
394
+ assert "prepared" not in live.raft("snapshots", box).stdout
395
+ assert live.raft("new", "--from", box + "/prepared", check=False).returncode
396
+ assert live.raft("snapshot-delete", box, "missing", check=False).returncode
397
+ live.raft("stop", box)
398
+ fork_args = ["fork", box, "--ttl", "600", "--cpu", "1", "--memory", "1GiB"]
399
+ if not worker:
400
+ fork_args.append("--disposable")
401
+ fork = live.raft(*fork_args).stdout.strip()
402
+ fixtures.append(fork)
403
+ assert metadata(fork)["expanded_config"]["limits.memory"] == "1GiB"
404
+ assert metadata(fork)["expanded_config"]["limits.cpu"] == "1"
405
+ assert metadata(box)["expanded_config"]["limits.memory"] == "4GiB"
406
+ if worker:
407
+ incus(
408
+ location,
409
+ "config",
410
+ "set",
411
+ parse_handle(fork)[1],
412
+ "user.raft.disposable=true",
413
+ locked=True,
414
+ )
415
+ live.raft("stop", fork)
416
+ assert not exists(fork)
417
+ live.raft("resume", box, "--ttl", "1800", "--memory", "1GiB")
418
+ assert metadata(box)["expanded_config"]["limits.cpu"] == "2"
419
+ print(
420
+ f"{location}: live-source templates, snapshot deletion and resume/fork sizing passed",
421
+ flush=True,
422
+ )
423
+ if worker:
424
+ rejected = live.raft("new", "--location", location, "--disposable", check=False)
425
+ assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
426
+ disposable = create("--memory", "1GiB")
427
+ incus(
428
+ location,
429
+ "config",
430
+ "set",
431
+ parse_handle(disposable)[1],
432
+ "user.raft.disposable=true",
433
+ locked=True,
434
+ )
435
+ rejected = live.raft("prune", "--location", location, "--older-than", "1", check=False)
436
+ assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
437
+ else:
438
+ disposable = create("--disposable", "--memory", "1GiB")
439
+ _, disposable_name = parse_handle(disposable)
440
+ if worker:
441
+ transaction(
442
+ location,
443
+ 'incus --project raft config set "$1" user.raft.expires=1; python3 "$2"',
444
+ disposable_name,
445
+ worker,
446
+ )
447
+ else:
448
+ incus(location, "config", "set", disposable_name, "user.raft.expires=1", locked=True)
449
+ deadline = time.monotonic() + 50
450
+ while exists(disposable):
451
+ if time.monotonic() > deadline:
452
+ raise RuntimeError("Timer did not delete expired disposable box")
453
+ time.sleep(1)
454
+ assert not exists(disposable)
455
+ recent = create("--memory", "1GiB")
456
+ old = create("--memory", "1GiB")
457
+ live.raft("stop", recent)
458
+ live.raft("stop", old)
459
+ _, old_name = parse_handle(old)
460
+ incus(
461
+ location,
462
+ "config",
463
+ "set",
464
+ old_name,
465
+ f"user.raft.stopped-at={int(time.time()) - 3 * 86400}",
466
+ locked=True,
467
+ )
468
+ incus(location, "config", "set", old_name, "user.raft=false", locked=True)
469
+ try:
470
+ assert old_name not in cleanup_command("--prune-days", "2")
471
+ finally:
472
+ incus(location, "config", "set", old_name, "user.raft=true", locked=True)
473
+ preview = cleanup_command("--prune-days", "2")
474
+ assert (
475
+ old_name in preview and parse_handle(recent)[1] not in preview and name not in preview
476
+ )
477
+ assert exists(old)
478
+ cleanup_command("--prune-days", "2", "--yes")
479
+ assert not exists(old) and exists(recent) and exists(box)
480
+ _, recent_name = parse_handle(recent)
481
+ incus(location, "config", "unset", recent_name, "user.raft.stopped-at", locked=True)
482
+ assert recent_name not in cleanup_command("--prune-days", "1")
483
+ assert live.raft("prune", "--older-than", "0", check=False).returncode
484
+ print(
485
+ f"{location}: disposable deletion, preview/apply retention and exclusions passed",
486
+ flush=True,
487
+ )
488
+ finally:
489
+ for handle in reversed(fixtures):
490
+ if exists(handle):
491
+ live.raft("destroy", handle)
492
+ if worker:
493
+ subprocess.run(
494
+ [
495
+ "ssh",
496
+ "-T",
497
+ host,
498
+ shlex.join(
499
+ [
500
+ "python3",
501
+ "-c",
502
+ "from pathlib import Path; import sys; Path(sys.argv[1]).unlink()",
503
+ worker,
504
+ ]
505
+ ),
506
+ ],
507
+ check=True,
508
+ )
509
+ print(f"{location}: feature fixtures and staged worker removed", flush=True)
510
+
511
+
512
+ if __name__ == "__main__":
513
+ parser = argparse.ArgumentParser(description=__doc__)
514
+ parser.add_argument("--location")
515
+ parser.add_argument("--isolated-worker", action="store_true")
516
+ args = parser.parse_args()
517
+ verify_archives()
518
+ if args.location:
519
+ verify(args.location, args.isolated_worker)