@microck/raft 0.2.1 → 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
 
@@ -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 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 |
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 |
@@ -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,114 @@ 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
+
160
276
  def verify_readiness(location, expect_stale=False):
161
277
  # Isolate the selected host without losing the controller's verified SSH config.
162
278
  with tempfile.TemporaryDirectory(prefix="raft-doctor-test-") as work:
@@ -294,6 +410,25 @@ def verify(location, isolated_worker=False):
294
410
  try:
295
411
  verify_readiness(location, expect_stale=isolated_worker)
296
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)
297
432
  box = create("--memory", "1GiB")
298
433
  live.wait_running(box)
299
434
  _, name = parse_handle(box)
@@ -514,6 +649,8 @@ if __name__ == "__main__":
514
649
  parser.add_argument("--location")
515
650
  parser.add_argument("--isolated-worker", action="store_true")
516
651
  args = parser.parse_args()
652
+ verify_location_notes()
653
+ verify_host_preflight()
517
654
  verify_archives()
518
655
  if args.location:
519
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 |
package/docs/contract.md CHANGED
@@ -23,6 +23,7 @@ Raft manages persistent unprivileged native-architecture Incus system containers
23
23
  - **Strict infrastructure validation**: Deployment refuses conflicting storage or network names and incomplete infrastructure rather than overwriting or repairing state.
24
24
  - **Storage pool sizing**: Existing owned Btrfs pools must retain the configured 60 GiB size; drift is rejected without automatic resizing.
25
25
  - **Firewall precedence**: Redeployment reapplies firewall rules. Incus startup requires successful firewall application before the daemon starts, including socket activation.
26
+ - **Guided onboarding**: `raft host add LOCATION --ssh TARGET` probes the target read-only and refuses unsupported hosts, including macOS, before any change. It prints the exact plan and applies it only after an interactive yes or `--yes`; `--dry-run` stops after the plan. It runs the same deployer as `deploy/deploy.py`, imports an image only after its SHA-256 matches (release `SHA256SUMS` or the local archive digest) and its architecture matches the host, then registers the location with the image fingerprint. It finishes with `doctor` and a disposable create/exec/destroy smoke test; if either fails, it removes the new location from the configuration and leaves host changes for a rerun. It uses existing key-based SSH and never collects passwords or changes SSH, other firewall rules or IP configuration. It requires the deployment files from the npm package or a source checkout.
26
27
  - **CLI coverage**: The single-operator workflow spans lifecycle, execution, files, snapshots, jobs, tunnels, backup, and capacity inspection; see the [command reference](./reference/cli.md).
27
28
 
28
29
  ## Configuration and lifecycle defaults
@@ -32,13 +33,15 @@ Raft manages persistent unprivileged native-architecture Incus system containers
32
33
  | Host identification | Configuration requires named SSH targets using letters, digits, underscores, or hyphens. |
33
34
  | Qualified handles | Every handle includes location and instance name (`<location>:<name>`). |
34
35
  | Image pinning | Workspace creation requires the immutable 64-hex image fingerprint, not an updating alias. |
35
- | Lifecycle defaults | Defaults to first configured location, 1 CPU, 2 GiB RAM, and 600-second lifetime. Supported choices are 1 or 2 CPUs, 1/2/4 GiB RAM, and 60 to 2,592,000 seconds (30 days) TTL. |
36
+ | Lifecycle defaults | Defaults to first configured location, 1 CPU, 2 GiB RAM, and 600-second lifetime. Supported choices are 1 or 2 CPUs, 1/2/4/8 GiB RAM, and 60 to 2,592,000 seconds (30 days) TTL. |
36
37
  | Admission limit | Hard ceiling of four saved boxes per host, including stopped boxes. |
37
38
  | TTL calculation | Running TTL begins after launch completes, using the host clock. Cold image preparation does not consume TTL. |
38
39
  | Host locking | Creation, lifecycle transitions, and expiry are serialized by host file lock (`/run/lock/raft-incus.lock`). Noninteractive transactions close controller stdin so commands cannot wait for piped input while holding this lock. Exec, SSH, and file-transfer paths remain unchanged. Piped input to `raft exec` is unsupported. Native Incus admin calls bypass CLI policy. |
39
40
 
40
41
  ## Capacity and admission inspection
41
42
 
43
+ `raft locations` lists configured locations as JSONL with `location`, `default` and `note` fields without contacting hosts. `raft note LOCATION TEXT` and `raft note LOCATION --clear` edit the optional `note` field in the controller configuration atomically, preserving location order and mode `0600`. Notes are advisory guidance; Raft shows them in `locations`, `limits` and on stderr from `new`, and never enforces them.
44
+
42
45
  `raft limits [--location NAME] [--json]` inspects hosts without modifying state. It reports effective CPUs, total and available RAM, saved and active box counts, active resource limits, and disk space.
43
46
 
44
47
  - **Reserve calculations**: Recommends running boxes after reserving 1 CPU and the larger of 2 GiB or 10% host RAM.
@@ -23,6 +23,7 @@ Raft does not have a global `--json` option. Parse each command's actual format:
23
23
  | `info` | One pretty-printed native Incus metadata JSON object |
24
24
  | `usage` | One JSON object containing running resource counters |
25
25
  | `limits --json` | One JSON object per location (JSONL across multiple hosts) |
26
+ | `locations` | One JSON object per configured location (JSONL) |
26
27
  | `prune` | JSONL candidates or deleted records with `box`, `action` |
27
28
  | `status` | systemd `ActiveState`, `SubState`, `ExecMainStatus` property lines |
28
29
  | `logs` | Guest journal text |