@microck/raft 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,25 @@ 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.4.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"]
33
+ # Box notes follow one template so any agent can tell why a box exists, who uses
34
+ # it and what happens to it next. Each value is free text; purpose is required.
35
+ BOX_NOTE_FIELDS = ["purpose", "owner", "next"]
24
36
 
25
37
  USAGE = """import json, shutil
26
38
  from pathlib import Path
@@ -43,10 +55,12 @@ def configuration():
43
55
  if not isinstance(inventory, dict) or not inventory:
44
56
  raise ValueError("Configure at least one host in ~/.config/raft/incus.json")
45
57
  for location, entry in inventory.items():
46
- if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", location):
58
+ if not re.fullmatch(LOCATION_NAME, location):
47
59
  raise ValueError("Location names must use letters, digits, underscores or hyphens")
48
60
  if not isinstance(entry, dict) or not isinstance(entry.get("ssh"), str) or not entry["ssh"]:
49
61
  raise ValueError("Each configured location needs a nonempty ssh target")
62
+ if not isinstance(entry.get("note", ""), str):
63
+ raise ValueError("Location notes must be strings")
50
64
  return inventory
51
65
 
52
66
 
@@ -57,6 +71,71 @@ def settings(location):
57
71
  return inventory[location]
58
72
 
59
73
 
74
+ def edit_configuration(change):
75
+ """Apply change(inventory) to the controller configuration and save it."""
76
+ # main() sets umask 077, so a new configuration directory is private.
77
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
78
+ # Hold a controller lock across read, edit and replace so concurrent edits
79
+ # cannot drop each other. Readers need no lock because replacement is atomic.
80
+ with (CONFIG.parent / ".incus.json.lock").open("w") as lock:
81
+ fcntl.flock(lock, fcntl.LOCK_EX)
82
+ # Onboarding can start on a controller that has no configuration yet.
83
+ inventory = configuration() if CONFIG.exists() else {}
84
+ change(inventory)
85
+ if not inventory:
86
+ # Removing the only location restores the unconfigured state.
87
+ CONFIG.unlink(missing_ok=True)
88
+ return
89
+ # Key order is preserved because the first location is the default for new.
90
+ # Replace atomically so an interrupted write cannot truncate the inventory;
91
+ # NamedTemporaryFile creates the staged file with mode 0600.
92
+ staged = tempfile.NamedTemporaryFile("w", dir=CONFIG.parent, prefix=".raft-", delete=False)
93
+ try:
94
+ with staged:
95
+ json.dump(inventory, staged, indent=2)
96
+ staged.write("\n")
97
+ os.replace(staged.name, CONFIG)
98
+ finally:
99
+ # A no-op after a successful replace; removes the file after a failure.
100
+ Path(staged.name).unlink(missing_ok=True)
101
+
102
+
103
+ def set_note(location, text):
104
+ """Record operator guidance for a location. Raft shows notes but never enforces them."""
105
+ if text is not None and not text.strip():
106
+ raise ValueError("Use --clear to remove a note")
107
+
108
+ def change(inventory):
109
+ if location not in inventory:
110
+ raise ValueError(f"Unknown location: {location}")
111
+ if text is None:
112
+ inventory[location].pop("note", None)
113
+ else:
114
+ inventory[location]["note"] = text.strip()
115
+
116
+ edit_configuration(change)
117
+
118
+
119
+ def box_note(args):
120
+ """Build a box note from --purpose, --owner and --next; None when none is given."""
121
+ note = {}
122
+ for field in BOX_NOTE_FIELDS:
123
+ value = getattr(args, field, None)
124
+ if value is not None:
125
+ if not value.strip():
126
+ raise ValueError(f"--{field} cannot be empty")
127
+ note[field] = value.strip()
128
+ if note and "purpose" not in note:
129
+ raise ValueError("Box notes need --purpose")
130
+ return note or None
131
+
132
+
133
+ def read_box_note(box):
134
+ """Return a box's note as a dict, or None. Raft writes it as JSON in user.raft.note."""
135
+ stored = box["config"].get("user.raft.note")
136
+ return json.loads(stored) if stored else None
137
+
138
+
60
139
  def receive(command, destination, *, stdin=None):
61
140
  """Write streamed bytes in Python: SSH can exit zero after a local write error."""
62
141
  with tempfile.TemporaryFile() as errors:
@@ -107,6 +186,188 @@ def incus(location, *argv, locked=False):
107
186
  return response.stdout.decode()
108
187
 
109
188
 
189
+ # Read-only probe. It runs through the login shell of any SSH target, including
190
+ # macOS, so each fact degrades to an empty value instead of failing the script.
191
+ # Sizes are printed in KiB because mawk formats large products in exponent form.
192
+ HOST_FACTS = """echo "kernel=$(uname -s)"
193
+ if [ -r /etc/os-release ]; then . /etc/os-release; echo "os=${ID:-}"; echo "version=${VERSION_ID:-}"; fi
194
+ echo "arch=$(dpkg --print-architecture 2>/dev/null)"
195
+ echo "cpus=$(getconf _NPROCESSORS_ONLN 2>/dev/null)"
196
+ echo "memory=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null)"
197
+ directory=/var/lib/incus; [ -d "$directory" ] || directory=/var/lib
198
+ echo "disk=$(df -Pk "$directory" 2>/dev/null | awk 'NR == 2 {print $4}')"
199
+ if sudo -n true 2>/dev/null; then echo sudo=yes; else echo sudo=no; fi
200
+ if command -v python3 >/dev/null 2>&1; then echo python3=yes; else echo python3=no; fi
201
+ 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
202
+ """
203
+
204
+
205
+ def host_problems(facts):
206
+ """Return reasons a probed SSH target cannot become a Raft host; empty means supported."""
207
+ kernel = facts.get("kernel") or "an unknown system"
208
+ if kernel != "Linux":
209
+ return [
210
+ f"The target runs {kernel}, not Linux. Raft hosts need Ubuntu 22.04 or 24.04 on "
211
+ "ARM64 or AMD64. On macOS, run an Ubuntu VM and onboard the VM's SSH target"
212
+ ]
213
+ problems = []
214
+ release = f"{facts.get('os', '')} {facts.get('version', '')}".strip() or "unknown"
215
+ if facts.get("os") != "ubuntu" or facts.get("version") not in {"22.04", "24.04"}:
216
+ problems.append(f"Raft hosts need Ubuntu 22.04 or 24.04; found {release}")
217
+ if facts.get("arch") not in {"arm64", "amd64"}:
218
+ problems.append(f"Raft hosts need ARM64 or AMD64; found {facts.get('arch') or 'unknown'}")
219
+ if facts.get("sudo") != "yes":
220
+ problems.append("Configure passwordless sudo for the SSH user; `sudo -n true` must succeed")
221
+ if facts.get("python3") != "yes":
222
+ problems.append("Install python3 on the host")
223
+ cpus = int(facts.get("cpus") or 0)
224
+ if cpus < 2:
225
+ problems.append(f"Raft hosts need at least 2 CPUs; found {cpus}")
226
+ # MemTotal excludes firmware and kernel reservations: 8 GiB machines report
227
+ # about 7.7 GiB. The threshold accepts them and rejects 6 GiB machines.
228
+ memory = int(facts.get("memory") or 0) * 1024
229
+ if memory < 7.5 * GIB:
230
+ problems.append(f"Raft hosts need at least 8 GiB RAM; found {memory / GIB:.1f} GiB")
231
+ # Only a fresh deployment creates the 60 GiB pool; deploy.py enforces the same limit.
232
+ disk = int(facts.get("disk") or 0) * 1024
233
+ if facts.get("deployed") != "yes" and disk < 80 * GIB:
234
+ problems.append(f"Fresh hosts need at least 80 GiB free disk; found {disk / GIB:.1f} GiB")
235
+ return problems
236
+
237
+
238
+ def deploy_module(filename):
239
+ """Load a host deployment script shipped beside the CLI."""
240
+ path = Path(__file__).resolve().parent / "deploy" / filename
241
+ if not path.exists():
242
+ raise ValueError(
243
+ "Host onboarding needs the deployment files in the npm package or a source "
244
+ "checkout; the Python wheel contains only the CLI"
245
+ )
246
+ spec = importlib.util.spec_from_file_location("raft_" + path.stem.replace("-", "_"), path)
247
+ module = importlib.util.module_from_spec(spec)
248
+ spec.loader.exec_module(module)
249
+ return module
250
+
251
+
252
+ def smoke_test(location):
253
+ """Create a disposable box, wait until it runs a command, then delete it."""
254
+ handle = new(
255
+ argparse.Namespace(
256
+ location=location, ttl=600, cpu=1, memory="2GiB", disposable=True, template=None
257
+ )
258
+ )
259
+ print("Smoke test box: " + handle, flush=True)
260
+ name = handle.partition(":")[2]
261
+ try:
262
+ # new returns once Incus starts the box, before every guest service is ready.
263
+ for _ in range(30):
264
+ if execute(location, name, ["true"], capture=True).returncode == 0:
265
+ break
266
+ time.sleep(2)
267
+ else:
268
+ raise RuntimeError("Smoke test box did not become command-ready: " + handle)
269
+ finally:
270
+ incus(location, "delete", name, "--force", locked=True)
271
+ print("Smoke test create/exec/destroy passed", flush=True)
272
+
273
+
274
+ def host_add(args):
275
+ """Check, provision and register a new host location, then prove it works."""
276
+ if not re.fullmatch(LOCATION_NAME, args.location):
277
+ raise ValueError("Location names must use letters, digits, underscores or hyphens")
278
+ if CONFIG.exists() and args.location in configuration():
279
+ raise ValueError(f"{args.location} is already configured; check it with raft doctor")
280
+ archive = Path(args.image_archive).resolve() if args.image_archive else None
281
+ if archive is not None and not archive.is_file():
282
+ raise ValueError(f"Image archive not found: {archive}")
283
+ deployer = deploy_module("deploy.py")
284
+ probe = subprocess.run(
285
+ [
286
+ "ssh",
287
+ "-T",
288
+ "-o",
289
+ "BatchMode=yes",
290
+ "-o",
291
+ "ConnectTimeout=10",
292
+ args.ssh,
293
+ shlex.join(["sh", "-c", HOST_FACTS]),
294
+ ],
295
+ capture_output=True,
296
+ text=True,
297
+ )
298
+ if probe.returncode:
299
+ raise RuntimeError(
300
+ f"Cannot reach {args.ssh} with key-based SSH: {probe.stderr.strip()}\n"
301
+ f"Verify the host key interactively, then check `ssh -o BatchMode=yes {args.ssh} true`"
302
+ )
303
+ facts = dict(line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line)
304
+ problems = host_problems(facts)
305
+ if problems:
306
+ raise ValueError(
307
+ f"{args.ssh} cannot host Raft boxes; nothing was changed:\n- " + "\n- ".join(problems)
308
+ )
309
+ architecture = facts["arch"]
310
+ if args.build:
311
+ image = f"build a native {architecture} image from Debian 13 on the host"
312
+ elif archive is not None:
313
+ image = f"upload and import {archive}"
314
+ else:
315
+ image = f"download raft-dev-{architecture}.tar.gz from release {args.image_release} and import it"
316
+ print(
317
+ f"Plan for {args.location} ({args.ssh}: Ubuntu {facts['version']} {architecture}, "
318
+ f"{facts['cpus']} CPUs, {int(facts['memory']) * 1024 / GIB:.1f} GiB RAM)\n"
319
+ "- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs and nftables if missing\n"
320
+ + (
321
+ "- reuse and verify the existing raft project, raft-data pool and rfbr0 bridge\n"
322
+ if facts["deployed"] == "yes"
323
+ else "- create Incus project raft, 60 GiB Btrfs pool raft-data and bridge rfbr0 "
324
+ "(10.232.0.1/24 with NAT)\n"
325
+ )
326
+ + "- install raft-network.service (rfbr0 isolation rules) and raft-expire.timer\n"
327
+ f"- {image}; images are checked by SHA-256 and architecture\n"
328
+ f"- add {args.location} with the image fingerprint to {CONFIG}\n"
329
+ "- run doctor and a disposable create/exec/destroy smoke test\n"
330
+ "SSH configuration, other firewall rules, IP addresses and account passwords are not changed.",
331
+ flush=True,
332
+ )
333
+ if args.dry_run:
334
+ print("Dry run: nothing was changed")
335
+ return
336
+ if not args.yes:
337
+ if not sys.stdin.isatty():
338
+ raise ValueError("Review the plan, then rerun with --yes to apply it")
339
+ if input("Apply this plan? [y/N] ").strip().lower() not in {"y", "yes"}:
340
+ raise ValueError("Onboarding cancelled; nothing was changed")
341
+ deployer.deploy(args.location, args.ssh)
342
+ if args.build:
343
+ # A fresh alias avoids refusing to build beside an earlier raft-dev image.
344
+ alias = "raft-dev-" + uuid.uuid4().hex[:8]
345
+ fingerprint = deploy_module("build-image.py").build(args.ssh, alias)
346
+ elif archive is not None:
347
+ fingerprint = deployer.import_archive(args.ssh, archive, architecture)
348
+ else:
349
+ fingerprint = deployer.import_release(args.ssh, args.image_release, architecture)
350
+
351
+ def register(inventory):
352
+ if args.location in inventory:
353
+ raise ValueError(f"{args.location} was configured while onboarding ran")
354
+ inventory[args.location] = {"ssh": args.ssh, "image": fingerprint}
355
+
356
+ edit_configuration(register)
357
+ try:
358
+ doctor(args.location)
359
+ smoke_test(args.location)
360
+ except BaseException:
361
+ # Never leave a plausible-looking but unusable location behind.
362
+ edit_configuration(lambda inventory: inventory.pop(args.location, None))
363
+ print(
364
+ f"Removed {args.location} from {CONFIG}. Host changes remain; a rerun reuses them.",
365
+ file=sys.stderr,
366
+ )
367
+ raise
368
+ print(f"{args.location} is ready: raft new --location {args.location}")
369
+
370
+
110
371
  def parse_handle(value):
111
372
  location, separator, name = value.partition(":")
112
373
  if not separator or not re.fullmatch(r"rf-[a-f0-9]{16}", name):
@@ -177,14 +438,13 @@ def capacity(host, boxes):
177
438
  memory_allocated = 0
178
439
  for box in active:
179
440
  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
- }:
441
+ if (
442
+ limits["limits.cpu"] not in {str(cpu) for cpu in CPU_SIZES}
443
+ or limits["limits.memory"] not in MEMORY_SIZES
444
+ ):
185
445
  raise ValueError("Active box has unsupported sizing; inspect its Incus configuration")
186
446
  cpu_allocated += int(limits["limits.cpu"])
187
- memory_allocated += int(limits["limits.memory"][0]) * gib
447
+ memory_allocated += int(limits["limits.memory"].removesuffix("GiB")) * gib
188
448
  memory_reserve = max(2 * gib, host["memoryTotalBytes"] // 10)
189
449
  cpu_budget = max(0, host["cpus"] - 1)
190
450
  memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
@@ -198,8 +458,8 @@ def capacity(host, boxes):
198
458
  )
199
459
  slots = max(0, SAVED_BOX_LIMIT - len(boxes))
200
460
  recommendations = []
201
- for cpu in [1, 2]:
202
- for memory in [1, 2, 4]:
461
+ for cpu in CPU_SIZES:
462
+ for memory in [int(size.removesuffix("GiB")) for size in MEMORY_SIZES]:
203
463
  total = min(SAVED_BOX_LIMIT, cpu_budget // cpu, memory_budget // (memory * gib))
204
464
  additional = min(
205
465
  max(0, SAVED_BOX_LIMIT - len(active)),
@@ -255,7 +515,11 @@ def host_limits(location):
255
515
  text=True,
256
516
  check=True,
257
517
  )
258
- return {"location": location, **capacity(json.loads(response.stdout), inventory(location))}
518
+ return {
519
+ "location": location,
520
+ "note": settings(location).get("note", ""),
521
+ **capacity(json.loads(response.stdout), inventory(location)),
522
+ }
259
523
 
260
524
 
261
525
  def print_limits(report):
@@ -266,6 +530,8 @@ def print_limits(report):
266
530
  f"{host['memoryTotalBytes'] / gib:.1f} GiB RAM "
267
531
  f"({host['memoryAvailableBytes'] / gib:.1f} GiB available)"
268
532
  )
533
+ if report["note"]:
534
+ print("Note: " + report["note"])
269
535
  print(
270
536
  f"Saved boxes: {report['savedBoxes']}/{report['savedBoxLimit']} enforced; "
271
537
  f"active: {report['activeBoxes']}; free saved slots: {report['savedSlots']}"
@@ -290,6 +556,32 @@ def print_limits(report):
290
556
  print("Warning: " + warning)
291
557
 
292
558
 
559
+ def doctor(location):
560
+ print(location + ": " + incus(location, "version").strip())
561
+ try:
562
+ transaction(location, CHECK_WORKER)
563
+ except RuntimeError as error:
564
+ raise RuntimeError(f"{location}: {error}") from error
565
+ response = subprocess.run(
566
+ [
567
+ "ssh",
568
+ "-T",
569
+ "-o",
570
+ "BatchMode=yes",
571
+ "-o",
572
+ "ConnectTimeout=10",
573
+ settings(location)["ssh"],
574
+ "systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
575
+ ],
576
+ capture_output=True,
577
+ text=True,
578
+ check=True,
579
+ )
580
+ print(response.stdout.strip())
581
+ print(incus(location, "storage", "info", "raft-data").strip())
582
+ print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
583
+
584
+
293
585
  def transaction(location, script, *arguments, stdout=None):
294
586
  # All lifecycle steps in a compound operation share the host expiry lock.
295
587
  command = [
@@ -338,6 +630,7 @@ def sizing(args):
338
630
 
339
631
 
340
632
  def new(args):
633
+ box_fields = box_note(args)
341
634
  source = None
342
635
  if args.template:
343
636
  handle, separator, snapshot = args.template.partition("/")
@@ -358,6 +651,10 @@ def new(args):
358
651
  raise ValueError(
359
652
  "Build an image and configure its full immutable fingerprint before creating boxes"
360
653
  )
654
+ note = settings(args.location).get("note")
655
+ if note:
656
+ # Stderr keeps stdout limited to the handle for scripts.
657
+ print(f"{args.location} note: {note}", file=sys.stderr)
361
658
  name = "rf-" + uuid.uuid4().hex[:16]
362
659
  # Initialize before starting, so a failed start still has its intended policy.
363
660
  transaction(
@@ -385,10 +682,13 @@ def new(args):
385
682
  "-c",
386
683
  f"user.raft.disposable={'true' if args.disposable else 'false'}",
387
684
  "-c",
685
+ # Always set, so a box created from a snapshot never inherits the source note.
686
+ "user.raft.note=" + (json.dumps(box_fields) if box_fields else ""),
687
+ "-c",
388
688
  "volatile.eth0.hwaddr=",
389
689
  *sizing(args),
390
690
  )
391
- print(f"{args.location}:{name}")
691
+ return f"{args.location}:{name}"
392
692
 
393
693
 
394
694
  def execute(location, name, command, *, capture=False):
@@ -576,6 +876,19 @@ def ttl(value):
576
876
  return seconds
577
877
 
578
878
 
879
+ def note_options(command):
880
+ """Add the box note template flags shared by new and note."""
881
+ command.add_argument(
882
+ "--purpose", help="Box note: what the box is for, e.g. 'Integration tests for PR 42'"
883
+ )
884
+ command.add_argument(
885
+ "--owner", help="Box note: who uses the box, e.g. 'codex session in ~/work/app'"
886
+ )
887
+ command.add_argument(
888
+ "--next", help="Box note: what happens next, e.g. 'Destroy after the tests pass'"
889
+ )
890
+
891
+
579
892
  def parser():
580
893
  cli = argparse.ArgumentParser(description=__doc__)
581
894
  cli.add_argument("--version", action="version", version=__version__)
@@ -583,10 +896,39 @@ def parser():
583
896
  create = commands.add_parser("new")
584
897
  create.add_argument("--location", default=None)
585
898
  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")
899
+ create.add_argument("--cpu", type=int, choices=CPU_SIZES, default=1)
900
+ create.add_argument("--memory", choices=MEMORY_SIZES, default="2GiB")
588
901
  create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
589
902
  create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
903
+ note_options(create)
904
+ commands.add_parser("locations", help="List configured locations and their notes")
905
+ note = commands.add_parser("note", help="Set or clear the note of a location or a box")
906
+ note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
907
+ note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
908
+ note.add_argument("--clear", action="store_true", help="Remove the note")
909
+ note_options(note)
910
+ host = commands.add_parser("host", help="Onboard workspace hosts")
911
+ host_commands = host.add_subparsers(dest="host_action", required=True)
912
+ onboard = host_commands.add_parser(
913
+ "add", help="Check, provision, register and smoke-test a new host location"
914
+ )
915
+ onboard.add_argument("location")
916
+ onboard.add_argument(
917
+ "--ssh", required=True, help="Key-based SSH target: user@host or an ~/.ssh/config alias"
918
+ )
919
+ source = onboard.add_mutually_exclusive_group()
920
+ source.add_argument(
921
+ "--image-release",
922
+ default="v" + __version__,
923
+ metavar="TAG",
924
+ help="Release whose checksummed image to download (default: this CLI's version)",
925
+ )
926
+ source.add_argument("--image-archive", metavar="PATH", help="Import a local image archive")
927
+ source.add_argument("--build", action="store_true", help="Build the image on the host")
928
+ onboard.add_argument(
929
+ "--dry-run", action="store_true", help="Check the host and print the plan; change nothing"
930
+ )
931
+ onboard.add_argument("--yes", action="store_true", help="Apply the plan without prompting")
590
932
  listing = commands.add_parser("list")
591
933
  listing.add_argument("--location")
592
934
  limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
@@ -630,8 +972,8 @@ def parser():
630
972
  if action in ["resume", "fork", "extend"]:
631
973
  command.add_argument("--ttl", type=ttl, required=True)
632
974
  if action in ["resume", "fork"]:
633
- command.add_argument("--cpu", type=int, choices=[1, 2])
634
- command.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"])
975
+ command.add_argument("--cpu", type=int, choices=CPU_SIZES)
976
+ command.add_argument("--memory", choices=MEMORY_SIZES)
635
977
  if action == "fork":
636
978
  command.add_argument("--disposable", action="store_true")
637
979
  if action == "exec":
@@ -660,7 +1002,35 @@ def dispatch(args):
660
1002
  recover(args)
661
1003
  return 0
662
1004
  if args.action == "new":
663
- new(args)
1005
+ print(new(args))
1006
+ return 0
1007
+ if args.action == "host":
1008
+ host_add(args)
1009
+ return 0
1010
+ if args.action == "note":
1011
+ fields = box_note(args)
1012
+ # Location names cannot contain ":", so a colon always means a box handle.
1013
+ if ":" not in args.target:
1014
+ if fields is not None:
1015
+ raise ValueError("--purpose, --owner and --next describe boxes; pass a box handle")
1016
+ if args.clear == (args.text is not None):
1017
+ raise ValueError("Give the location note TEXT or --clear")
1018
+ set_note(args.target, None if args.clear else args.text)
1019
+ return 0
1020
+ if args.text is not None:
1021
+ raise ValueError("Describe boxes with --purpose, --owner and --next")
1022
+ if args.clear == (fields is not None):
1023
+ raise ValueError("Give --purpose (with optional --owner and --next) or --clear")
1024
+ location, name = parse_handle(args.target)
1025
+ instance(location, name)
1026
+ # Each call replaces the whole note; an empty value removes the key.
1027
+ value = "" if args.clear else json.dumps(fields)
1028
+ incus(location, "config", "set", name, "user.raft.note=" + value, locked=True)
1029
+ return 0
1030
+ if args.action == "locations":
1031
+ for index, (location, entry) in enumerate(configuration().items()):
1032
+ record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
1033
+ print(json.dumps(record))
664
1034
  return 0
665
1035
  if args.action in ["list", "limits", "doctor", "gc", "prune"]:
666
1036
  if args.action == "prune" and args.older_than < 1:
@@ -711,27 +1081,7 @@ def dispatch(args):
711
1081
  else:
712
1082
  print(output, end="")
713
1083
  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
- )
1084
+ doctor(location)
735
1085
  else:
736
1086
  for box in inventory(location):
737
1087
  print(
@@ -740,6 +1090,7 @@ def dispatch(args):
740
1090
  "box": location + ":" + box["name"],
741
1091
  "state": box["status"],
742
1092
  "stopAt": box["config"].get("user.raft.expires", ""),
1093
+ "note": read_box_note(box),
743
1094
  }
744
1095
  )
745
1096
  )
@@ -8,11 +8,31 @@ 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
 
21
+ Note every box you create. Box notes follow one template so the next agent can
22
+ tell whether a box is in use:
23
+
24
+ - `--purpose`: what the box is for (required), e.g. `Integration tests for PR 42`
25
+ - `--owner`: who uses it, e.g. `codex session in ~/work/app`
26
+ - `--next`: what happens next, e.g. `Destroy after the tests pass`
27
+
28
+ Pass them to `new`, and run `raft note "$box" --purpose ...` when the plan
29
+ changes; each call replaces the whole note. `raft list` shows every box's
30
+ `note`. Read it before you resume, stop, fork or destroy a box. Treat a box whose
31
+ note names another owner or a job unrelated to your task as not yours. Forks
32
+ keep the source note, so update a fork's note when its job differs.
33
+
14
34
  ```sh
15
- box=$(raft new --location lab --ttl 600)
35
+ box=$(raft new --location lab --ttl 600 --purpose "Run the project tests" --owner "codex task 123" --next "Destroy when the tests finish")
16
36
  raft exec "$box" -- timeout 240 bash -lc 'git clone <url> /workspace/project && cd /workspace/project && <test-command>'
17
37
  raft download "$box" /workspace/project/report.json ./report.json
18
38
  raft destroy "$box"
@@ -32,7 +52,7 @@ Incus execution over verified host SSH. It does not expose a guest SSH port.
32
52
 
33
53
  Defaults are the first configured location, one CPU, 2 GiB RAM and 600 seconds. TTL accepts 60 seconds
34
54
  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`
55
+ boxes count because their disk still occupies space. Use `--memory 4GiB` or `--memory 8GiB`
36
56
  and `--cpu 2` when needed. Btrfs bounds workspace storage to a 60 GiB pool per host.
37
57
  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
58
  reports CPU/RAM recommendations for all supported sizes, active allocations and