@microck/raft 0.2.0 → 0.3.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 +14 -1
- package/deploy/build-image.py +22 -18
- package/deploy/deploy.py +100 -6
- package/deploy/verify-features.py +196 -1
- 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 -3
- package/docs/first-box.mdx +4 -0
- package/docs/guides/background-jobs.mdx +3 -0
- package/docs/guides/desktop-services.mdx +4 -1
- package/docs/guides/long-running-services.mdx +109 -0
- package/docs/guides/meta.json +4 -0
- package/docs/guides/project-setup.mdx +112 -0
- package/docs/guides/scripting-ci.mdx +134 -0
- package/docs/guides/troubleshooting.mdx +28 -3
- package/docs/guides/upgrades.mdx +113 -0
- package/docs/index.mdx +4 -0
- package/docs/operations.mdx +51 -12
- package/docs/reference/capacity-limits.mdx +12 -3
- package/docs/reference/cli.md +75 -8
- package/docs/reference/configuration.mdx +16 -2
- package/docs/releases/0.2.1.md +14 -0
- package/docs/releases/0.3.0.md +15 -0
- package/docs/requirements.mdx +2 -0
- package/docs/verification.mdx +9 -3
- package/package.json +1 -1
- package/raft.py +332 -34
- package/skills/raft-cli/SKILL.md +9 -2
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
|
|
|
@@ -76,6 +84,11 @@ see the task guides for [background jobs](docs/guides/background-jobs.mdx),
|
|
|
76
84
|
[backup and recovery](docs/guides/backup-recovery.mdx)
|
|
77
85
|
and [Docker](docs/guides/docker.mdx).
|
|
78
86
|
|
|
87
|
+
See [project setup and templates](docs/guides/project-setup.mdx),
|
|
88
|
+
[persistent services](docs/guides/long-running-services.mdx),
|
|
89
|
+
[scripting and CI](docs/guides/scripting-ci.mdx), and
|
|
90
|
+
[upgrades](docs/guides/upgrades.mdx) for complete operator workflows.
|
|
91
|
+
|
|
79
92
|
stop the source box before taking a snapshot, restoring one or making a fork.
|
|
80
93
|
stopping a box ends its jobs without saving process memory.
|
|
81
94
|
|
|
@@ -118,7 +131,7 @@ records differences, sources and test coverage.
|
|
|
118
131
|
| Create/list/inspect/delete | ✅ | ✅ | Single operator |
|
|
119
132
|
| Persistent stop/resume | ✅ | ✅ | Shared host kernel |
|
|
120
133
|
| TTL and lifetime extension | ✅ | ✅ | Persistent boxes retain disk; disposable boxes delete |
|
|
121
|
-
| 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 |
|
|
122
135
|
| Host capacity recommendations | ❌ | ✅ | Raft recommends running capacity and enforces four saved boxes. Boat reports plan limits |
|
|
123
136
|
| Root terminal and command execution | ✅ | ✅ | Host SSH transport |
|
|
124
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,170 @@ 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
|
+
assert cli("note", "lab", "x", "--clear").returncode == 2
|
|
200
|
+
# Concurrent edits to different locations must all survive.
|
|
201
|
+
many = {f"host{index}": {"ssh": f"host{index}"} for index in range(8)}
|
|
202
|
+
saved = config.read_text()
|
|
203
|
+
config.write_text(json.dumps(many))
|
|
204
|
+
writers = [
|
|
205
|
+
subprocess.Popen(
|
|
206
|
+
[sys.executable, str(ROOT / "raft.py"), "note", location, "busy " + location],
|
|
207
|
+
env={**os.environ, "HOME": home},
|
|
208
|
+
)
|
|
209
|
+
for location in many
|
|
210
|
+
]
|
|
211
|
+
assert all(writer.wait() == 0 for writer in writers)
|
|
212
|
+
notes = {key: entry.get("note") for key, entry in json.loads(config.read_text()).items()}
|
|
213
|
+
assert notes == {location: "busy " + location for location in many}, notes
|
|
214
|
+
config.write_text(saved)
|
|
215
|
+
assert cli("note", "spare", "--clear").returncode == 0
|
|
216
|
+
assert "note" not in json.loads(config.read_text())["spare"]
|
|
217
|
+
assert not list(config.parent.glob(".raft-*")), "Staged configuration left behind"
|
|
218
|
+
config.write_text(json.dumps({"lab": {"ssh": "lab-host", "note": 1}}))
|
|
219
|
+
rejected = cli("locations")
|
|
220
|
+
assert rejected.returncode == 1 and "notes must be strings" in rejected.stderr, rejected
|
|
221
|
+
print("Location note editing, listing, ordering and permissions passed", flush=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def verify_host_preflight():
|
|
225
|
+
"""Check onboarding decisions and refusals that need no host."""
|
|
226
|
+
ready = {
|
|
227
|
+
"kernel": "Linux",
|
|
228
|
+
"os": "ubuntu",
|
|
229
|
+
"version": "24.04",
|
|
230
|
+
"arch": "arm64",
|
|
231
|
+
"cpus": "2",
|
|
232
|
+
# MemTotal of an 8 GiB VM, in KiB.
|
|
233
|
+
"memory": str(8_131_000),
|
|
234
|
+
"disk": str(90 * 1024**2),
|
|
235
|
+
"sudo": "yes",
|
|
236
|
+
"python3": "yes",
|
|
237
|
+
"deployed": "no",
|
|
238
|
+
}
|
|
239
|
+
assert host_problems(ready) == []
|
|
240
|
+
# An existing deployment already owns its pool, so free disk no longer gates it.
|
|
241
|
+
assert host_problems({**ready, "disk": "0", "deployed": "yes"}) == []
|
|
242
|
+
darwin = host_problems({"kernel": "Darwin", "sudo": "no"})
|
|
243
|
+
assert len(darwin) == 1 and "macOS" in darwin[0], darwin
|
|
244
|
+
for change, reason in [
|
|
245
|
+
({"os": "debian", "version": "13"}, "Ubuntu 22.04 or 24.04"),
|
|
246
|
+
({"version": "20.04"}, "Ubuntu 22.04 or 24.04"),
|
|
247
|
+
({"arch": "armhf"}, "ARM64 or AMD64"),
|
|
248
|
+
({"sudo": "no"}, "passwordless sudo"),
|
|
249
|
+
({"python3": "no"}, "python3"),
|
|
250
|
+
({"cpus": "1"}, "2 CPUs"),
|
|
251
|
+
({"memory": str(6 * 1024**2)}, "8 GiB RAM"),
|
|
252
|
+
({"disk": str(40 * 1024**2)}, "80 GiB"),
|
|
253
|
+
]:
|
|
254
|
+
problems = host_problems({**ready, **change})
|
|
255
|
+
assert len(problems) == 1 and reason in problems[0], (change, problems)
|
|
256
|
+
with tempfile.TemporaryDirectory() as home:
|
|
257
|
+
config = Path(home) / ".config/raft/incus.json"
|
|
258
|
+
config.parent.mkdir(parents=True)
|
|
259
|
+
config.write_text(json.dumps({"lab": {"ssh": "lab-host", "image": ""}}))
|
|
260
|
+
# Each refusal happens before SSH; the unroutable target proves no host is contacted.
|
|
261
|
+
for arguments, error in [
|
|
262
|
+
(["lab"], "already configured"),
|
|
263
|
+
(["bad name"], "Location names"),
|
|
264
|
+
(["spare", "--image-archive", str(Path(home) / "missing.tar.gz")], "not found"),
|
|
265
|
+
]:
|
|
266
|
+
rejected = subprocess.run(
|
|
267
|
+
[sys.executable, str(ROOT / "raft.py"), "host", "add", *arguments, "--ssh", "x"],
|
|
268
|
+
env={**os.environ, "HOME": home},
|
|
269
|
+
capture_output=True,
|
|
270
|
+
text=True,
|
|
271
|
+
)
|
|
272
|
+
assert rejected.returncode == 1 and error in rejected.stderr, rejected
|
|
273
|
+
print("Host preflight decisions and pre-SSH onboarding refusals passed", flush=True)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def verify_readiness(location, expect_stale=False):
|
|
277
|
+
# Isolate the selected host without losing the controller's verified SSH config.
|
|
278
|
+
with tempfile.TemporaryDirectory(prefix="raft-doctor-test-") as work:
|
|
279
|
+
home = Path(work)
|
|
280
|
+
(home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
|
|
281
|
+
config = home / ".config/raft/incus.json"
|
|
282
|
+
config.parent.mkdir(parents=True)
|
|
283
|
+
config.write_text(json.dumps({location: settings(location)}))
|
|
284
|
+
response = live.raft("doctor", check=False, env={**os.environ, "HOME": str(home)})
|
|
285
|
+
assert location + ": " in response.stdout, response
|
|
286
|
+
if expect_stale:
|
|
287
|
+
assert response.returncode == 1, response
|
|
288
|
+
assert (
|
|
289
|
+
f"raft: {location}: Host expiry worker differs from this CLI; "
|
|
290
|
+
"deploy the current Raft checkout first"
|
|
291
|
+
) in response.stderr, response
|
|
292
|
+
else:
|
|
293
|
+
assert response.returncode == 0, response
|
|
294
|
+
assert "saved boxes; system containers (shared host kernel)" in response.stdout, response
|
|
295
|
+
print(f"{location}: doctor {'rejected stale worker' if expect_stale else 'passed'}", flush=True)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def verify_transaction_stdin(location):
|
|
299
|
+
program = """import sys, tempfile
|
|
300
|
+
from raft import transaction
|
|
301
|
+
assert transaction(sys.argv[1], 'cat') == ''
|
|
302
|
+
with tempfile.TemporaryFile() as output:
|
|
303
|
+
transaction(sys.argv[1], 'cat', stdout=output)
|
|
304
|
+
output.seek(0)
|
|
305
|
+
assert output.read() == b''
|
|
306
|
+
"""
|
|
307
|
+
with subprocess.Popen(
|
|
308
|
+
[sys.executable, "-c", program, location],
|
|
309
|
+
cwd=ROOT,
|
|
310
|
+
stdin=subprocess.PIPE,
|
|
311
|
+
stdout=subprocess.PIPE,
|
|
312
|
+
stderr=subprocess.PIPE,
|
|
313
|
+
) as process:
|
|
314
|
+
try:
|
|
315
|
+
process.stdin.write(b"must-not-reach-the-host\n")
|
|
316
|
+
process.stdin.flush()
|
|
317
|
+
process.wait(timeout=30)
|
|
318
|
+
finally:
|
|
319
|
+
# Release even a regressed remote reader before leaving this test.
|
|
320
|
+
process.stdin.close()
|
|
321
|
+
try:
|
|
322
|
+
process.wait(timeout=30)
|
|
323
|
+
except subprocess.TimeoutExpired:
|
|
324
|
+
process.kill()
|
|
325
|
+
process.wait()
|
|
326
|
+
raise
|
|
327
|
+
assert process.returncode == 0, process.stderr.read().decode()
|
|
328
|
+
assert transaction(location, "printf lock-released") == "lock-released"
|
|
329
|
+
print(f"{location}: open stdin ignored and lifecycle lock released", flush=True)
|
|
330
|
+
|
|
331
|
+
|
|
160
332
|
def verify(location, isolated_worker=False):
|
|
161
333
|
if inventory(location):
|
|
162
334
|
raise RuntimeError(
|
|
@@ -236,6 +408,27 @@ def verify(location, isolated_worker=False):
|
|
|
236
408
|
return any(box["name"] == parse_handle(handle)[1] for box in inventory(location))
|
|
237
409
|
|
|
238
410
|
try:
|
|
411
|
+
verify_readiness(location, expect_stale=isolated_worker)
|
|
412
|
+
verify_transaction_stdin(location)
|
|
413
|
+
# A location note goes to stderr; stdout stays the bare handle for scripts.
|
|
414
|
+
# A private HOME copy carries the note, leaving the operator config untouched;
|
|
415
|
+
# OpenSSH reads keys and aliases from the passwd home, not HOME.
|
|
416
|
+
with tempfile.TemporaryDirectory() as home:
|
|
417
|
+
noted = Path(home) / ".config/raft/incus.json"
|
|
418
|
+
noted.parent.mkdir(parents=True)
|
|
419
|
+
noted.write_text(
|
|
420
|
+
json.dumps({location: {**settings(location), "note": "feature verifier note"}})
|
|
421
|
+
)
|
|
422
|
+
response = live.raft(
|
|
423
|
+
"new", "--location", location, "--ttl", "600", env={**os.environ, "HOME": home}
|
|
424
|
+
)
|
|
425
|
+
handle = response.stdout.strip()
|
|
426
|
+
fixtures.append(handle)
|
|
427
|
+
assert response.stdout == handle + "\n", response.stdout
|
|
428
|
+
assert response.stderr == f"{location} note: feature verifier note\n", response.stderr
|
|
429
|
+
live.raft("destroy", handle)
|
|
430
|
+
fixtures.remove(handle)
|
|
431
|
+
print(f"{location}: noted new kept stdout to the handle and the note on stderr", flush=True)
|
|
239
432
|
box = create("--memory", "1GiB")
|
|
240
433
|
live.wait_running(box)
|
|
241
434
|
_, name = parse_handle(box)
|
|
@@ -456,6 +649,8 @@ if __name__ == "__main__":
|
|
|
456
649
|
parser.add_argument("--location")
|
|
457
650
|
parser.add_argument("--isolated-worker", action="store_true")
|
|
458
651
|
args = parser.parse_args()
|
|
652
|
+
verify_location_notes()
|
|
653
|
+
verify_host_preflight()
|
|
459
654
|
verify_archives()
|
|
460
655
|
if args.location:
|
|
461
656
|
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 |
|