@microck/raft 0.4.0 → 0.6.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.
Files changed (38) hide show
  1. package/README.md +30 -14
  2. package/deploy/deploy.py +112 -19
  3. package/deploy/install-incus.sh +10 -0
  4. package/deploy/raft-expire.py +109 -31
  5. package/deploy/verify-controls.py +34 -48
  6. package/deploy/verify-features.py +592 -3
  7. package/deploy/verify-fresh-host.py +64 -1
  8. package/deploy/verify-limits.py +327 -72
  9. package/deploy/verify-live.py +56 -31
  10. package/deploy/verify-self-update.py +372 -0
  11. package/docs/boat-capabilities.md +8 -7
  12. package/docs/boot-performance.mdx +1 -1
  13. package/docs/contract.md +33 -23
  14. package/docs/first-box.mdx +1 -1
  15. package/docs/guides/backup-recovery.mdx +2 -2
  16. package/docs/guides/config-policy.mdx +253 -0
  17. package/docs/guides/docker.mdx +9 -1
  18. package/docs/guides/long-running-services.mdx +10 -4
  19. package/docs/guides/meta.json +1 -0
  20. package/docs/guides/project-setup.mdx +4 -3
  21. package/docs/guides/scripting-ci.mdx +27 -10
  22. package/docs/guides/snapshots-forks.mdx +9 -3
  23. package/docs/guides/troubleshooting.mdx +37 -8
  24. package/docs/guides/upgrades.mdx +17 -5
  25. package/docs/index.mdx +1 -0
  26. package/docs/operations.mdx +16 -14
  27. package/docs/reference/capacity.mdx +198 -0
  28. package/docs/reference/cli.md +178 -35
  29. package/docs/reference/configuration.mdx +40 -2
  30. package/docs/reference/meta.json +1 -1
  31. package/docs/releases/0.5.0.md +19 -0
  32. package/docs/releases/0.6.0.md +16 -0
  33. package/docs/requirements.mdx +2 -2
  34. package/docs/verification.mdx +14 -8
  35. package/package.json +1 -1
  36. package/raft.py +1181 -128
  37. package/skills/raft-cli/SKILL.md +79 -13
  38. package/docs/reference/capacity-limits.mdx +0 -92
@@ -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 MEMORY_SIZES, 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([*CLI, *arguments], capture_output=True, text=True, env=env)
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
@@ -352,7 +366,9 @@ def browser_session(box):
352
366
  assert.equal(response.result.errorText, undefined);
353
367
  socket.send(JSON.stringify(viewerProof));
354
368
  } else if (response.id === 4) {
355
- const viewer = JSON.parse(response.result.result.value);
369
+ // While noVNC is still loading, the evaluation can return no value.
370
+ const value = response.result.result.value;
371
+ const viewer = typeof value === 'string' ? JSON.parse(value) : {connected: false};
356
372
  if (!viewer.connected) {
357
373
  setTimeout(() => socket.send(JSON.stringify(viewerProof)), 200); return;
358
374
  }
@@ -424,7 +440,7 @@ def verify_configuration():
424
440
 
425
441
  def verify_arguments():
426
442
  # Invoke actual CLI processes: help and argument errors must not access a host.
427
- commands = "new locations note host 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()
443
+ commands = "new locations config policy note host list capacity 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 self-update".split()
428
444
  assert raft("--help").returncode == 0
429
445
  for command in commands:
430
446
  assert raft(command, "--help").returncode == 0
@@ -435,7 +451,15 @@ def verify_arguments():
435
451
  for lifetime in ["59", "2592001", "invalid"]:
436
452
  rejected = raft(*prefix, "--ttl", lifetime, check=False)
437
453
  assert rejected.returncode == 2 and "Traceback" not in rejected.stderr
438
- for options in [["--cpu", "0"], ["--cpu", "3"], ["--memory", "16GiB"]]:
454
+ for options in [
455
+ ["--cpu", "0"],
456
+ ["--cpu", "1.5"],
457
+ ["--memory", "16"],
458
+ ["--memory", "0GiB"],
459
+ ["--memory", "1.5GiB"],
460
+ ["--disk", "20G"],
461
+ ["--disk", "0GiB"],
462
+ ]:
439
463
  assert raft("new", *options, check=False).returncode == 2
440
464
  for arguments in [["exec", missing], ["recover", "missing"], ["forward", missing]]:
441
465
  assert raft(*arguments, check=False).returncode == 2
@@ -444,14 +468,14 @@ def verify_arguments():
444
468
 
445
469
  def verify_option_edges(location, box):
446
470
  assert any(json.loads(line)["box"] == box for line in raft("list").stdout.splitlines())
447
- reports = [json.loads(line) for line in raft("limits", "--json").stdout.splitlines()]
471
+ reports = [json.loads(line) for line in raft("capacity", "--json").stdout.splitlines()]
448
472
  assert {report["location"] for report in reports} == set(configuration())
449
- assert "CPU RAM GiB" in raft("limits").stdout
473
+ assert "CPU RAM GiB" in raft("capacity").stdout
450
474
  for arguments, message in [
451
475
  (["info", "invalid"], "location-qualified handle"),
452
476
  (["info", location + ":rf-0000000000000000"], "Raft box not found"),
453
477
  (["list", "--location", "missing-location"], "Unknown location"),
454
- (["limits", "--location", "missing-location"], "Unknown location"),
478
+ (["capacity", "--location", "missing-location"], "Unknown location"),
455
479
  (["new", "--location", "missing-location"], "Unknown location"),
456
480
  (["upload", box, "missing", "relative"], "must be absolute"),
457
481
  (["download", box, "relative", "missing"], "must be absolute"),
@@ -691,29 +715,30 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
691
715
  verify_option_edges(location, box)
692
716
  if extended:
693
717
  subprocess.run([sys.executable, str(ROOT / "deploy/verify-tools.py"), box], check=True)
694
- for cpu in [1, 2]:
695
- for memory in MEMORY_SIZES:
696
- sized = raft(
697
- "new",
698
- "--location",
699
- location,
700
- "--cpu",
701
- str(cpu),
702
- "--memory",
703
- memory,
704
- ).stdout.strip()
705
- fixtures.append(sized)
706
- assert (
707
- int(json.loads(raft("info", sized).stdout)["config"]["user.raft.expires"])
708
- > int(execute(sized, "date", "+%s")) + 550
709
- )
710
- assert execute(sized, "nproc") == str(cpu)
711
- assert execute(sized, "cat", "/sys/fs/cgroup/memory.max") == str(
712
- int(memory.removesuffix("GiB")) * 1024**3
713
- )
714
- raft("destroy", sized)
715
- fixtures.remove(sized)
716
- print(f"{location}: developer tools and all resource combinations passed", flush=True)
718
+ # Sizes are free-form; these cover the smallest box, an odd CPU count and
719
+ # more RAM per CPU than the default shape. CI hosts have 4 CPUs and 16 GiB.
720
+ for cpu, memory in [(1, "1GiB"), (3, "3GiB"), (2, "8GiB")]:
721
+ sized = raft(
722
+ "new",
723
+ "--location",
724
+ location,
725
+ "--cpu",
726
+ str(cpu),
727
+ "--memory",
728
+ memory,
729
+ ).stdout.strip()
730
+ fixtures.append(sized)
731
+ assert (
732
+ int(json.loads(raft("info", sized).stdout)["config"]["user.raft.expires"])
733
+ > int(execute(sized, "date", "+%s")) + 550
734
+ )
735
+ assert execute(sized, "nproc") == str(cpu)
736
+ assert execute(sized, "cat", "/sys/fs/cgroup/memory.max") == str(
737
+ int(memory.removesuffix("GiB")) * 1024**3
738
+ )
739
+ raft("destroy", sized)
740
+ fixtures.remove(sized)
741
+ print(f"{location}: developer tools and free-form resource sizes passed", flush=True)
717
742
  assert execute(box, "pwd") == "/workspace"
718
743
  host_machine = subprocess.check_output(
719
744
  ["ssh", "-T", "-o", "BatchMode=yes", settings(location)["ssh"], "uname -m"],
@@ -0,0 +1,372 @@
1
+ """Exercise self-update and the passive release notice without GitHub or npmjs.
2
+
3
+ A local HTTP server plays the GitHub releases API, the release assets and an npm
4
+ registry. Real npm and uv installs in temporary directories are upgraded to a
5
+ locally built 99.0.0 release; nothing outside those directories changes.
6
+ """
7
+
8
+ import argparse
9
+ import base64
10
+ import contextlib
11
+ import hashlib
12
+ import http.server
13
+ import io
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import threading
22
+ import time
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ sys.path.insert(0, str(ROOT))
26
+ import raft # noqa: E402
27
+
28
+ CURRENT = raft.__version__
29
+ NEXT = "99.0.0"
30
+ # These checks own the notice setting; CI may disable it for the other suites.
31
+ os.environ.pop("RAFT_NO_UPDATE_CHECK", None)
32
+
33
+
34
+ class Releases(http.server.BaseHTTPRequestHandler):
35
+ """Serve fixed bytes per path and record every request."""
36
+
37
+ routes = {}
38
+ requests = []
39
+ delay = 0
40
+
41
+ def do_GET(self):
42
+ Releases.requests.append(self.path)
43
+ time.sleep(Releases.delay)
44
+ body = Releases.routes.get(self.path)
45
+ if body is None:
46
+ self.send_error(404)
47
+ return
48
+ self.send_response(200)
49
+ self.send_header("Content-Length", str(len(body)))
50
+ self.end_headers()
51
+ self.wfile.write(body)
52
+
53
+ def log_message(self, *arguments):
54
+ pass
55
+
56
+
57
+ def serve_release(base, version, wheel, checksums):
58
+ """Publish a GitHub-style release whose SHA256SUMS text is chosen by the caller."""
59
+ tag = "v" + version
60
+ assets = {wheel.name: wheel.read_bytes(), "SHA256SUMS": checksums.encode()}
61
+ metadata = {
62
+ "tag_name": tag,
63
+ "html_url": f"{base}/releases/tag/{tag}",
64
+ "assets": [
65
+ {"name": name, "browser_download_url": f"{base}/assets/{tag}/{name}"} for name in assets
66
+ ],
67
+ }
68
+ for name, content in assets.items():
69
+ Releases.routes[f"/assets/{tag}/{name}"] = content
70
+ Releases.routes["/api/latest"] = Releases.routes[f"/api/tags/{tag}"] = json.dumps(
71
+ metadata
72
+ ).encode()
73
+
74
+
75
+ def build(source, destination):
76
+ """Build the wheel and npm tarball of a source tree."""
77
+ subprocess.run(
78
+ ["uv", "build", "-q", "--wheel", "-o", str(destination), str(source)], check=True
79
+ )
80
+ subprocess.run(
81
+ ["npm", "pack", "--silent", "--ignore-scripts", "--pack-destination", str(destination)],
82
+ cwd=source,
83
+ check=True,
84
+ capture_output=True,
85
+ )
86
+ return next(destination.glob("*.whl")), next(destination.glob("*.tgz"))
87
+
88
+
89
+ def next_release(work):
90
+ """Build the current sources as release NEXT."""
91
+ source = work / "next-source"
92
+ source.mkdir()
93
+ for name in ["raft_files.py", "pyproject.toml", "README.md", "LICENSE"]:
94
+ shutil.copy(ROOT / name, source)
95
+ code = (ROOT / "raft.py").read_text()
96
+ (source / "raft.py").write_text(code.replace(f'"{CURRENT}"', f'"{NEXT}"', 1))
97
+ metadata = json.loads((ROOT / "package.json").read_text())
98
+ (source / "package.json").write_text(json.dumps({**metadata, "version": NEXT}))
99
+ return build(source, work / "next")
100
+
101
+
102
+ def installed_update(python, *arguments, env, check=False, version=None, api):
103
+ """Run self_update inside an installed copy, as its own raft.py and sys.prefix see it."""
104
+ program = (
105
+ "import argparse, sys\n"
106
+ "sys.path[:0] = sys.argv[4:]\n"
107
+ "import raft\n"
108
+ "sys.exit(raft.self_update(argparse.Namespace(check=sys.argv[2] == 'True', "
109
+ "version=sys.argv[3] or None), api=sys.argv[1]))"
110
+ )
111
+ return subprocess.run(
112
+ [python, "-I", "-c", program, api, str(check), version or "", *arguments],
113
+ env=env,
114
+ capture_output=True,
115
+ text=True,
116
+ )
117
+
118
+
119
+ def version_of(executable):
120
+ return subprocess.check_output([str(executable), "--version"], text=True).strip()
121
+
122
+
123
+ def verify_versions():
124
+ assert raft.parse_version("0.10.0") > raft.parse_version("0.9.12"), "Compared as strings"
125
+ assert raft.parse_version("v1.2.3") == (1, 2, 3)
126
+ assert raft.release_version("v1.2.3") == "1.2.3"
127
+ for text in ["1.2", "1.2.3-rc1", "", "v1.2.3.4", " 1.2.3", "1.2.3\n", "latest"]:
128
+ try:
129
+ raft.parse_version(text)
130
+ except ValueError:
131
+ pass
132
+ else:
133
+ raise AssertionError("Accepted release version " + repr(text))
134
+ print("Release version parsing and numeric comparison passed", flush=True)
135
+
136
+
137
+ def verify_channels(work):
138
+ """Refuse layouts that self-update does not own, before any network access."""
139
+ prefix = Path(sys.prefix).resolve()
140
+ for source, error in [
141
+ (ROOT / "raft.py", "source checkout"),
142
+ (work / "loose/raft.py", "neither"),
143
+ # A project-local npm install has no PREFIX/bin/raft link to its raft.py.
144
+ (work / "project/node_modules/@microck/raft/raft.py", "neither"),
145
+ (work / "lib/node_modules/@microck/raft/raft.py", "neither"),
146
+ ]:
147
+ source.parent.mkdir(parents=True, exist_ok=True)
148
+ source.touch()
149
+ try:
150
+ raft.install_channel(source.resolve(), prefix)
151
+ except ValueError as rejection:
152
+ assert error in str(rejection), rejection
153
+ else:
154
+ raise AssertionError("self-update accepted " + str(source))
155
+ # An editable uv tool keeps raft.py outside its environment.
156
+ editable = work / "tools/raft"
157
+ editable.mkdir(parents=True)
158
+ (editable / "uv-receipt.toml").touch()
159
+ try:
160
+ raft.install_channel((work / "loose/raft.py").resolve(), editable.resolve())
161
+ except ValueError:
162
+ pass
163
+ else:
164
+ raise AssertionError("self-update accepted an editable uv tool")
165
+ print("Checkout, unknown, project-local npm and editable uv layouts are refused", flush=True)
166
+
167
+
168
+ def verify_uv(work, base, current_wheel, next_wheel):
169
+ tools, binaries = work / "uv-tools", work / "uv-bin"
170
+ env = {**os.environ, "UV_TOOL_DIR": str(tools), "UV_TOOL_BIN_DIR": str(binaries)}
171
+ subprocess.run(["uv", "tool", "install", "-q", str(current_wheel)], env=env, check=True)
172
+ python, executable = tools / "raft/bin/python", tools / "raft/bin/raft"
173
+ digest = hashlib.sha256(next_wheel.read_bytes()).hexdigest()
174
+ for checksums, error in [
175
+ (f"{'0' * 64} {next_wheel.name}\n", "does not match"),
176
+ (f"{digest} raft-{NEXT}.tar.gz\n", "does not match"),
177
+ ]:
178
+ serve_release(base, NEXT, next_wheel, checksums)
179
+ rejected = installed_update(python, env=env, api=base + "/api")
180
+ assert rejected.returncode == 1 and error in rejected.stderr, rejected
181
+ assert version_of(executable) == CURRENT, "A failed verification changed the install"
182
+ serve_release(base, NEXT, next_wheel, f"{digest} {next_wheel.name}\n")
183
+ # uv would install into its default tool directory; refuse instead of moving.
184
+ moved = {key: value for key, value in env.items() if key != "UV_TOOL_DIR"}
185
+ rejected = installed_update(python, env=moved, api=base + "/api")
186
+ assert rejected.returncode == 1 and "UV_TOOL_DIR" in rejected.stderr, rejected
187
+ updated = installed_update(python, env=env, api=base + "/api")
188
+ assert updated.returncode == 0, updated
189
+ assert updated.stdout.splitlines()[0] == f"raft {CURRENT} -> {NEXT}", updated.stdout
190
+ assert "raft doctor" in updated.stdout and "Verified" in updated.stderr, updated
191
+ assert version_of(executable) == NEXT and version_of(binaries / "raft") == NEXT
192
+ current = installed_update(python, env=env, api=base + "/api")
193
+ assert current.returncode == 0 and "up to date" in current.stdout, current
194
+ print("uv tool update, SHA256SUMS refusals and tool-directory guard passed", flush=True)
195
+
196
+
197
+ def verify_npm(work, base, current_package, next_package):
198
+ prefix = work / "npm"
199
+ env = {
200
+ **os.environ,
201
+ "npm_config_registry": base + "/",
202
+ "npm_config_cache": str(work / "npm-cache"),
203
+ "npm_config_audit": "false",
204
+ "npm_config_fund": "false",
205
+ "npm_config_update_notifier": "false",
206
+ }
207
+ if sys.platform != "linux":
208
+ # The package declares os: linux; allow developer runs on other systems.
209
+ env["npm_config_force"] = "true"
210
+ subprocess.run(
211
+ ["npm", "install", "--global", "--prefix", str(prefix), str(current_package)],
212
+ env=env,
213
+ check=True,
214
+ capture_output=True,
215
+ )
216
+ content = next_package.read_bytes()
217
+ manifest = json.loads((ROOT / "package.json").read_text())
218
+ tarball = f"{base}/npm/{next_package.name}"
219
+ Releases.routes["/npm/" + next_package.name] = content
220
+ Releases.routes["/@microck%2fraft"] = json.dumps(
221
+ {
222
+ "name": manifest["name"],
223
+ "dist-tags": {"latest": NEXT},
224
+ "versions": {
225
+ NEXT: {
226
+ **manifest,
227
+ "version": NEXT,
228
+ "dist": {
229
+ "tarball": tarball,
230
+ "shasum": hashlib.sha1(content).hexdigest(),
231
+ "integrity": "sha512-"
232
+ + base64.b64encode(hashlib.sha512(content).digest()).decode(),
233
+ },
234
+ }
235
+ },
236
+ }
237
+ ).encode()
238
+ serve_release(base, NEXT, work / "next" / f"raft-{NEXT}-py3-none-any.whl", "")
239
+ package = prefix / "lib/node_modules/@microck/raft"
240
+ updated = installed_update(sys.executable, str(package), env=env, api=base + "/api")
241
+ assert updated.returncode == 0, updated
242
+ assert updated.stdout.splitlines()[0] == f"raft {CURRENT} -> {NEXT}", updated.stdout
243
+ assert version_of(prefix / "bin/raft") == NEXT
244
+ assert any(path.startswith("/@microck") for path in Releases.requests), "npm skipped registry"
245
+ print("npm global update through the registry passed", flush=True)
246
+
247
+
248
+ def verify_check(base, next_wheel):
249
+ with tempfile.TemporaryDirectory() as cache:
250
+ os.environ["XDG_CACHE_HOME"] = cache
251
+ for latest, status, text in [
252
+ (NEXT, raft.UPDATE_AVAILABLE, f"raft {NEXT} is available (you have {CURRENT})"),
253
+ (CURRENT, 0, "up to date"),
254
+ ]:
255
+ serve_release(base, latest, next_wheel, "")
256
+ output = io.StringIO()
257
+ with contextlib.redirect_stdout(output):
258
+ result = raft.self_update(
259
+ argparse.Namespace(check=True, version=None), api=base + "/api"
260
+ )
261
+ assert result == status and text in output.getvalue(), (result, output.getvalue())
262
+ try:
263
+ raft.self_update(argparse.Namespace(check=False, version="98.0.0"), api=base + "/api")
264
+ except ValueError as error:
265
+ # The checkout refusal comes first; it proves no download was attempted.
266
+ assert "source checkout" in str(error), error
267
+ else:
268
+ raise AssertionError("self-update ran from a source checkout")
269
+ print("self-update --check exit status passed", flush=True)
270
+
271
+
272
+ def notice(action, api):
273
+ """Run one passive check and return (stderr text, seconds)."""
274
+ stderr = io.StringIO()
275
+ started = time.monotonic()
276
+ with contextlib.redirect_stderr(stderr):
277
+ raft.update_check(action, api=api)()
278
+ return stderr.getvalue(), time.monotonic() - started
279
+
280
+
281
+ def verify_notice(work, base, next_wheel):
282
+ serve_release(base, NEXT, next_wheel, "")
283
+ expected = f"raft {NEXT} is available (you have {CURRENT}). Run: raft self-update\n"
284
+ api = base + "/api"
285
+ with tempfile.TemporaryDirectory() as cache:
286
+ os.environ["XDG_CACHE_HOME"] = cache
287
+ cached = Path(cache) / "raft/update.json"
288
+ Releases.requests.clear()
289
+ assert notice("list", api)[0] == expected
290
+ assert Releases.requests == ["/api/latest"], Releases.requests
291
+ # Within the interval there is neither a request nor a second notice.
292
+ assert notice("list", api)[0] == "" and len(Releases.requests) == 1
293
+ assert notice("self-update", api)[0] == ""
294
+ cached.write_text("not json")
295
+ os.environ["RAFT_NO_UPDATE_CHECK"] = "1"
296
+ assert notice("list", api)[0] == "" and len(Releases.requests) == 1
297
+ del os.environ["RAFT_NO_UPDATE_CHECK"]
298
+ assert notice("list", api)[0] == expected and len(Releases.requests) == 2
299
+ # An old check expires; an unreachable API stays silent and is not retried.
300
+ cached.write_text(json.dumps({"checkedAt": time.time() - 25 * 3600}))
301
+ assert notice("list", "http://127.0.0.1:9/api")[0] == ""
302
+ assert time.time() - json.loads(cached.read_text())["checkedAt"] < 60
303
+ assert notice("list", api)[0] == "" and len(Releases.requests) == 2
304
+ # A hanging API costs at most the bounded wait.
305
+ cached.unlink()
306
+ Releases.delay = 5
307
+ try:
308
+ output, seconds = notice("list", api)
309
+ finally:
310
+ Releases.delay = 0
311
+ assert output == "" and seconds < raft.UPDATE_CHECK_TIMEOUT + 1, (output, seconds)
312
+ # The CLI adds the notice to stderr only; stdout stays byte-identical.
313
+ home = work / "home"
314
+ config = home / ".config/raft/incus.json"
315
+ config.parent.mkdir(parents=True)
316
+ config.write_text(json.dumps({"lab": {"ssh": "lab-host"}}))
317
+ cache = home / ".cache/raft/update.json"
318
+ cache.parent.mkdir(parents=True)
319
+ env = {key: value for key, value in os.environ.items() if key != "XDG_CACHE_HOME"}
320
+ # main() reads RELEASES_API when it runs, so the CLI can target the local server.
321
+ entry = (
322
+ f"import sys; sys.path.insert(0, {str(ROOT)!r}); import raft; "
323
+ f"raft.RELEASES_API = {api!r}; sys.argv[0] = 'raft'; sys.exit(raft.main())"
324
+ )
325
+
326
+ def cli(*arguments, **extra):
327
+ return subprocess.run(
328
+ [sys.executable, "-c", entry, *arguments],
329
+ env={**env, "HOME": str(home), **extra},
330
+ capture_output=True,
331
+ text=True,
332
+ )
333
+
334
+ listing = cli("locations")
335
+ assert listing.stdout == '{"location": "lab", "default": true, "note": ""}\n', listing
336
+ assert listing.stderr == expected, listing
337
+ # The notice also follows a failed command, but only once per interval.
338
+ cache.unlink()
339
+ failed = cli("note", "missing", "x")
340
+ assert failed.returncode == 1 and failed.stderr.endswith(expected), failed
341
+ again = cli("note", "missing", "x")
342
+ assert again.returncode == 1 and not again.stderr.endswith(expected), again
343
+ for response in [
344
+ cli("locations", RAFT_NO_UPDATE_CHECK="1"),
345
+ cli("--version"),
346
+ cli("self-update", "--version", CURRENT),
347
+ ]:
348
+ assert response.returncode == 0 and response.stderr == "", response
349
+ print("Passive notice throttling, opt-out, failures and stderr-only output passed", flush=True)
350
+
351
+
352
+ def main():
353
+ verify_versions()
354
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Releases)
355
+ threading.Thread(target=server.serve_forever, daemon=True).start()
356
+ base = f"http://127.0.0.1:{server.server_address[1]}"
357
+ try:
358
+ with tempfile.TemporaryDirectory(prefix="raft-self-update-") as directory:
359
+ work = Path(directory).resolve()
360
+ verify_channels(work)
361
+ current_wheel, current_package = build(ROOT, work / "current")
362
+ next_wheel, next_package = next_release(work)
363
+ verify_check(base, next_wheel)
364
+ verify_notice(work, base, next_wheel)
365
+ verify_uv(work, base, current_wheel, next_wheel)
366
+ verify_npm(work, base, current_package, next_package)
367
+ finally:
368
+ server.shutdown()
369
+
370
+
371
+ if __name__ == "__main__":
372
+ main()
@@ -18,11 +18,11 @@ Tests verify Raft behavior, not hosted Boat infrastructure. See [verification](.
18
18
  | Create/list/inspect/delete | ✅ | ✅ | Qualified handles, explicit locations, native Incus metadata. Single operator; no organization/account API |
19
19
  | Stop/resume | ✅ | ✅ | Stops processes, retains disk; resume requires TTL. Shared-kernel containers, not Boat VMs |
20
20
  | Running lifetime | ✅ | ✅ | Host-enforced TTL; extend resets deadline without restart. Long locked transactions delay expiry checks |
21
- | Sizing | ✅ | ✅ | 1/2 CPUs and 1/2/4/8 GiB choices. Resume/fork support sizing overrides; Boat offers larger sizes |
22
- | Host capacity recommendations | ❌ | ✅ | Raft reports host CPU/RAM budgets, active allocations, saved slots and disk headroom. Boat limits describe hosted plan credits and concurrency, not operator-host sizing. Raft enforces four saved boxes per host |
21
+ | Sizing | ✅ | ✅ | Any whole CPU count and GiB of RAM up to the host, plus an optional own disk. Resume/fork support CPU/RAM overrides; Boat offers fixed machine sizes |
22
+ | Host capacity estimates | ❌ | ✅ | Raft reports advisory host CPU/RAM budgets, active allocations, headroom and disk space, and enforces no box count. Boat limits describe hosted plan credits and concurrency, not operator-host sizing |
23
23
  | Hosted credit and start-rate limits | ✅ | ❌ | Boat reports plan credits, concurrency and start rates. Raft has no billing service |
24
24
  | Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides, retaining omitted limits |
25
- | Usage | ✅ | ✅ | Cgroup memory, CPU counters, shared pool space. No billing meter or per-box disk accounting |
25
+ | Usage | ✅ | ✅ | Cgroup memory, CPU counters, and shared-pool or own-disk space. No billing meter |
26
26
  | Root terminal | ✅ | ✅ | PTY over host SSH and native Incus exec. No directly exposed guest SSH endpoint |
27
27
  | Synchronous exec | ✅ | ✅ | Literal argv and preserved exit code. Shell syntax requires an explicit shell |
28
28
  | Detached exec | ✅ | ✅ | Guest systemd unit, status, logs, cancellation. No process checkpoint or restart after stop |
@@ -39,8 +39,8 @@ Tests verify Raft behavior, not hosted Boat infrastructure. See [verification](.
39
39
  | Desktop | ✅ | ✅ | On-demand noVNC, framebuffer, pointer and browser transport checks. See verification for desktop interaction coverage |
40
40
  | Browser-only streaming | ✅ | ❌ | Missing. Full desktop only; browser confinement deferred |
41
41
  | Network separation | ❓ | ✅ | Raft tests host/private/metadata and IPv4/IPv6 peer restrictions with startup-gated filtering. Boat's equivalent filtering policy is not established; its VM boundary is listed separately |
42
- | Bounded host storage pool | ❓ | ✅ | Raft configures a shared 60 GiB Btrfs host pool and rejects size drift. Boat does not document an operator-managed host storage pool |
43
- | Strict per-box disk allocation | ✅ | ❌ | Nested subvolumes prevent claiming strict quotas; no independent Boat-sized disks |
42
+ | Bounded host storage pools | ❓ | ✅ | Raft configures a shared 60 GiB Btrfs pool and, when enabled, a fully allocated LVM thin pool for own box disks that it never overcommits, and rejects size drift. Boat does not document an operator-managed host storage pool |
43
+ | Strict per-box disk allocation | ✅ | ✅ | On hosts with own box disks enabled, `new --disk SIZE` gives a box a fixed-size, copy-on-write ext4 disk that guest and Docker writes cannot exceed. Shared-pool boxes, the default, have no per-box quota |
44
44
  | VM kernel isolation | ✅ | ❌ | VM deployment not implemented |
45
45
  | Nested KVM / Android emulator acceleration | ✅ | ❌ | Boat documents KVM on standard hosts; overflow cloud VMs lack it. Raft has no guest KVM device or VM mode |
46
46
  | External guest SSH | ✅ | ❌ | Boat supports a public IP or an SSH endpoint; public IPv4 is not guaranteed. Raft uses host-mediated private access |
@@ -50,9 +50,10 @@ Tests verify Raft behavior, not hosted Boat infrastructure. See [verification](.
50
50
  | Automatic deletion/retention | ✅ | ✅ | Disposable delete-on-stop/expiry; opt-in stopped-box retention cleanup with preview |
51
51
  | Dashboard | ✅ | ❌ | Missing. CLI and private desktop cover current workflow |
52
52
  | SDK/API keys | ✅ | ❌ | Host SSH authentication. No remote Raft service API |
53
- | CLI-wide JSON mode | ✅ | ❌ | Boat supports JSON/JSONL for most commands; argument errors may use stderr. Raft provides JSON for list/info/usage/limits and JSONL for prune |
53
+ | CLI-wide JSON mode | ✅ | ❌ | Boat supports JSON/JSONL for most commands; argument errors may use stderr. Raft provides JSON for list/info/usage/capacity and JSONL for prune |
54
54
  | Snapshot file browsing/download | ✅ | ❌ | Full native archives only; no Raft snapshot tree or selected-file interface |
55
- | CLI self-update/completions | ✅ | ❌ | Standard npm or Python package installation/help. No CLI update command or dynamic shell completions |
55
+ | CLI self-update | ✅ | ✅ | `raft self-update` upgrades npm global installs through the registry and uv tools from a release wheel checked against `SHA256SUMS`. A daily stderr notice reports new releases. Boat also updates itself after commands; Raft never installs without the command and never changes hosts |
56
+ | Dynamic shell completions | ✅ | ❌ | Static `--help` only |
56
57
  | Managed agents | ✅ | ❌ | Not implemented. No prompt/events/steer/interrupt/conversation API |
57
58
 
58
59
  ## Reference check
@@ -96,7 +96,7 @@ Successful CI runs attach seven-day artifacts (`raft-dev-amd64` and `raft-dev-ar
96
96
 
97
97
  Versioned public images are published under [releases](https://github.com/Microck/raft/releases) after recovery and fresh-host validation; public release assets do not expire on the seven-day CI schedule. Each archive must be imported into a host of the matching CPU architecture.
98
98
 
99
- The CI runner uses a sparse Btrfs pool on an ephemeral VM and does not certify the 80 GiB production free-disk preflight. Pinned images retain their contents until you configure a new fingerprint.
99
+ The CI runner uses sparse Btrfs and LVM pools on an ephemeral VM and does not certify the production free-disk preflight. Pinned images retain their contents until you configure a new fingerprint.
100
100
 
101
101
  </details>
102
102