@microck/raft 0.2.1 → 0.4.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 +9 -1
- package/deploy/build-image.py +22 -18
- package/deploy/deploy.py +100 -6
- package/deploy/verify-features.py +173 -2
- package/deploy/verify-fresh-host.py +31 -26
- package/deploy/verify-limits.py +27 -1
- package/deploy/verify-live.py +6 -5
- package/docs/boat-capabilities.md +1 -1
- package/docs/contract.md +6 -1
- package/docs/guides/scripting-ci.mdx +4 -2
- package/docs/operations.mdx +33 -9
- package/docs/reference/capacity-limits.mdx +2 -2
- package/docs/reference/cli.md +84 -6
- package/docs/reference/configuration.mdx +15 -1
- package/docs/releases/0.3.0.md +15 -0
- package/docs/releases/0.4.0.md +15 -0
- package/docs/requirements.mdx +2 -0
- package/docs/verification.mdx +1 -1
- package/package.json +1 -1
- package/raft.py +389 -38
- package/skills/raft-cli/SKILL.md +23 -3
package/README.md
CHANGED
|
@@ -40,6 +40,14 @@ up hosts or download workspace images.
|
|
|
40
40
|
| host access | SSH and passwordless sudo |
|
|
41
41
|
| controller | Linux, Python 3.11.8+, SSH and SCP; npm or uv for installation |
|
|
42
42
|
|
|
43
|
+
then onboard a dedicated Ubuntu host over key-based SSH. `raft host add` checks
|
|
44
|
+
the host, shows its plan, provisions Incus, imports a verified image and runs a
|
|
45
|
+
smoke test:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
raft host add lab --ssh ubuntu@192.0.2.10
|
|
49
|
+
```
|
|
50
|
+
|
|
43
51
|
once your host and image are configured, run the commands below. replace `lab`
|
|
44
52
|
with a location from your configuration:
|
|
45
53
|
|
|
@@ -123,7 +131,7 @@ records differences, sources and test coverage.
|
|
|
123
131
|
| Create/list/inspect/delete | ✅ | ✅ | Single operator |
|
|
124
132
|
| Persistent stop/resume | ✅ | ✅ | Shared host kernel |
|
|
125
133
|
| TTL and lifetime extension | ✅ | ✅ | Persistent boxes retain disk; disposable boxes delete |
|
|
126
|
-
| CPU/memory sizing and usage | ✅ | ✅ | 1 or 2 CPUs, 1, 2 or
|
|
134
|
+
| CPU/memory sizing and usage | ✅ | ✅ | 1 or 2 CPUs, 1, 2, 4 or 8 GiB RAM. Disk usage covers the shared pool |
|
|
127
135
|
| Host capacity recommendations | ❌ | ✅ | Raft recommends running capacity and enforces four saved boxes. Boat reports plan limits |
|
|
128
136
|
| Root terminal and command execution | ✅ | ✅ | Host SSH transport |
|
|
129
137
|
| Background jobs, logs and cancellation | ✅ | ✅ | No process checkpoints |
|
package/deploy/build-image.py
CHANGED
|
@@ -10,27 +10,17 @@ import time
|
|
|
10
10
|
import uuid
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
-
from deploy import
|
|
13
|
+
from deploy import ROOT, copy, host_architecture, hosts, remote # noqa: E402
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
parser.add_argument("--location", choices=HOSTS, required=True)
|
|
19
|
-
parser.add_argument(
|
|
20
|
-
"--alias", default="raft-dev", help="New image alias; existing aliases are refused"
|
|
21
|
-
)
|
|
22
|
-
args = parser.parse_args()
|
|
23
|
-
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", args.alias):
|
|
24
|
-
parser.error("alias must use letters, digits, underscores or hyphens")
|
|
25
|
-
host = HOSTS[args.location]
|
|
16
|
+
def build(host, alias):
|
|
17
|
+
"""Build and publish a native image under a new alias, returning its fingerprint."""
|
|
26
18
|
architecture = host_architecture(host)
|
|
27
19
|
started = time.monotonic()
|
|
28
20
|
prefix = "sudo -n incus --project raft"
|
|
29
21
|
aliases = json.loads(remote(host, prefix + " image list --format json"))
|
|
30
|
-
if any(a["name"] ==
|
|
31
|
-
raise RuntimeError(
|
|
32
|
-
args.alias + " exists; choose a fresh alias or review its removal explicitly"
|
|
33
|
-
)
|
|
22
|
+
if any(a["name"] == alias for image in aliases for a in image["aliases"]):
|
|
23
|
+
raise RuntimeError(alias + " exists; choose a fresh alias or review its removal explicitly")
|
|
34
24
|
images = json.loads(
|
|
35
25
|
remote(host, prefix + f" image list images:debian/13/{architecture} --format json")
|
|
36
26
|
)
|
|
@@ -66,7 +56,7 @@ def main():
|
|
|
66
56
|
host,
|
|
67
57
|
prefix
|
|
68
58
|
+ " publish raft-builder --alias "
|
|
69
|
-
+ shlex.quote(
|
|
59
|
+
+ shlex.quote(alias)
|
|
70
60
|
+ " --compression gzip description="
|
|
71
61
|
+ shlex.quote(f"Raft Debian 13 {architecture} development workspace"),
|
|
72
62
|
)
|
|
@@ -74,11 +64,11 @@ def main():
|
|
|
74
64
|
# Build failures intentionally retain their named builder for diagnostics.
|
|
75
65
|
# A successful immutable publication no longer needs its writable builder.
|
|
76
66
|
remote(host, prefix + " delete raft-builder")
|
|
77
|
-
published = json.loads(remote(host, prefix + " image list " +
|
|
67
|
+
published = json.loads(remote(host, prefix + " image list " + alias + " --format json"))
|
|
78
68
|
fingerprint = next(
|
|
79
69
|
image["fingerprint"]
|
|
80
70
|
for image in published
|
|
81
|
-
if any(
|
|
71
|
+
if any(item["name"] == alias for item in image["aliases"])
|
|
82
72
|
)
|
|
83
73
|
# Unpacking a full image can take a minute. Pay it during setup, before new
|
|
84
74
|
# reports a usable box; init prepares the native cache without booting a guest.
|
|
@@ -89,6 +79,20 @@ def main():
|
|
|
89
79
|
print(
|
|
90
80
|
f"Native {architecture} image built in {time.monotonic() - started:.1f} seconds", flush=True
|
|
91
81
|
)
|
|
82
|
+
return fingerprint
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
configured = hosts()
|
|
87
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
88
|
+
parser.add_argument("--location", choices=configured, required=True)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--alias", default="raft-dev", help="New image alias; existing aliases are refused"
|
|
91
|
+
)
|
|
92
|
+
args = parser.parse_args()
|
|
93
|
+
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", args.alias):
|
|
94
|
+
parser.error("alias must use letters, digits, underscores or hyphens")
|
|
95
|
+
build(configured[args.location], args.alias)
|
|
92
96
|
|
|
93
97
|
|
|
94
98
|
if __name__ == "__main__":
|
package/deploy/deploy.py
CHANGED
|
@@ -5,7 +5,9 @@ selects system containers explicitly and never silently substitutes a VM.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
8
|
+
import hashlib
|
|
8
9
|
import json
|
|
10
|
+
import re
|
|
9
11
|
import shlex
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
import subprocess
|
|
@@ -15,7 +17,49 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
15
17
|
sys.path.insert(0, str(ROOT))
|
|
16
18
|
from raft import configuration # noqa: E402
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
RELEASES = "https://github.com/Microck/raft/releases/download/"
|
|
21
|
+
# Matches the user agent install-incus.sh sends for its own downloads.
|
|
22
|
+
USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
|
|
23
|
+
INCUS_ARCHITECTURES = {"arm64": "aarch64", "amd64": "x86_64"}
|
|
24
|
+
|
|
25
|
+
# Shared tail of both image scripts. A unified image's Incus fingerprint is the
|
|
26
|
+
# SHA-256 of its archive, so checking the digest also predicts the fingerprint and
|
|
27
|
+
# lets a rerun reuse an earlier import instead of failing on a duplicate.
|
|
28
|
+
IMPORT_IMAGE = """fingerprint=$(sha256sum "$archive" | cut -d ' ' -f 1)
|
|
29
|
+
test "$fingerprint" = "$expected" || { echo 'Image SHA-256 does not match the expected checksum' >&2; exit 1; }
|
|
30
|
+
if ! sudo -n incus --project raft image info "$fingerprint" >/dev/null 2>&1; then
|
|
31
|
+
sudo -n incus --project raft image import "$archive" >&2
|
|
32
|
+
fi
|
|
33
|
+
echo "$fingerprint"
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
RELEASE_IMAGE = (
|
|
37
|
+
"""set -eu
|
|
38
|
+
work=$(mktemp -d /tmp/raft-image.XXXXXXXX)
|
|
39
|
+
trap 'rm -rf "$work"' EXIT
|
|
40
|
+
archive="$work/raft-dev-$2.tar.gz"
|
|
41
|
+
curl -fsSL -A "$3" -o "$work/SHA256SUMS" "$1/SHA256SUMS"
|
|
42
|
+
curl -fsSL -A "$3" -o "$archive" "$1/raft-dev-$2.tar.gz"
|
|
43
|
+
expected=$(awk -v name="raft-dev-$2.tar.gz" '$2 == name {print $1}' "$work/SHA256SUMS")
|
|
44
|
+
test -n "$expected" || { echo "Release SHA256SUMS lists no raft-dev-$2.tar.gz" >&2; exit 1; }
|
|
45
|
+
"""
|
|
46
|
+
+ IMPORT_IMAGE
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
ARCHIVE_IMAGE = (
|
|
50
|
+
"""set -eu
|
|
51
|
+
archive=$(mktemp /tmp/raft-image.XXXXXXXX)
|
|
52
|
+
trap 'rm -f "$archive"' EXIT
|
|
53
|
+
cat > "$archive"
|
|
54
|
+
expected="$1"
|
|
55
|
+
"""
|
|
56
|
+
+ IMPORT_IMAGE
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def hosts():
|
|
61
|
+
# Read at call time: onboarding imports this module before its location exists.
|
|
62
|
+
return {name: entry["ssh"] for name, entry in configuration().items()}
|
|
19
63
|
|
|
20
64
|
|
|
21
65
|
def remote(host, command):
|
|
@@ -40,8 +84,57 @@ def host_architecture(host):
|
|
|
40
84
|
return architecture
|
|
41
85
|
|
|
42
86
|
|
|
43
|
-
def
|
|
44
|
-
host
|
|
87
|
+
def script(host, body, *arguments, stdin=None):
|
|
88
|
+
"""Run a shell script on the host with positional arguments, returning its stdout."""
|
|
89
|
+
response = subprocess.run(
|
|
90
|
+
[
|
|
91
|
+
"ssh",
|
|
92
|
+
"-T",
|
|
93
|
+
"-o",
|
|
94
|
+
"BatchMode=yes",
|
|
95
|
+
host,
|
|
96
|
+
shlex.join(["sh", "-c", body, "raft", *arguments]),
|
|
97
|
+
],
|
|
98
|
+
stdin=stdin,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
)
|
|
102
|
+
if response.returncode:
|
|
103
|
+
raise RuntimeError(response.stderr.strip() or "Remote script failed")
|
|
104
|
+
return response.stdout.strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def checked_image(host, fingerprint, architecture):
|
|
108
|
+
"""Confirm the imported image exists and matches the host architecture."""
|
|
109
|
+
if not re.fullmatch(r"[a-f0-9]{64}", fingerprint):
|
|
110
|
+
raise RuntimeError("Image import returned an invalid fingerprint")
|
|
111
|
+
images = json.loads(
|
|
112
|
+
remote(host, f"sudo -n incus --project raft image list {fingerprint} --format json")
|
|
113
|
+
)
|
|
114
|
+
image = next((item for item in images if item["fingerprint"] == fingerprint), None)
|
|
115
|
+
if image is None or image["architecture"] != INCUS_ARCHITECTURES[architecture]:
|
|
116
|
+
raise RuntimeError(f"Imported image is missing or not a native {architecture} image")
|
|
117
|
+
return fingerprint
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def import_release(host, tag, architecture):
|
|
121
|
+
"""Download a release image on the host, verify it against SHA256SUMS and import it."""
|
|
122
|
+
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
|
123
|
+
raise ValueError("Release tags look like v0.3.0")
|
|
124
|
+
fingerprint = script(host, RELEASE_IMAGE, RELEASES + tag, architecture, USER_AGENT)
|
|
125
|
+
return checked_image(host, fingerprint, architecture)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def import_archive(host, archive, architecture):
|
|
129
|
+
"""Upload a local image archive over SSH stdin, verify its digest and import it."""
|
|
130
|
+
with Path(archive).open("rb") as source:
|
|
131
|
+
expected = hashlib.file_digest(source, "sha256").hexdigest()
|
|
132
|
+
source.seek(0)
|
|
133
|
+
fingerprint = script(host, ARCHIVE_IMAGE, expected, stdin=source)
|
|
134
|
+
return checked_image(host, fingerprint, architecture)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def deploy(name, host):
|
|
45
138
|
host_architecture(host)
|
|
46
139
|
copy(host, ROOT / "deploy/install-incus.sh", "/tmp/raft-install-incus.sh")
|
|
47
140
|
print(remote(host, "bash /tmp/raft-install-incus.sh </dev/null"), flush=True)
|
|
@@ -166,10 +259,11 @@ def deploy(name):
|
|
|
166
259
|
|
|
167
260
|
def main():
|
|
168
261
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
169
|
-
|
|
262
|
+
configured = hosts()
|
|
263
|
+
parser.add_argument("--location", choices=configured)
|
|
170
264
|
args = parser.parse_args()
|
|
171
|
-
for name in [args.location] if args.location else
|
|
172
|
-
deploy(name)
|
|
265
|
+
for name in [args.location] if args.location else configured:
|
|
266
|
+
deploy(name, configured[name])
|
|
173
267
|
|
|
174
268
|
|
|
175
269
|
if __name__ == "__main__":
|
|
@@ -22,7 +22,15 @@ import time
|
|
|
22
22
|
|
|
23
23
|
ROOT = Path(__file__).resolve().parent.parent
|
|
24
24
|
sys.path.insert(0, str(ROOT))
|
|
25
|
-
from raft import
|
|
25
|
+
from raft import ( # noqa: E402
|
|
26
|
+
host_problems,
|
|
27
|
+
incus,
|
|
28
|
+
inventory,
|
|
29
|
+
parse_handle,
|
|
30
|
+
remote,
|
|
31
|
+
settings,
|
|
32
|
+
transaction,
|
|
33
|
+
)
|
|
26
34
|
import raft_files # noqa: E402
|
|
27
35
|
from raft_files import pack_directory, publish, unpack_directory # noqa: E402
|
|
28
36
|
|
|
@@ -157,6 +165,128 @@ def verify_archives():
|
|
|
157
165
|
)
|
|
158
166
|
|
|
159
167
|
|
|
168
|
+
def verify_location_notes():
|
|
169
|
+
"""Edit notes in a private controller home; no host is contacted."""
|
|
170
|
+
with tempfile.TemporaryDirectory() as home:
|
|
171
|
+
config = Path(home) / ".config/raft/incus.json"
|
|
172
|
+
config.parent.mkdir(parents=True)
|
|
173
|
+
config.write_text(
|
|
174
|
+
json.dumps({"lab": {"ssh": "lab-host", "image": ""}, "spare": {"ssh": "spare-host"}})
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def cli(*arguments):
|
|
178
|
+
return subprocess.run(
|
|
179
|
+
[sys.executable, str(ROOT / "raft.py"), *arguments],
|
|
180
|
+
env={**os.environ, "HOME": home},
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert cli("note", "spare", " Avoid for Docker builds ").returncode == 0
|
|
186
|
+
records = [json.loads(line) for line in cli("locations").stdout.splitlines()]
|
|
187
|
+
assert records == [
|
|
188
|
+
{"location": "lab", "default": True, "note": ""},
|
|
189
|
+
{"location": "spare", "default": False, "note": "Avoid for Docker builds"},
|
|
190
|
+
], records
|
|
191
|
+
assert list(json.loads(config.read_text())) == ["lab", "spare"], "Default location moved"
|
|
192
|
+
assert config.stat().st_mode & 0o777 == 0o600, "Note editing widened permissions"
|
|
193
|
+
for arguments, error in [
|
|
194
|
+
(["note", "missing", "x"], "Unknown location"),
|
|
195
|
+
(["note", "lab", " "], "--clear"),
|
|
196
|
+
]:
|
|
197
|
+
rejected = cli(*arguments)
|
|
198
|
+
assert rejected.returncode == 1 and error in rejected.stderr, rejected
|
|
199
|
+
# Box note mistakes are rejected before any host is contacted.
|
|
200
|
+
handle = "lab:rf-0123456789abcdef"
|
|
201
|
+
for arguments, error in [
|
|
202
|
+
(["note", "lab", "x", "--clear"], "TEXT or --clear"),
|
|
203
|
+
(["note", "lab"], "TEXT or --clear"),
|
|
204
|
+
(["note", "lab", "--purpose", "x"], "pass a box handle"),
|
|
205
|
+
(["note", handle, "x"], "--purpose, --owner and --next"),
|
|
206
|
+
(["note", handle], "--purpose (with optional"),
|
|
207
|
+
(["note", handle, "--purpose", "x", "--clear"], "--purpose (with optional"),
|
|
208
|
+
(["note", handle, "--owner", "agent"], "need --purpose"),
|
|
209
|
+
(["note", handle, "--purpose", " "], "--purpose cannot be empty"),
|
|
210
|
+
(["new", "--location", "lab", "--next", "later"], "need --purpose"),
|
|
211
|
+
]:
|
|
212
|
+
rejected = cli(*arguments)
|
|
213
|
+
assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
|
|
214
|
+
# Concurrent edits to different locations must all survive.
|
|
215
|
+
many = {f"host{index}": {"ssh": f"host{index}"} for index in range(8)}
|
|
216
|
+
saved = config.read_text()
|
|
217
|
+
config.write_text(json.dumps(many))
|
|
218
|
+
writers = [
|
|
219
|
+
subprocess.Popen(
|
|
220
|
+
[sys.executable, str(ROOT / "raft.py"), "note", location, "busy " + location],
|
|
221
|
+
env={**os.environ, "HOME": home},
|
|
222
|
+
)
|
|
223
|
+
for location in many
|
|
224
|
+
]
|
|
225
|
+
assert all(writer.wait() == 0 for writer in writers)
|
|
226
|
+
notes = {key: entry.get("note") for key, entry in json.loads(config.read_text()).items()}
|
|
227
|
+
assert notes == {location: "busy " + location for location in many}, notes
|
|
228
|
+
config.write_text(saved)
|
|
229
|
+
assert cli("note", "spare", "--clear").returncode == 0
|
|
230
|
+
assert "note" not in json.loads(config.read_text())["spare"]
|
|
231
|
+
assert not list(config.parent.glob(".raft-*")), "Staged configuration left behind"
|
|
232
|
+
config.write_text(json.dumps({"lab": {"ssh": "lab-host", "note": 1}}))
|
|
233
|
+
rejected = cli("locations")
|
|
234
|
+
assert rejected.returncode == 1 and "notes must be strings" in rejected.stderr, rejected
|
|
235
|
+
print("Location note editing, listing, ordering and permissions passed", flush=True)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def verify_host_preflight():
|
|
239
|
+
"""Check onboarding decisions and refusals that need no host."""
|
|
240
|
+
ready = {
|
|
241
|
+
"kernel": "Linux",
|
|
242
|
+
"os": "ubuntu",
|
|
243
|
+
"version": "24.04",
|
|
244
|
+
"arch": "arm64",
|
|
245
|
+
"cpus": "2",
|
|
246
|
+
# MemTotal of an 8 GiB VM, in KiB.
|
|
247
|
+
"memory": str(8_131_000),
|
|
248
|
+
"disk": str(90 * 1024**2),
|
|
249
|
+
"sudo": "yes",
|
|
250
|
+
"python3": "yes",
|
|
251
|
+
"deployed": "no",
|
|
252
|
+
}
|
|
253
|
+
assert host_problems(ready) == []
|
|
254
|
+
# An existing deployment already owns its pool, so free disk no longer gates it.
|
|
255
|
+
assert host_problems({**ready, "disk": "0", "deployed": "yes"}) == []
|
|
256
|
+
darwin = host_problems({"kernel": "Darwin", "sudo": "no"})
|
|
257
|
+
assert len(darwin) == 1 and "macOS" in darwin[0], darwin
|
|
258
|
+
for change, reason in [
|
|
259
|
+
({"os": "debian", "version": "13"}, "Ubuntu 22.04 or 24.04"),
|
|
260
|
+
({"version": "20.04"}, "Ubuntu 22.04 or 24.04"),
|
|
261
|
+
({"arch": "armhf"}, "ARM64 or AMD64"),
|
|
262
|
+
({"sudo": "no"}, "passwordless sudo"),
|
|
263
|
+
({"python3": "no"}, "python3"),
|
|
264
|
+
({"cpus": "1"}, "2 CPUs"),
|
|
265
|
+
({"memory": str(6 * 1024**2)}, "8 GiB RAM"),
|
|
266
|
+
({"disk": str(40 * 1024**2)}, "80 GiB"),
|
|
267
|
+
]:
|
|
268
|
+
problems = host_problems({**ready, **change})
|
|
269
|
+
assert len(problems) == 1 and reason in problems[0], (change, problems)
|
|
270
|
+
with tempfile.TemporaryDirectory() as home:
|
|
271
|
+
config = Path(home) / ".config/raft/incus.json"
|
|
272
|
+
config.parent.mkdir(parents=True)
|
|
273
|
+
config.write_text(json.dumps({"lab": {"ssh": "lab-host", "image": ""}}))
|
|
274
|
+
# Each refusal happens before SSH; the unroutable target proves no host is contacted.
|
|
275
|
+
for arguments, error in [
|
|
276
|
+
(["lab"], "already configured"),
|
|
277
|
+
(["bad name"], "Location names"),
|
|
278
|
+
(["spare", "--image-archive", str(Path(home) / "missing.tar.gz")], "not found"),
|
|
279
|
+
]:
|
|
280
|
+
rejected = subprocess.run(
|
|
281
|
+
[sys.executable, str(ROOT / "raft.py"), "host", "add", *arguments, "--ssh", "x"],
|
|
282
|
+
env={**os.environ, "HOME": home},
|
|
283
|
+
capture_output=True,
|
|
284
|
+
text=True,
|
|
285
|
+
)
|
|
286
|
+
assert rejected.returncode == 1 and error in rejected.stderr, rejected
|
|
287
|
+
print("Host preflight decisions and pre-SSH onboarding refusals passed", flush=True)
|
|
288
|
+
|
|
289
|
+
|
|
160
290
|
def verify_readiness(location, expect_stale=False):
|
|
161
291
|
# Isolate the selected host without losing the controller's verified SSH config.
|
|
162
292
|
with tempfile.TemporaryDirectory(prefix="raft-doctor-test-") as work:
|
|
@@ -294,9 +424,46 @@ def verify(location, isolated_worker=False):
|
|
|
294
424
|
try:
|
|
295
425
|
verify_readiness(location, expect_stale=isolated_worker)
|
|
296
426
|
verify_transaction_stdin(location)
|
|
297
|
-
|
|
427
|
+
# A location note goes to stderr; stdout stays the bare handle for scripts.
|
|
428
|
+
# A private HOME copy carries the note, leaving the operator config untouched;
|
|
429
|
+
# OpenSSH reads keys and aliases from the passwd home, not HOME.
|
|
430
|
+
with tempfile.TemporaryDirectory() as home:
|
|
431
|
+
noted = Path(home) / ".config/raft/incus.json"
|
|
432
|
+
noted.parent.mkdir(parents=True)
|
|
433
|
+
noted.write_text(
|
|
434
|
+
json.dumps({location: {**settings(location), "note": "feature verifier note"}})
|
|
435
|
+
)
|
|
436
|
+
response = live.raft(
|
|
437
|
+
"new", "--location", location, "--ttl", "600", env={**os.environ, "HOME": home}
|
|
438
|
+
)
|
|
439
|
+
handle = response.stdout.strip()
|
|
440
|
+
fixtures.append(handle)
|
|
441
|
+
assert response.stdout == handle + "\n", response.stdout
|
|
442
|
+
assert response.stderr == f"{location} note: feature verifier note\n", response.stderr
|
|
443
|
+
live.raft("destroy", handle)
|
|
444
|
+
fixtures.remove(handle)
|
|
445
|
+
print(f"{location}: noted new kept stdout to the handle and the note on stderr", flush=True)
|
|
446
|
+
box = create("--memory", "1GiB", "--purpose", " Feature verification ", "--owner", "verify")
|
|
447
|
+
|
|
448
|
+
def listed_note(handle):
|
|
449
|
+
for line in live.raft("list", "--location", location).stdout.splitlines():
|
|
450
|
+
record = json.loads(line)
|
|
451
|
+
if record["box"] == handle:
|
|
452
|
+
return record["note"]
|
|
453
|
+
raise AssertionError(f"{handle} missing from list")
|
|
454
|
+
|
|
455
|
+
assert listed_note(box) == {"purpose": "Feature verification", "owner": "verify"}
|
|
456
|
+
# Each note edit replaces the whole note.
|
|
457
|
+
live.raft("note", box, "--purpose", "Feature verification", "--next", "Destroy at the end")
|
|
458
|
+
assert listed_note(box) == {"purpose": "Feature verification", "next": "Destroy at the end"}
|
|
459
|
+
live.raft("note", box, "--clear")
|
|
460
|
+
assert listed_note(box) is None
|
|
461
|
+
live.raft("note", box, "--purpose", "Feature verification")
|
|
298
462
|
live.wait_running(box)
|
|
299
463
|
_, name = parse_handle(box)
|
|
464
|
+
print(
|
|
465
|
+
f"{location}: box note template, listing, replacement and clearing passed", flush=True
|
|
466
|
+
)
|
|
300
467
|
with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
|
|
301
468
|
root = Path(work)
|
|
302
469
|
source = root / "source with spaces"
|
|
@@ -387,6 +554,7 @@ def verify(location, isolated_worker=False):
|
|
|
387
554
|
!= metadata(box)["config"]["volatile.eth0.hwaddr"]
|
|
388
555
|
)
|
|
389
556
|
assert metadata(template)["config"]["user.raft.disposable"] == "false"
|
|
557
|
+
assert listed_note(template) is None, "new --from inherited the source note"
|
|
390
558
|
assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
|
|
391
559
|
live.raft("snapshot-delete", box, "prepared")
|
|
392
560
|
assert live.execute(template, "cat", "/workspace/proof") == "original"
|
|
@@ -401,6 +569,7 @@ def verify(location, isolated_worker=False):
|
|
|
401
569
|
fork = live.raft(*fork_args).stdout.strip()
|
|
402
570
|
fixtures.append(fork)
|
|
403
571
|
assert metadata(fork)["expanded_config"]["limits.memory"] == "1GiB"
|
|
572
|
+
assert listed_note(fork) == {"purpose": "Feature verification"}, "fork lost the note"
|
|
404
573
|
assert metadata(fork)["expanded_config"]["limits.cpu"] == "1"
|
|
405
574
|
assert metadata(box)["expanded_config"]["limits.memory"] == "4GiB"
|
|
406
575
|
if worker:
|
|
@@ -514,6 +683,8 @@ if __name__ == "__main__":
|
|
|
514
683
|
parser.add_argument("--location")
|
|
515
684
|
parser.add_argument("--isolated-worker", action="store_true")
|
|
516
685
|
args = parser.parse_args()
|
|
686
|
+
verify_location_notes()
|
|
687
|
+
verify_host_preflight()
|
|
517
688
|
verify_archives()
|
|
518
689
|
if args.location:
|
|
519
690
|
verify(args.location, args.isolated_worker)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Onboard and reboot a disposable KVM Ubuntu host through `raft host add`.
|
|
2
2
|
|
|
3
3
|
CI-only harness. Writes temporary verified SSH/configuration in its own HOME.
|
|
4
4
|
Uses a sparse 100 GiB virtual disk, not an 80 GiB physical capacity guarantee.
|
|
@@ -115,7 +115,6 @@ def verify(archive):
|
|
|
115
115
|
ssh = home / ".ssh"
|
|
116
116
|
config = home / ".config/raft"
|
|
117
117
|
ssh.mkdir(parents=True, mode=0o700)
|
|
118
|
-
config.mkdir(parents=True, mode=0o700)
|
|
119
118
|
for key in ["client", "host"]:
|
|
120
119
|
run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(work / key))
|
|
121
120
|
(ssh / "config").write_text(
|
|
@@ -124,9 +123,8 @@ def verify(archive):
|
|
|
124
123
|
f" UserKnownHostsFile {ssh / 'known_hosts'}\n StrictHostKeyChecking yes\n"
|
|
125
124
|
)
|
|
126
125
|
(ssh / "known_hosts").write_text("[localhost]:2222 " + (work / "host.pub").read_text())
|
|
126
|
+
# Onboarding starts from a controller with no Raft configuration at all.
|
|
127
127
|
configuration = config / "incus.json"
|
|
128
|
-
configuration.write_text(json.dumps({"lab": {"ssh": "raft-fresh", "image": ""}}))
|
|
129
|
-
configuration.chmod(0o600)
|
|
130
128
|
# A per-harness HOME keeps the operator's controller config untouched.
|
|
131
129
|
env = {**os.environ, "HOME": str(home)}
|
|
132
130
|
cloud = {
|
|
@@ -230,33 +228,40 @@ def verify(archive):
|
|
|
230
228
|
else:
|
|
231
229
|
raise RuntimeError("Fresh VM SSH did not become ready")
|
|
232
230
|
run("ssh", "raft-fresh", "sudo cloud-init status --wait --long", env=env)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
231
|
+
onboard = [sys.executable, str(ROOT / "raft.py"), "host", "add", "lab"]
|
|
232
|
+
onboard += ["--ssh", "raft-fresh"]
|
|
233
|
+
preview = subprocess.run(
|
|
234
|
+
[*onboard, "--dry-run"], env=env, capture_output=True, text=True, check=True
|
|
235
|
+
)
|
|
236
|
+
assert "Dry run: nothing was changed" in preview.stdout, preview.stdout
|
|
237
|
+
assert not config.exists(), "Dry run wrote controller configuration"
|
|
238
|
+
deployed = subprocess.run(
|
|
239
|
+
["ssh", "raft-fresh", "command -v incus"], env=env, capture_output=True
|
|
241
240
|
)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
241
|
+
assert deployed.returncode != 0, "Dry run installed Incus"
|
|
242
|
+
# Without a terminal or --yes, onboarding must refuse before changing anything.
|
|
243
|
+
refused = subprocess.run(
|
|
244
|
+
onboard, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True
|
|
245
|
+
)
|
|
246
|
+
assert refused.returncode == 1 and "--yes" in refused.stderr, refused
|
|
247
|
+
run(*onboard, "--image-archive", str(archive), "--yes", env=env)
|
|
248
|
+
with archive.open("rb") as image_archive:
|
|
249
|
+
digest = hashlib.file_digest(image_archive, "sha256").hexdigest()
|
|
250
|
+
saved = json.loads(configuration.read_text())
|
|
251
|
+
assert saved == {"lab": {"ssh": "raft-fresh", "image": digest}}, saved
|
|
252
|
+
assert configuration.stat().st_mode & 0o777 == 0o600
|
|
253
|
+
leftovers = subprocess.check_output(
|
|
254
|
+
["ssh", "raft-fresh", "ls /tmp | grep -c '^raft-image' || true"],
|
|
248
255
|
env=env,
|
|
249
256
|
text=True,
|
|
250
257
|
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if any(a["name"] == "raft-dev" for a in item["aliases"])
|
|
255
|
-
)
|
|
256
|
-
configuration.write_text(
|
|
257
|
-
json.dumps({"lab": {"ssh": "raft-fresh", "image": fingerprint}})
|
|
258
|
+
assert leftovers.strip() == "0", "Image staging file left on the host"
|
|
259
|
+
repeated = subprocess.run(
|
|
260
|
+
[*onboard, "--yes"], env=env, capture_output=True, text=True
|
|
258
261
|
)
|
|
259
|
-
|
|
262
|
+
assert repeated.returncode == 1 and "already configured" in repeated.stderr
|
|
263
|
+
print("Guided onboarding: dry run, confirmation, import and smoke test passed")
|
|
264
|
+
verify_firewall(env)
|
|
260
265
|
run(
|
|
261
266
|
sys.executable,
|
|
262
267
|
str(ROOT / "deploy/verify-live.py"),
|
package/deploy/verify-limits.py
CHANGED
|
@@ -32,6 +32,32 @@ def policy_checks():
|
|
|
32
32
|
assert report["allocated"] == {"cpus": 2, "memoryBytes": 4 * GIB}
|
|
33
33
|
size = next(r for r in report["recommendations"] if r["cpu"] == 2 and r["memoryGiB"] == 4)
|
|
34
34
|
assert (size["totalRunning"], size["additionalRunning"], size["newBoxes"]) == (3, 2, 2)
|
|
35
|
+
assert len(report["recommendations"]) == 8, "Every CPU/RAM combination needs advice"
|
|
36
|
+
# An 8 GiB box uses most of a 16 GiB host's 14 GiB budget: one fits on an empty
|
|
37
|
+
# host, none more while it runs, but a 4 GiB box still does.
|
|
38
|
+
large_box = {
|
|
39
|
+
"status": "Running",
|
|
40
|
+
"expanded_config": {"limits.cpu": "2", "limits.memory": "8GiB"},
|
|
41
|
+
}
|
|
42
|
+
eight = capacity(host, [large_box])
|
|
43
|
+
assert eight["allocated"] == {"cpus": 2, "memoryBytes": 8 * GIB}
|
|
44
|
+
by_size = {(r["cpu"], r["memoryGiB"]): r for r in eight["recommendations"]}
|
|
45
|
+
assert (by_size[2, 8]["totalRunning"], by_size[2, 8]["additionalRunning"]) == (1, 0)
|
|
46
|
+
assert by_size[1, 4]["additionalRunning"] == 1
|
|
47
|
+
try:
|
|
48
|
+
capacity(
|
|
49
|
+
host,
|
|
50
|
+
[
|
|
51
|
+
{
|
|
52
|
+
"status": "Running",
|
|
53
|
+
"expanded_config": {"limits.cpu": "1", "limits.memory": "16GiB"},
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
else:
|
|
60
|
+
raise AssertionError("Unsupported box size was counted")
|
|
35
61
|
full = capacity(host, [stopped] * 4)
|
|
36
62
|
assert full["savedSlots"] == 0
|
|
37
63
|
assert all(r["newBoxes"] == 0 for r in full["recommendations"])
|
|
@@ -58,7 +84,7 @@ def policy_checks():
|
|
|
58
84
|
)
|
|
59
85
|
assert default["totalRunning"] == expected
|
|
60
86
|
print(
|
|
61
|
-
"Capacity policy: mixed sizes, stopped/frozen boxes, low RAM/disk, reserves and saved cap passed",
|
|
87
|
+
"Capacity policy: mixed and 8 GiB sizes, unsupported sizes, stopped/frozen boxes, low RAM/disk, reserves and saved cap passed",
|
|
62
88
|
flush=True,
|
|
63
89
|
)
|
|
64
90
|
|
package/deploy/verify-live.py
CHANGED
|
@@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
19
19
|
CLI = [sys.executable, str(ROOT / "raft.py")]
|
|
20
20
|
USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
|
|
21
21
|
sys.path.insert(0, str(ROOT))
|
|
22
|
-
from raft import configuration, incus, remote, settings, transaction # noqa: E402
|
|
22
|
+
from raft import MEMORY_SIZES, configuration, incus, remote, settings, transaction # noqa: E402
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def raft(*arguments, check=True, env=None):
|
|
@@ -424,17 +424,18 @@ def verify_configuration():
|
|
|
424
424
|
|
|
425
425
|
def verify_arguments():
|
|
426
426
|
# Invoke actual CLI processes: help and argument errors must not access a host.
|
|
427
|
-
commands = "new 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()
|
|
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()
|
|
428
428
|
assert raft("--help").returncode == 0
|
|
429
429
|
for command in commands:
|
|
430
430
|
assert raft(command, "--help").returncode == 0
|
|
431
|
+
assert raft("host", "add", "--help").returncode == 0
|
|
431
432
|
missing = "lab:rf-0000000000000000"
|
|
432
433
|
for command in ["new", "resume", "extend", "fork"]:
|
|
433
434
|
prefix = [command] if command == "new" else [command, missing]
|
|
434
435
|
for lifetime in ["59", "2592001", "invalid"]:
|
|
435
436
|
rejected = raft(*prefix, "--ttl", lifetime, check=False)
|
|
436
437
|
assert rejected.returncode == 2 and "Traceback" not in rejected.stderr
|
|
437
|
-
for options in [["--cpu", "0"], ["--cpu", "3"], ["--memory", "
|
|
438
|
+
for options in [["--cpu", "0"], ["--cpu", "3"], ["--memory", "16GiB"]]:
|
|
438
439
|
assert raft("new", *options, check=False).returncode == 2
|
|
439
440
|
for arguments in [["exec", missing], ["recover", "missing"], ["forward", missing]]:
|
|
440
441
|
assert raft(*arguments, check=False).returncode == 2
|
|
@@ -691,7 +692,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
691
692
|
if extended:
|
|
692
693
|
subprocess.run([sys.executable, str(ROOT / "deploy/verify-tools.py"), box], check=True)
|
|
693
694
|
for cpu in [1, 2]:
|
|
694
|
-
for memory in
|
|
695
|
+
for memory in MEMORY_SIZES:
|
|
695
696
|
sized = raft(
|
|
696
697
|
"new",
|
|
697
698
|
"--location",
|
|
@@ -708,7 +709,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
708
709
|
)
|
|
709
710
|
assert execute(sized, "nproc") == str(cpu)
|
|
710
711
|
assert execute(sized, "cat", "/sys/fs/cgroup/memory.max") == str(
|
|
711
|
-
int(memory
|
|
712
|
+
int(memory.removesuffix("GiB")) * 1024**3
|
|
712
713
|
)
|
|
713
714
|
raft("destroy", sized)
|
|
714
715
|
fixtures.remove(sized)
|
|
@@ -18,7 +18,7 @@ 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 GiB choices. Resume/fork support sizing overrides; Boat offers larger sizes |
|
|
21
|
+
| Sizing | ✅ | ✅ | 1/2 CPUs and 1/2/4/8 GiB choices. Resume/fork support sizing overrides; Boat offers larger sizes |
|
|
22
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 |
|
|
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 |
|