@microck/raft 0.0.0-stage → 0.2.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 (62) hide show
  1. package/.github/assets/raft-logo.png +0 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -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 +461 -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 +82 -0
  34. package/docs/guides/background-jobs.mdx +92 -0
  35. package/docs/guides/backup-recovery.mdx +111 -0
  36. package/docs/guides/desktop-services.mdx +87 -0
  37. package/docs/guides/docker.mdx +84 -0
  38. package/docs/guides/files.mdx +91 -0
  39. package/docs/guides/meta.json +14 -0
  40. package/docs/guides/snapshots-forks.mdx +107 -0
  41. package/docs/guides/troubleshooting.mdx +127 -0
  42. package/docs/incus.example.json +3 -0
  43. package/docs/index.mdx +36 -0
  44. package/docs/meta.json +21 -0
  45. package/docs/operations.mdx +224 -0
  46. package/docs/publishing.md +48 -0
  47. package/docs/reference/capacity-limits.mdx +83 -0
  48. package/docs/reference/cli.md +279 -0
  49. package/docs/reference/configuration.mdx +77 -0
  50. package/docs/reference/meta.json +10 -0
  51. package/docs/releases/0.2.0.md +22 -0
  52. package/docs/requirements.mdx +47 -0
  53. package/docs/reviews/code-review-2026-10-06.md +168 -0
  54. package/docs/site-development.md +80 -0
  55. package/docs/verification.mdx +231 -0
  56. package/images/incus/build.sh +57 -0
  57. package/images/incus/desktop.sh +21 -0
  58. package/images/incus/raft-desktop.service +7 -0
  59. package/package.json +46 -4
  60. package/raft.py +955 -0
  61. package/raft_files.py +134 -0
  62. package/skills/raft-cli/SKILL.md +117 -0
@@ -0,0 +1,461 @@
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(location, isolated_worker=False):
161
+ if inventory(location):
162
+ raise RuntimeError(
163
+ "Retention verification requires an empty Raft host; existing boxes are untouched"
164
+ )
165
+ if not isolated_worker:
166
+ rejected = subprocess.run(
167
+ [
168
+ sys.executable,
169
+ str(Path(__file__).resolve()),
170
+ "--location",
171
+ location,
172
+ "--isolated-worker",
173
+ ],
174
+ capture_output=True,
175
+ text=True,
176
+ )
177
+ assert rejected.returncode and "targets older deployments" in rejected.stderr
178
+ assert not inventory(location), "Rejected verification mode allocated a box"
179
+ fixtures = []
180
+ worker = None
181
+ host = settings(location)["ssh"]
182
+ if isolated_worker:
183
+ protocol = subprocess.check_output(
184
+ [
185
+ "ssh",
186
+ "-T",
187
+ host,
188
+ shlex.join(
189
+ [
190
+ "sudo",
191
+ "-n",
192
+ "python3",
193
+ "-c",
194
+ "import runpy; print(runpy.run_path('/usr/local/lib/raft/expire-worker').get('PROTOCOL'))",
195
+ ]
196
+ ),
197
+ ],
198
+ text=True,
199
+ ).strip()
200
+ if protocol == "1":
201
+ raise RuntimeError(
202
+ "--isolated-worker targets older deployments; use normal verification on a current host"
203
+ )
204
+ worker = subprocess.check_output(
205
+ ["ssh", "-T", host, "mktemp /tmp/raft-feature-worker.XXXXXXXX"], text=True
206
+ ).strip()
207
+ subprocess.run(
208
+ ["ssh", "-T", host, shlex.join(["sh", "-c", 'cat > "$1"', "raft", worker])],
209
+ input=(ROOT / "deploy/raft-expire.py").read_bytes(),
210
+ check=True,
211
+ )
212
+
213
+ def cleanup_command(*arguments):
214
+ if worker:
215
+ return transaction(location, 'exec python3 "$@"', worker, *arguments)
216
+ command = ["prune", "--location", location]
217
+ if arguments:
218
+ command += ["--older-than", arguments[1]]
219
+ if "--yes" in arguments:
220
+ command += ["--yes"]
221
+ else:
222
+ command = ["gc", "--location", location]
223
+ return live.raft(*command).stdout
224
+
225
+ def create(*arguments):
226
+ handle = live.raft(
227
+ "new", "--location", location, "--ttl", "1800", *arguments
228
+ ).stdout.strip()
229
+ fixtures.append(handle)
230
+ return handle
231
+
232
+ def metadata(handle):
233
+ return json.loads(live.raft("info", handle).stdout)
234
+
235
+ def exists(handle):
236
+ return any(box["name"] == parse_handle(handle)[1] for box in inventory(location))
237
+
238
+ try:
239
+ box = create("--memory", "1GiB")
240
+ live.wait_running(box)
241
+ _, name = parse_handle(box)
242
+ with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
243
+ root = Path(work)
244
+ source = root / "source with spaces"
245
+ source.mkdir()
246
+ (source / "binary").write_bytes(bytes(range(256)) * 1024)
247
+ (source / "link").symlink_to("binary")
248
+ os.link(source / "binary", source / "hard")
249
+ (source / "empty").mkdir()
250
+ (source / "run").write_text("#!/bin/sh\necho tree\n")
251
+ (source / "run").chmod(0o755)
252
+ guest = "/workspace/tree with spaces"
253
+ live.raft("upload", box, str(source), guest, "--recursive")
254
+ output = root / "download"
255
+ live.raft("download", box, guest, str(output), "--recursive")
256
+ assert (output / "binary").read_bytes() == (source / "binary").read_bytes()
257
+ assert (output / "link").is_symlink() and (output / "link").readlink() == Path("binary")
258
+ assert (output / "hard").stat().st_ino == (output / "binary").stat().st_ino
259
+ assert (output / "empty").is_dir() and (output / "run").stat().st_mode & 0o777 == 0o755
260
+ assert live.raft(
261
+ "upload", box, str(source), guest, "--recursive", check=False
262
+ ).returncode
263
+ assert live.raft(
264
+ "download", box, guest, str(output), "--recursive", check=False
265
+ ).returncode
266
+ (source / "unsafe").symlink_to("../../escape")
267
+ assert live.raft(
268
+ "upload", box, str(source), "/workspace/rejected", "--recursive", check=False
269
+ ).returncode
270
+ live.execute(box, "test", "!", "-e", "/workspace/rejected")
271
+ live.execute(box, "ln", "-s", "/etc/shadow", guest + "/unsafe")
272
+ assert live.raft(
273
+ "download", box, guest, str(root / "rejected"), "--recursive", check=False
274
+ ).returncode
275
+ assert not (root / "rejected").exists()
276
+ live.execute(box, "rm", guest + "/unsafe")
277
+ complete = two_file_archive()
278
+ interrupted = root / "interrupted.tar"
279
+ interrupted.write_bytes(complete[:1024])
280
+ with interrupted.open("rb") as truncated:
281
+ rejected = remote(
282
+ location,
283
+ [
284
+ "exec",
285
+ name,
286
+ "--",
287
+ "python3",
288
+ "-c",
289
+ Path(raft_files.__file__).read_text(),
290
+ "unpack",
291
+ "/workspace/truncated",
292
+ "--size",
293
+ str(len(complete)),
294
+ ],
295
+ stdin=truncated,
296
+ )
297
+ assert rejected.returncode and b"size mismatch" in rejected.stderr
298
+ live.execute(box, "test", "!", "-e", "/workspace/truncated")
299
+ assert not live.execute(
300
+ box, "find", "/workspace", "-maxdepth", "1", "-name", ".raft-tree-*"
301
+ )
302
+ print(
303
+ f"{location}: recursive trees, permissions, links, overwrite and unsafe-link rejection passed",
304
+ flush=True,
305
+ )
306
+ live.execute(box, "bash", "-ec", "echo original > /workspace/proof")
307
+ live.raft("stop", box)
308
+ assert metadata(box)["config"].get("user.raft.stopped-at")
309
+ live.raft("snapshot", box, "prepared")
310
+ live.raft("resume", box, "--ttl", "1800", "--cpu", "2", "--memory", "4GiB")
311
+ resized = metadata(box)
312
+ assert resized["expanded_config"]["limits.cpu"] == "2"
313
+ assert resized["expanded_config"]["limits.memory"] == "4GiB"
314
+ assert not resized["config"].get("user.raft.stopped-at")
315
+ assert live.execute(box, "cat", "/sys/fs/cgroup/memory.max") == str(4 * 1024**3)
316
+ assert (
317
+ live.execute(box, "python3", "-c", "import os; print(len(os.sched_getaffinity(0)))")
318
+ == "2"
319
+ )
320
+ assert live.execute(box, "cat", "/workspace/proof") == "original"
321
+ assert live.raft("resume", box, "--ttl", "600", "--cpu", "1", check=False).returncode
322
+ assert metadata(box)["expanded_config"]["limits.cpu"] == "2"
323
+ template = live.raft("new", "--from", box + "/prepared", "--ttl", "600").stdout.strip()
324
+ fixtures.append(template)
325
+ live.wait_running(template)
326
+ assert live.execute(template, "cat", "/workspace/proof") == "original"
327
+ assert (
328
+ metadata(template)["config"]["volatile.eth0.hwaddr"]
329
+ != metadata(box)["config"]["volatile.eth0.hwaddr"]
330
+ )
331
+ assert metadata(template)["config"]["user.raft.disposable"] == "false"
332
+ assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
333
+ live.raft("snapshot-delete", box, "prepared")
334
+ assert live.execute(template, "cat", "/workspace/proof") == "original"
335
+ live.raft("destroy", template)
336
+ assert "prepared" not in live.raft("snapshots", box).stdout
337
+ assert live.raft("new", "--from", box + "/prepared", check=False).returncode
338
+ assert live.raft("snapshot-delete", box, "missing", check=False).returncode
339
+ live.raft("stop", box)
340
+ fork_args = ["fork", box, "--ttl", "600", "--cpu", "1", "--memory", "1GiB"]
341
+ if not worker:
342
+ fork_args.append("--disposable")
343
+ fork = live.raft(*fork_args).stdout.strip()
344
+ fixtures.append(fork)
345
+ assert metadata(fork)["expanded_config"]["limits.memory"] == "1GiB"
346
+ assert metadata(fork)["expanded_config"]["limits.cpu"] == "1"
347
+ assert metadata(box)["expanded_config"]["limits.memory"] == "4GiB"
348
+ if worker:
349
+ incus(
350
+ location,
351
+ "config",
352
+ "set",
353
+ parse_handle(fork)[1],
354
+ "user.raft.disposable=true",
355
+ locked=True,
356
+ )
357
+ live.raft("stop", fork)
358
+ assert not exists(fork)
359
+ live.raft("resume", box, "--ttl", "1800", "--memory", "1GiB")
360
+ assert metadata(box)["expanded_config"]["limits.cpu"] == "2"
361
+ print(
362
+ f"{location}: live-source templates, snapshot deletion and resume/fork sizing passed",
363
+ flush=True,
364
+ )
365
+ if worker:
366
+ rejected = live.raft("new", "--location", location, "--disposable", check=False)
367
+ assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
368
+ disposable = create("--memory", "1GiB")
369
+ incus(
370
+ location,
371
+ "config",
372
+ "set",
373
+ parse_handle(disposable)[1],
374
+ "user.raft.disposable=true",
375
+ locked=True,
376
+ )
377
+ rejected = live.raft("prune", "--location", location, "--older-than", "1", check=False)
378
+ assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
379
+ else:
380
+ disposable = create("--disposable", "--memory", "1GiB")
381
+ _, disposable_name = parse_handle(disposable)
382
+ if worker:
383
+ transaction(
384
+ location,
385
+ 'incus --project raft config set "$1" user.raft.expires=1; python3 "$2"',
386
+ disposable_name,
387
+ worker,
388
+ )
389
+ else:
390
+ incus(location, "config", "set", disposable_name, "user.raft.expires=1", locked=True)
391
+ deadline = time.monotonic() + 50
392
+ while exists(disposable):
393
+ if time.monotonic() > deadline:
394
+ raise RuntimeError("Timer did not delete expired disposable box")
395
+ time.sleep(1)
396
+ assert not exists(disposable)
397
+ recent = create("--memory", "1GiB")
398
+ old = create("--memory", "1GiB")
399
+ live.raft("stop", recent)
400
+ live.raft("stop", old)
401
+ _, old_name = parse_handle(old)
402
+ incus(
403
+ location,
404
+ "config",
405
+ "set",
406
+ old_name,
407
+ f"user.raft.stopped-at={int(time.time()) - 3 * 86400}",
408
+ locked=True,
409
+ )
410
+ incus(location, "config", "set", old_name, "user.raft=false", locked=True)
411
+ try:
412
+ assert old_name not in cleanup_command("--prune-days", "2")
413
+ finally:
414
+ incus(location, "config", "set", old_name, "user.raft=true", locked=True)
415
+ preview = cleanup_command("--prune-days", "2")
416
+ assert (
417
+ old_name in preview and parse_handle(recent)[1] not in preview and name not in preview
418
+ )
419
+ assert exists(old)
420
+ cleanup_command("--prune-days", "2", "--yes")
421
+ assert not exists(old) and exists(recent) and exists(box)
422
+ _, recent_name = parse_handle(recent)
423
+ incus(location, "config", "unset", recent_name, "user.raft.stopped-at", locked=True)
424
+ assert recent_name not in cleanup_command("--prune-days", "1")
425
+ assert live.raft("prune", "--older-than", "0", check=False).returncode
426
+ print(
427
+ f"{location}: disposable deletion, preview/apply retention and exclusions passed",
428
+ flush=True,
429
+ )
430
+ finally:
431
+ for handle in reversed(fixtures):
432
+ if exists(handle):
433
+ live.raft("destroy", handle)
434
+ if worker:
435
+ subprocess.run(
436
+ [
437
+ "ssh",
438
+ "-T",
439
+ host,
440
+ shlex.join(
441
+ [
442
+ "python3",
443
+ "-c",
444
+ "from pathlib import Path; import sys; Path(sys.argv[1]).unlink()",
445
+ worker,
446
+ ]
447
+ ),
448
+ ],
449
+ check=True,
450
+ )
451
+ print(f"{location}: feature fixtures and staged worker removed", flush=True)
452
+
453
+
454
+ if __name__ == "__main__":
455
+ parser = argparse.ArgumentParser(description=__doc__)
456
+ parser.add_argument("--location")
457
+ parser.add_argument("--isolated-worker", action="store_true")
458
+ args = parser.parse_args()
459
+ verify_archives()
460
+ if args.location:
461
+ verify(args.location, args.isolated_worker)