@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 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 4 GiB RAM. Disk usage covers the shared pool |
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 |
@@ -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 HOSTS, ROOT, copy, host_architecture, remote # noqa: E402
13
+ from deploy import ROOT, copy, host_architecture, hosts, remote # noqa: E402
14
14
 
15
15
 
16
- def main():
17
- parser = argparse.ArgumentParser(description=__doc__)
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"] == args.alias for image in aliases for a in image["aliases"]):
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(args.alias)
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 " + args.alias + " --format json"))
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(alias["name"] == args.alias for alias in image["aliases"])
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
- HOSTS = {name: entry["ssh"] for name, entry in configuration().items()}
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 deploy(name):
44
- host = HOSTS[name]
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
- parser.add_argument("--location", choices=HOSTS)
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 HOSTS:
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 incus, inventory, parse_handle, remote, settings, transaction # noqa: E402
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
- """Provision and reboot a disposable KVM Ubuntu host using the canonical deployer.
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
- run(sys.executable, str(ROOT / "deploy/deploy.py"), "--location", "lab", env=env)
234
- verify_firewall(env)
235
- run("scp", str(archive), "raft-fresh:/tmp/raft-image.tar.gz", env=env)
236
- run(
237
- "ssh",
238
- "raft-fresh",
239
- "sudo incus --project raft image import /tmp/raft-image.tar.gz --alias raft-dev",
240
- env=env,
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
- images = subprocess.check_output(
243
- [
244
- "ssh",
245
- "raft-fresh",
246
- "sudo incus --project raft image list raft-dev --format json",
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
- fingerprint = next(
252
- item["fingerprint"]
253
- for item in json.loads(images)
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
- run("ssh", "raft-fresh", "rm /tmp/raft-image.tar.gz", env=env)
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"),
@@ -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
 
@@ -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", "8GiB"]]:
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 ["1GiB", "2GiB", "4GiB"]:
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[0]) * 1024**3
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 |