@microck/raft 0.5.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 +27 -15
- package/deploy/build-image.py +4 -2
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +6 -6
- package/deploy/verify-features.py +329 -19
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +22 -3
- package/deploy/verify-live.py +31 -6
- 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 +88 -13
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +4 -3
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +9 -3
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +36 -14
- package/docs/guides/snapshots-forks.mdx +4 -2
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +11 -9
- 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 +17 -10
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +135 -43
- package/docs/reference/configuration.mdx +10 -6
- 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 +17 -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 +22 -4
- package/package.json +3 -1
- package/raft.py +526 -193
- 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 -182
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Real process/store checks and optional disposable-host retained-job verification."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
13
|
+
sys.path.insert(0, str(ROOT))
|
|
14
|
+
import raft_jobs as jobs # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stored(job, *, state="completed", finished=None):
|
|
18
|
+
return {
|
|
19
|
+
"job": job,
|
|
20
|
+
"box": "rf-" + "a" * 16,
|
|
21
|
+
"state": state,
|
|
22
|
+
"exitCode": 7,
|
|
23
|
+
"finishedAt": int(time.time()) if finished is None else finished,
|
|
24
|
+
"bootId": "old-boot",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify():
|
|
29
|
+
with tempfile.TemporaryDirectory(prefix="raft-jobs-") as directory:
|
|
30
|
+
root = Path(directory)
|
|
31
|
+
with jobs.private_store(root):
|
|
32
|
+
pass
|
|
33
|
+
status, dropped = jobs.capture(
|
|
34
|
+
root,
|
|
35
|
+
[
|
|
36
|
+
sys.executable,
|
|
37
|
+
"-c",
|
|
38
|
+
"import os,sys; os.write(1,b'x'*(2*1024*1024)); os.write(2,b'err'); sys.exit(7)",
|
|
39
|
+
],
|
|
40
|
+
)
|
|
41
|
+
assert status == 7 and dropped == jobs.LOG_LIMIT + 3
|
|
42
|
+
assert (root / "output").read_bytes() == b"x" * jobs.LOG_LIMIT
|
|
43
|
+
(root / "output").unlink()
|
|
44
|
+
# A real file-size limit fails the first record write before any service launch.
|
|
45
|
+
rejected = subprocess.run(
|
|
46
|
+
[
|
|
47
|
+
sys.executable,
|
|
48
|
+
"-c",
|
|
49
|
+
"import resource,signal,sys; from pathlib import Path; import raft_jobs; "
|
|
50
|
+
"signal.signal(signal.SIGXFSZ, signal.SIG_IGN); "
|
|
51
|
+
"resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)); "
|
|
52
|
+
"raft_jobs.start(Path(sys.argv[1]), 'rf-'+'a'*16, 'rfcmd-'+'d'*32, ['true'], '')",
|
|
53
|
+
str(root),
|
|
54
|
+
],
|
|
55
|
+
cwd=ROOT,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
)
|
|
59
|
+
assert rejected.returncode != 0 and "File too large" in rejected.stderr
|
|
60
|
+
assert not list(root.iterdir()), "Failed allocation consumed a record slot"
|
|
61
|
+
old = root / ("rfcmd-" + "a" * 32)
|
|
62
|
+
old.mkdir()
|
|
63
|
+
jobs.save(old, stored(old.name, finished=int(time.time()) - jobs.RETENTION - 1))
|
|
64
|
+
current = root / ("rfcmd-" + "b" * 32)
|
|
65
|
+
current.mkdir()
|
|
66
|
+
jobs.save(current, stored(current.name))
|
|
67
|
+
interrupted = root / ("rfcmd-" + "c" * 32)
|
|
68
|
+
interrupted.mkdir()
|
|
69
|
+
jobs.save(interrupted, stored(interrupted.name, state="running"))
|
|
70
|
+
incomplete = root / ("rfcmd-" + "e" * 32)
|
|
71
|
+
incomplete.mkdir()
|
|
72
|
+
damaged = root / ("rfcmd-" + "f" * 32)
|
|
73
|
+
damaged.mkdir()
|
|
74
|
+
(damaged / "result.json").write_text('{"state":')
|
|
75
|
+
(damaged / "output").write_bytes(b"retained evidence")
|
|
76
|
+
jobs.prune(root)
|
|
77
|
+
assert incomplete.exists() and (damaged / "output").read_bytes() == b"retained evidence"
|
|
78
|
+
for unreadable in (incomplete, damaged):
|
|
79
|
+
try:
|
|
80
|
+
jobs.load(unreadable)
|
|
81
|
+
except ValueError as error:
|
|
82
|
+
assert str(unreadable / "result.json") in str(error)
|
|
83
|
+
else:
|
|
84
|
+
raise AssertionError("Unreadable record accepted")
|
|
85
|
+
inspected = subprocess.run(
|
|
86
|
+
[
|
|
87
|
+
sys.executable,
|
|
88
|
+
"-c",
|
|
89
|
+
"from pathlib import Path; import sys,raft_jobs; "
|
|
90
|
+
"root=Path(sys.argv.pop(1)); raft_jobs.main(root)",
|
|
91
|
+
str(root),
|
|
92
|
+
"status",
|
|
93
|
+
"rf-" + "a" * 16,
|
|
94
|
+
current.name,
|
|
95
|
+
],
|
|
96
|
+
cwd=ROOT,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
check=True,
|
|
100
|
+
)
|
|
101
|
+
assert json.loads(inspected.stdout)["exitCode"] == 7
|
|
102
|
+
for malformed in (None, {}, {**stored(damaged.name), "finishedAt": "invalid"}):
|
|
103
|
+
(damaged / "result.json").write_text(json.dumps(malformed))
|
|
104
|
+
jobs.prune(root)
|
|
105
|
+
assert jobs.load(current)["exitCode"] == 7
|
|
106
|
+
try:
|
|
107
|
+
jobs.load(damaged)
|
|
108
|
+
except ValueError:
|
|
109
|
+
pass
|
|
110
|
+
else:
|
|
111
|
+
raise AssertionError("Malformed record accepted")
|
|
112
|
+
assert not old.exists() and current.exists()
|
|
113
|
+
assert jobs.load(interrupted)["state"] == "interrupted"
|
|
114
|
+
assert jobs.load(interrupted)["exitCode"] is None
|
|
115
|
+
root.chmod(0o755)
|
|
116
|
+
try:
|
|
117
|
+
with jobs.private_store(root):
|
|
118
|
+
raise AssertionError("World-readable job store accepted")
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
finally:
|
|
122
|
+
root.chmod(0o700)
|
|
123
|
+
for index in range(jobs.RECORD_LIMIT - len(list(root.iterdir()))):
|
|
124
|
+
(root / f"slot-{index}").mkdir()
|
|
125
|
+
try:
|
|
126
|
+
jobs.start(root, "rf-" + "a" * 16, "rfcmd-" + "d" * 32, ["true"], "")
|
|
127
|
+
except ValueError as error:
|
|
128
|
+
assert "full" in str(error)
|
|
129
|
+
else:
|
|
130
|
+
raise AssertionError("Full store accepted a new job")
|
|
131
|
+
print(
|
|
132
|
+
"Real process capture, damaged-record isolation, retention, reboot interruption and store limits passed"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def native(location):
|
|
137
|
+
spec = importlib.util.spec_from_file_location("live", ROOT / "deploy/verify-live.py")
|
|
138
|
+
live = importlib.util.module_from_spec(spec)
|
|
139
|
+
spec.loader.exec_module(live)
|
|
140
|
+
box = live.raft(
|
|
141
|
+
"new",
|
|
142
|
+
"--location",
|
|
143
|
+
location,
|
|
144
|
+
"--persistent",
|
|
145
|
+
"--ttl",
|
|
146
|
+
"600",
|
|
147
|
+
"--purpose",
|
|
148
|
+
"Retained job verification",
|
|
149
|
+
"--owner",
|
|
150
|
+
"verify-jobs",
|
|
151
|
+
).stdout.strip()
|
|
152
|
+
destroyed = False
|
|
153
|
+
try:
|
|
154
|
+
deadline = time.monotonic() + 40
|
|
155
|
+
while live.raft(
|
|
156
|
+
"exec", box, "--", "systemctl", "show", "-p", "Version", check=False
|
|
157
|
+
).returncode:
|
|
158
|
+
assert time.monotonic() < deadline, "Guest systemd did not become ready"
|
|
159
|
+
time.sleep(0.2)
|
|
160
|
+
completed = live.raft(
|
|
161
|
+
"exec", box, "--detach", "--", "sh", "-c", "printf retained-output; exit 23"
|
|
162
|
+
).stdout.strip()
|
|
163
|
+
record = live.wait_job(box, completed)
|
|
164
|
+
output = live.raft("logs", box, completed).stdout
|
|
165
|
+
if record["exitCode"] != 23 or record["state"] != "completed":
|
|
166
|
+
unit = live.raft("exec", box, "--", "systemctl", "show", completed, check=False)
|
|
167
|
+
raise AssertionError(
|
|
168
|
+
{"record": record, "output": output, "unit": unit.stdout, "error": unit.stderr}
|
|
169
|
+
)
|
|
170
|
+
assert output == "retained-output"
|
|
171
|
+
for signum in (1, 2, 13, 15):
|
|
172
|
+
signalled = live.raft(
|
|
173
|
+
"exec",
|
|
174
|
+
box,
|
|
175
|
+
"--detach",
|
|
176
|
+
"--",
|
|
177
|
+
"python3",
|
|
178
|
+
"-c",
|
|
179
|
+
f"import os,signal; signal.signal({signum},signal.SIG_DFL); "
|
|
180
|
+
f"os.kill(os.getpid(),{signum})",
|
|
181
|
+
).stdout.strip()
|
|
182
|
+
record = live.wait_job(box, signalled)
|
|
183
|
+
assert record["state"] == "completed" and record["exitCode"] == 128 + signum, (
|
|
184
|
+
record,
|
|
185
|
+
live.raft("logs", box, signalled).stdout,
|
|
186
|
+
)
|
|
187
|
+
active = live.raft(
|
|
188
|
+
"exec", box, "--detach", "--", "sh", "-c", "echo ready; sleep 600"
|
|
189
|
+
).stdout.strip()
|
|
190
|
+
deadline = time.monotonic() + 20
|
|
191
|
+
while "ready" not in live.raft("logs", box, active).stdout:
|
|
192
|
+
assert time.monotonic() < deadline
|
|
193
|
+
time.sleep(0.2)
|
|
194
|
+
live.raft("cancel", box, active)
|
|
195
|
+
record = live.wait_job(box, active)
|
|
196
|
+
assert record["state"] == "cancelled", (record, live.raft("logs", box, active).stdout)
|
|
197
|
+
orphan = live.raft(
|
|
198
|
+
"exec", box, "--detach", "--", "sh", "-c", "echo orphan-ready; exec sleep 600"
|
|
199
|
+
).stdout.strip()
|
|
200
|
+
deadline = time.monotonic() + 20
|
|
201
|
+
while "orphan-ready" not in live.raft("logs", box, orphan).stdout:
|
|
202
|
+
assert time.monotonic() < deadline, "Orphan test payload did not start"
|
|
203
|
+
time.sleep(0.2)
|
|
204
|
+
live.transaction(location, 'systemctl kill --signal=SIGKILL "$1"', "raft-" + orphan)
|
|
205
|
+
record = live.wait_job(box, orphan)
|
|
206
|
+
assert record["state"] == "interrupted", record
|
|
207
|
+
assert int(live.execute(box, "systemctl", "show", orphan, "-p", "MainPID", "--value")) > 0
|
|
208
|
+
live.raft("cancel", box, orphan)
|
|
209
|
+
record = json.loads(live.raft("status", box, orphan).stdout)
|
|
210
|
+
assert record["state"] == "cancelled" and record["exitCode"] is None, record
|
|
211
|
+
assert record["finishedAt"] is not None
|
|
212
|
+
# ExecMainPID retains the historical PID after exit; MainPID tracks the live process.
|
|
213
|
+
assert int(live.execute(box, "systemctl", "show", orphan, "-p", "MainPID", "--value")) == 0
|
|
214
|
+
live.raft("cancel", box, orphan)
|
|
215
|
+
assert json.loads(live.raft("status", box, orphan).stdout) == record
|
|
216
|
+
interrupted = live.raft("exec", box, "--detach", "--", "sleep", "600").stdout.strip()
|
|
217
|
+
zero_on_stop = live.raft(
|
|
218
|
+
"exec",
|
|
219
|
+
box,
|
|
220
|
+
"--detach",
|
|
221
|
+
"--",
|
|
222
|
+
"python3",
|
|
223
|
+
"-u",
|
|
224
|
+
"-c",
|
|
225
|
+
"import signal,sys,time; signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)); "
|
|
226
|
+
"print('shutdown-handler-ready'); time.sleep(600)",
|
|
227
|
+
).stdout.strip()
|
|
228
|
+
deadline = time.monotonic() + 20
|
|
229
|
+
while "shutdown-handler-ready" not in live.raft("logs", box, zero_on_stop).stdout:
|
|
230
|
+
assert time.monotonic() < deadline, "Shutdown handler did not start"
|
|
231
|
+
time.sleep(0.2)
|
|
232
|
+
live.raft("stop", box)
|
|
233
|
+
for stopped_job in (interrupted, zero_on_stop):
|
|
234
|
+
record = live.wait_job(box, stopped_job)
|
|
235
|
+
assert record["state"] == "interrupted" and record["exitCode"] is None, (
|
|
236
|
+
record,
|
|
237
|
+
live.raft("logs", box, stopped_job).stdout,
|
|
238
|
+
)
|
|
239
|
+
assert live.raft("logs", box, completed).stdout == "retained-output"
|
|
240
|
+
count_records = "find /var/lib/raft-jobs -mindepth 1 -maxdepth 1 -type d | wc -l"
|
|
241
|
+
before = live.transaction(location, count_records)
|
|
242
|
+
refused = live.raft("exec", box, "--detach", "--", "true", check=False)
|
|
243
|
+
assert refused.returncode == 1 and not refused.stdout, refused
|
|
244
|
+
assert "no job was created" in refused.stderr, refused.stderr
|
|
245
|
+
assert live.transaction(location, count_records) == before
|
|
246
|
+
live.raft("destroy", box)
|
|
247
|
+
destroyed = True
|
|
248
|
+
assert json.loads(live.raft("status", box, completed).stdout)["exitCode"] == 23
|
|
249
|
+
assert live.raft("logs", box, completed).stdout == "retained-output"
|
|
250
|
+
wrong = location + ":rf-" + "0" * 16
|
|
251
|
+
assert live.raft("logs", wrong, completed, check=False).returncode == 1
|
|
252
|
+
print(f"{location}: completed, cancelled and interrupted results survive stop and deletion")
|
|
253
|
+
finally:
|
|
254
|
+
if not destroyed:
|
|
255
|
+
live.raft("destroy", box)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
cli = argparse.ArgumentParser(description=__doc__)
|
|
260
|
+
cli.add_argument("--location")
|
|
261
|
+
args = cli.parse_args()
|
|
262
|
+
verify()
|
|
263
|
+
if args.location:
|
|
264
|
+
native(args.location)
|
package/deploy/verify-limits.py
CHANGED
|
@@ -108,7 +108,8 @@ def policy_checks():
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
def command(*args):
|
|
111
|
-
|
|
111
|
+
# The private controller HOME keeps this controller's defaults for new out.
|
|
112
|
+
return subprocess.check_output([*CLI, *args], text=True, env=live.CONTROLLER_ENV).strip()
|
|
112
113
|
|
|
113
114
|
|
|
114
115
|
def report(location):
|
|
@@ -298,8 +299,26 @@ def disk_checks(location):
|
|
|
298
299
|
time.sleep(2)
|
|
299
300
|
assert disk_size(box)[0] == total, disk_size(box)
|
|
300
301
|
# Removing the container frees the space; restart Docker in case it lost state.
|
|
301
|
-
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
302
|
-
live.
|
|
302
|
+
removed = live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
303
|
+
restarted = live.raft("exec", box, "--", "systemctl", "restart", "docker", check=False)
|
|
304
|
+
if restarted.returncode:
|
|
305
|
+
print(
|
|
306
|
+
"Docker removal after disk exhaustion:", removed.stdout, removed.stderr, flush=True
|
|
307
|
+
)
|
|
308
|
+
journal = live.raft(
|
|
309
|
+
"exec",
|
|
310
|
+
box,
|
|
311
|
+
"--",
|
|
312
|
+
"journalctl",
|
|
313
|
+
"--no-pager",
|
|
314
|
+
"-n",
|
|
315
|
+
"100",
|
|
316
|
+
"-u",
|
|
317
|
+
"docker",
|
|
318
|
+
check=False,
|
|
319
|
+
)
|
|
320
|
+
print(journal.stdout, journal.stderr, flush=True)
|
|
321
|
+
raise RuntimeError("Docker restart after disk exhaustion failed: " + restarted.stderr)
|
|
303
322
|
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
304
323
|
assert disk_size(box)[1] > GIB, disk_size(box)
|
|
305
324
|
assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
|
package/deploy/verify-live.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import pty
|
|
8
8
|
import select
|
|
9
9
|
import shlex
|
|
10
|
+
import shutil
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
import socket
|
|
12
13
|
import subprocess
|
|
@@ -19,11 +20,24 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
19
20
|
CLI = [sys.executable, str(ROOT / "raft.py")]
|
|
20
21
|
USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
|
|
21
22
|
sys.path.insert(0, str(ROOT))
|
|
22
|
-
from raft import configuration, incus, remote, settings, transaction # noqa: E402
|
|
23
|
+
from raft import CONFIG, configuration, incus, remote, settings, transaction # noqa: E402
|
|
24
|
+
|
|
25
|
+
# Suites run the CLI in a private HOME that holds only the location inventory, so
|
|
26
|
+
# this controller's defaults for new (config.json) never reach them: a disposable
|
|
27
|
+
# default would delete boxes a suite stops and expects to keep, and size defaults
|
|
28
|
+
# would change asserted limits. OpenSSH reads keys and aliases from the passwd
|
|
29
|
+
# home, not HOME. The inventory is copied once; suites that edit it use their own.
|
|
30
|
+
CONTROLLER_HOME = tempfile.TemporaryDirectory(prefix="raft-verify-home-")
|
|
31
|
+
if CONFIG.exists():
|
|
32
|
+
(Path(CONTROLLER_HOME.name) / ".config/raft").mkdir(parents=True)
|
|
33
|
+
shutil.copy2(CONFIG, Path(CONTROLLER_HOME.name) / ".config/raft/incus.json")
|
|
34
|
+
CONTROLLER_ENV = {**os.environ, "HOME": CONTROLLER_HOME.name}
|
|
23
35
|
|
|
24
36
|
|
|
25
37
|
def raft(*arguments, check=True, env=None):
|
|
26
|
-
response = subprocess.run(
|
|
38
|
+
response = subprocess.run(
|
|
39
|
+
[*CLI, *arguments], capture_output=True, text=True, env=env or CONTROLLER_ENV
|
|
40
|
+
)
|
|
27
41
|
if check and response.returncode:
|
|
28
42
|
raise RuntimeError(f"Raft {arguments!r} failed: {response.stderr.strip()}")
|
|
29
43
|
return response
|
|
@@ -43,6 +57,16 @@ def wait_running(box):
|
|
|
43
57
|
raise RuntimeError("Guest Docker and SSH did not become ready")
|
|
44
58
|
|
|
45
59
|
|
|
60
|
+
def wait_job(box, job):
|
|
61
|
+
deadline = time.monotonic() + 30
|
|
62
|
+
while time.monotonic() < deadline:
|
|
63
|
+
record = json.loads(raft("status", box, job).stdout)
|
|
64
|
+
if record["state"] in {"completed", "cancelled", "interrupted", "failed"}:
|
|
65
|
+
return record
|
|
66
|
+
time.sleep(0.2)
|
|
67
|
+
raise RuntimeError("Detached job did not finish: " + job)
|
|
68
|
+
|
|
69
|
+
|
|
46
70
|
def terminal_session(box):
|
|
47
71
|
master, slave = pty.openpty()
|
|
48
72
|
process = subprocess.Popen([*CLI, "ssh", box], stdin=slave, stdout=slave, stderr=slave)
|
|
@@ -352,7 +376,9 @@ def browser_session(box):
|
|
|
352
376
|
assert.equal(response.result.errorText, undefined);
|
|
353
377
|
socket.send(JSON.stringify(viewerProof));
|
|
354
378
|
} else if (response.id === 4) {
|
|
355
|
-
|
|
379
|
+
// While noVNC is still loading, the evaluation can return no value.
|
|
380
|
+
const value = response.result.result.value;
|
|
381
|
+
const viewer = typeof value === 'string' ? JSON.parse(value) : {connected: false};
|
|
356
382
|
if (!viewer.connected) {
|
|
357
383
|
setTimeout(() => socket.send(JSON.stringify(viewerProof)), 200); return;
|
|
358
384
|
}
|
|
@@ -780,8 +806,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
780
806
|
job = raft(
|
|
781
807
|
"exec", box, "--detach", "--", "bash", "-lc", "echo detached-ok; exit 23"
|
|
782
808
|
).stdout.strip()
|
|
783
|
-
|
|
784
|
-
assert "ExecMainStatus=23" in raft("status", box, job).stdout
|
|
809
|
+
assert wait_job(box, job)["exitCode"] == 23
|
|
785
810
|
assert "detached-ok" in raft("logs", box, job).stdout
|
|
786
811
|
execute(box, "bash", "-lc", "echo saved > /workspace/persistent")
|
|
787
812
|
raft("stop", box)
|
|
@@ -808,7 +833,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
808
833
|
fetch_through_tunnel(["forward", box, "--remote", "8080"], "/persistent").strip()
|
|
809
834
|
== b"saved"
|
|
810
835
|
)
|
|
811
|
-
assert
|
|
836
|
+
assert json.loads(raft("status", box, server).stdout)["state"] == "running"
|
|
812
837
|
assert b"noVNC" in fetch_through_tunnel(["desktop", box], "/vnc.html", timeout=20)
|
|
813
838
|
assert b"noVNC" in fetch_through_tunnel(
|
|
814
839
|
["desktop", box], "/vnc.html", timeout=20, default_port=6080
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Verify JSONL bytes, errors, completion discovery and native command status."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
14
|
+
sys.path.insert(0, str(ROOT))
|
|
15
|
+
import raft # noqa: E402
|
|
16
|
+
import raft_output # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def events(command, env):
|
|
20
|
+
response = subprocess.run(command, env=env, capture_output=True)
|
|
21
|
+
records = [json.loads(line) for line in response.stdout.splitlines()]
|
|
22
|
+
assert not response.stderr, response.stderr
|
|
23
|
+
assert records[-1] == {"type": "exit", "code": response.returncode}, records
|
|
24
|
+
return response, records
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def stream(records, kind):
|
|
28
|
+
return b"".join(base64.b64decode(r["data"]) for r in records if r["type"] == kind)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify():
|
|
32
|
+
with tempfile.TemporaryDirectory(prefix="raft-output-") as directory:
|
|
33
|
+
env = {**os.environ, "HOME": directory, "RAFT_NO_UPDATE_CHECK": "1"}
|
|
34
|
+
command = [sys.executable, str(ROOT / "raft.py"), "--json"]
|
|
35
|
+
response, records = events(command + ["--help"], env)
|
|
36
|
+
assert response.returncode == 0 and b"usage:" in stream(records, "stdout")
|
|
37
|
+
for arguments in [["invalid"], ["new", "--ttl", "no"], ["new", "--unknown"]]:
|
|
38
|
+
response, records = events(command + arguments, env)
|
|
39
|
+
assert response.returncode == 2 and records[0]["code"] == "usage", records
|
|
40
|
+
response, records = events(command + ["locations"], env)
|
|
41
|
+
assert response.returncode == 1 and records[0]["code"] == "operation", records
|
|
42
|
+
response, records = events(command + ["run", "--", "true"], env)
|
|
43
|
+
failures = [record for record in records if record["type"] == "error"]
|
|
44
|
+
assert response.returncode == 1 and len(failures) == 1, records
|
|
45
|
+
assert failures[0]["code"] == "operation" and "incus.json" in failures[0]["message"]
|
|
46
|
+
assert b"raft run: finished" in stream(records, "stderr"), records
|
|
47
|
+
plain = subprocess.run(
|
|
48
|
+
[sys.executable, str(ROOT / "raft.py"), "run", "--", "true"],
|
|
49
|
+
env=env,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
)
|
|
52
|
+
assert plain.returncode == 1 and not plain.stdout
|
|
53
|
+
assert plain.stderr.startswith(b"raft run: ") and b"incus.json" in plain.stderr
|
|
54
|
+
inventory = Path(directory) / ".config/raft"
|
|
55
|
+
inventory.mkdir(parents=True)
|
|
56
|
+
(inventory / "incus.json").write_text('{"fixture":{"ssh":"offline.invalid","image":""}}')
|
|
57
|
+
response, records = events(command + ["locations"], env)
|
|
58
|
+
assert records[0] == {
|
|
59
|
+
"type": "data",
|
|
60
|
+
"value": {"location": "fixture", "default": True, "note": ""},
|
|
61
|
+
}
|
|
62
|
+
# Real subprocess descriptors, including binary bytes and more than a pipe
|
|
63
|
+
# buffer on both channels. Reconstruction must be exact and cannot hang.
|
|
64
|
+
program = """
|
|
65
|
+
import os, subprocess, sys
|
|
66
|
+
import raft_output
|
|
67
|
+
out = raft_output.JsonOutput().start()
|
|
68
|
+
subprocess.run([sys.executable, '-c', 'import os; os.write(1, bytes(range(256))*512); os.write(2,b"error"*32768)'], check=True)
|
|
69
|
+
out.finish(7)
|
|
70
|
+
sys.exit(7)
|
|
71
|
+
"""
|
|
72
|
+
response, records = events(
|
|
73
|
+
[sys.executable, "-c", program], {**env, "PYTHONPATH": str(ROOT)}
|
|
74
|
+
)
|
|
75
|
+
assert response.returncode == 7
|
|
76
|
+
assert stream(records, "stdout") == bytes(range(256)) * 512
|
|
77
|
+
assert stream(records, "stderr") == b"error" * 32768
|
|
78
|
+
for number in (9, 15):
|
|
79
|
+
# The event and shell status must agree after real child termination.
|
|
80
|
+
signalled = f"""
|
|
81
|
+
import subprocess, sys
|
|
82
|
+
import raft, raft_output
|
|
83
|
+
out = raft_output.JsonOutput().start()
|
|
84
|
+
child = subprocess.run([sys.executable, '-c', 'import os, signal; os.kill(os.getpid(), {number})'])
|
|
85
|
+
status = raft.exit_status(child.returncode)
|
|
86
|
+
out.finish(status)
|
|
87
|
+
sys.exit(status)
|
|
88
|
+
"""
|
|
89
|
+
response, records = events(
|
|
90
|
+
[sys.executable, "-c", signalled], {**env, "PYTHONPATH": str(ROOT)}
|
|
91
|
+
)
|
|
92
|
+
assert response.returncode == 128 + number, records
|
|
93
|
+
parser = raft.parser()
|
|
94
|
+
parser.add_argument("--future-flag", action="store_true")
|
|
95
|
+
assert raft_output.candidates(parser, ["--future"]) == ["--future-flag"]
|
|
96
|
+
assert "--location" in raft_output.candidates(parser, ["new", "--lo"])
|
|
97
|
+
assert "set" in raft_output.candidates(parser, ["config", ""])
|
|
98
|
+
assert raft_output.candidates(parser, ["exec", "box", "--", ""]) == []
|
|
99
|
+
assert raft_output.candidates(parser, ["completions", "z"]) == ["zsh"]
|
|
100
|
+
for shell in ("bash", "zsh", "fish"):
|
|
101
|
+
script = subprocess.run(
|
|
102
|
+
[sys.executable, str(ROOT / "raft.py"), "completions", shell],
|
|
103
|
+
env=env,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
check=True,
|
|
106
|
+
)
|
|
107
|
+
assert b"_complete" in script.stdout and not script.stderr
|
|
108
|
+
setup = raft_output.completions("bash")
|
|
109
|
+
completed = subprocess.run(
|
|
110
|
+
[
|
|
111
|
+
"bash",
|
|
112
|
+
"-c",
|
|
113
|
+
setup
|
|
114
|
+
+ "\nraft() { "
|
|
115
|
+
+ sys.executable
|
|
116
|
+
+ " "
|
|
117
|
+
+ str(ROOT / "raft.py")
|
|
118
|
+
+ ' "$@"; }\nCOMP_WORDS=(raft config ""); COMP_CWORD=2; _raft_complete; printf "%s\\n" "${COMPREPLY[@]}"',
|
|
119
|
+
],
|
|
120
|
+
env=env,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
check=True,
|
|
123
|
+
)
|
|
124
|
+
assert "set" in completed.stdout.decode().splitlines(), completed
|
|
125
|
+
print("JSONL bytes, subprocess streams, structured errors, reports and Bash completion passed")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def native(location):
|
|
129
|
+
spec = importlib.util.spec_from_file_location("live", ROOT / "deploy/verify-live.py")
|
|
130
|
+
live = importlib.util.module_from_spec(spec)
|
|
131
|
+
spec.loader.exec_module(live)
|
|
132
|
+
box = live.raft(
|
|
133
|
+
"new",
|
|
134
|
+
"--location",
|
|
135
|
+
location,
|
|
136
|
+
"--ttl",
|
|
137
|
+
"600",
|
|
138
|
+
"--disposable",
|
|
139
|
+
"--purpose",
|
|
140
|
+
"JSON output verification",
|
|
141
|
+
"--owner",
|
|
142
|
+
"verify-output",
|
|
143
|
+
).stdout.strip()
|
|
144
|
+
try:
|
|
145
|
+
live.wait_running(box)
|
|
146
|
+
response, records = events(
|
|
147
|
+
[
|
|
148
|
+
sys.executable,
|
|
149
|
+
str(ROOT / "raft.py"),
|
|
150
|
+
"--json",
|
|
151
|
+
"exec",
|
|
152
|
+
box,
|
|
153
|
+
"--",
|
|
154
|
+
"sh",
|
|
155
|
+
"-c",
|
|
156
|
+
"printf hello; printf error >&2; exit 7",
|
|
157
|
+
],
|
|
158
|
+
live.CONTROLLER_ENV,
|
|
159
|
+
)
|
|
160
|
+
assert response.returncode == 7
|
|
161
|
+
assert stream(records, "stdout") == b"hello" and stream(records, "stderr") == b"error", (
|
|
162
|
+
records
|
|
163
|
+
)
|
|
164
|
+
response, records = events(
|
|
165
|
+
[sys.executable, str(ROOT / "raft.py"), "--json", "info", box], live.CONTROLLER_ENV
|
|
166
|
+
)
|
|
167
|
+
assert records[0]["value"]["name"] == box.partition(":")[2], records
|
|
168
|
+
with tempfile.TemporaryDirectory(prefix="raft-output-artifacts-") as artifacts:
|
|
169
|
+
response, records = events(
|
|
170
|
+
[
|
|
171
|
+
sys.executable,
|
|
172
|
+
str(ROOT / "raft.py"),
|
|
173
|
+
"--json",
|
|
174
|
+
"run",
|
|
175
|
+
"--location",
|
|
176
|
+
location,
|
|
177
|
+
"--purpose",
|
|
178
|
+
"JSON artifact failure verification",
|
|
179
|
+
"--owner",
|
|
180
|
+
"verify-output",
|
|
181
|
+
"--pull",
|
|
182
|
+
"/missing-raft-artifacts",
|
|
183
|
+
str(Path(artifacts) / "collected"),
|
|
184
|
+
"--",
|
|
185
|
+
"true",
|
|
186
|
+
],
|
|
187
|
+
live.CONTROLLER_ENV,
|
|
188
|
+
)
|
|
189
|
+
assert response.returncode == 1, records
|
|
190
|
+
assert any(
|
|
191
|
+
record["type"] == "error"
|
|
192
|
+
and record["code"] == "operation"
|
|
193
|
+
and "could not collect /missing-raft-artifacts" in record["message"]
|
|
194
|
+
for record in records
|
|
195
|
+
), records
|
|
196
|
+
assert b"cleanup confirmed" in stream(records, "stderr"), records
|
|
197
|
+
print(f"{location}: JSON exec, reports, run artifact failure and cleanup passed")
|
|
198
|
+
finally:
|
|
199
|
+
live.raft("destroy", box)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
cli = argparse.ArgumentParser(description=__doc__)
|
|
204
|
+
cli.add_argument("--location")
|
|
205
|
+
args = cli.parse_args()
|
|
206
|
+
verify()
|
|
207
|
+
if args.location:
|
|
208
|
+
native(args.location)
|
package/deploy/verify-release.py
CHANGED
|
@@ -68,12 +68,21 @@ def verify_npm():
|
|
|
68
68
|
content = archive.extractfile(member).read()
|
|
69
69
|
inspect(member.name, content)
|
|
70
70
|
names.add(member.name)
|
|
71
|
-
if member.name in {
|
|
71
|
+
if member.name in {
|
|
72
|
+
"package/raft.py",
|
|
73
|
+
"package/raft_files.py",
|
|
74
|
+
"package/raft_run.py",
|
|
75
|
+
"package/raft_jobs.py",
|
|
76
|
+
"package/raft_output.py",
|
|
77
|
+
}:
|
|
72
78
|
if content != (ROOT / PurePosixPath(member.name).name).read_bytes():
|
|
73
79
|
raise ValueError("npm runtime differs from canonical Python source")
|
|
74
80
|
required = {
|
|
75
81
|
"package/raft.py",
|
|
76
82
|
"package/raft_files.py",
|
|
83
|
+
"package/raft_run.py",
|
|
84
|
+
"package/raft_jobs.py",
|
|
85
|
+
"package/raft_output.py",
|
|
77
86
|
"package/LICENSE",
|
|
78
87
|
"package/deploy/deploy.py",
|
|
79
88
|
"package/images/incus/build.sh",
|
|
@@ -105,12 +114,28 @@ def main():
|
|
|
105
114
|
raise ValueError("Unexpected non-file member: " + member.name)
|
|
106
115
|
inspect(member.name, archive.extractfile(member).read())
|
|
107
116
|
names = {PurePosixPath(member.name).parts[1] for member in members}
|
|
108
|
-
if
|
|
117
|
+
if (
|
|
118
|
+
not {
|
|
119
|
+
"raft.py",
|
|
120
|
+
"raft_files.py",
|
|
121
|
+
"raft_run.py",
|
|
122
|
+
"raft_jobs.py",
|
|
123
|
+
"raft_output.py",
|
|
124
|
+
"deploy",
|
|
125
|
+
"docs",
|
|
126
|
+
"images",
|
|
127
|
+
"skills",
|
|
128
|
+
"LICENSE",
|
|
129
|
+
}
|
|
130
|
+
<= names
|
|
131
|
+
):
|
|
109
132
|
raise ValueError("Source archive missing installation resources")
|
|
110
133
|
with zipfile.ZipFile(wheels[0]) as archive:
|
|
111
134
|
for name in archive.namelist():
|
|
112
135
|
inspect(name, archive.read(name))
|
|
113
|
-
if not {"raft.py", "raft_files.py"} <= set(
|
|
136
|
+
if not {"raft.py", "raft_files.py", "raft_run.py", "raft_jobs.py", "raft_output.py"} <= set(
|
|
137
|
+
archive.namelist()
|
|
138
|
+
):
|
|
114
139
|
raise ValueError("Wheel missing CLI module")
|
|
115
140
|
# A new environment avoids both editable checkouts and uv's cached same-version
|
|
116
141
|
# tool environments. Run the installed entry point outside the source tree.
|
|
@@ -123,7 +148,11 @@ def main():
|
|
|
123
148
|
check=True,
|
|
124
149
|
)
|
|
125
150
|
verify_cli(environment / "bin/raft", work)
|
|
126
|
-
subprocess.run(
|
|
151
|
+
subprocess.run(
|
|
152
|
+
[str(python), "-I", "-c", "import raft, raft_files, raft_run, raft_jobs, raft_output"],
|
|
153
|
+
cwd=work,
|
|
154
|
+
check=True,
|
|
155
|
+
)
|
|
127
156
|
verify_npm()
|
|
128
157
|
print("Release archives and isolated wheel installation passed")
|
|
129
158
|
|