@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/raft.py CHANGED
@@ -2,6 +2,8 @@
2
2
  """Self-hosted persistent Linux workspaces powered by Incus."""
3
3
 
4
4
  import argparse
5
+ import fcntl
6
+ import importlib.util
5
7
  import json
6
8
  import os
7
9
  from pathlib import Path
@@ -12,15 +14,22 @@ import subprocess
12
14
  import sys
13
15
  import tempfile
14
16
  import tarfile
17
+ import time
15
18
  import uuid
16
19
 
17
20
  import raft_files
18
21
 
19
- __version__ = "0.2.1"
22
+ __version__ = "0.3.0"
20
23
 
21
24
  CONFIG = Path.home() / ".config/raft/incus.json"
22
25
  PROJECT = "raft"
23
26
  SAVED_BOX_LIMIT = 4
27
+ LOCATION_NAME = r"[a-zA-Z0-9][a-zA-Z0-9_-]*"
28
+ GIB = 1024**3
29
+ # Supported box sizes. Parser choices, capacity validation and recommendations
30
+ # all derive from these lists, so adding a size here covers every command.
31
+ CPU_SIZES = [1, 2]
32
+ MEMORY_SIZES = ["1GiB", "2GiB", "4GiB", "8GiB"]
24
33
 
25
34
  USAGE = """import json, shutil
26
35
  from pathlib import Path
@@ -43,10 +52,12 @@ def configuration():
43
52
  if not isinstance(inventory, dict) or not inventory:
44
53
  raise ValueError("Configure at least one host in ~/.config/raft/incus.json")
45
54
  for location, entry in inventory.items():
46
- if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", location):
55
+ if not re.fullmatch(LOCATION_NAME, location):
47
56
  raise ValueError("Location names must use letters, digits, underscores or hyphens")
48
57
  if not isinstance(entry, dict) or not isinstance(entry.get("ssh"), str) or not entry["ssh"]:
49
58
  raise ValueError("Each configured location needs a nonempty ssh target")
59
+ if not isinstance(entry.get("note", ""), str):
60
+ raise ValueError("Location notes must be strings")
50
61
  return inventory
51
62
 
52
63
 
@@ -57,6 +68,51 @@ def settings(location):
57
68
  return inventory[location]
58
69
 
59
70
 
71
+ def edit_configuration(change):
72
+ """Apply change(inventory) to the controller configuration and save it."""
73
+ # main() sets umask 077, so a new configuration directory is private.
74
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
75
+ # Hold a controller lock across read, edit and replace so concurrent edits
76
+ # cannot drop each other. Readers need no lock because replacement is atomic.
77
+ with (CONFIG.parent / ".incus.json.lock").open("w") as lock:
78
+ fcntl.flock(lock, fcntl.LOCK_EX)
79
+ # Onboarding can start on a controller that has no configuration yet.
80
+ inventory = configuration() if CONFIG.exists() else {}
81
+ change(inventory)
82
+ if not inventory:
83
+ # Removing the only location restores the unconfigured state.
84
+ CONFIG.unlink(missing_ok=True)
85
+ return
86
+ # Key order is preserved because the first location is the default for new.
87
+ # Replace atomically so an interrupted write cannot truncate the inventory;
88
+ # NamedTemporaryFile creates the staged file with mode 0600.
89
+ staged = tempfile.NamedTemporaryFile("w", dir=CONFIG.parent, prefix=".raft-", delete=False)
90
+ try:
91
+ with staged:
92
+ json.dump(inventory, staged, indent=2)
93
+ staged.write("\n")
94
+ os.replace(staged.name, CONFIG)
95
+ finally:
96
+ # A no-op after a successful replace; removes the file after a failure.
97
+ Path(staged.name).unlink(missing_ok=True)
98
+
99
+
100
+ def set_note(location, text):
101
+ """Record operator guidance for a location. Raft shows notes but never enforces them."""
102
+ if text is not None and not text.strip():
103
+ raise ValueError("Use --clear to remove a note")
104
+
105
+ def change(inventory):
106
+ if location not in inventory:
107
+ raise ValueError(f"Unknown location: {location}")
108
+ if text is None:
109
+ inventory[location].pop("note", None)
110
+ else:
111
+ inventory[location]["note"] = text.strip()
112
+
113
+ edit_configuration(change)
114
+
115
+
60
116
  def receive(command, destination, *, stdin=None):
61
117
  """Write streamed bytes in Python: SSH can exit zero after a local write error."""
62
118
  with tempfile.TemporaryFile() as errors:
@@ -107,6 +163,188 @@ def incus(location, *argv, locked=False):
107
163
  return response.stdout.decode()
108
164
 
109
165
 
166
+ # Read-only probe. It runs through the login shell of any SSH target, including
167
+ # macOS, so each fact degrades to an empty value instead of failing the script.
168
+ # Sizes are printed in KiB because mawk formats large products in exponent form.
169
+ HOST_FACTS = """echo "kernel=$(uname -s)"
170
+ if [ -r /etc/os-release ]; then . /etc/os-release; echo "os=${ID:-}"; echo "version=${VERSION_ID:-}"; fi
171
+ echo "arch=$(dpkg --print-architecture 2>/dev/null)"
172
+ echo "cpus=$(getconf _NPROCESSORS_ONLN 2>/dev/null)"
173
+ echo "memory=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null)"
174
+ directory=/var/lib/incus; [ -d "$directory" ] || directory=/var/lib
175
+ echo "disk=$(df -Pk "$directory" 2>/dev/null | awk 'NR == 2 {print $4}')"
176
+ if sudo -n true 2>/dev/null; then echo sudo=yes; else echo sudo=no; fi
177
+ if command -v python3 >/dev/null 2>&1; then echo python3=yes; else echo python3=no; fi
178
+ if sudo -n incus project list --format csv -c n 2>/dev/null | grep -qx raft; then echo deployed=yes; else echo deployed=no; fi
179
+ """
180
+
181
+
182
+ def host_problems(facts):
183
+ """Return reasons a probed SSH target cannot become a Raft host; empty means supported."""
184
+ kernel = facts.get("kernel") or "an unknown system"
185
+ if kernel != "Linux":
186
+ return [
187
+ f"The target runs {kernel}, not Linux. Raft hosts need Ubuntu 22.04 or 24.04 on "
188
+ "ARM64 or AMD64. On macOS, run an Ubuntu VM and onboard the VM's SSH target"
189
+ ]
190
+ problems = []
191
+ release = f"{facts.get('os', '')} {facts.get('version', '')}".strip() or "unknown"
192
+ if facts.get("os") != "ubuntu" or facts.get("version") not in {"22.04", "24.04"}:
193
+ problems.append(f"Raft hosts need Ubuntu 22.04 or 24.04; found {release}")
194
+ if facts.get("arch") not in {"arm64", "amd64"}:
195
+ problems.append(f"Raft hosts need ARM64 or AMD64; found {facts.get('arch') or 'unknown'}")
196
+ if facts.get("sudo") != "yes":
197
+ problems.append("Configure passwordless sudo for the SSH user; `sudo -n true` must succeed")
198
+ if facts.get("python3") != "yes":
199
+ problems.append("Install python3 on the host")
200
+ cpus = int(facts.get("cpus") or 0)
201
+ if cpus < 2:
202
+ problems.append(f"Raft hosts need at least 2 CPUs; found {cpus}")
203
+ # MemTotal excludes firmware and kernel reservations: 8 GiB machines report
204
+ # about 7.7 GiB. The threshold accepts them and rejects 6 GiB machines.
205
+ memory = int(facts.get("memory") or 0) * 1024
206
+ if memory < 7.5 * GIB:
207
+ problems.append(f"Raft hosts need at least 8 GiB RAM; found {memory / GIB:.1f} GiB")
208
+ # Only a fresh deployment creates the 60 GiB pool; deploy.py enforces the same limit.
209
+ disk = int(facts.get("disk") or 0) * 1024
210
+ if facts.get("deployed") != "yes" and disk < 80 * GIB:
211
+ problems.append(f"Fresh hosts need at least 80 GiB free disk; found {disk / GIB:.1f} GiB")
212
+ return problems
213
+
214
+
215
+ def deploy_module(filename):
216
+ """Load a host deployment script shipped beside the CLI."""
217
+ path = Path(__file__).resolve().parent / "deploy" / filename
218
+ if not path.exists():
219
+ raise ValueError(
220
+ "Host onboarding needs the deployment files in the npm package or a source "
221
+ "checkout; the Python wheel contains only the CLI"
222
+ )
223
+ spec = importlib.util.spec_from_file_location("raft_" + path.stem.replace("-", "_"), path)
224
+ module = importlib.util.module_from_spec(spec)
225
+ spec.loader.exec_module(module)
226
+ return module
227
+
228
+
229
+ def smoke_test(location):
230
+ """Create a disposable box, wait until it runs a command, then delete it."""
231
+ handle = new(
232
+ argparse.Namespace(
233
+ location=location, ttl=600, cpu=1, memory="2GiB", disposable=True, template=None
234
+ )
235
+ )
236
+ print("Smoke test box: " + handle, flush=True)
237
+ name = handle.partition(":")[2]
238
+ try:
239
+ # new returns once Incus starts the box, before every guest service is ready.
240
+ for _ in range(30):
241
+ if execute(location, name, ["true"], capture=True).returncode == 0:
242
+ break
243
+ time.sleep(2)
244
+ else:
245
+ raise RuntimeError("Smoke test box did not become command-ready: " + handle)
246
+ finally:
247
+ incus(location, "delete", name, "--force", locked=True)
248
+ print("Smoke test create/exec/destroy passed", flush=True)
249
+
250
+
251
+ def host_add(args):
252
+ """Check, provision and register a new host location, then prove it works."""
253
+ if not re.fullmatch(LOCATION_NAME, args.location):
254
+ raise ValueError("Location names must use letters, digits, underscores or hyphens")
255
+ if CONFIG.exists() and args.location in configuration():
256
+ raise ValueError(f"{args.location} is already configured; check it with raft doctor")
257
+ archive = Path(args.image_archive).resolve() if args.image_archive else None
258
+ if archive is not None and not archive.is_file():
259
+ raise ValueError(f"Image archive not found: {archive}")
260
+ deployer = deploy_module("deploy.py")
261
+ probe = subprocess.run(
262
+ [
263
+ "ssh",
264
+ "-T",
265
+ "-o",
266
+ "BatchMode=yes",
267
+ "-o",
268
+ "ConnectTimeout=10",
269
+ args.ssh,
270
+ shlex.join(["sh", "-c", HOST_FACTS]),
271
+ ],
272
+ capture_output=True,
273
+ text=True,
274
+ )
275
+ if probe.returncode:
276
+ raise RuntimeError(
277
+ f"Cannot reach {args.ssh} with key-based SSH: {probe.stderr.strip()}\n"
278
+ f"Verify the host key interactively, then check `ssh -o BatchMode=yes {args.ssh} true`"
279
+ )
280
+ facts = dict(line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line)
281
+ problems = host_problems(facts)
282
+ if problems:
283
+ raise ValueError(
284
+ f"{args.ssh} cannot host Raft boxes; nothing was changed:\n- " + "\n- ".join(problems)
285
+ )
286
+ architecture = facts["arch"]
287
+ if args.build:
288
+ image = f"build a native {architecture} image from Debian 13 on the host"
289
+ elif archive is not None:
290
+ image = f"upload and import {archive}"
291
+ else:
292
+ image = f"download raft-dev-{architecture}.tar.gz from release {args.image_release} and import it"
293
+ print(
294
+ f"Plan for {args.location} ({args.ssh}: Ubuntu {facts['version']} {architecture}, "
295
+ f"{facts['cpus']} CPUs, {int(facts['memory']) * 1024 / GIB:.1f} GiB RAM)\n"
296
+ "- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs and nftables if missing\n"
297
+ + (
298
+ "- reuse and verify the existing raft project, raft-data pool and rfbr0 bridge\n"
299
+ if facts["deployed"] == "yes"
300
+ else "- create Incus project raft, 60 GiB Btrfs pool raft-data and bridge rfbr0 "
301
+ "(10.232.0.1/24 with NAT)\n"
302
+ )
303
+ + "- install raft-network.service (rfbr0 isolation rules) and raft-expire.timer\n"
304
+ f"- {image}; images are checked by SHA-256 and architecture\n"
305
+ f"- add {args.location} with the image fingerprint to {CONFIG}\n"
306
+ "- run doctor and a disposable create/exec/destroy smoke test\n"
307
+ "SSH configuration, other firewall rules, IP addresses and account passwords are not changed.",
308
+ flush=True,
309
+ )
310
+ if args.dry_run:
311
+ print("Dry run: nothing was changed")
312
+ return
313
+ if not args.yes:
314
+ if not sys.stdin.isatty():
315
+ raise ValueError("Review the plan, then rerun with --yes to apply it")
316
+ if input("Apply this plan? [y/N] ").strip().lower() not in {"y", "yes"}:
317
+ raise ValueError("Onboarding cancelled; nothing was changed")
318
+ deployer.deploy(args.location, args.ssh)
319
+ if args.build:
320
+ # A fresh alias avoids refusing to build beside an earlier raft-dev image.
321
+ alias = "raft-dev-" + uuid.uuid4().hex[:8]
322
+ fingerprint = deploy_module("build-image.py").build(args.ssh, alias)
323
+ elif archive is not None:
324
+ fingerprint = deployer.import_archive(args.ssh, archive, architecture)
325
+ else:
326
+ fingerprint = deployer.import_release(args.ssh, args.image_release, architecture)
327
+
328
+ def register(inventory):
329
+ if args.location in inventory:
330
+ raise ValueError(f"{args.location} was configured while onboarding ran")
331
+ inventory[args.location] = {"ssh": args.ssh, "image": fingerprint}
332
+
333
+ edit_configuration(register)
334
+ try:
335
+ doctor(args.location)
336
+ smoke_test(args.location)
337
+ except BaseException:
338
+ # Never leave a plausible-looking but unusable location behind.
339
+ edit_configuration(lambda inventory: inventory.pop(args.location, None))
340
+ print(
341
+ f"Removed {args.location} from {CONFIG}. Host changes remain; a rerun reuses them.",
342
+ file=sys.stderr,
343
+ )
344
+ raise
345
+ print(f"{args.location} is ready: raft new --location {args.location}")
346
+
347
+
110
348
  def parse_handle(value):
111
349
  location, separator, name = value.partition(":")
112
350
  if not separator or not re.fullmatch(r"rf-[a-f0-9]{16}", name):
@@ -177,14 +415,13 @@ def capacity(host, boxes):
177
415
  memory_allocated = 0
178
416
  for box in active:
179
417
  limits = box["expanded_config"]
180
- if limits["limits.cpu"] not in {"1", "2"} or limits["limits.memory"] not in {
181
- "1GiB",
182
- "2GiB",
183
- "4GiB",
184
- }:
418
+ if (
419
+ limits["limits.cpu"] not in {str(cpu) for cpu in CPU_SIZES}
420
+ or limits["limits.memory"] not in MEMORY_SIZES
421
+ ):
185
422
  raise ValueError("Active box has unsupported sizing; inspect its Incus configuration")
186
423
  cpu_allocated += int(limits["limits.cpu"])
187
- memory_allocated += int(limits["limits.memory"][0]) * gib
424
+ memory_allocated += int(limits["limits.memory"].removesuffix("GiB")) * gib
188
425
  memory_reserve = max(2 * gib, host["memoryTotalBytes"] // 10)
189
426
  cpu_budget = max(0, host["cpus"] - 1)
190
427
  memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
@@ -198,8 +435,8 @@ def capacity(host, boxes):
198
435
  )
199
436
  slots = max(0, SAVED_BOX_LIMIT - len(boxes))
200
437
  recommendations = []
201
- for cpu in [1, 2]:
202
- for memory in [1, 2, 4]:
438
+ for cpu in CPU_SIZES:
439
+ for memory in [int(size.removesuffix("GiB")) for size in MEMORY_SIZES]:
203
440
  total = min(SAVED_BOX_LIMIT, cpu_budget // cpu, memory_budget // (memory * gib))
204
441
  additional = min(
205
442
  max(0, SAVED_BOX_LIMIT - len(active)),
@@ -255,7 +492,11 @@ def host_limits(location):
255
492
  text=True,
256
493
  check=True,
257
494
  )
258
- return {"location": location, **capacity(json.loads(response.stdout), inventory(location))}
495
+ return {
496
+ "location": location,
497
+ "note": settings(location).get("note", ""),
498
+ **capacity(json.loads(response.stdout), inventory(location)),
499
+ }
259
500
 
260
501
 
261
502
  def print_limits(report):
@@ -266,6 +507,8 @@ def print_limits(report):
266
507
  f"{host['memoryTotalBytes'] / gib:.1f} GiB RAM "
267
508
  f"({host['memoryAvailableBytes'] / gib:.1f} GiB available)"
268
509
  )
510
+ if report["note"]:
511
+ print("Note: " + report["note"])
269
512
  print(
270
513
  f"Saved boxes: {report['savedBoxes']}/{report['savedBoxLimit']} enforced; "
271
514
  f"active: {report['activeBoxes']}; free saved slots: {report['savedSlots']}"
@@ -290,6 +533,32 @@ def print_limits(report):
290
533
  print("Warning: " + warning)
291
534
 
292
535
 
536
+ def doctor(location):
537
+ print(location + ": " + incus(location, "version").strip())
538
+ try:
539
+ transaction(location, CHECK_WORKER)
540
+ except RuntimeError as error:
541
+ raise RuntimeError(f"{location}: {error}") from error
542
+ response = subprocess.run(
543
+ [
544
+ "ssh",
545
+ "-T",
546
+ "-o",
547
+ "BatchMode=yes",
548
+ "-o",
549
+ "ConnectTimeout=10",
550
+ settings(location)["ssh"],
551
+ "systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
552
+ ],
553
+ capture_output=True,
554
+ text=True,
555
+ check=True,
556
+ )
557
+ print(response.stdout.strip())
558
+ print(incus(location, "storage", "info", "raft-data").strip())
559
+ print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
560
+
561
+
293
562
  def transaction(location, script, *arguments, stdout=None):
294
563
  # All lifecycle steps in a compound operation share the host expiry lock.
295
564
  command = [
@@ -358,6 +627,10 @@ def new(args):
358
627
  raise ValueError(
359
628
  "Build an image and configure its full immutable fingerprint before creating boxes"
360
629
  )
630
+ note = settings(args.location).get("note")
631
+ if note:
632
+ # Stderr keeps stdout limited to the handle for scripts.
633
+ print(f"{args.location} note: {note}", file=sys.stderr)
361
634
  name = "rf-" + uuid.uuid4().hex[:16]
362
635
  # Initialize before starting, so a failed start still has its intended policy.
363
636
  transaction(
@@ -388,7 +661,7 @@ def new(args):
388
661
  "volatile.eth0.hwaddr=",
389
662
  *sizing(args),
390
663
  )
391
- print(f"{args.location}:{name}")
664
+ return f"{args.location}:{name}"
392
665
 
393
666
 
394
667
  def execute(location, name, command, *, capture=False):
@@ -583,10 +856,38 @@ def parser():
583
856
  create = commands.add_parser("new")
584
857
  create.add_argument("--location", default=None)
585
858
  create.add_argument("--ttl", type=ttl, default=600)
586
- create.add_argument("--cpu", type=int, choices=[1, 2], default=1)
587
- create.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"], default="2GiB")
859
+ create.add_argument("--cpu", type=int, choices=CPU_SIZES, default=1)
860
+ create.add_argument("--memory", choices=MEMORY_SIZES, default="2GiB")
588
861
  create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
589
862
  create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
863
+ commands.add_parser("locations", help="List configured locations and their notes")
864
+ note = commands.add_parser("note", help="Set or clear operator guidance for a location")
865
+ note.add_argument("location")
866
+ text = note.add_mutually_exclusive_group(required=True)
867
+ text.add_argument("text", nargs="?", help="Guidance, e.g. 'Preferred for builds'")
868
+ text.add_argument("--clear", action="store_true", help="Remove the location's note")
869
+ host = commands.add_parser("host", help="Onboard workspace hosts")
870
+ host_commands = host.add_subparsers(dest="host_action", required=True)
871
+ onboard = host_commands.add_parser(
872
+ "add", help="Check, provision, register and smoke-test a new host location"
873
+ )
874
+ onboard.add_argument("location")
875
+ onboard.add_argument(
876
+ "--ssh", required=True, help="Key-based SSH target: user@host or an ~/.ssh/config alias"
877
+ )
878
+ source = onboard.add_mutually_exclusive_group()
879
+ source.add_argument(
880
+ "--image-release",
881
+ default="v" + __version__,
882
+ metavar="TAG",
883
+ help="Release whose checksummed image to download (default: this CLI's version)",
884
+ )
885
+ source.add_argument("--image-archive", metavar="PATH", help="Import a local image archive")
886
+ source.add_argument("--build", action="store_true", help="Build the image on the host")
887
+ onboard.add_argument(
888
+ "--dry-run", action="store_true", help="Check the host and print the plan; change nothing"
889
+ )
890
+ onboard.add_argument("--yes", action="store_true", help="Apply the plan without prompting")
590
891
  listing = commands.add_parser("list")
591
892
  listing.add_argument("--location")
592
893
  limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
@@ -630,8 +931,8 @@ def parser():
630
931
  if action in ["resume", "fork", "extend"]:
631
932
  command.add_argument("--ttl", type=ttl, required=True)
632
933
  if action in ["resume", "fork"]:
633
- command.add_argument("--cpu", type=int, choices=[1, 2])
634
- command.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"])
934
+ command.add_argument("--cpu", type=int, choices=CPU_SIZES)
935
+ command.add_argument("--memory", choices=MEMORY_SIZES)
635
936
  if action == "fork":
636
937
  command.add_argument("--disposable", action="store_true")
637
938
  if action == "exec":
@@ -660,7 +961,18 @@ def dispatch(args):
660
961
  recover(args)
661
962
  return 0
662
963
  if args.action == "new":
663
- new(args)
964
+ print(new(args))
965
+ return 0
966
+ if args.action == "host":
967
+ host_add(args)
968
+ return 0
969
+ if args.action == "note":
970
+ set_note(args.location, None if args.clear else args.text)
971
+ return 0
972
+ if args.action == "locations":
973
+ for index, (location, entry) in enumerate(configuration().items()):
974
+ record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
975
+ print(json.dumps(record))
664
976
  return 0
665
977
  if args.action in ["list", "limits", "doctor", "gc", "prune"]:
666
978
  if args.action == "prune" and args.older_than < 1:
@@ -711,27 +1023,7 @@ def dispatch(args):
711
1023
  else:
712
1024
  print(output, end="")
713
1025
  elif args.action == "doctor":
714
- print(location + ": " + incus(location, "version").strip())
715
- try:
716
- transaction(location, CHECK_WORKER)
717
- except RuntimeError as error:
718
- raise RuntimeError(f"{location}: {error}") from error
719
- response = subprocess.run(
720
- [
721
- "ssh",
722
- "-T",
723
- settings(location)["ssh"],
724
- "systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
725
- ],
726
- capture_output=True,
727
- text=True,
728
- check=True,
729
- )
730
- print(response.stdout.strip())
731
- print(incus(location, "storage", "info", "raft-data").strip())
732
- print(
733
- f"{len(inventory(location))} saved boxes; system containers (shared host kernel)"
734
- )
1026
+ doctor(location)
735
1027
  else:
736
1028
  for box in inventory(location):
737
1029
  print(
@@ -8,7 +8,14 @@ description: Create and use private persistent Linux workspaces on configured Li
8
8
  Raft runs unprivileged native ARM64 or AMD64 Incus system containers. They share the host kernel.
9
9
  The current deployment does not implement Incus VMs. Do not describe these boxes as VMs or promise cross-architecture emulation.
10
10
 
11
- Run `raft doctor`, `raft limits` and `raft list` before allocating. Keep the exact
11
+ `raft host add` installs packages and services on a machine. Run it only when the
12
+ user explicitly asks to onboard that host, and show its `--dry-run` plan first.
13
+
14
+ Run `raft locations`, `raft doctor`, `raft limits` and `raft list` before allocating.
15
+ Follow each location's `note`: the operator uses it to mark preferred hosts and
16
+ hosts to avoid for some work. Pass `--location` explicitly when a note rules out
17
+ the default. Do not change notes unless the user asks; `raft note LOCATION TEXT`
18
+ sets one and `raft note LOCATION --clear` removes it. Keep the exact
12
19
  location-qualified handle returned by `new`. There is no implicit current box.
13
20
 
14
21
  ```sh
@@ -32,7 +39,7 @@ Incus execution over verified host SSH. It does not expose a guest SSH port.
32
39
 
33
40
  Defaults are the first configured location, one CPU, 2 GiB RAM and 600 seconds. TTL accepts 60 seconds
34
41
  to 30 days. The controller admits at most four saved boxes per host; stopped
35
- boxes count because their disk still occupies space. Use `--memory 4GiB`
42
+ boxes count because their disk still occupies space. Use `--memory 4GiB` or `--memory 8GiB`
36
43
  and `--cpu 2` when needed. Btrfs bounds workspace storage to a 60 GiB pool per host.
37
44
  It does not guarantee strict per-box disk quotas with nested subvolumes. Check host disk before large builds or forks. `raft limits --location lab --json`
38
45
  reports CPU/RAM recommendations for all supported sizes, active allocations and