@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,903 @@
1
+ """Real Raft lifecycle verification; each run destroys only its own fixtures."""
2
+
3
+ import argparse
4
+ import errno
5
+ import json
6
+ import os
7
+ import pty
8
+ import select
9
+ import shlex
10
+ from pathlib import Path
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ import urllib.request
17
+
18
+ ROOT = Path(__file__).resolve().parent.parent
19
+ CLI = [sys.executable, str(ROOT / "raft.py")]
20
+ USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
21
+ sys.path.insert(0, str(ROOT))
22
+ from raft import configuration, incus, remote, settings, transaction # noqa: E402
23
+
24
+
25
+ def raft(*arguments, check=True, env=None):
26
+ response = subprocess.run([*CLI, *arguments], capture_output=True, text=True, env=env)
27
+ if check and response.returncode:
28
+ raise RuntimeError(f"Raft {arguments!r} failed: {response.stderr.strip()}")
29
+ return response
30
+
31
+
32
+ def execute(box, *arguments):
33
+ return raft("exec", box, "--", *arguments).stdout.strip()
34
+
35
+
36
+ def wait_running(box):
37
+ deadline = time.monotonic() + 40
38
+ while time.monotonic() < deadline:
39
+ response = raft("exec", box, "--", "systemctl", "is-active", "docker", "ssh", check=False)
40
+ if response.returncode == 0:
41
+ return
42
+ time.sleep(1)
43
+ raise RuntimeError("Guest Docker and SSH did not become ready")
44
+
45
+
46
+ def terminal_session(box):
47
+ master, slave = pty.openpty()
48
+ process = subprocess.Popen([*CLI, "ssh", box], stdin=slave, stdout=slave, stderr=slave)
49
+ os.close(slave)
50
+ transcript = b""
51
+ deadline = time.monotonic() + 30
52
+ sent = False
53
+ try:
54
+ while time.monotonic() < deadline:
55
+ if select.select([master], [], [], 0.5)[0]:
56
+ try:
57
+ transcript += os.read(master, 65536)
58
+ except OSError:
59
+ break
60
+ if not sent and transcript.rstrip().endswith((b"#", b"$")):
61
+ os.write(master, b"test -t 0 && echo raft-terminal-ok; exit\n")
62
+ sent = True
63
+ if process.poll() is not None:
64
+ break
65
+ process.wait(timeout=5)
66
+ assert process.returncode == 0 and b"\r\nraft-terminal-ok\r\n" in transcript, (
67
+ transcript.decode(errors="replace")
68
+ )
69
+ finally:
70
+ if process.poll() is None:
71
+ process.terminate()
72
+ process.wait(timeout=10)
73
+ os.close(master)
74
+
75
+
76
+ def verify_peers(box, child):
77
+ # Prove the peer SSH service is reachable from itself before testing denial.
78
+ assert execute(child, "systemctl", "is-active", "ssh") == "active"
79
+ nic = json.loads(execute(child, "ip", "-json", "addr", "show", "dev", "eth0"))[0]
80
+ addresses = [
81
+ item["local"] + ("%eth0" if item["family"] == "inet6" else "")
82
+ for item in nic["addr_info"]
83
+ if item["family"] in ["inet", "inet6"]
84
+ ]
85
+ assert any(":" in address for address in addresses), "Expected a link-local IPv6 address"
86
+ execute(
87
+ child,
88
+ "python3",
89
+ "-c",
90
+ f"import socket\nfor host in {addresses!r}: socket.create_connection((host,22),2).close()",
91
+ )
92
+ assert (
93
+ execute(
94
+ box,
95
+ "python3",
96
+ "-c",
97
+ f'import socket\nfor host in {addresses!r}:\n try: socket.create_connection((host,22),2)\n except OSError: continue\n raise SystemExit("peer reachable: " + host)\nprint("peers-blocked")',
98
+ )
99
+ == "peers-blocked"
100
+ )
101
+
102
+
103
+ def desktop_session(box):
104
+ # Check the browser transport too, before using a complete raw VNC viewer.
105
+ websocket_proof = """const assert = require('node:assert/strict');
106
+ const socket = new WebSocket('ws://127.0.0.1:6080/websockify', {headers: {'User-Agent': process.argv[1]}});
107
+ socket.binaryType = 'arraybuffer';
108
+ let incoming = Buffer.alloc(0), wake;
109
+ const timeout = setTimeout(() => { console.error('WebSocket timeout'); process.exit(1); }, 10000);
110
+ socket.addEventListener('error', () => process.exit(1));
111
+ socket.addEventListener('message', event => {
112
+ incoming = Buffer.concat([incoming, Buffer.from(event.data)]);
113
+ if (wake) { const resume = wake; wake = undefined; resume(); }
114
+ });
115
+ async function read(count) {
116
+ while (incoming.length < count) await new Promise(resolve => { wake = resolve; });
117
+ const payload = incoming.subarray(0, count);
118
+ incoming = incoming.subarray(count);
119
+ return payload;
120
+ }
121
+ socket.addEventListener('open', async () => {
122
+ try {
123
+ assert.equal((await read(12)).toString(), 'RFB 003.008\\n');
124
+ socket.send(Buffer.from('RFB 003.008\\n'));
125
+ const security = await read((await read(1))[0]);
126
+ assert.ok(security.includes(1));
127
+ socket.send(Buffer.from([1]));
128
+ assert.deepEqual(await read(4), Buffer.alloc(4));
129
+ socket.send(Buffer.from([1]));
130
+ const init = await read(24);
131
+ assert.equal(init.readUInt16BE(0), 1280);
132
+ assert.equal(init.readUInt16BE(2), 720);
133
+ clearTimeout(timeout); socket.close();
134
+ console.log('desktop-websocket-ok');
135
+ } catch (error) { console.error(error); process.exit(1); }
136
+ });
137
+ """
138
+ assert execute(box, "node", "-e", websocket_proof, USER_AGENT) == "desktop-websocket-ok"
139
+ proof = r"""import ctypes, select, socket, struct, time
140
+ s = socket.create_connection(("127.0.0.1", 5900), 5)
141
+ s.settimeout(5)
142
+ def read(count):
143
+ payload = b""
144
+ while len(payload) < count:
145
+ chunk = s.recv(count - len(payload))
146
+ assert chunk, "VNC disconnected"
147
+ payload += chunk
148
+ return payload
149
+ assert read(12).startswith(b"RFB ")
150
+ s.sendall(b"RFB 003.008\n")
151
+ security = read(read(1)[0])
152
+ assert 1 in security
153
+ s.sendall(b"\x01")
154
+ assert read(4) == b"\x00" * 4
155
+ s.sendall(b"\x01")
156
+ width, height = struct.unpack("!HH", read(4))
157
+ assert (width, height) == (1280, 720)
158
+ pixel_format = read(16)
159
+ read(struct.unpack("!I", read(4))[0])
160
+ s.sendall(struct.pack("!BBHi", 2, 0, 1, 0))
161
+ s.sendall(struct.pack("!BBHHHH", 3, 0, 0, 0, width, height))
162
+ def frame():
163
+ message = read(1)
164
+ if message == b"\x03":
165
+ read(3)
166
+ read(struct.unpack("!I", read(4))[0])
167
+ return 0
168
+ if message == b"\x02":
169
+ return 0
170
+ assert message == b"\x00"
171
+ read(1)
172
+ rectangles = struct.unpack("!H", read(2))[0]
173
+ for _ in range(rectangles):
174
+ x, y, w, h, encoding = struct.unpack("!HHHHi", read(12))
175
+ assert encoding == 0
176
+ assert len(read(w * h * (pixel_format[0] // 8))) == w * h * (pixel_format[0] // 8)
177
+ return rectangles
178
+ assert frame() > 0
179
+ xlib = ctypes.CDLL("libX11.so.6")
180
+ xlib.XOpenDisplay.restype = ctypes.c_void_p
181
+ xlib.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
182
+ xlib.XDefaultRootWindow.restype = ctypes.c_ulong
183
+ xlib.XQueryPointer.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + [ctypes.c_void_p] * 7
184
+ display = xlib.XOpenDisplay(b":99")
185
+ assert display
186
+ root = xlib.XDefaultRootWindow(display)
187
+ root_return, child_return = ctypes.c_ulong(), ctypes.c_ulong()
188
+ root_x, root_y, window_x, window_y, mask = [ctypes.c_int() for _ in range(5)]
189
+ # Keep the framebuffer request stream active, as a real VNC viewer does.
190
+ s.sendall(struct.pack("!BBHH", 5, 0, 321, 234))
191
+ s.sendall(struct.pack("!BBHHHH", 3, 1, 0, 0, width, height))
192
+ deadline = time.monotonic() + 3
193
+ while time.monotonic() < deadline:
194
+ assert xlib.XQueryPointer(display, root, *[ctypes.byref(v) for v in [root_return, child_return, root_x, root_y, window_x, window_y, mask]])
195
+ if (root_x.value, root_y.value) == (321, 234):
196
+ break
197
+ if select.select([s], [], [], 0.05)[0]:
198
+ frame()
199
+ s.sendall(struct.pack("!BBHHHH", 3, 1, 0, 0, width, height))
200
+ assert (root_x.value, root_y.value) == (321, 234), (root_x.value, root_y.value)
201
+ # Deliver keyboard input through VNC and inspect the receiving X event.
202
+ class KeyEvent(ctypes.Structure):
203
+ _fields_ = [("type", ctypes.c_int), ("serial", ctypes.c_ulong),
204
+ ("send_event", ctypes.c_int), ("display", ctypes.c_void_p),
205
+ ("window", ctypes.c_ulong), ("root", ctypes.c_ulong),
206
+ ("subwindow", ctypes.c_ulong), ("time", ctypes.c_ulong),
207
+ ("x", ctypes.c_int), ("y", ctypes.c_int),
208
+ ("x_root", ctypes.c_int), ("y_root", ctypes.c_int),
209
+ ("state", ctypes.c_uint), ("keycode", ctypes.c_uint),
210
+ ("same_screen", ctypes.c_int)]
211
+ xlib.XCreateSimpleWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong,
212
+ ctypes.c_int, ctypes.c_int, ctypes.c_uint, ctypes.c_uint,
213
+ ctypes.c_uint, ctypes.c_ulong, ctypes.c_ulong]
214
+ xlib.XCreateSimpleWindow.restype = ctypes.c_ulong
215
+ window = xlib.XCreateSimpleWindow(display, root, 0, 0, 200, 100, 0, 0, 0)
216
+ xlib.XSelectInput.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_long]
217
+ xlib.XMapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
218
+ xlib.XSetInputFocus.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
219
+ xlib.XFlush.argtypes = [ctypes.c_void_p]
220
+ xlib.XPending.argtypes = [ctypes.c_void_p]
221
+ xlib.XNextEvent.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
222
+ xlib.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
223
+ xlib.XSelectInput(display, window, 1)
224
+ xlib.XMapWindow(display, window)
225
+ xlib.XFlush(display)
226
+ time.sleep(0.5) # Openbox must finish mapping before focus is valid.
227
+ xlib.XSetInputFocus(display, window, 1, 0)
228
+ xlib.XFlush(display)
229
+ time.sleep(0.2)
230
+ s.sendall(struct.pack("!BBHI", 4, 1, 0, ord("a")))
231
+ s.sendall(struct.pack("!BBHI", 4, 0, 0, ord("a")))
232
+ event = ctypes.create_string_buffer(192)
233
+ deadline = time.monotonic() + 5
234
+ received = False
235
+ while time.monotonic() < deadline:
236
+ while xlib.XPending(display):
237
+ xlib.XNextEvent(display, event)
238
+ key = KeyEvent.from_buffer(event)
239
+ if key.type == 2 and key.keycode == xlib.XKeysymToKeycode(display, ord("a")):
240
+ received = True
241
+ if received: break
242
+ if select.select([s], [], [], 0.05)[0]:
243
+ frame()
244
+ s.sendall(struct.pack("!BBHHHH", 3, 1, 0, 0, width, height))
245
+ assert received, "VNC keyboard input did not reach X server"
246
+ # Clipboard input must become an X selection, not just arrive at the socket.
247
+ clipboard = b"raft-clipboard-proof"
248
+ s.sendall(struct.pack("!B3xI", 6, len(clipboard)) + clipboard)
249
+ xlib.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
250
+ xlib.XInternAtom.restype = ctypes.c_ulong
251
+ selection = xlib.XInternAtom(display, b"CLIPBOARD", 0)
252
+ property_atom = xlib.XInternAtom(display, b"RAFT_PROOF", 0)
253
+ xlib.XConvertSelection.argtypes = [ctypes.c_void_p] + [ctypes.c_ulong] * 5
254
+ xlib.XGetWindowProperty.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong,
255
+ ctypes.c_long, ctypes.c_long, ctypes.c_int, ctypes.c_ulong,
256
+ ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
257
+ xlib.XFree.argtypes = [ctypes.c_void_p]
258
+ xlib.XGetSelectionOwner.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
259
+ xlib.XGetSelectionOwner.restype = ctypes.c_ulong
260
+ deadline = time.monotonic() + 5
261
+ while not xlib.XGetSelectionOwner(display, selection):
262
+ assert time.monotonic() < deadline, "VNC did not claim clipboard selection"
263
+ if select.select([s], [], [], 0.05)[0]:
264
+ frame()
265
+ s.sendall(struct.pack("!BBHHHH", 3, 1, 0, 0, width, height))
266
+ xlib.XConvertSelection(display, selection, 31, property_atom, window, 0)
267
+ xlib.XFlush(display)
268
+ deadline = time.monotonic() + 5
269
+ copied = b""
270
+ while time.monotonic() < deadline:
271
+ actual_type, count, remaining = ctypes.c_ulong(), ctypes.c_ulong(), ctypes.c_ulong()
272
+ fmt, data = ctypes.c_int(), ctypes.c_void_p()
273
+ xlib.XGetWindowProperty(display, window, property_atom, 0, 1024, 0, 0,
274
+ ctypes.byref(actual_type), ctypes.byref(fmt), ctypes.byref(count),
275
+ ctypes.byref(remaining), ctypes.byref(data))
276
+ if data:
277
+ copied = ctypes.string_at(data, count.value)
278
+ xlib.XFree(data)
279
+ if copied == clipboard: break
280
+ if select.select([s], [], [], 0.05)[0]:
281
+ frame()
282
+ s.sendall(struct.pack("!BBHHHH", 3, 1, 0, 0, width, height))
283
+ assert copied == clipboard, ("VNC clipboard mismatch", copied)
284
+ s.close()
285
+ print("desktop-frame-and-input-ok")
286
+ """
287
+ assert execute(box, "python3", "-c", proof) == "desktop-frame-and-input-ok"
288
+
289
+
290
+ def browser_session(box):
291
+ # Chromium's DOM/window protocol reports modern captions more reliably than XFetchName.
292
+ execute(
293
+ box,
294
+ "python3",
295
+ "-c",
296
+ "from pathlib import Path; p=Path('/tmp/raft-gui-proof.html'); "
297
+ "p.write_text('<title>raft-gui-proof</title><h1>raft-gui-proof</h1>'); p.chmod(0o644)",
298
+ )
299
+ job = raft(
300
+ "exec",
301
+ box,
302
+ "--detach",
303
+ "--",
304
+ "runuser",
305
+ "-u",
306
+ "developer",
307
+ "--",
308
+ "env",
309
+ "DISPLAY=:99",
310
+ "chromium",
311
+ "--remote-debugging-port=9222",
312
+ "--user-data-dir=/tmp/raft-browser-profile",
313
+ "--no-first-run",
314
+ "--no-default-browser-check",
315
+ "--disable-background-networking",
316
+ "--disable-dev-shm-usage",
317
+ "--user-agent=" + USER_AGENT,
318
+ "file:///tmp/raft-gui-proof.html",
319
+ ).stdout.strip()
320
+ proof = """const assert = require('node:assert/strict');
321
+ (async () => {
322
+ let page;
323
+ const deadline = Date.now() + 15000;
324
+ while (Date.now() < deadline) {
325
+ try {
326
+ const response = await fetch('http://127.0.0.1:9222/json/list', {headers: {'User-Agent': process.argv[1]}});
327
+ page = (await response.json()).find(target => target.type === 'page' && target.url === 'file:///tmp/raft-gui-proof.html');
328
+ if (page) break;
329
+ } catch {}
330
+ await new Promise(resolve => setTimeout(resolve, 200));
331
+ }
332
+ assert.ok(page, 'Headed Chromium target missing');
333
+ const socket = new WebSocket(page.webSocketDebuggerUrl, {headers: {'User-Agent': process.argv[1]}});
334
+ const viewerProof = {id: 4, method: 'Runtime.evaluate', params: {expression:
335
+ `JSON.stringify({connected: document.documentElement.classList.contains('noVNC_connected'),
336
+ canvases: Array.from(document.querySelectorAll('canvas')).map(c => ({width: c.width, height: c.height}))})`}};
337
+ const timeout = setTimeout(() => process.exit(1), 15000);
338
+ socket.addEventListener('error', () => process.exit(1));
339
+ socket.addEventListener('open', () => socket.send(JSON.stringify({id: 1, method: 'Runtime.evaluate', params: {expression: 'document.title'}})));
340
+ socket.addEventListener('message', event => {
341
+ try {
342
+ const response = JSON.parse(event.data);
343
+ assert.equal(response.error, undefined);
344
+ if (response.id === 1) {
345
+ assert.equal(response.result.result.value, 'raft-gui-proof');
346
+ socket.send(JSON.stringify({id: 2, method: 'Browser.getWindowForTarget', params: {targetId: page.id}}));
347
+ } else if (response.id === 2) {
348
+ assert.ok(response.result.bounds.width > 0 && response.result.bounds.height > 0);
349
+ socket.send(JSON.stringify({id: 3, method: 'Page.navigate', params:
350
+ {url: 'http://127.0.0.1:6080/vnc.html?autoconnect=true&resize=scale'}}));
351
+ } else if (response.id === 3) {
352
+ assert.equal(response.result.errorText, undefined);
353
+ socket.send(JSON.stringify(viewerProof));
354
+ } else if (response.id === 4) {
355
+ const viewer = JSON.parse(response.result.result.value);
356
+ if (!viewer.connected) {
357
+ setTimeout(() => socket.send(JSON.stringify(viewerProof)), 200); return;
358
+ }
359
+ assert.ok(viewer.canvases.some(c => c.width === 1280 && c.height === 720));
360
+ clearTimeout(timeout); socket.close(); console.log('developer-gui-and-novnc-ok');
361
+ }
362
+ } catch (error) { console.error(error); process.exit(1); }
363
+ });
364
+ })().catch(error => { console.error(error); process.exit(1); });
365
+ """
366
+ try:
367
+ assert execute(box, "node", "-e", proof, USER_AGENT) == "developer-gui-and-novnc-ok"
368
+ except (RuntimeError, AssertionError):
369
+ print(raft("logs", box, job, check=False).stdout, file=sys.stderr, flush=True)
370
+ raise
371
+ finally:
372
+ raft("cancel", box, job)
373
+
374
+
375
+ def fetch_through_tunnel(arguments, path, timeout=15, default_port=None):
376
+ if default_port is None:
377
+ with socket.socket() as listener:
378
+ listener.bind(("127.0.0.1", 0))
379
+ port = listener.getsockname()[1]
380
+ arguments = [*arguments, "--local", str(port)]
381
+ else:
382
+ port = default_port
383
+ tunnel = subprocess.Popen(
384
+ [*CLI, *arguments],
385
+ stdout=subprocess.PIPE,
386
+ stderr=subprocess.PIPE,
387
+ )
388
+ try:
389
+ deadline = time.monotonic() + timeout
390
+ while True:
391
+ try:
392
+ with urllib.request.urlopen(
393
+ urllib.request.Request(
394
+ f"http://127.0.0.1:{port}{path}", headers={"User-Agent": USER_AGENT}
395
+ ),
396
+ timeout=2,
397
+ ) as response:
398
+ return response.read()
399
+ except OSError:
400
+ if time.monotonic() >= deadline:
401
+ raise
402
+ time.sleep(0.5)
403
+ finally:
404
+ tunnel.terminate()
405
+ tunnel.wait(timeout=10)
406
+
407
+
408
+ def verify_configuration():
409
+ # Real CLI processes must reject invalid settings before host access.
410
+ cases = [
411
+ {},
412
+ {"invalid:name": {"ssh": "unused"}},
413
+ {"lab": {}},
414
+ {"lab": {"ssh": "unused", "image": ""}},
415
+ ]
416
+ for invalid_configuration in cases:
417
+ with tempfile.TemporaryDirectory() as directory:
418
+ config = Path(directory) / ".config/raft"
419
+ config.mkdir(parents=True)
420
+ (config / "incus.json").write_text(json.dumps(invalid_configuration))
421
+ response = raft("new", check=False, env={**os.environ, "HOME": directory})
422
+ assert response.returncode == 1 and "Traceback" not in response.stderr
423
+
424
+
425
+ def verify_arguments():
426
+ # Invoke actual CLI processes: help and argument errors must not access a host.
427
+ commands = "new list limits doctor gc prune recover info stop resume extend destroy ssh exec upload download snapshot snapshots snapshot-delete restore fork forward desktop logs status cancel backup usage".split()
428
+ assert raft("--help").returncode == 0
429
+ for command in commands:
430
+ assert raft(command, "--help").returncode == 0
431
+ missing = "lab:rf-0000000000000000"
432
+ for command in ["new", "resume", "extend", "fork"]:
433
+ prefix = [command] if command == "new" else [command, missing]
434
+ for lifetime in ["59", "2592001", "invalid"]:
435
+ rejected = raft(*prefix, "--ttl", lifetime, check=False)
436
+ assert rejected.returncode == 2 and "Traceback" not in rejected.stderr
437
+ for options in [["--cpu", "0"], ["--cpu", "3"], ["--memory", "8GiB"]]:
438
+ assert raft("new", *options, check=False).returncode == 2
439
+ for arguments in [["exec", missing], ["recover", "missing"], ["forward", missing]]:
440
+ assert raft(*arguments, check=False).returncode == 2
441
+ print("CLI help and invalid sizing, TTL and required arguments passed", flush=True)
442
+
443
+
444
+ def verify_option_edges(location, box):
445
+ assert any(json.loads(line)["box"] == box for line in raft("list").stdout.splitlines())
446
+ reports = [json.loads(line) for line in raft("limits", "--json").stdout.splitlines()]
447
+ assert {report["location"] for report in reports} == set(configuration())
448
+ assert "CPU RAM GiB" in raft("limits").stdout
449
+ for arguments, message in [
450
+ (["info", "invalid"], "location-qualified handle"),
451
+ (["info", location + ":rf-0000000000000000"], "Raft box not found"),
452
+ (["list", "--location", "missing-location"], "Unknown location"),
453
+ (["limits", "--location", "missing-location"], "Unknown location"),
454
+ (["new", "--location", "missing-location"], "Unknown location"),
455
+ (["upload", box, "missing", "relative"], "must be absolute"),
456
+ (["download", box, "relative", "missing"], "must be absolute"),
457
+ (["snapshot", box, "../invalid"], "Invalid snapshot name"),
458
+ (["restore", box, "../invalid"], "Invalid snapshot name"),
459
+ (["forward", box, "--remote", "0", "--local", "8080"], "Ports must"),
460
+ (["forward", box, "--remote", "8080", "--local", "65536"], "Ports must"),
461
+ (["desktop", box, "--local", "0"], "Ports must"),
462
+ ]:
463
+ rejected = raft(*arguments, check=False)
464
+ assert rejected.returncode == 1 and message in rejected.stderr, (arguments, rejected.stderr)
465
+ for command in ["status", "logs", "cancel"]:
466
+ for job, message in [
467
+ ("invalid", "Use the job ID"),
468
+ ("rfcmd-" + "0" * 32, "Detached job not found"),
469
+ ]:
470
+ rejected = raft(command, box, job, check=False)
471
+ assert rejected.returncode == 1 and message in rejected.stderr
472
+ print(
473
+ f"{location}: global reports and invalid handles, paths, jobs and ports passed", flush=True
474
+ )
475
+ # /dev/full rejects real writes with ENOSPC without filling the controller disk.
476
+ # OpenSSH alone returns zero here, so exercise both artifact receiving paths.
477
+ for operation in ["download", "backup"]:
478
+ with open("/dev/full", "wb", buffering=0) as destination:
479
+ try:
480
+ if operation == "download":
481
+ remote(
482
+ location,
483
+ ["exec", box.split(":", 1)[1], "--", "printf", "stream-proof"],
484
+ stdout=destination,
485
+ )
486
+ else:
487
+ transaction(location, 'printf "%s" "$1"', "stream-proof", stdout=destination)
488
+ except OSError as error:
489
+ assert error.errno == errno.ENOSPC
490
+ else:
491
+ raise AssertionError(f"{operation}: silently accepted a failed controller write")
492
+ print(f"{location}: download and backup streams rejected real ENOSPC writes", flush=True)
493
+
494
+
495
+ def verify_stopped_race(location, box):
496
+ """Queue real disk operations behind the lock, then start their source first."""
497
+ name = box.split(":", 1)[1]
498
+ host = settings(location)["ssh"]
499
+ command = ["ssh", "-T", "-o", "BatchMode=yes", host]
500
+ holder_script = """import fcntl, os, subprocess, sys, time
501
+ with open('/run/lock/raft-incus.lock', 'a') as lock:
502
+ fcntl.flock(lock, fcntl.LOCK_EX)
503
+ info = os.fstat(lock.fileno())
504
+ print(f'{os.major(info.st_dev):02x}:{os.minor(info.st_dev):02x}:{info.st_ino}', flush=True)
505
+ if sys.stdin.readline().strip() == 'start':
506
+ subprocess.run(['incus', '--project', 'raft', 'start', sys.argv[1]], check=True)
507
+ subprocess.run(['incus', '--project', 'raft', 'config', 'set', sys.argv[1],
508
+ f'user.raft.expires={int(time.time()) + 600}'], check=True)
509
+ """
510
+ queued_script = """from pathlib import Path
511
+ import sys
512
+ for line in Path('/proc/locks').read_text().splitlines():
513
+ if '->' in line and sys.argv[1] in line.split():
514
+ fields = line.split()
515
+ pid = fields[fields.index('WRITE') + 1]
516
+ try:
517
+ arguments = (Path('/proc') / pid / 'cmdline').read_bytes().split(bytes([0]))
518
+ except FileNotFoundError:
519
+ continue
520
+ if sys.argv[2].encode() in arguments:
521
+ print('queued')
522
+ """
523
+ raft("resume", box, "--ttl", "600")
524
+ wait_running(box)
525
+ execute(box, "sh", "-c", "echo guarded > /workspace/persistent")
526
+ raft("stop", box)
527
+ children = []
528
+ try:
529
+ for operation in [
530
+ ["snapshot", box, "blocked-race"],
531
+ ["restore", box, "prepared"],
532
+ ["fork", box, "--ttl", "600"],
533
+ ]:
534
+ with subprocess.Popen(
535
+ [*command, shlex.join(["sudo", "-n", "python3", "-c", holder_script, name])],
536
+ stdin=subprocess.PIPE,
537
+ stdout=subprocess.PIPE,
538
+ stderr=subprocess.PIPE,
539
+ text=True,
540
+ ) as holder:
541
+ contender = None
542
+ try:
543
+ assert select.select([holder.stdout], [], [], 15)[0], (
544
+ "Lock holder did not become ready"
545
+ )
546
+ lock_key = holder.stdout.readline().strip()
547
+ assert lock_key.count(":") == 2, "Lock holder exited before acquiring the lock"
548
+ contender = subprocess.Popen(
549
+ [*CLI, *operation],
550
+ stdout=subprocess.PIPE,
551
+ stderr=subprocess.PIPE,
552
+ text=True,
553
+ )
554
+ deadline = time.monotonic() + 20
555
+ while time.monotonic() < deadline:
556
+ assert contender.poll() is None, (
557
+ "Operation did not queue behind the lifecycle lock"
558
+ )
559
+ queued = subprocess.check_output(
560
+ [
561
+ *command,
562
+ shlex.join(
563
+ ["sudo", "-n", "python3", "-c", queued_script, lock_key, name]
564
+ ),
565
+ ],
566
+ text=True,
567
+ )
568
+ if "queued" in queued:
569
+ break
570
+ time.sleep(0.1)
571
+ else:
572
+ raise RuntimeError("No blocked lifecycle operation observed in /proc/locks")
573
+ holder.stdin.write("start\n")
574
+ holder.stdin.flush()
575
+ holder.wait(timeout=40)
576
+ assert holder.returncode == 0, holder.stderr.read()
577
+ output, errors = contender.communicate(timeout=40)
578
+ if operation[0] == "fork" and contender.returncode == 0:
579
+ children.append(output.strip())
580
+ assert contender.returncode != 0 and "Stop the source box" in errors, (
581
+ operation[0],
582
+ output,
583
+ errors,
584
+ )
585
+ wait_running(box)
586
+ assert execute(box, "cat", "/workspace/persistent") == "guarded"
587
+ assert "blocked-race" not in raft("snapshots", box).stdout
588
+ raft("stop", box)
589
+ finally:
590
+ if holder.poll() is None:
591
+ holder.stdin.close()
592
+ holder.wait(timeout=15)
593
+ if contender and contender.poll() is None:
594
+ contender.terminate()
595
+ contender.wait(timeout=15)
596
+ raft("restore", box, "prepared")
597
+ print(
598
+ f"{location}: snapshot, restore and fork rejected a source resumed while queued",
599
+ flush=True,
600
+ )
601
+ finally:
602
+ for child in children:
603
+ raft("destroy", child)
604
+
605
+
606
+ def verify_reboot(location, box, child):
607
+ """Reboot only an explicitly selected disposable host, then prove recovery."""
608
+ host = settings(location)["ssh"]
609
+ command = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=2", host]
610
+ boot_id = subprocess.check_output(
611
+ [*command, "cat /proc/sys/kernel/random/boot_id"], text=True
612
+ ).strip()
613
+ raft("stop", child)
614
+ raft("extend", box, "--ttl", "600")
615
+ subprocess.run([*command, "sudo systemctl reboot"], capture_output=True)
616
+ deadline = time.monotonic() + 180
617
+ while time.monotonic() < deadline:
618
+ probe = subprocess.run(
619
+ [
620
+ *command,
621
+ "systemctl is-active incus raft-network.service raft-expire.timer >/dev/null "
622
+ "&& cat /proc/sys/kernel/random/boot_id",
623
+ ],
624
+ capture_output=True,
625
+ text=True,
626
+ )
627
+ if probe.returncode == 0 and probe.stdout.strip() != boot_id:
628
+ break
629
+ time.sleep(2)
630
+ else:
631
+ raise RuntimeError("Host did not return with a new kernel boot ID")
632
+ timing = json.loads(
633
+ subprocess.check_output(
634
+ [
635
+ *command,
636
+ "python3 -c "
637
+ + shlex.quote(
638
+ "import json,subprocess; print(json.dumps({unit: int(subprocess.check_output(['systemctl','show',unit,'-p',prop,'--value'])) for unit,prop in [('raft-network.service','ActiveEnterTimestampMonotonic'),('incus.service','ExecMainStartTimestampMonotonic')]}))"
639
+ ),
640
+ ],
641
+ text=True,
642
+ )
643
+ )
644
+ assert 0 < timing["raft-network.service"] < timing["incus.service"], (
645
+ "Incus started before firewall installation completed"
646
+ )
647
+ assert json.loads(raft("info", child).stdout)["status"] == "Stopped"
648
+ wait_running(box)
649
+ assert execute(box, "cat", "/workspace/persistent") == "saved"
650
+ assert "prepared" in raft("snapshots", box).stdout
651
+ assert execute(box, "docker", "run", "--rm", "raft-verify") == "raft-docker-ok"
652
+ raft("resume", child, "--ttl", "300")
653
+ wait_running(child)
654
+ verify_peers(box, child)
655
+ assert execute(child, "cat", "/workspace/persistent") == "saved"
656
+ print(
657
+ f"{location}: real host reboot retained files, snapshots, state, Docker and peer isolation",
658
+ flush=True,
659
+ )
660
+
661
+
662
+ def verify(location, extended=False, restart_incus=False, reboot_host=False):
663
+ verify_configuration()
664
+ verify_arguments()
665
+ fixtures = []
666
+ print(f"{location}: allocation", flush=True)
667
+ try:
668
+ started = time.monotonic()
669
+ target = [] if location == next(iter(configuration())) else ["--location", location]
670
+ box = raft("new", *target, "--ttl", "1800").stdout.strip()
671
+ fixtures.append(box)
672
+ print(f"{location}: new took {time.monotonic() - started:.1f}s", flush=True)
673
+ wait_running(box)
674
+ inventory = json.loads(raft("info", box).stdout)
675
+ assert (
676
+ int(inventory["config"]["user.raft.expires"]) >= int(execute(box, "date", "+%s")) + 1750
677
+ )
678
+ assert inventory["expanded_config"]["limits.memory"] == "2GiB"
679
+ assert inventory["expanded_config"]["limits.cpu"] == "1"
680
+ assert inventory["expanded_config"].get("security.privileged", "false") == "false"
681
+ assert any(
682
+ json.loads(line)["box"] == box
683
+ for line in raft("list", "--location", location).stdout.splitlines()
684
+ )
685
+ assert raft("new", "--location", location, "--ttl", "59", check=False).returncode != 0
686
+ assert raft("resume", box, "--ttl", "600", check=False).returncode != 0
687
+ assert raft("snapshot", box, "running", check=False).returncode != 0
688
+ assert raft("fork", box, "--ttl", "600", check=False).returncode != 0
689
+ terminal_session(box)
690
+ verify_option_edges(location, box)
691
+ if extended:
692
+ subprocess.run([sys.executable, str(ROOT / "deploy/verify-tools.py"), box], check=True)
693
+ for cpu in [1, 2]:
694
+ for memory in ["1GiB", "2GiB", "4GiB"]:
695
+ sized = raft(
696
+ "new",
697
+ "--location",
698
+ location,
699
+ "--cpu",
700
+ str(cpu),
701
+ "--memory",
702
+ memory,
703
+ ).stdout.strip()
704
+ fixtures.append(sized)
705
+ assert (
706
+ int(json.loads(raft("info", sized).stdout)["config"]["user.raft.expires"])
707
+ > int(execute(sized, "date", "+%s")) + 550
708
+ )
709
+ assert execute(sized, "nproc") == str(cpu)
710
+ assert execute(sized, "cat", "/sys/fs/cgroup/memory.max") == str(
711
+ int(memory[0]) * 1024**3
712
+ )
713
+ raft("destroy", sized)
714
+ fixtures.remove(sized)
715
+ print(f"{location}: developer tools and all resource combinations passed", flush=True)
716
+ assert execute(box, "pwd") == "/workspace"
717
+ host_machine = subprocess.check_output(
718
+ ["ssh", "-T", "-o", "BatchMode=yes", settings(location)["ssh"], "uname -m"],
719
+ text=True,
720
+ ).strip()
721
+ assert execute(box, "uname", "-m") == host_machine
722
+ assert execute(box, "nproc") == "1"
723
+ assert execute(box, "cat", "/sys/fs/cgroup/memory.max") == "2147483648"
724
+ assert execute(box, "printf", "%s", "spaces $HOME `literal`") == "spaces $HOME `literal`"
725
+ assert raft("exec", box, "--", "bash", "-lc", "exit 37", check=False).returncode == 37
726
+ tools = "node npm pnpm bun deno python3 uv go rustc cargo java mvn gradle kotlin scala ruby php composer elixir dotnet R gcc clang cmake ninja git rg jq chromium ffmpeg docker"
727
+ execute(
728
+ box,
729
+ "bash",
730
+ "-lc",
731
+ f'for tool in {tools}; do command -v "$tool" >/dev/null || exit 1; done',
732
+ )
733
+ assert execute(box, "node", "--version").startswith("v24.")
734
+ assert execute(box, "docker", "info", "--format", "{{.Driver}}") in [
735
+ "overlay2",
736
+ "overlayfs",
737
+ ]
738
+ docker = execute(
739
+ box,
740
+ "bash",
741
+ "-lc",
742
+ 'mkdir -p /workspace/docker-test; printf "FROM alpine:3.22\\nRUN echo raft-docker-ok > /proof\\nCMD [\\"cat\\",\\"/proof\\"]\\n" > /workspace/docker-test/Dockerfile; docker build -q -t raft-verify /workspace/docker-test; docker run --rm raft-verify',
743
+ )
744
+ assert "raft-docker-ok" in docker
745
+ assert (
746
+ execute(
747
+ box,
748
+ "python3",
749
+ "-c",
750
+ 'import socket\nfor host in ["169.254.169.254","10.232.0.1"]:\n try:\n socket.create_connection((host,22 if host.endswith(".1") else 80),2)\n except OSError: continue\n raise SystemExit("private destination reachable")\nprint("private-access-blocked")',
751
+ )
752
+ == "private-access-blocked"
753
+ )
754
+ with tempfile.TemporaryDirectory() as directory:
755
+ source = Path(directory) / "source with spaces.bin"
756
+ target = Path(directory) / "target with spaces.bin"
757
+ source.write_bytes(bytes(range(256)) * 128)
758
+ raft("upload", box, str(source), "/workspace/file with spaces.bin")
759
+ raft("download", box, "/workspace/file with spaces.bin", str(target))
760
+ assert source.read_bytes() == target.read_bytes()
761
+ assert (
762
+ raft(
763
+ "download", box, "/workspace/missing-file", str(target), check=False
764
+ ).returncode
765
+ != 0
766
+ )
767
+ assert source.read_bytes() == target.read_bytes(), (
768
+ "Failed download replaced existing destination"
769
+ )
770
+ job = raft(
771
+ "exec", box, "--detach", "--", "bash", "-lc", "echo detached-ok; exit 23"
772
+ ).stdout.strip()
773
+ time.sleep(2)
774
+ assert "ExecMainStatus=23" in raft("status", box, job).stdout
775
+ assert "detached-ok" in raft("logs", box, job).stdout
776
+ execute(box, "bash", "-lc", "echo saved > /workspace/persistent")
777
+ raft("stop", box)
778
+ assert json.loads(raft("info", box).stdout)["status"] == "Stopped"
779
+ raft("snapshot", box, "prepared")
780
+ verify_stopped_race(location, box)
781
+ child = raft("fork", box, "--ttl", "1800").stdout.strip()
782
+ fixtures.append(child)
783
+ wait_running(child)
784
+ assert execute(child, "cat", "/workspace/persistent") == "saved"
785
+ raft("resume", box, "--ttl", "1800")
786
+ wait_running(box)
787
+ assert execute(box, "cat", "/workspace/persistent") == "saved"
788
+ execute(box, "bash", "-lc", "echo changed > /workspace/persistent")
789
+ raft("stop", box)
790
+ raft("restore", box, "prepared")
791
+ raft("resume", box, "--ttl", "1800")
792
+ wait_running(box)
793
+ assert execute(box, "cat", "/workspace/persistent") == "saved"
794
+ server = raft(
795
+ "exec", box, "--detach", "--", "python3", "-m", "http.server", "8080"
796
+ ).stdout.strip()
797
+ assert (
798
+ fetch_through_tunnel(["forward", box, "--remote", "8080"], "/persistent").strip()
799
+ == b"saved"
800
+ )
801
+ assert "ActiveState=active" in raft("status", box, server).stdout
802
+ assert b"noVNC" in fetch_through_tunnel(["desktop", box], "/vnc.html", timeout=20)
803
+ assert b"noVNC" in fetch_through_tunnel(
804
+ ["desktop", box], "/vnc.html", timeout=20, default_port=6080
805
+ )
806
+ assert execute(box, "systemctl", "is-active", "raft-desktop") == "active"
807
+ assert "noVNC" in execute(
808
+ box,
809
+ "curl",
810
+ "-A",
811
+ USER_AGENT,
812
+ "--retry",
813
+ "10",
814
+ "--retry-connrefused",
815
+ "--retry-delay",
816
+ "1",
817
+ "-fsS",
818
+ "http://127.0.0.1:6080/vnc.html",
819
+ )
820
+ assert "raft-browser-ok" in execute(
821
+ box,
822
+ "chromium",
823
+ "--headless",
824
+ "--no-sandbox",
825
+ "--disable-dev-shm-usage",
826
+ "--dump-dom",
827
+ "data:text/html,<h1>raft-browser-ok</h1>",
828
+ )
829
+ desktop_session(box)
830
+ desktop_session(box) # A second full connection proves reconnect.
831
+ browser_session(box)
832
+ execute(box, "systemctl", "stop", "raft-desktop")
833
+ verify_peers(box, child)
834
+ raft("stop", child)
835
+ raft("resume", child, "--ttl", "60")
836
+ if restart_incus:
837
+ subprocess.run(
838
+ ["ssh", "-T", settings(location)["ssh"], "sudo -n systemctl restart incus"],
839
+ check=True,
840
+ )
841
+ wait_running(box)
842
+ assert execute(box, "cat", "/workspace/persistent") == "saved"
843
+ print(
844
+ f"{location}: tools, Docker, files, limits, snapshots, restore, forks, jobs, tunnels, desktop and isolation passed",
845
+ flush=True,
846
+ )
847
+ deadline = time.monotonic() + 100
848
+ while time.monotonic() < deadline:
849
+ if json.loads(raft("info", child).stdout)["status"] == "Stopped":
850
+ break
851
+ time.sleep(5)
852
+ else:
853
+ raise RuntimeError("Automatic host expiry did not stop the child")
854
+ raft("resume", child, "--ttl", "300")
855
+ assert execute(child, "cat", "/workspace/persistent") == "saved"
856
+ print(f"{location}: scheduled TTL stop retained disk and resumed successfully", flush=True)
857
+ incus(
858
+ location,
859
+ "config",
860
+ "set",
861
+ child.split(":", 1)[1],
862
+ "user.raft.expires=1",
863
+ locked=True,
864
+ )
865
+ raft("gc")
866
+ assert json.loads(raft("info", child).stdout)["status"] == "Stopped"
867
+ raft("resume", child, "--ttl", "2592000")
868
+ assert execute(child, "cat", "/workspace/persistent") == "saved"
869
+ assert (
870
+ int(json.loads(raft("info", child).stdout)["config"]["user.raft.expires"])
871
+ > int(execute(child, "date", "+%s")) + 2591950
872
+ )
873
+ print(
874
+ f"{location}: manual gc retained disk and maximum TTL resumed successfully", flush=True
875
+ )
876
+ if reboot_host:
877
+ verify_reboot(location, box, child)
878
+ finally:
879
+ for box in reversed(fixtures):
880
+ raft("destroy", box)
881
+ print(f"{location}: test boxes destroyed", flush=True)
882
+
883
+
884
+ if __name__ == "__main__":
885
+ parser = argparse.ArgumentParser(description=__doc__)
886
+ parser.add_argument("--location", required=True)
887
+ parser.add_argument(
888
+ "--extended",
889
+ action="store_true",
890
+ help="Run language programs and every resource combination",
891
+ )
892
+ parser.add_argument(
893
+ "--restart-incus",
894
+ action="store_true",
895
+ help="Restart the host Incus daemon; use only on dedicated test hosts",
896
+ )
897
+ parser.add_argument(
898
+ "--reboot-host",
899
+ action="store_true",
900
+ help="Reboot the selected host; use only on a disposable host with independent controller",
901
+ )
902
+ args = parser.parse_args()
903
+ verify(args.location, args.extended, args.restart_incus, args.reboot_host)