@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.
- package/.github/assets/raft-logo.png +0 -0
- package/LICENSE +21 -0
- package/README.md +162 -2
- package/deploy/benchmark-boot.py +150 -0
- package/deploy/build-image.py +95 -0
- package/deploy/deploy.py +176 -0
- package/deploy/incus-network.conf +4 -0
- package/deploy/install-incus.sh +22 -0
- package/deploy/raft-expire.py +62 -0
- package/deploy/raft-expire.service +8 -0
- package/deploy/raft-expire.timer +8 -0
- package/deploy/raft-network.service +12 -0
- package/deploy/raft-network.sh +40 -0
- package/deploy/verify-controls.py +241 -0
- package/deploy/verify-features.py +461 -0
- package/deploy/verify-fresh-host.py +301 -0
- package/deploy/verify-image-tests.py +139 -0
- package/deploy/verify-image.py +95 -0
- package/deploy/verify-limits.py +110 -0
- package/deploy/verify-live.py +903 -0
- package/deploy/verify-release.py +132 -0
- package/deploy/verify-tools.py +105 -0
- package/docs/benchmarks/amd64-native-ci.json +137 -0
- package/docs/benchmarks/arm64-baseline-a.json +108 -0
- package/docs/benchmarks/arm64-baseline-b.json +108 -0
- package/docs/benchmarks/arm64-candidate-a.json +109 -0
- package/docs/benchmarks/arm64-desktop-local.json +22 -0
- package/docs/benchmarks/arm64-native-ci.json +137 -0
- package/docs/boat-capabilities.md +64 -0
- package/docs/boat-parity-design.md +26 -0
- package/docs/boot-performance.mdx +102 -0
- package/docs/contract.md +93 -0
- package/docs/first-box.mdx +82 -0
- package/docs/guides/background-jobs.mdx +92 -0
- package/docs/guides/backup-recovery.mdx +111 -0
- package/docs/guides/desktop-services.mdx +87 -0
- package/docs/guides/docker.mdx +84 -0
- package/docs/guides/files.mdx +91 -0
- package/docs/guides/meta.json +14 -0
- package/docs/guides/snapshots-forks.mdx +107 -0
- package/docs/guides/troubleshooting.mdx +127 -0
- package/docs/incus.example.json +3 -0
- package/docs/index.mdx +36 -0
- package/docs/meta.json +21 -0
- package/docs/operations.mdx +224 -0
- package/docs/publishing.md +48 -0
- package/docs/reference/capacity-limits.mdx +83 -0
- package/docs/reference/cli.md +279 -0
- package/docs/reference/configuration.mdx +77 -0
- package/docs/reference/meta.json +10 -0
- package/docs/releases/0.2.0.md +22 -0
- package/docs/requirements.mdx +47 -0
- package/docs/reviews/code-review-2026-10-06.md +168 -0
- package/docs/site-development.md +80 -0
- package/docs/verification.mdx +231 -0
- package/images/incus/build.sh +57 -0
- package/images/incus/desktop.sh +21 -0
- package/images/incus/raft-desktop.service +7 -0
- package/package.json +46 -4
- package/raft.py +955 -0
- package/raft_files.py +134 -0
- 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)
|