@microck/raft 0.6.0 → 0.7.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/README.md +13 -4
- package/deploy/build-image.py +4 -2
- package/deploy/verify-controls.py +5 -5
- package/deploy/verify-features.py +35 -2
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +20 -2
- package/deploy/verify-live.py +12 -3
- package/deploy/verify-output.py +208 -0
- package/deploy/verify-release.py +33 -4
- package/deploy/verify-run.py +571 -0
- package/deploy/verify-self-update.py +9 -1
- package/deploy/verify-sync.py +122 -0
- package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
- package/docs/boat-capabilities.md +60 -7
- package/docs/boat-parity-design.md +1 -0
- package/docs/boot-performance.mdx +21 -0
- package/docs/contract.md +76 -2
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +3 -2
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +1 -1
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +28 -9
- package/docs/guides/snapshots-forks.mdx +3 -1
- package/docs/guides/upgrades.mdx +7 -7
- package/docs/index.mdx +26 -28
- package/docs/maintainers/index.mdx +18 -0
- package/docs/maintainers/meta.json +12 -0
- package/docs/meta.json +5 -9
- package/docs/operations.mdx +7 -0
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +85 -19
- package/docs/reference/meta.json +3 -1
- package/docs/releases/0.2.0.md +1 -0
- package/docs/releases/0.2.1.md +1 -0
- package/docs/releases/0.3.0.md +1 -0
- package/docs/releases/0.4.0.md +1 -0
- package/docs/releases/0.5.0.md +1 -0
- package/docs/releases/0.6.0.md +1 -0
- package/docs/releases/0.7.0.md +23 -0
- package/docs/releases/index.mdx +21 -0
- package/docs/releases/meta.json +15 -0
- package/docs/requirements.mdx +1 -1
- package/docs/reviews/docs-audit-2026-10-24.md +92 -0
- package/docs/reviews/performance-audit-2026-10-29.md +90 -0
- package/docs/site-development.md +1 -0
- package/docs/verification.mdx +21 -3
- package/package.json +3 -1
- package/raft.py +380 -179
- package/raft_files.py +13 -1
- package/raft_jobs.py +390 -0
- package/raft_output.py +166 -0
- package/raft_run.py +330 -0
- package/skills/raft-cli/SKILL.md +78 -197
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Fault-injection, real signal/file tests, and optional native-host CI-run verification."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import select
|
|
10
|
+
import shlex
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from types import SimpleNamespace
|
|
19
|
+
import unittest
|
|
20
|
+
import uuid
|
|
21
|
+
from unittest.mock import patch
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
import raft # noqa: E402
|
|
26
|
+
import raft_files # noqa: E402
|
|
27
|
+
import raft_run # noqa: E402
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Transport:
|
|
31
|
+
"""Explicit orchestration fault fixture; not a native Incus verification."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, fault="", status=0):
|
|
34
|
+
self.fault, self.status = fault, status
|
|
35
|
+
self.events = []
|
|
36
|
+
|
|
37
|
+
def new(self, args, *, run_id, on_allocate):
|
|
38
|
+
self.events.append("allocate")
|
|
39
|
+
assert args.disposable is True and args.ttl == args.timeout + 120
|
|
40
|
+
self.token = run_id
|
|
41
|
+
on_allocate("lab", "rf-fixture")
|
|
42
|
+
if self.fault == "create":
|
|
43
|
+
raise RuntimeError("uncertain SSH create outcome")
|
|
44
|
+
self.wait("create")
|
|
45
|
+
return "lab:rf-fixture"
|
|
46
|
+
|
|
47
|
+
def wait(self, phase):
|
|
48
|
+
if self.fault == "timeout-" + phase:
|
|
49
|
+
time.sleep(2)
|
|
50
|
+
if self.fault == "signal-" + phase:
|
|
51
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
52
|
+
|
|
53
|
+
def execute(self, _location, _name, command, **_kwargs):
|
|
54
|
+
if command == ["true"]:
|
|
55
|
+
self.events.append("ready")
|
|
56
|
+
return SimpleNamespace(returncode=0)
|
|
57
|
+
self.events.append("payload")
|
|
58
|
+
self.wait("payload")
|
|
59
|
+
return SimpleNamespace(returncode=self.status)
|
|
60
|
+
|
|
61
|
+
def transfer_tree(self, args, _location, _name):
|
|
62
|
+
self.events.append(args.action + ":" + str(args.source))
|
|
63
|
+
self.wait(args.action)
|
|
64
|
+
if self.fault == args.action or (self.fault == "first-pull" and args.source == "/first"):
|
|
65
|
+
raise RuntimeError(args.action + " failed")
|
|
66
|
+
|
|
67
|
+
def transaction(self, _location, _script, name, token, creation, code):
|
|
68
|
+
self.events.append("cleanup")
|
|
69
|
+
assert creation == (
|
|
70
|
+
"pending"
|
|
71
|
+
if self.fault in {"create", "timeout-create", "signal-create"}
|
|
72
|
+
else "acknowledged"
|
|
73
|
+
)
|
|
74
|
+
assert name == "rf-fixture" and token == self.token and code == raft_run.CLEANUP
|
|
75
|
+
self.wait("cleanup")
|
|
76
|
+
if self.fault == "cleanup":
|
|
77
|
+
raise RuntimeError("cleanup SSH failed")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RunTests(unittest.TestCase):
|
|
81
|
+
def invoke(self, transport, *options, timeout=0.1):
|
|
82
|
+
args = raft.parser().parse_args(["run", *options, "--", "echo", "hello"])
|
|
83
|
+
args.timeout = timeout
|
|
84
|
+
old = {
|
|
85
|
+
number: signal.getsignal(number)
|
|
86
|
+
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGALRM)
|
|
87
|
+
}
|
|
88
|
+
with contextlib.redirect_stderr(io.StringIO()), patch.object(raft_run, "GRACE", 0.1):
|
|
89
|
+
result = raft_run.run(args, transport)
|
|
90
|
+
for number, handler in old.items():
|
|
91
|
+
self.assertEqual(signal.getsignal(number), handler)
|
|
92
|
+
self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0, 0))
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
def test_receive_deadline_after_stdout_eof_kills_child(self):
|
|
96
|
+
class ArmOnWrite(io.BytesIO):
|
|
97
|
+
def write(self, data):
|
|
98
|
+
signal.setitimer(signal.ITIMER_REAL, 0.1)
|
|
99
|
+
return super().write(data)
|
|
100
|
+
|
|
101
|
+
previous = signal.getsignal(signal.SIGALRM)
|
|
102
|
+
|
|
103
|
+
def expired(_number, _frame):
|
|
104
|
+
raise raft_run.Stopped(124, "test deadline")
|
|
105
|
+
|
|
106
|
+
began = time.monotonic()
|
|
107
|
+
try:
|
|
108
|
+
signal.signal(signal.SIGALRM, expired)
|
|
109
|
+
with self.assertRaises(raft_run.Stopped):
|
|
110
|
+
raft.receive(
|
|
111
|
+
[
|
|
112
|
+
sys.executable,
|
|
113
|
+
"-c",
|
|
114
|
+
"import os,time; print('ready', flush=True); os.close(1); time.sleep(5)",
|
|
115
|
+
],
|
|
116
|
+
ArmOnWrite(),
|
|
117
|
+
)
|
|
118
|
+
finally:
|
|
119
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
120
|
+
signal.signal(signal.SIGALRM, previous)
|
|
121
|
+
self.assertLess(time.monotonic() - began, 2, "EOF-before-exit must not evade the deadline")
|
|
122
|
+
|
|
123
|
+
def test_success_and_payload_status(self):
|
|
124
|
+
for status, expected in [(0, 0), (23, 23), (-signal.SIGTERM, 143)]:
|
|
125
|
+
api = Transport(status=status)
|
|
126
|
+
self.assertEqual(self.invoke(api), expected)
|
|
127
|
+
self.assertEqual(api.events, ["allocate", "ready", "payload", "cleanup"])
|
|
128
|
+
|
|
129
|
+
def test_uncertain_creation_and_upload_cleanup(self):
|
|
130
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
131
|
+
for fault in ["create", "upload"]:
|
|
132
|
+
api = Transport(fault)
|
|
133
|
+
self.assertEqual(self.invoke(api, "--push-all", directory), 1)
|
|
134
|
+
self.assertEqual(api.events[-1], "cleanup")
|
|
135
|
+
self.assertNotIn("payload", api.events)
|
|
136
|
+
|
|
137
|
+
def test_signal_and_timeout_cleanup(self):
|
|
138
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
139
|
+
for phase in ["create", "upload", "payload"]:
|
|
140
|
+
for prefix, expected in [("timeout-", 124), ("signal-", 143)]:
|
|
141
|
+
api = Transport(prefix + phase)
|
|
142
|
+
self.assertEqual(self.invoke(api, "--push-all", directory), expected)
|
|
143
|
+
self.assertEqual(api.events[-1], "cleanup")
|
|
144
|
+
|
|
145
|
+
def test_artifacts_and_error_precedence(self):
|
|
146
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
147
|
+
options = [
|
|
148
|
+
"--pull",
|
|
149
|
+
"/first",
|
|
150
|
+
directory + "/one",
|
|
151
|
+
"--pull",
|
|
152
|
+
"/second",
|
|
153
|
+
directory + "/two",
|
|
154
|
+
]
|
|
155
|
+
for failure in [
|
|
156
|
+
"first-pull",
|
|
157
|
+
"download",
|
|
158
|
+
"timeout-download",
|
|
159
|
+
"cleanup",
|
|
160
|
+
"timeout-cleanup",
|
|
161
|
+
]:
|
|
162
|
+
for payload, expected in [(0, 1), (23, 23)]:
|
|
163
|
+
api = Transport(failure, status=payload)
|
|
164
|
+
self.assertEqual(self.invoke(api, *options), expected)
|
|
165
|
+
self.assertEqual(api.events[-1], "cleanup")
|
|
166
|
+
if failure == "first-pull":
|
|
167
|
+
self.assertIn("download:/second", api.events)
|
|
168
|
+
|
|
169
|
+
def test_invalid_paths_and_command_do_not_allocate(self):
|
|
170
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
171
|
+
for extra in [
|
|
172
|
+
["--pull", "/x", directory],
|
|
173
|
+
["--cwd", "relative"],
|
|
174
|
+
["--pull", "/x", directory + "/new", "--pull", "/y", directory + "/new"],
|
|
175
|
+
]:
|
|
176
|
+
api = Transport()
|
|
177
|
+
with self.assertRaises(ValueError):
|
|
178
|
+
self.invoke(api, *extra)
|
|
179
|
+
self.assertFalse(api.events)
|
|
180
|
+
link = Path(directory) / "link"
|
|
181
|
+
link.symlink_to(Path(directory) / "absent")
|
|
182
|
+
with self.assertRaises(ValueError):
|
|
183
|
+
self.invoke(Transport(), "--pull", "/x", str(link))
|
|
184
|
+
args = raft.parser().parse_args(["run"])
|
|
185
|
+
with self.assertRaises(ValueError):
|
|
186
|
+
raft_run.run(args, Transport())
|
|
187
|
+
for value in ["0", "2592000", "1.5"]:
|
|
188
|
+
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
|
189
|
+
raft.parser().parse_args(["run", "--timeout", value, "--", "true"])
|
|
190
|
+
|
|
191
|
+
def test_creation_plan_sets_ownership_and_expiry_before_start(self):
|
|
192
|
+
args = raft.parser().parse_args(["run", "--location", "lab", "--", "true"])
|
|
193
|
+
args.ttl, args.disposable = 720, True
|
|
194
|
+
calls = []
|
|
195
|
+
config = {"ssh": "fixture", "image": "a" * 64}
|
|
196
|
+
with (
|
|
197
|
+
patch.object(raft, "configured_defaults", return_value={}),
|
|
198
|
+
patch.object(raft, "configuration", return_value={"lab": config}),
|
|
199
|
+
patch.object(raft, "settings", return_value=config),
|
|
200
|
+
patch.object(raft, "require_note"),
|
|
201
|
+
patch.object(raft, "fits_host", return_value=""),
|
|
202
|
+
patch.object(raft, "transaction", side_effect=lambda *args: calls.append(args)),
|
|
203
|
+
):
|
|
204
|
+
allocated = []
|
|
205
|
+
handle = raft.new(
|
|
206
|
+
args, run_id="1" * 32, on_allocate=lambda *args: allocated.append(args)
|
|
207
|
+
)
|
|
208
|
+
self.assertEqual(handle, ":".join(allocated[0]))
|
|
209
|
+
script = calls[0][1]
|
|
210
|
+
self.assertLess(
|
|
211
|
+
script.index('user.raft.expires="$(( '), script.index("incus --project raft start")
|
|
212
|
+
)
|
|
213
|
+
self.assertEqual(script.count("user.raft.expires="), 1)
|
|
214
|
+
self.assertTrue(script.startswith(raft_run.creation_guard("1" * 32)))
|
|
215
|
+
self.assertIn("user.raft.run=" + "1" * 32, calls[0])
|
|
216
|
+
self.assertIn("user.raft.disposable=true", calls[0])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class FileTests(unittest.TestCase):
|
|
220
|
+
def test_git_filter_and_pinned_directory_packing(self):
|
|
221
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
222
|
+
root = Path(directory)
|
|
223
|
+
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
|
224
|
+
(root / ".gitignore").write_text(".env\nignored/\n")
|
|
225
|
+
(root / ".env").write_text("not-for-upload")
|
|
226
|
+
(root / "tracked").write_text("tracked")
|
|
227
|
+
(root / "ignored").mkdir()
|
|
228
|
+
(root / "ignored/kept").write_text("tracked even though ignored")
|
|
229
|
+
(root / "ignored/secret").write_text("not-for-upload")
|
|
230
|
+
strange = "line\nbreak-" + os.fsdecode(b"\xff")
|
|
231
|
+
(root / strange).write_text("untracked, not ignored")
|
|
232
|
+
subprocess.run(["git", "-C", str(root), "add", ".gitignore", "tracked"], check=True)
|
|
233
|
+
subprocess.run(["git", "-C", str(root), "add", "-f", "ignored/kept"], check=True)
|
|
234
|
+
selected = raft_run.git_files(root)
|
|
235
|
+
self.assertIn(strange, selected)
|
|
236
|
+
self.assertIn("ignored/kept", selected)
|
|
237
|
+
self.assertNotIn(".env", selected)
|
|
238
|
+
self.assertNotIn("ignored/secret", selected)
|
|
239
|
+
stream = io.BytesIO()
|
|
240
|
+
raft_files.pack_directory(root, stream, include=selected)
|
|
241
|
+
stream.seek(0)
|
|
242
|
+
with tarfile.open(fileobj=stream) as archive:
|
|
243
|
+
names = {name.removeprefix("./") for name in archive.getnames()}
|
|
244
|
+
self.assertIn(strange, names)
|
|
245
|
+
self.assertIn("ignored/kept", names)
|
|
246
|
+
self.assertNotIn("ignored/secret", names)
|
|
247
|
+
self.assertNotIn(".env", names)
|
|
248
|
+
self.assertFalse(any(".git" in Path(name).parts for name in names))
|
|
249
|
+
for bad in [["../outside"], ["/absolute"], ["nested/.git/secret"]]:
|
|
250
|
+
with self.assertRaises(ValueError):
|
|
251
|
+
raft_files.pack_directory(root, io.BytesIO(), include=bad)
|
|
252
|
+
subprocess.run(
|
|
253
|
+
[
|
|
254
|
+
"git",
|
|
255
|
+
"-C",
|
|
256
|
+
str(root),
|
|
257
|
+
"update-index",
|
|
258
|
+
"--add",
|
|
259
|
+
"--cacheinfo",
|
|
260
|
+
"160000," + "1" * 40 + ",module",
|
|
261
|
+
],
|
|
262
|
+
check=True,
|
|
263
|
+
)
|
|
264
|
+
with self.assertRaisesRegex(ValueError, "submodules"):
|
|
265
|
+
raft_run.git_files(root)
|
|
266
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
267
|
+
with self.assertRaises(ValueError):
|
|
268
|
+
raft_run.git_files(Path(directory))
|
|
269
|
+
|
|
270
|
+
def test_cleanup_program_checks_ownership_and_lookup_failures(self):
|
|
271
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
272
|
+
root = Path(directory)
|
|
273
|
+
shim = root / "incus"
|
|
274
|
+
shim.write_text(
|
|
275
|
+
"#!/usr/bin/env python3\n"
|
|
276
|
+
+ """import json, os, pathlib, sys
|
|
277
|
+
path = pathlib.Path(os.environ['RUN_STATE'])
|
|
278
|
+
state = json.loads(path.read_text())
|
|
279
|
+
if state.get('fail'):
|
|
280
|
+
sys.exit(7)
|
|
281
|
+
if sys.argv[3] == 'list':
|
|
282
|
+
print(json.dumps(state['boxes']))
|
|
283
|
+
elif sys.argv[3] == 'delete':
|
|
284
|
+
state['boxes'] = []; state['deleted'] = True
|
|
285
|
+
path.write_text(json.dumps(state))
|
|
286
|
+
else:
|
|
287
|
+
sys.exit(8)
|
|
288
|
+
"""
|
|
289
|
+
)
|
|
290
|
+
shim.chmod(0o755)
|
|
291
|
+
state = root / "state.json"
|
|
292
|
+
environment = {
|
|
293
|
+
**os.environ,
|
|
294
|
+
"PATH": str(root) + os.pathsep + os.environ["PATH"],
|
|
295
|
+
"RUN_STATE": str(state),
|
|
296
|
+
}
|
|
297
|
+
for boxes, fail, expected, deleted in [
|
|
298
|
+
([], False, 0, False),
|
|
299
|
+
(
|
|
300
|
+
[
|
|
301
|
+
{
|
|
302
|
+
"name": "rf-fixture",
|
|
303
|
+
"config": {"user.raft": "true", "user.raft.run": "token"},
|
|
304
|
+
}
|
|
305
|
+
],
|
|
306
|
+
False,
|
|
307
|
+
0,
|
|
308
|
+
True,
|
|
309
|
+
),
|
|
310
|
+
(
|
|
311
|
+
[
|
|
312
|
+
{
|
|
313
|
+
"name": "rf-fixture",
|
|
314
|
+
"config": {"user.raft": "true", "user.raft.run": "other"},
|
|
315
|
+
}
|
|
316
|
+
],
|
|
317
|
+
False,
|
|
318
|
+
1,
|
|
319
|
+
False,
|
|
320
|
+
),
|
|
321
|
+
([], True, 1, False),
|
|
322
|
+
]:
|
|
323
|
+
state.write_text(json.dumps({"boxes": boxes, "fail": fail}))
|
|
324
|
+
result = subprocess.run(
|
|
325
|
+
[sys.executable, "-c", raft_run.CLEANUP, "rf-fixture", "token", "acknowledged"],
|
|
326
|
+
env=environment,
|
|
327
|
+
capture_output=True,
|
|
328
|
+
)
|
|
329
|
+
self.assertEqual(bool(result.returncode), bool(expected), result.stderr)
|
|
330
|
+
self.assertEqual(json.loads(state.read_text()).get("deleted", False), deleted)
|
|
331
|
+
|
|
332
|
+
isolated = raft_run.CLEANUP.replace(
|
|
333
|
+
'"/run/raft-ci-cancelled"', repr(str(root / "cancelled"))
|
|
334
|
+
)
|
|
335
|
+
for fail, token in [(False, "a" * 32), (True, "b" * 32)]:
|
|
336
|
+
state.write_text(json.dumps({"boxes": [], "fail": fail}))
|
|
337
|
+
result = subprocess.run(
|
|
338
|
+
[sys.executable, "-c", isolated, "rf-fixture", token, "pending"],
|
|
339
|
+
env=environment,
|
|
340
|
+
capture_output=True,
|
|
341
|
+
)
|
|
342
|
+
self.assertEqual(bool(result.returncode), fail, result.stderr)
|
|
343
|
+
self.assertTrue((root / "cancelled" / token).exists())
|
|
344
|
+
|
|
345
|
+
def test_cancelled_creation_cannot_start_later(self):
|
|
346
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
347
|
+
root = Path(directory)
|
|
348
|
+
markers = root / "cancellations"
|
|
349
|
+
token = "a" * 32
|
|
350
|
+
common = raft_run.CANCELLATIONS.replace('"/run/raft-ci-cancelled"', repr(str(markers)))
|
|
351
|
+
# Isolated runtime state; the actual CLEANUP pending path is tested above.
|
|
352
|
+
cleanup = common + "\ncancellation(sys.argv[1], record=True)\n"
|
|
353
|
+
guard_command = shlex.split(raft_run.creation_guard(token))
|
|
354
|
+
guard_command[2] = guard_command[2].replace(
|
|
355
|
+
'"/run/raft-ci-cancelled"', repr(str(markers))
|
|
356
|
+
)
|
|
357
|
+
guard = guard_command[2]
|
|
358
|
+
subprocess.run([sys.executable, "-c", cleanup, token], check=True)
|
|
359
|
+
self.assertEqual(markers.stat().st_mode & 0o777, 0o700)
|
|
360
|
+
self.assertTrue((markers / token).exists())
|
|
361
|
+
created = root / "late-created"
|
|
362
|
+
late = (
|
|
363
|
+
"set -e\n" + shlex.join(guard_command) + "\n" + shlex.join(["touch", str(created)])
|
|
364
|
+
)
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
["flock", str(root / "lifecycle.lock"), "sh", "-c", late], capture_output=True
|
|
367
|
+
)
|
|
368
|
+
self.assertNotEqual(result.returncode, 0)
|
|
369
|
+
self.assertFalse(created.exists(), "A queued creation must honor earlier cancellation")
|
|
370
|
+
self.assertEqual(subprocess.run([sys.executable, "-c", guard, "b" * 32]).returncode, 0)
|
|
371
|
+
# Do not follow or truncate a marker symlink, even in tampered private state.
|
|
372
|
+
victim = root / "victim"
|
|
373
|
+
victim.write_text("preserve")
|
|
374
|
+
(markers / ("c" * 32)).symlink_to(victim)
|
|
375
|
+
subprocess.run([sys.executable, "-c", cleanup, "c" * 32], check=True)
|
|
376
|
+
self.assertEqual(victim.read_text(), "preserve")
|
|
377
|
+
markers.chmod(0o755)
|
|
378
|
+
refused = subprocess.run([sys.executable, "-c", cleanup, "d" * 32], capture_output=True)
|
|
379
|
+
self.assertNotEqual(refused.returncode, 0)
|
|
380
|
+
self.assertFalse((markers / ("d" * 32)).exists())
|
|
381
|
+
markers.chmod(0o700)
|
|
382
|
+
link = root / "linked"
|
|
383
|
+
link.symlink_to(markers, target_is_directory=True)
|
|
384
|
+
unsafe = cleanup.replace(repr(str(markers)), repr(str(link)))
|
|
385
|
+
refused = subprocess.run([sys.executable, "-c", unsafe, "e" * 32], capture_output=True)
|
|
386
|
+
self.assertNotEqual(refused.returncode, 0)
|
|
387
|
+
self.assertFalse((markers / ("e" * 32)).exists())
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def native(location):
|
|
391
|
+
"""Real CLI/SSH/Incus transfers, templates and cancellation on a selected fixture host."""
|
|
392
|
+
owner = "verify-run-" + uuid.uuid4().hex
|
|
393
|
+
before = {box["name"] for box in raft.inventory(location)}
|
|
394
|
+
owned = set()
|
|
395
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
396
|
+
root = Path(directory)
|
|
397
|
+
config = root / ".config/raft"
|
|
398
|
+
config.mkdir(parents=True)
|
|
399
|
+
(config / "incus.json").write_text(json.dumps({location: raft.settings(location)}))
|
|
400
|
+
environment = {**os.environ, "HOME": directory, "RAFT_NO_UPDATE_CHECK": "1"}
|
|
401
|
+
command = [sys.executable, str(ROOT / "raft.py")]
|
|
402
|
+
options = [
|
|
403
|
+
"--location",
|
|
404
|
+
location,
|
|
405
|
+
"--purpose",
|
|
406
|
+
"Disposable CI verification",
|
|
407
|
+
"--owner",
|
|
408
|
+
owner,
|
|
409
|
+
"--next",
|
|
410
|
+
"Delete after verification",
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
def remember(stderr):
|
|
414
|
+
for handle in re.findall(r"^raft run: allocated (.+)$", stderr, re.MULTILINE):
|
|
415
|
+
if raft.parse_handle(handle)[0] == location:
|
|
416
|
+
owned.add(handle)
|
|
417
|
+
|
|
418
|
+
def cli(*args):
|
|
419
|
+
result = subprocess.run(
|
|
420
|
+
[*command, *args], env=environment, capture_output=True, text=True, timeout=150
|
|
421
|
+
)
|
|
422
|
+
remember(result.stderr)
|
|
423
|
+
return result
|
|
424
|
+
|
|
425
|
+
source = root / "source"
|
|
426
|
+
source.mkdir()
|
|
427
|
+
(source / "input.txt").write_text("source arrived")
|
|
428
|
+
try:
|
|
429
|
+
for status in [0, 23]:
|
|
430
|
+
output = root / f"output-{status}"
|
|
431
|
+
result = cli(
|
|
432
|
+
"run",
|
|
433
|
+
*options,
|
|
434
|
+
"--timeout",
|
|
435
|
+
"60",
|
|
436
|
+
"--push-all",
|
|
437
|
+
str(source),
|
|
438
|
+
"--pull",
|
|
439
|
+
"/workspace/project/out",
|
|
440
|
+
str(output),
|
|
441
|
+
"--",
|
|
442
|
+
"bash",
|
|
443
|
+
"-lc",
|
|
444
|
+
f"mkdir out; cp input.txt out/result.txt; printf payload-output; exit {status}",
|
|
445
|
+
)
|
|
446
|
+
assert result.returncode == status, result.stderr
|
|
447
|
+
assert result.stdout == "payload-output", result.stdout
|
|
448
|
+
assert (output / "result.txt").read_text() == "source arrived"
|
|
449
|
+
assert "cleanup confirmed" in result.stderr, result.stderr
|
|
450
|
+
subprocess.run(["git", "init", "-q", str(source)], check=True)
|
|
451
|
+
(source / ".gitignore").write_text(".env\n")
|
|
452
|
+
(source / ".env").write_text("fixture-not-a-real-secret")
|
|
453
|
+
result = cli(
|
|
454
|
+
"run",
|
|
455
|
+
*options,
|
|
456
|
+
"--timeout",
|
|
457
|
+
"60",
|
|
458
|
+
"--push",
|
|
459
|
+
str(source),
|
|
460
|
+
"--",
|
|
461
|
+
"bash",
|
|
462
|
+
"-lc",
|
|
463
|
+
"set -e; test ! -e .git; test ! -e .env; cat input.txt",
|
|
464
|
+
)
|
|
465
|
+
assert result.returncode == 0 and result.stdout == "source arrived", result.stderr
|
|
466
|
+
result = cli(
|
|
467
|
+
"run", *options, "--timeout", "10", "--", "bash", "-lc", "echo started; sleep 60"
|
|
468
|
+
)
|
|
469
|
+
assert result.returncode == 124 and "started" in result.stdout, (
|
|
470
|
+
result.returncode,
|
|
471
|
+
result.stderr,
|
|
472
|
+
)
|
|
473
|
+
assert "cleanup confirmed" in result.stderr, result.stderr
|
|
474
|
+
for cancellation in [signal.SIGINT, signal.SIGTERM]:
|
|
475
|
+
with tempfile.TemporaryFile(mode="w+") as errors:
|
|
476
|
+
with subprocess.Popen(
|
|
477
|
+
[
|
|
478
|
+
*command,
|
|
479
|
+
"run",
|
|
480
|
+
*options,
|
|
481
|
+
"--timeout",
|
|
482
|
+
"60",
|
|
483
|
+
"--",
|
|
484
|
+
"bash",
|
|
485
|
+
"-lc",
|
|
486
|
+
"echo started; sleep 60",
|
|
487
|
+
],
|
|
488
|
+
env=environment,
|
|
489
|
+
stdout=subprocess.PIPE,
|
|
490
|
+
stderr=errors,
|
|
491
|
+
text=True,
|
|
492
|
+
) as process:
|
|
493
|
+
try:
|
|
494
|
+
assert select.select([process.stdout], [], [], 30)[0], (
|
|
495
|
+
"No payload-ready marker"
|
|
496
|
+
)
|
|
497
|
+
assert process.stdout.readline().strip() == "started"
|
|
498
|
+
process.send_signal(cancellation)
|
|
499
|
+
process.communicate(timeout=70)
|
|
500
|
+
assert process.returncode == 128 + cancellation, process.returncode
|
|
501
|
+
finally:
|
|
502
|
+
if process.poll() is None:
|
|
503
|
+
process.kill()
|
|
504
|
+
process.wait()
|
|
505
|
+
errors.seek(0)
|
|
506
|
+
diagnostic = errors.read()
|
|
507
|
+
remember(diagnostic)
|
|
508
|
+
assert "cleanup confirmed" in diagnostic, diagnostic
|
|
509
|
+
created = cli(
|
|
510
|
+
"new", *options, "--persistent", "--ttl", "600", "--cpu", "1", "--memory", "2GiB"
|
|
511
|
+
)
|
|
512
|
+
assert created.returncode == 0, created.stderr
|
|
513
|
+
base = created.stdout.strip()
|
|
514
|
+
owned.add(base)
|
|
515
|
+
prepared = cli(
|
|
516
|
+
"exec",
|
|
517
|
+
base,
|
|
518
|
+
"--",
|
|
519
|
+
"bash",
|
|
520
|
+
"-lc",
|
|
521
|
+
"mkdir -p /opt/ci-cache; echo warm >/opt/ci-cache/proof",
|
|
522
|
+
)
|
|
523
|
+
assert prepared.returncode == 0, prepared.stderr
|
|
524
|
+
assert cli("stop", base).returncode == 0
|
|
525
|
+
assert cli("snapshot", base, "ci-base").returncode == 0
|
|
526
|
+
for _ in range(2):
|
|
527
|
+
result = cli(
|
|
528
|
+
"run",
|
|
529
|
+
*options,
|
|
530
|
+
"--from",
|
|
531
|
+
base + "/ci-base",
|
|
532
|
+
"--timeout",
|
|
533
|
+
"60",
|
|
534
|
+
"--",
|
|
535
|
+
"bash",
|
|
536
|
+
"-lc",
|
|
537
|
+
'set -e; test "$(cat /opt/ci-cache/proof)" = warm; '
|
|
538
|
+
"test ! -e /opt/ci-cache/job; touch /opt/ci-cache/job; cat /sys/fs/cgroup/memory.max",
|
|
539
|
+
)
|
|
540
|
+
assert result.returncode == 0 and result.stdout.strip() == str(2 * 1024**3), (
|
|
541
|
+
result.stderr
|
|
542
|
+
)
|
|
543
|
+
assert cli("info", base).returncode == 0, "Template source must remain"
|
|
544
|
+
finally:
|
|
545
|
+
# Only recorded handles whose unique note still belongs to this invocation.
|
|
546
|
+
for handle in owned:
|
|
547
|
+
name = raft.parse_handle(handle)[1]
|
|
548
|
+
found = next((box for box in raft.inventory(location) if box["name"] == name), None)
|
|
549
|
+
if (
|
|
550
|
+
found
|
|
551
|
+
and json.loads(found["config"].get("user.raft.note") or "{}").get("owner")
|
|
552
|
+
== owner
|
|
553
|
+
):
|
|
554
|
+
removed = cli("destroy", handle)
|
|
555
|
+
assert removed.returncode == 0, removed.stderr
|
|
556
|
+
after = {box["name"] for box in raft.inventory(location)}
|
|
557
|
+
assert before == after, f"CI run leaked boxes: {after - before}"
|
|
558
|
+
print(f"{location}: native transfers, status, signals, timeout and template isolation passed")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == "__main__":
|
|
562
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
563
|
+
parser.add_argument("--location")
|
|
564
|
+
args = parser.parse_args()
|
|
565
|
+
result = unittest.TextTestRunner(verbosity=2).run(
|
|
566
|
+
unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__])
|
|
567
|
+
)
|
|
568
|
+
if not result.wasSuccessful():
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
if args.location:
|
|
571
|
+
native(args.location)
|
|
@@ -90,7 +90,15 @@ def next_release(work):
|
|
|
90
90
|
"""Build the current sources as release NEXT."""
|
|
91
91
|
source = work / "next-source"
|
|
92
92
|
source.mkdir()
|
|
93
|
-
for name in [
|
|
93
|
+
for name in [
|
|
94
|
+
"raft_files.py",
|
|
95
|
+
"raft_run.py",
|
|
96
|
+
"raft_jobs.py",
|
|
97
|
+
"raft_output.py",
|
|
98
|
+
"pyproject.toml",
|
|
99
|
+
"README.md",
|
|
100
|
+
"LICENSE",
|
|
101
|
+
]:
|
|
94
102
|
shutil.copy(ROOT / name, source)
|
|
95
103
|
code = (ROOT / "raft.py").read_text()
|
|
96
104
|
(source / "raft.py").write_text(code.replace(f'"{CURRENT}"', f'"{NEXT}"', 1))
|