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