@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.
- package/README.md +30 -14
- package/deploy/deploy.py +112 -19
- package/deploy/install-incus.sh +10 -0
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +34 -48
- package/deploy/verify-features.py +592 -3
- package/deploy/verify-fresh-host.py +64 -1
- package/deploy/verify-limits.py +327 -72
- package/deploy/verify-live.py +56 -31
- package/deploy/verify-self-update.py +372 -0
- package/docs/boat-capabilities.md +8 -7
- package/docs/boot-performance.mdx +1 -1
- package/docs/contract.md +33 -23
- package/docs/first-box.mdx +1 -1
- package/docs/guides/backup-recovery.mdx +2 -2
- package/docs/guides/config-policy.mdx +253 -0
- package/docs/guides/docker.mdx +9 -1
- package/docs/guides/long-running-services.mdx +10 -4
- package/docs/guides/meta.json +1 -0
- package/docs/guides/project-setup.mdx +4 -3
- package/docs/guides/scripting-ci.mdx +27 -10
- package/docs/guides/snapshots-forks.mdx +9 -3
- package/docs/guides/troubleshooting.mdx +37 -8
- package/docs/guides/upgrades.mdx +17 -5
- package/docs/index.mdx +1 -0
- package/docs/operations.mdx +16 -14
- package/docs/reference/capacity.mdx +198 -0
- package/docs/reference/cli.md +178 -35
- package/docs/reference/configuration.mdx +40 -2
- package/docs/reference/meta.json +1 -1
- package/docs/releases/0.5.0.md +19 -0
- package/docs/releases/0.6.0.md +16 -0
- package/docs/requirements.mdx +2 -2
- package/docs/verification.mdx +14 -8
- package/package.json +1 -1
- package/raft.py +1181 -128
- package/skills/raft-cli/SKILL.md +79 -13
- package/docs/reference/capacity-limits.mdx +0 -92
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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 [
|
|
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("
|
|
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("
|
|
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
|
-
(["
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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 | ✅ | ✅ |
|
|
22
|
-
| Host capacity
|
|
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
|
|
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
|
|
43
|
-
| Strict per-box disk allocation | ✅ |
|
|
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/
|
|
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
|
|
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
|
|
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
|
|