@microck/raft 0.3.0 → 0.5.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
@@ -3,6 +3,8 @@
3
3
 
4
4
  import argparse
5
5
  import fcntl
6
+ import getpass
7
+ import hashlib
6
8
  import importlib.util
7
9
  import json
8
10
  import os
@@ -10,26 +12,41 @@ from pathlib import Path
10
12
  import re
11
13
  import shlex
12
14
  import shutil
15
+ import socket
13
16
  import subprocess
14
17
  import sys
15
18
  import tempfile
16
19
  import tarfile
20
+ import threading
17
21
  import time
22
+ import urllib.error
23
+ import urllib.request
18
24
  import uuid
19
25
 
20
26
  import raft_files
21
27
 
22
- __version__ = "0.3.0"
28
+ __version__ = "0.5.0"
23
29
 
24
30
  CONFIG = Path.home() / ".config/raft/incus.json"
31
+ # Controller defaults for new live apart from the location inventory, so location
32
+ # names can never collide with setting names.
33
+ DEFAULTS_CONFIG = Path.home() / ".config/raft/config.json"
25
34
  PROJECT = "raft"
26
- SAVED_BOX_LIMIT = 4
27
35
  LOCATION_NAME = r"[a-zA-Z0-9][a-zA-Z0-9_-]*"
28
36
  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"]
37
+ # Boxes use the shared Btrfs pool unless `new --disk` gives them their own disk:
38
+ # a fixed-size thin logical volume in this LVM pool. Deployment always creates the
39
+ # shared pool; the own-disk pool exists only on hosts deployed with --disk-pool.
40
+ SHARED_POOL = "raft-data"
41
+ BOX_DISK_POOL = "raft-disks"
42
+ # Size of a box created from the image without --cpu, --memory or a config.json
43
+ # default. Boxes created from a snapshot keep the snapshot's CPU, RAM and disk
44
+ # unless a flag overrides it; config.json defaults never do.
45
+ DEFAULT_CPU = 1
46
+ DEFAULT_MEMORY = "2GiB"
47
+ # Box notes follow one template so any agent can tell why a box exists, who uses
48
+ # it and what happens to it next. Each value is free text; purpose is required.
49
+ BOX_NOTE_FIELDS = ["purpose", "owner", "next"]
33
50
 
34
51
  USAGE = """import json, shutil
35
52
  from pathlib import Path
@@ -42,7 +59,6 @@ print(json.dumps({
42
59
  'effectiveCpus': (group / 'cpuset.cpus.effective').read_text().strip(),
43
60
  'filesystemUsedBytes': disk.used,
44
61
  'filesystemAvailableBytes': disk.free,
45
- 'filesystemScope': 'shared Raft pool, not per-box usage',
46
62
  }))
47
63
  """
48
64
 
@@ -68,35 +84,43 @@ def settings(location):
68
84
  return inventory[location]
69
85
 
70
86
 
71
- def edit_configuration(change):
72
- """Apply change(inventory) to the controller configuration and save it."""
87
+ def edit_json(path, load, change):
88
+ """Apply change(document) to a controller JSON file and save it.
89
+
90
+ load() reads and validates the current file. An empty document removes the file.
91
+ """
73
92
  # main() sets umask 077, so a new configuration directory is private.
74
- CONFIG.parent.mkdir(parents=True, exist_ok=True)
93
+ path.parent.mkdir(parents=True, exist_ok=True)
75
94
  # Hold a controller lock across read, edit and replace so concurrent edits
76
95
  # cannot drop each other. Readers need no lock because replacement is atomic.
77
- with (CONFIG.parent / ".incus.json.lock").open("w") as lock:
96
+ with (path.parent / f".{path.name}.lock").open("w") as lock:
78
97
  fcntl.flock(lock, fcntl.LOCK_EX)
79
98
  # 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)
99
+ document = load() if path.exists() else {}
100
+ change(document)
101
+ if not document:
102
+ # Removing the last entry restores the unconfigured state.
103
+ path.unlink(missing_ok=True)
85
104
  return
86
105
  # Key order is preserved because the first location is the default for new.
87
- # Replace atomically so an interrupted write cannot truncate the inventory;
106
+ # Replace atomically so an interrupted write cannot truncate the file;
88
107
  # NamedTemporaryFile creates the staged file with mode 0600.
89
- staged = tempfile.NamedTemporaryFile("w", dir=CONFIG.parent, prefix=".raft-", delete=False)
108
+ staged = tempfile.NamedTemporaryFile("w", dir=path.parent, prefix=".raft-", delete=False)
90
109
  try:
91
110
  with staged:
92
- json.dump(inventory, staged, indent=2)
111
+ json.dump(document, staged, indent=2)
93
112
  staged.write("\n")
94
- os.replace(staged.name, CONFIG)
113
+ os.replace(staged.name, path)
95
114
  finally:
96
115
  # A no-op after a successful replace; removes the file after a failure.
97
116
  Path(staged.name).unlink(missing_ok=True)
98
117
 
99
118
 
119
+ def edit_configuration(change):
120
+ """Apply change(inventory) to the location inventory and save it."""
121
+ edit_json(CONFIG, configuration, change)
122
+
123
+
100
124
  def set_note(location, text):
101
125
  """Record operator guidance for a location. Raft shows notes but never enforces them."""
102
126
  if text is not None and not text.strip():
@@ -113,6 +137,159 @@ def set_note(location, text):
113
137
  edit_configuration(change)
114
138
 
115
139
 
140
+ def box_note(args):
141
+ """Build a box note from --purpose, --owner and --next; None when none is given."""
142
+ note = {}
143
+ for field in BOX_NOTE_FIELDS:
144
+ value = getattr(args, field, None)
145
+ if value is not None:
146
+ if not value.strip():
147
+ raise ValueError(f"--{field} cannot be empty")
148
+ note[field] = value.strip()
149
+ if note and "purpose" not in note:
150
+ raise ValueError("Box notes need --purpose")
151
+ return note or None
152
+
153
+
154
+ def read_box_note(box):
155
+ """Return a box's note as a dict, or None. Raft writes it as JSON in user.raft.note."""
156
+ stored = box["config"].get("user.raft.note")
157
+ return json.loads(stored) if stored else None
158
+
159
+
160
+ def cpu_count(value):
161
+ """Parse --cpu: any positive whole number of CPUs."""
162
+ if not re.fullmatch(r"[1-9][0-9]*", value):
163
+ raise argparse.ArgumentTypeError("CPU count must be a positive whole number")
164
+ return int(value)
165
+
166
+
167
+ def gib_size(value):
168
+ """Parse --memory, --disk and --disk-pool. Whole GiB like 16GiB is the one accepted form."""
169
+ if not re.fullmatch(r"[1-9][0-9]*GiB", value):
170
+ raise argparse.ArgumentTypeError("Use a whole number of GiB, e.g. 16GiB")
171
+ return value
172
+
173
+
174
+ def disk_size(value):
175
+ """Parse new --disk: an own-disk size like 20GiB, or shared for the shared pool."""
176
+ return value if value == "shared" else gib_size(value)
177
+
178
+
179
+ def gib(size):
180
+ return int(size.removesuffix("GiB"))
181
+
182
+
183
+ def box_disk(box):
184
+ """Return a box's own disk size, e.g. 20GiB, or "shared" for the shared pool."""
185
+ root = box["expanded_devices"].get("root", {})
186
+ return root.get("size", "") if root.get("pool") == BOX_DISK_POOL else "shared"
187
+
188
+
189
+ # Box note policy. A host can require note fields for every box it holds; the
190
+ # policy lives on the host, in the Incus project key user.raft.require (for
191
+ # example "purpose,owner"), so every controller reads the same rule. Each
192
+ # command that creates a box or changes its note checks the policy twice: once
193
+ # from the controller for an actionable message before any work, and again in
194
+ # the host transaction that writes the note, under the host lock. This is a
195
+ # guardrail for agents: SSH and sudo on the host bypass it, and controllers
196
+ # older than this check never run it.
197
+ POLICY_KEY = "user.raft.require"
198
+ POLICY_CHANGED_KEY = "user.raft.require.changed"
199
+
200
+ # Host half of the check, run with the required fields and the note the box
201
+ # will carry. Kept short; the controller builds the detailed message.
202
+ CHECK_NOTE_CODE = """import json, sys
203
+ required = [field for field in sys.argv[1].split(",") if field]
204
+ note = json.loads(sys.argv[2]) if sys.argv[2] else {}
205
+ missing = [field for field in required if not note.get(field)]
206
+ if missing:
207
+ raise SystemExit(
208
+ "This host requires box note fields " + ", ".join(required)
209
+ + " (Incus project key user.raft.require); missing " + ", ".join(missing)
210
+ + ". Nothing was changed. Give the note with "
211
+ + " ".join("--" + field for field in required) + " and retry"
212
+ )
213
+ """
214
+ # Read the policy with errexit active: inside a command substitution used as an
215
+ # argument, a failed read would pass an empty policy and let every note through.
216
+ READ_NOTE_POLICY = f"require=$(incus project get raft {POLICY_KEY})\n"
217
+ # Callers set $note to the exact value they write to user.raft.note.
218
+ CHECK_NOTE = "python3 -c " + shlex.quote(CHECK_NOTE_CODE) + ' "$require" "$note"'
219
+
220
+
221
+ def note_policy(location):
222
+ """Return a location's required note fields and its last recorded policy change."""
223
+ # `incus query` rejects the --project flag that remote() always passes.
224
+ projects = json.loads(incus(location, "project", "list", "--format", "json"))
225
+ project = next(item for item in projects if item["name"] == PROJECT)
226
+ stored = project["config"].get(POLICY_KEY, "")
227
+ required = [field for field in stored.split(",") if field]
228
+ unknown = [field for field in required if field not in BOX_NOTE_FIELDS]
229
+ if unknown:
230
+ raise ValueError(
231
+ f"{location}: host policy {POLICY_KEY}={stored!r} names unknown note fields; "
232
+ f"fix it with raft policy {location} --require FIELDS or --clear"
233
+ )
234
+ return {"require": required, "changed": project["config"].get(POLICY_CHANGED_KEY, "")}
235
+
236
+
237
+ def note_problem(location, required, note, command, hint=""):
238
+ """Describe how note falls short of the required fields, or return None."""
239
+ missing = [field for field in required if not (note or {}).get(field)]
240
+ if not missing:
241
+ return None
242
+ flags = " and ".join("--" + field for field in missing)
243
+ usage = " ".join(
244
+ f'--{field} "..."' if field in required else f'[--{field} "..."]'
245
+ for field in BOX_NOTE_FIELDS
246
+ )
247
+ return (
248
+ f"location {location} requires {flags} (host policy).{hint} "
249
+ f"Add {'it' if len(missing) == 1 else 'them'}: raft {command} {usage}"
250
+ )
251
+
252
+
253
+ def require_note(location, note, command, hint=""):
254
+ """Fail before any host change when note lacks a field the location requires."""
255
+ problem = note_problem(location, note_policy(location)["require"], note, command, hint)
256
+ if problem:
257
+ raise ValueError(problem)
258
+
259
+
260
+ def note_fields(text):
261
+ """Parse --require FIELDS into BOX_NOTE_FIELDS order."""
262
+ fields = [field.strip() for field in text.split(",") if field.strip()]
263
+ unknown = [field for field in fields if field not in BOX_NOTE_FIELDS]
264
+ if unknown or not fields:
265
+ raise ValueError(
266
+ "Give note fields from " + ", ".join(BOX_NOTE_FIELDS) + ", e.g. --require purpose,owner"
267
+ )
268
+ if "purpose" not in fields:
269
+ # Every box note has a purpose, so a policy without it would be misleading.
270
+ raise ValueError("Box notes always need purpose; include it, e.g. --require purpose,owner")
271
+ return [field for field in BOX_NOTE_FIELDS if field in fields]
272
+
273
+
274
+ def set_policy(location, fields):
275
+ """Set (fields) or clear (None) a location's note policy and record the change on the host."""
276
+ settings(location)
277
+ value = ",".join(fields or [])
278
+ # Who and when is recorded on the host beside the policy and in its journal.
279
+ # The controller identity is self-reported; SUDO_USER is the SSH account.
280
+ before = transaction(
281
+ location,
282
+ f"before=$(incus project get raft {POLICY_KEY})\n"
283
+ 'changed="$(date -u +%Y-%m-%dT%H:%M:%SZ) by ${SUDO_USER:-root} from $2"\n'
284
+ f'incus project set raft {POLICY_KEY}="$1" {POLICY_CHANGED_KEY}="$changed"\n'
285
+ "logger -t raft \"Box note policy: required fields '$before' -> '$1' ($changed)\"\n"
286
+ "printf '%s\\n' \"$before\"",
287
+ value,
288
+ f"{getpass.getuser()}@{socket.gethostname()}",
289
+ ).strip()
290
+ print(f"{location}: required note fields {before or 'none'} -> {value or 'none'}")
291
+
292
+
116
293
  def receive(command, destination, *, stdin=None):
117
294
  """Write streamed bytes in Python: SSH can exit zero after a local write error."""
118
295
  with tempfile.TemporaryFile() as errors:
@@ -179,7 +356,7 @@ if sudo -n incus project list --format csv -c n 2>/dev/null | grep -qx raft; the
179
356
  """
180
357
 
181
358
 
182
- def host_problems(facts):
359
+ def host_problems(facts, disk_pool=None):
183
360
  """Return reasons a probed SSH target cannot become a Raft host; empty means supported."""
184
361
  kernel = facts.get("kernel") or "an unknown system"
185
362
  if kernel != "Linux":
@@ -205,10 +382,17 @@ def host_problems(facts):
205
382
  memory = int(facts.get("memory") or 0) * 1024
206
383
  if memory < 7.5 * GIB:
207
384
  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.
385
+ # Only a fresh deployment creates the 60 GiB shared pool, and the own-disk
386
+ # pool when --disk-pool asks for it; deploy.py enforces the same minimum.
209
387
  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")
388
+ needed = 80 + (gib(disk_pool) if disk_pool else 0)
389
+ if facts.get("deployed") != "yes" and disk < needed * GIB:
390
+ problems.append(
391
+ f"Fresh hosts need at least {needed} GiB free disk"
392
+ + (f" (80 GiB plus the {disk_pool} own-disk pool)" if disk_pool else "")
393
+ + f"; found {disk / GIB:.1f} GiB"
394
+ + (". A smaller --disk-pool needs less" if disk_pool else "")
395
+ )
212
396
  return problems
213
397
 
214
398
 
@@ -230,7 +414,18 @@ def smoke_test(location):
230
414
  """Create a disposable box, wait until it runs a command, then delete it."""
231
415
  handle = new(
232
416
  argparse.Namespace(
233
- location=location, ttl=600, cpu=1, memory="2GiB", disposable=True, template=None
417
+ location=location,
418
+ ttl=600,
419
+ cpu=1,
420
+ memory="2GiB",
421
+ # Explicit, so a configured disk default never moves the smoke box off the shared pool.
422
+ disk="shared",
423
+ disposable=True,
424
+ template=None,
425
+ # A full note satisfies any note policy left on a reused deployment.
426
+ purpose="raft host add smoke test",
427
+ owner="raft host add",
428
+ next="Deleted by the smoke test",
234
429
  )
235
430
  )
236
431
  print("Smoke test box: " + handle, flush=True)
@@ -278,7 +473,7 @@ def host_add(args):
278
473
  f"Verify the host key interactively, then check `ssh -o BatchMode=yes {args.ssh} true`"
279
474
  )
280
475
  facts = dict(line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line)
281
- problems = host_problems(facts)
476
+ problems = host_problems(facts, args.disk_pool)
282
477
  if problems:
283
478
  raise ValueError(
284
479
  f"{args.ssh} cannot host Raft boxes; nothing was changed:\n- " + "\n- ".join(problems)
@@ -293,13 +488,20 @@ def host_add(args):
293
488
  print(
294
489
  f"Plan for {args.location} ({args.ssh}: Ubuntu {facts['version']} {architecture}, "
295
490
  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"
491
+ "- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs, lvm2 and nftables "
492
+ "if missing\n"
297
493
  + (
298
494
  "- reuse and verify the existing raft project, raft-data pool and rfbr0 bridge\n"
299
495
  if facts["deployed"] == "yes"
300
496
  else "- create Incus project raft, 60 GiB Btrfs pool raft-data and bridge rfbr0 "
301
497
  "(10.232.0.1/24 with NAT)\n"
302
498
  )
499
+ + (
500
+ f"- create the {args.disk_pool} thin LVM pool raft-disks for own box disks if it is "
501
+ "missing, and allocate its file on the host disk in full\n"
502
+ if args.disk_pool
503
+ else ""
504
+ )
303
505
  + "- install raft-network.service (rfbr0 isolation rules) and raft-expire.timer\n"
304
506
  f"- {image}; images are checked by SHA-256 and architecture\n"
305
507
  f"- add {args.location} with the image fingerprint to {CONFIG}\n"
@@ -315,7 +517,7 @@ def host_add(args):
315
517
  raise ValueError("Review the plan, then rerun with --yes to apply it")
316
518
  if input("Apply this plan? [y/N] ").strip().lower() not in {"y", "yes"}:
317
519
  raise ValueError("Onboarding cancelled; nothing was changed")
318
- deployer.deploy(args.location, args.ssh)
520
+ deployer.deploy(args.location, args.ssh, args.disk_pool)
319
521
  if args.build:
320
522
  # A fresh alias avoids refusing to build beside an earlier raft-dev image.
321
523
  alias = "raft-dev-" + uuid.uuid4().hex[:8]
@@ -369,11 +571,6 @@ def inventory(location):
369
571
  ]
370
572
 
371
573
 
372
- ADMISSION = f"""count=$(incus --project raft list --fast --format json | python3 -c 'import json,sys; print(sum(x["config"].get("user.raft")=="true" for x in json.load(sys.stdin)))')
373
- test "$count" -lt {SAVED_BOX_LIMIT} || {{ echo 'Location already holds four boxes' >&2; exit 1; }}
374
- """
375
-
376
-
377
574
  REQUIRE_STOPPED = """state=$(incus --project raft list "$1" --fast --format json | python3 -c 'import json,sys; print(next(x["status"] for x in json.load(sys.stdin) if x["name"] == sys.argv[1] and x["config"].get("user.raft") == "true"))' "$1")
378
575
  test "$state" = Stopped || { echo 'Stop the source box before resume, snapshot, restore, fork or backup' >&2; exit 1; }
379
576
  """
@@ -391,92 +588,250 @@ CHECK_WORKER = (
391
588
  )
392
589
 
393
590
 
394
- HOST_RESOURCES = """import json, os, shutil, subprocess
591
+ # Host-side accounting for the own-disk pool, shared by capacity reports and the
592
+ # reservation check. It runs as root on the host, where python3 and lvs exist.
593
+ #
594
+ # raft-disks is a thin LVM pool, so forks, templates and snapshots of own-disk
595
+ # boxes are thin snapshots that share blocks instead of copies. Thin volumes can
596
+ # promise more space than the pool holds, and a full thin pool fails every volume
597
+ # in it at once. Raft never lets that happen: a thin volume can never hold more
598
+ # data than its virtual size, so while the virtual sizes of all thin volumes in
599
+ # the pool (boxes, their snapshots, the cached image and anything else) add up to
600
+ # no more than the pool's data space, the pool cannot fill. Every Raft operation
601
+ # that adds a thin volume to raft-disks checks that sum first, under the host lock.
602
+ BOX_DISK_ACCOUNTING = (
603
+ "POOL = "
604
+ + repr(BOX_DISK_POOL)
605
+ + """
606
+ import json, subprocess, sys
607
+ GIB = 1024**3
608
+
609
+ def query(path):
610
+ return json.loads(subprocess.run(['incus', 'query', path], check=True, capture_output=True).stdout)
611
+
612
+ def size_bytes(value):
613
+ # Raft sizes own disks in whole GiB; anything else came from a native Incus edit.
614
+ if not value.endswith('GiB') or not value[:-3].isdigit():
615
+ sys.exit(f'Unexpected own-disk size {value!r}; inspect it with incus')
616
+ return int(value[:-3]) * GIB
617
+
618
+ def box_disk_pool():
619
+ # Own box disks are opt-in per host, so a missing pool is a normal state.
620
+ if '/1.0/storage-pools/' + POOL not in query('/1.0/storage-pools'):
621
+ return None
622
+ config = query('/1.0/storage-pools/' + POOL)['config']
623
+ thinpool = config['lvm.thinpool_name']
624
+ columns = 'lv_name,lv_size,pool_lv,data_percent,metadata_percent'
625
+ report = subprocess.run(['lvs', '--reportformat', 'json', '--units', 'b', '--nosuffix', '-o', columns, config['lvm.vg_name']], check=True, capture_output=True)
626
+ volumes = json.loads(report.stdout)['report'][0]['lv']
627
+ pool = next(volume for volume in volumes if volume['lv_name'] == thinpool)
628
+ # LVM reports usage only while the thin pool is active; reservations never need it.
629
+ active = pool['data_percent'] != ''
630
+ total = int(pool['lv_size'])
631
+ return {
632
+ 'totalBytes': total,
633
+ 'reservedBytes': sum(int(volume['lv_size']) for volume in volumes if volume['pool_lv'] == thinpool),
634
+ 'usedBytes': int(total * float(pool['data_percent']) / 100) if active else None,
635
+ 'metadataUsedPercent': float(pool['metadata_percent']) if active else None,
636
+ # Incus caches the image as a thin volume of the pool's volume.size, or
637
+ # its 10 GiB default, and starts each own-disk box as a snapshot of it.
638
+ 'imageVolumeBytes': size_bytes(config.get('volume.size', '10GiB')),
639
+ }
640
+
641
+ def reserve(location, pool, added, origin=''):
642
+ # Exit with the reason unless thin volumes of the added sizes fit in full.
643
+ # origin, when given, is a sentence saying where the requested size came from.
644
+ suffix = '. ' + origin if origin else ''
645
+ if added and pool is None:
646
+ sys.exit(
647
+ f'{location} has no {POOL} pool, so own box disks are not enabled there; nothing was '
648
+ f'created. Enable them with python3 deploy/deploy.py --location {location} '
649
+ '--disk-pool SIZE' + suffix
650
+ )
651
+ if added and pool['reservedBytes'] + sum(added) > pool['totalBytes']:
652
+ sys.exit(
653
+ f'{location}: own box disks would need {sum(added) / GIB:.1f} GiB more of {POOL}, '
654
+ f'which has {(pool["totalBytes"] - pool["reservedBytes"]) / GIB:.1f} of '
655
+ f'{pool["totalBytes"] / GIB:.1f} GiB unreserved; nothing was created. Destroy '
656
+ 'own-disk boxes or snapshots you no longer need, or use a smaller disk or the '
657
+ 'shared pool' + suffix
658
+ )
659
+ """
660
+ )
661
+
662
+ HOST_RESOURCES = (
663
+ BOX_DISK_ACCOUNTING
664
+ + """
665
+ import os, shutil
395
666
  from pathlib import Path
396
667
  memory = dict(line.split()[:2] for line in Path('/proc/meminfo').read_text().splitlines())
397
668
  disk = shutil.disk_usage('/var/lib/incus')
398
- pool = json.loads(subprocess.check_output(['incus', 'query', '/1.0/storage-pools/raft-data/resources']))
669
+ space = query('/1.0/storage-pools/raft-data/resources')['space']
670
+ own = box_disk_pool()
671
+ if own is not None:
672
+ del own['imageVolumeBytes']
399
673
  print(json.dumps({
400
674
  'cpus': len(os.sched_getaffinity(0)),
401
675
  'memoryTotalBytes': int(memory['MemTotal:']) * 1024,
402
676
  'memoryAvailableBytes': int(memory['MemAvailable:']) * 1024,
403
677
  'hostDiskFreeBytes': disk.free,
404
- 'poolTotalBytes': pool['space']['total'],
405
- 'poolFreeBytes': pool['space']['total'] - pool['space']['used'],
678
+ 'sharedPool': {'totalBytes': space['total'], 'freeBytes': space['total'] - space['used']},
679
+ 'boxDiskPool': own,
406
680
  }))
407
681
  """
682
+ )
683
+
684
+ # Reservation check run inside lifecycle transactions before Incus adds a thin
685
+ # volume to raft-disks. Arguments: location (for messages), the requested own-disk
686
+ # size in GiB (0 for none), the source: image:FINGERPRINT, BOX or BOX/SNAPSHOT,
687
+ # and the origin sentence for refusals (empty when the size is a flag).
688
+ # A new volume starts as a thin snapshot of its source and is then resized to the
689
+ # requested size, so it needs the larger of the two. Copies of shared-pool boxes
690
+ # without --disk never touch raft-disks and need nothing.
691
+ RESERVE_BOX_DISK = (
692
+ BOX_DISK_ACCOUNTING
693
+ + """
694
+ location, requested, source, origin = sys.argv[1], int(sys.argv[2]) * GIB, sys.argv[3], sys.argv[4]
695
+ pool = box_disk_pool()
696
+ added = []
697
+ if source.startswith('image:'):
698
+ if requested:
699
+ image = pool['imageVolumeBytes'] if pool else 0
700
+ added.append(max(requested, image))
701
+ # Incus caches the image in the pool the first time an own-disk box needs it.
702
+ volume = f'/1.0/storage-pools/{POOL}/volumes/image/{source[6:]}'
703
+ if pool and subprocess.run(['incus', 'query', volume], capture_output=True).returncode:
704
+ added.append(image)
705
+ else:
706
+ path = '/1.0/instances/' + source.replace('/', '/snapshots/') + '?project=raft'
707
+ root = query(path)['expanded_devices']['root']
708
+ if root.get('pool') == POOL:
709
+ added.append(max(requested, size_bytes(root['size'])))
710
+ elif requested:
711
+ added.append(requested)
712
+ reserve(location, pool, added, origin)
713
+ """
714
+ )
715
+
716
+ # Prints the pool `raft recover` imports into, from backup/index.yaml, the first
717
+ # entry of every Incus export. Own-disk backups return to raft-disks with their
718
+ # recorded size after the same reservation check: Incus restores the box and each
719
+ # of its snapshots as a thin volume of that size. Other backups use the shared pool.
720
+ RECOVERY_POOL = (
721
+ BOX_DISK_ACCOUNTING
722
+ + "SHARED_POOL = "
723
+ + repr(SHARED_POOL)
724
+ + """
725
+ import tarfile, yaml
726
+ archive, location = sys.argv[1], sys.argv[2]
727
+ with tarfile.open(archive, 'r|gz') as backup:
728
+ entry = backup.next()
729
+ if entry is None or entry.name != 'backup/index.yaml':
730
+ sys.exit('Not an Incus instance backup: backup/index.yaml is not its first entry')
731
+ index = yaml.safe_load(backup.extractfile(entry))
732
+ if index.get('pool') != POOL:
733
+ print(SHARED_POOL)
734
+ sys.exit()
735
+ size = size_bytes(index['config']['container']['expanded_devices']['root']['size'])
736
+ reserve(location, box_disk_pool(), [size] * (1 + len(index.get('snapshots') or [])))
737
+ print(POOL)
738
+ """
739
+ )
740
+
741
+
742
+ def reserve_box_disk(location, disk, source, origin=""):
743
+ """Shell line that refuses an own-disk volume raft-disks could not hold in full."""
744
+ size = str(gib(disk) if disk else 0)
745
+ return shlex.join(["python3", "-c", RESERVE_BOX_DISK, location, size, source, origin]) + "\n"
746
+
747
+
748
+ # Agents read capacity output to plan work. Earlier wording that looked like a cap
749
+ # made them skip boxes they needed, so every report leads with this sentence.
750
+ ADVISORY = (
751
+ "Advisory estimate only. Raft enforces no box count or size budget; "
752
+ "create the boxes your task needs."
753
+ )
408
754
 
409
755
 
410
756
  def capacity(host, boxes):
411
- """Conservative allocation advice, not an exclusive CPU or memory reservation."""
412
- gib = 1024**3
757
+ """Advisory planning numbers, not a reservation, admission rule or limit."""
413
758
  active = [box for box in boxes if box["status"] != "Stopped"]
414
759
  cpu_allocated = 0
415
760
  memory_allocated = 0
416
761
  for box in active:
417
762
  limits = box["expanded_config"]
418
- if (
419
- limits["limits.cpu"] not in {str(cpu) for cpu in CPU_SIZES}
420
- or limits["limits.memory"] not in MEMORY_SIZES
763
+ # Raft writes whole CPU counts and whole GiB; other forms come from native Incus edits.
764
+ if not re.fullmatch(r"[1-9][0-9]*", limits["limits.cpu"]) or not re.fullmatch(
765
+ r"[1-9][0-9]*GiB", limits["limits.memory"]
421
766
  ):
422
- raise ValueError("Active box has unsupported sizing; inspect its Incus configuration")
767
+ raise ValueError(
768
+ f"Active box {box['name']} has sizing Raft did not set; inspect its Incus configuration"
769
+ )
423
770
  cpu_allocated += int(limits["limits.cpu"])
424
- memory_allocated += int(limits["limits.memory"].removesuffix("GiB")) * gib
425
- memory_reserve = max(2 * gib, host["memoryTotalBytes"] // 10)
771
+ memory_allocated += gib(limits["limits.memory"]) * GIB
772
+ memory_reserve = max(2 * GIB, host["memoryTotalBytes"] // 10)
426
773
  cpu_budget = max(0, host["cpus"] - 1)
427
774
  memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
428
- cpu_remaining = max(0, cpu_budget - cpu_allocated)
429
- memory_remaining = max(
775
+ cpu_headroom = max(0, cpu_budget - cpu_allocated)
776
+ memory_headroom = max(
430
777
  0,
431
778
  min(
432
779
  memory_budget - memory_allocated,
433
780
  host["memoryAvailableBytes"] - memory_reserve,
434
781
  ),
435
782
  )
436
- slots = max(0, SAVED_BOX_LIMIT - len(boxes))
437
- recommendations = []
438
- for cpu in CPU_SIZES:
439
- for memory in [int(size.removesuffix("GiB")) for size in MEMORY_SIZES]:
440
- total = min(SAVED_BOX_LIMIT, cpu_budget // cpu, memory_budget // (memory * gib))
441
- additional = min(
442
- max(0, SAVED_BOX_LIMIT - len(active)),
443
- cpu_remaining // cpu,
444
- memory_remaining // (memory * gib),
445
- )
446
- recommendations.append(
447
- {
448
- "cpu": cpu,
449
- "memoryGiB": memory,
450
- "totalRunning": total,
451
- "additionalRunning": additional,
452
- "newBoxes": min(slots, additional),
453
- }
454
- )
783
+ # Example shapes double from the 1 CPU / 2 GiB default at 2 GiB per CPU and
784
+ # stop at the largest that fits an empty host. Boxes may use any other size;
785
+ # headroom lets callers work out counts for those.
786
+ estimates = []
787
+ cpu = 1
788
+ while cpu == 1 or (cpu <= cpu_budget and 2 * cpu * GIB <= memory_budget):
789
+ memory = 2 * cpu * GIB
790
+ estimates.append(
791
+ {
792
+ "cpu": cpu,
793
+ "memoryGiB": 2 * cpu,
794
+ "fitEmptyHost": min(cpu_budget // cpu, memory_budget // memory),
795
+ "fitNow": min(cpu_headroom // cpu, memory_headroom // memory),
796
+ }
797
+ )
798
+ cpu *= 2
455
799
  warnings = []
456
800
  if cpu_allocated > cpu_budget or memory_allocated > memory_budget:
457
- warnings.append("Active configured limits exceed the recommended host budget")
801
+ warnings.append(
802
+ "Active boxes may use more CPU or RAM than the host budget; under load they share it"
803
+ )
458
804
  if host["memoryAvailableBytes"] < memory_reserve:
459
805
  warnings.append(
460
- "Available host RAM is below the reserve; stop boxes or reduce other workloads"
806
+ "Available host RAM is below the reserve; stop idle boxes or reduce other workloads"
461
807
  )
462
- if host["hostDiskFreeBytes"] < 5 * gib or host["poolFreeBytes"] < 5 * gib:
808
+ if host["hostDiskFreeBytes"] < 5 * GIB or host["sharedPool"]["freeBytes"] < 5 * GIB:
463
809
  warnings.append(
464
810
  "Host or shared-pool free disk is below 5 GiB; check image and workspace growth"
465
811
  )
812
+ # LVM sizes thin pool metadata for every data block mapped, and reservations
813
+ # keep mappings within that, so this should never fire. It stays because full
814
+ # metadata stops every own-disk box and needs offline repair.
815
+ own = host["boxDiskPool"]
816
+ if own is not None and (own["metadataUsedPercent"] or 0) >= 80:
817
+ warnings.append(
818
+ f"{BOX_DISK_POOL} thin pool metadata is {own['metadataUsedPercent']:.0f}% full; "
819
+ "inspect it with lvs before creating more own-disk boxes"
820
+ )
466
821
  return {
822
+ "notice": ADVISORY,
467
823
  "host": host,
468
- "savedBoxLimit": SAVED_BOX_LIMIT,
469
- "savedBoxes": len(boxes),
824
+ "boxes": len(boxes),
470
825
  "activeBoxes": len(active),
471
- "savedSlots": slots,
472
826
  "allocated": {"cpus": cpu_allocated, "memoryBytes": memory_allocated},
473
827
  "reserve": {"cpus": 1, "memoryBytes": memory_reserve},
474
- "recommendations": recommendations,
828
+ "headroom": {"cpus": cpu_headroom, "memoryBytes": memory_headroom},
829
+ "estimates": estimates,
475
830
  "warnings": warnings,
476
831
  }
477
832
 
478
833
 
479
- def host_limits(location):
834
+ def host_capacity(location):
480
835
  response = subprocess.run(
481
836
  [
482
837
  "ssh",
@@ -499,36 +854,47 @@ def host_limits(location):
499
854
  }
500
855
 
501
856
 
502
- def print_limits(report):
503
- gib = 1024**3
857
+ def print_capacity(report):
504
858
  host = report["host"]
859
+ print(report["notice"])
505
860
  print(
506
861
  f"{report['location']}: {host['cpus']} CPUs, "
507
- f"{host['memoryTotalBytes'] / gib:.1f} GiB RAM "
508
- f"({host['memoryAvailableBytes'] / gib:.1f} GiB available)"
862
+ f"{host['memoryTotalBytes'] / GIB:.1f} GiB RAM "
863
+ f"({host['memoryAvailableBytes'] / GIB:.1f} GiB available)"
509
864
  )
510
865
  if report["note"]:
511
866
  print("Note: " + report["note"])
867
+ print(f"Boxes: {report['boxes']} saved, {report['activeBoxes']} active")
512
868
  print(
513
- f"Saved boxes: {report['savedBoxes']}/{report['savedBoxLimit']} enforced; "
514
- f"active: {report['activeBoxes']}; free saved slots: {report['savedSlots']}"
515
- )
516
- print(
517
- f"Active limits: {report['allocated']['cpus']} CPUs, "
518
- f"{report['allocated']['memoryBytes'] / gib:.1f} GiB; "
519
- f"host reserve: 1 CPU, {report['reserve']['memoryBytes'] / gib:.1f} GiB"
869
+ f"Allocated to active boxes: {report['allocated']['cpus']} CPUs, "
870
+ f"{report['allocated']['memoryBytes'] / GIB:.1f} GiB; "
871
+ f"host reserve: 1 CPU, {report['reserve']['memoryBytes'] / GIB:.1f} GiB; "
872
+ f"headroom: {report['headroom']['cpus']} CPUs, "
873
+ f"{report['headroom']['memoryBytes'] / GIB:.1f} GiB"
520
874
  )
521
875
  print(
522
- f"Disk free: {host['poolFreeBytes'] / gib:.1f} GiB shared pool, "
523
- f"{host['hostDiskFreeBytes'] / gib:.1f} GiB host"
876
+ f"Disk free: {host['sharedPool']['freeBytes'] / GIB:.1f} of "
877
+ f"{host['sharedPool']['totalBytes'] / GIB:.1f} GiB shared pool, "
878
+ f"{host['hostDiskFreeBytes'] / GIB:.1f} GiB host"
524
879
  )
525
- print("CPU RAM GiB Total running More running New boxes")
526
- for size in report["recommendations"]:
880
+ own = host["boxDiskPool"]
881
+ if own is None:
882
+ print("Own box disks: not enabled (redeploy with --disk-pool SIZE)")
883
+ else:
884
+ print(
885
+ f"Own box disks: {(own['totalBytes'] - own['reservedBytes']) / GIB:.1f} of "
886
+ f"{own['totalBytes'] / GIB:.1f} GiB unreserved"
887
+ )
888
+ print("CPU RAM GiB Fit on empty host Fit now")
889
+ for size in report["estimates"]:
527
890
  print(
528
- f"{size['cpu']:>3} {size['memoryGiB']:>7} {size['totalRunning']:>13} "
529
- f"{size['additionalRunning']:>12} {size['newBoxes']:>9}"
891
+ f"{size['cpu']:>3} {size['memoryGiB']:>7} {size['fitEmptyHost']:>17} "
892
+ f"{size['fitNow']:>7}"
530
893
  )
531
- print("Advisory CPU/RAM counts; total assumes an empty host. Disk growth is not included.")
894
+ print(
895
+ "Counts assume every box uses its full CPU and RAM at once. Boxes share host CPUs, "
896
+ "so more can run when they are not all busy. Any whole CPU count and GiB size works."
897
+ )
532
898
  for warning in report["warnings"]:
533
899
  print("Warning: " + warning)
534
900
 
@@ -555,7 +921,12 @@ def doctor(location):
555
921
  check=True,
556
922
  )
557
923
  print(response.stdout.strip())
558
- print(incus(location, "storage", "info", "raft-data").strip())
924
+ print(incus(location, "storage", "info", SHARED_POOL).strip())
925
+ # Own box disks are opt-in, so a host without their pool is healthy.
926
+ if BOX_DISK_POOL in incus(location, "storage", "list", "--format", "csv", "-c", "n").split():
927
+ print(incus(location, "storage", "info", BOX_DISK_POOL).strip())
928
+ else:
929
+ print("own box disks: not enabled (redeploy with --disk-pool SIZE)")
559
930
  print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
560
931
 
561
932
 
@@ -606,7 +977,52 @@ def sizing(args):
606
977
  return values
607
978
 
608
979
 
980
+ def fits_host(args):
981
+ """Shell checks that refuse a CPU or RAM limit larger than the whole host.
982
+
983
+ Incus accepts such limits and quietly gives the box less, so an agent would
984
+ plan around resources it never gets. MemTotal is rounded up to whole GiB:
985
+ an 8 GiB machine reports about 7.7 GiB and must still accept 8GiB. The
986
+ parsers already restrict both values to whole numbers, so they embed safely.
987
+ """
988
+ checks = ""
989
+ if args.cpu is not None:
990
+ checks += (
991
+ f'test {args.cpu} -le "$(nproc)" || '
992
+ f'{{ echo "Requested {args.cpu} CPUs, but this host has $(nproc)" >&2; exit 1; }}\n'
993
+ )
994
+ if args.memory is not None:
995
+ checks += (
996
+ "total=$(awk '/^MemTotal:/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)\n"
997
+ f'test {gib(args.memory)} -le "$total" || '
998
+ f'{{ echo "Requested {args.memory} RAM, but this host has $total GiB" >&2; exit 1; }}\n'
999
+ )
1000
+ return checks
1001
+
1002
+
1003
+ def own_disk(size):
1004
+ """Incus device overrides that give a box its own fixed-size disk in raft-disks.
1005
+
1006
+ The full root device is spelled out because `incus copy` of a snapshot whose
1007
+ root comes from the profile adds an override as a whole new device.
1008
+ """
1009
+ return [
1010
+ "-d",
1011
+ "root,type=disk",
1012
+ "-d",
1013
+ "root,path=/",
1014
+ "-d",
1015
+ f"root,pool={BOX_DISK_POOL}",
1016
+ "-d",
1017
+ f"root,size={size}",
1018
+ ]
1019
+
1020
+
609
1021
  def new(args):
1022
+ box_fields = box_note(args)
1023
+ # Sizes are filled here only for boxes from the image; a box from a snapshot
1024
+ # keeps the snapshot's CPU, RAM and disk unless a flag overrides them.
1025
+ from_config = apply_new_defaults(args)
610
1026
  source = None
611
1027
  if args.template:
612
1028
  handle, separator, snapshot = args.template.partition("/")
@@ -619,8 +1035,6 @@ def new(args):
619
1035
  args.location = location
620
1036
  instance(location, original)
621
1037
  source = original + "/" + snapshot
622
- if args.location is None:
623
- args.location = next(iter(configuration()))
624
1038
  if source is None:
625
1039
  source = settings(args.location).get("image", "")
626
1040
  if not isinstance(source, str) or not re.fullmatch(r"[a-f0-9]{64}", source):
@@ -631,18 +1045,37 @@ def new(args):
631
1045
  if note:
632
1046
  # Stderr keeps stdout limited to the handle for scripts.
633
1047
  print(f"{args.location} note: {note}", file=sys.stderr)
1048
+ require_note(args.location, box_fields, "new")
634
1049
  name = "rf-" + uuid.uuid4().hex[:16]
635
1050
  # Initialize before starting, so a failed start still has its intended policy.
636
1051
  transaction(
637
1052
  args.location,
638
1053
  (CHECK_WORKER if args.disposable else "")
639
- + ADMISSION
640
- + 'lifetime="$1"; name="$2"; shift 2; "$@"; '
1054
+ + fits_host(args)
1055
+ # Plain boxes from the image stay off raft-disks and skip the check.
1056
+ + (
1057
+ reserve_box_disk(
1058
+ args.location,
1059
+ args.disk,
1060
+ source if args.template else "image:" + source,
1061
+ # A refusal of a configured disk says where the size came from.
1062
+ disk_origin(args.disk) if "disk" in from_config else "",
1063
+ )
1064
+ if args.disk or args.template
1065
+ else ""
1066
+ )
1067
+ + 'lifetime="$1"; name="$2"; note="$3"; shift 3\n'
1068
+ + READ_NOTE_POLICY
1069
+ + CHECK_NOTE
1070
+ # Always set the note, so a box created from a snapshot never inherits the
1071
+ # source note. The host checks the same value it writes.
1072
+ + '\n"$@" -c user.raft.note="$note"; '
641
1073
  'incus --project raft start "$name"; '
642
1074
  'incus --project raft config set "$name" '
643
1075
  'user.raft.expires="$(( $(date +%s) + lifetime ))"',
644
1076
  str(args.ttl),
645
1077
  name,
1078
+ json.dumps(box_fields) if box_fields else "",
646
1079
  "incus",
647
1080
  "--project",
648
1081
  PROJECT,
@@ -660,6 +1093,10 @@ def new(args):
660
1093
  "-c",
661
1094
  "volatile.eth0.hwaddr=",
662
1095
  *sizing(args),
1096
+ # Without --disk, init uses the profile's shared pool and copy keeps the
1097
+ # snapshot's disk: the shared pool or an own disk of the same size. Copies
1098
+ # within either pool are snapshots that share the source's blocks.
1099
+ *(own_disk(args.disk) if args.disk else []),
663
1100
  )
664
1101
  return f"{args.location}:{name}"
665
1102
 
@@ -759,7 +1196,11 @@ def backup(args, location, name):
759
1196
 
760
1197
 
761
1198
  def recover(args):
1199
+ box_fields = box_note(args)
762
1200
  host = settings(args.location)["ssh"]
1201
+ if box_fields:
1202
+ # Without a new note the archived one applies; the host checks it after import.
1203
+ require_note(args.location, box_fields, f"recover {args.source} --location {args.location}")
763
1204
  name = "rf-" + uuid.uuid4().hex[:16]
764
1205
  response = subprocess.run(
765
1206
  ["ssh", "-T", "-o", "BatchMode=yes", host, "mktemp /tmp/raft-recovery.XXXXXXXX"],
@@ -785,12 +1226,24 @@ def recover(args):
785
1226
  stdin=archive,
786
1227
  check=True,
787
1228
  )
1229
+ # The archive carries the source note, known only after import. A rejected
1230
+ # note deletes the imported box inside the same locked transaction.
788
1231
  transaction(
789
1232
  args.location,
790
- ADMISSION + 'incus --project raft import "$1" "$2" --storage raft-data '
791
- "-c user.raft=true -c user.raft.expires= -c user.raft.disposable=false -c user.raft.stopped-at= -c volatile.eth0.hwaddr=",
1233
+ # The reservation check runs first and creates nothing when it refuses.
1234
+ 'pool=$(python3 -c "$3" "$1" "$4")\n'
1235
+ + READ_NOTE_POLICY
1236
+ + 'incus --project raft import "$1" "$2" --storage "$pool" '
1237
+ "-c user.raft=true -c user.raft.expires= -c user.raft.disposable=false -c user.raft.stopped-at= -c volatile.eth0.hwaddr=\n"
1238
+ 'if [ -n "$5" ]; then incus --project raft config set "$2" user.raft.note="$5"; fi\n'
1239
+ 'note=$(incus --project raft config get "$2" user.raft.note)\n'
1240
+ + CHECK_NOTE
1241
+ + ' || { incus --project raft delete "$2" --force; exit 1; }',
792
1242
  staged,
793
1243
  name,
1244
+ RECOVERY_POOL,
1245
+ args.location,
1246
+ json.dumps(box_fields) if box_fields else "",
794
1247
  )
795
1248
  if instance(args.location, name)["status"] != "Stopped":
796
1249
  raise RuntimeError("Recovered box unexpectedly running: " + args.location + ":" + name)
@@ -849,23 +1302,446 @@ def ttl(value):
849
1302
  return seconds
850
1303
 
851
1304
 
1305
+ def location_name(value):
1306
+ if not re.fullmatch(LOCATION_NAME, value):
1307
+ raise ValueError("Location names must use letters, digits, underscores or hyphens")
1308
+ return value
1309
+
1310
+
1311
+ # Options of new that ~/.config/raft/config.json can default, with their built-in
1312
+ # defaults and value checks. Values go through str() first, so a configured value
1313
+ # is accepted exactly when the same text passed as the flag would be. Adding a
1314
+ # defaultable option means adding one entry here and keeping its flag default None.
1315
+ NEW_DEFAULTS = {
1316
+ # None means the first configured location.
1317
+ "location": (None, location_name),
1318
+ "ttl": (600, ttl),
1319
+ "cpu": (DEFAULT_CPU, cpu_count),
1320
+ "memory": (DEFAULT_MEMORY, gib_size),
1321
+ "disk": ("shared", disk_size),
1322
+ }
1323
+ # A box from a snapshot keeps the snapshot's size, so these defaults, configured
1324
+ # or built-in, apply only to boxes created from the image.
1325
+ SIZE_DEFAULTS = {"cpu", "memory", "disk"}
1326
+
1327
+
1328
+ def default_value(key, value):
1329
+ """Validate one configured default; errors name the file and the key."""
1330
+ if key not in NEW_DEFAULTS:
1331
+ raise ValueError(
1332
+ f"{DEFAULTS_CONFIG}: unknown key defaults.{key}; "
1333
+ "supported keys: " + ", ".join(NEW_DEFAULTS)
1334
+ )
1335
+ try:
1336
+ return NEW_DEFAULTS[key][1](str(value))
1337
+ except (ValueError, argparse.ArgumentTypeError) as error:
1338
+ raise ValueError(
1339
+ f"{DEFAULTS_CONFIG}: defaults.{key}={value!r} is invalid: {error}"
1340
+ ) from None
1341
+
1342
+
1343
+ def configured_defaults(unchecked=None):
1344
+ """Return the validated defaults from config.json; a missing file means none.
1345
+
1346
+ The unchecked key is returned as stored, so `raft config set/unset KEY` can
1347
+ repair a file whose only problem is that key.
1348
+ """
1349
+ if not DEFAULTS_CONFIG.exists():
1350
+ return {}
1351
+ try:
1352
+ document = json.loads(DEFAULTS_CONFIG.read_text())
1353
+ except json.JSONDecodeError as error:
1354
+ raise ValueError(f"{DEFAULTS_CONFIG}: invalid JSON: {error}") from None
1355
+ if (
1356
+ not isinstance(document, dict)
1357
+ or set(document) - {"defaults"}
1358
+ or not isinstance(document.get("defaults", {}), dict)
1359
+ ):
1360
+ raise ValueError(f'{DEFAULTS_CONFIG}: expected {{"defaults": {{"KEY": VALUE, ...}}}}')
1361
+ return {
1362
+ key: value if key == unchecked else default_value(key, value)
1363
+ for key, value in document.get("defaults", {}).items()
1364
+ }
1365
+
1366
+
1367
+ def apply_new_defaults(args):
1368
+ """Fill omitted new options: explicit flag, then config.json, then the built-in default.
1369
+
1370
+ Returns the keys whose value came from config.json. With --from only explicit
1371
+ flags change the size: the snapshot's CPU, RAM and disk beat any default.
1372
+ """
1373
+ configured = configured_defaults()
1374
+ used = set()
1375
+ for key, (builtin, _) in NEW_DEFAULTS.items():
1376
+ # getattr's default lets internal callers such as smoke_test omit options.
1377
+ if key == "location" or getattr(args, key, None) is not None:
1378
+ continue
1379
+ if args.template and key in SIZE_DEFAULTS:
1380
+ continue
1381
+ if key in configured:
1382
+ used.add(key)
1383
+ setattr(args, key, configured.get(key, builtin))
1384
+ if args.disk == "shared":
1385
+ if args.template:
1386
+ raise ValueError(
1387
+ "--from keeps the snapshot's disk; leave out --disk, or give --disk SIZE "
1388
+ "for an own disk of that size"
1389
+ )
1390
+ # The rest of new() spells the shared pool as no own disk.
1391
+ args.disk = None
1392
+ # A --from template fixes the location, so only plain creation takes a default.
1393
+ if args.location is None and not args.template:
1394
+ if "location" not in configured:
1395
+ args.location = next(iter(configuration()))
1396
+ elif configured["location"] in configuration():
1397
+ args.location = configured["location"]
1398
+ used.add("location")
1399
+ else:
1400
+ raise ValueError(
1401
+ f"{DEFAULTS_CONFIG}: defaults.location names unknown location "
1402
+ f"{configured['location']}; run raft config set location NAME or "
1403
+ "raft config unset location"
1404
+ )
1405
+ return used
1406
+
1407
+
1408
+ def effective_defaults():
1409
+ """Return each defaultable option's effective value and where it comes from."""
1410
+ configured = configured_defaults()
1411
+ report = {}
1412
+ for key, (builtin, _) in NEW_DEFAULTS.items():
1413
+ if key in configured:
1414
+ report[key] = {"value": configured[key], "source": "config"}
1415
+ else:
1416
+ value = next(iter(configuration())) if key == "location" else builtin
1417
+ report[key] = {"value": value, "source": "built-in"}
1418
+ return report
1419
+
1420
+
1421
+ def disk_origin(size):
1422
+ """Explain a reservation refusal for an own disk that config.json chose, not a flag."""
1423
+ return (
1424
+ f"The {size} own disk comes from defaults.disk in {DEFAULTS_CONFIG}; "
1425
+ "give --disk shared or --disk SIZE, or run raft config unset disk"
1426
+ )
1427
+
1428
+
1429
+ def set_default(key, value):
1430
+ """Set (value) or remove (None) one default in config.json and print the change."""
1431
+ if value is not None:
1432
+ value = default_value(key, value)
1433
+ if key == "location":
1434
+ settings(value)
1435
+ previous = {}
1436
+
1437
+ def change(document):
1438
+ defaults = document.setdefault("defaults", {})
1439
+ previous.update(defaults)
1440
+ if value is None:
1441
+ defaults.pop(key, None)
1442
+ else:
1443
+ defaults[key] = value
1444
+ if not defaults:
1445
+ document.pop("defaults")
1446
+
1447
+ edit_json(DEFAULTS_CONFIG, lambda: {"defaults": configured_defaults(key)}, change)
1448
+ before = previous.get(key, "built-in default")
1449
+ print(f"defaults.{key}: {before} -> {'built-in default' if value is None else value}")
1450
+
1451
+
1452
+ # GitHub releases are the one source of version truth. The release workflow
1453
+ # publishes npm before it creates the GitHub release, so every version this API
1454
+ # reports is already installable from both supported channels.
1455
+ RELEASES_API = "https://api.github.com/repos/Microck/raft/releases"
1456
+ # Matches the user agent deploy/deploy.py sends for release downloads.
1457
+ USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
1458
+ # self-update --check exit status when a newer release exists, as dnf check-update uses.
1459
+ UPDATE_AVAILABLE = 100
1460
+ UPDATE_CHECK_INTERVAL = 24 * 3600
1461
+ UPDATE_CHECK_TIMEOUT = 2
1462
+
1463
+
1464
+ def parse_version(text):
1465
+ """Return an X.Y.Z release version, optionally v-prefixed, as a comparable tuple."""
1466
+ match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", text)
1467
+ if match is None:
1468
+ raise ValueError(f"Not a release version: {text}")
1469
+ return tuple(int(part) for part in match.groups())
1470
+
1471
+
1472
+ def release_version(value):
1473
+ """Parser type for self-update --version; returns the version without its v."""
1474
+ parse_version(value)
1475
+ return value.removeprefix("v")
1476
+
1477
+
1478
+ def fetch(url, timeout):
1479
+ request = urllib.request.Request(
1480
+ url, headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"}
1481
+ )
1482
+ return urllib.request.urlopen(request, timeout=timeout)
1483
+
1484
+
1485
+ def release(api, version=None, timeout=30):
1486
+ """Return GitHub metadata for the latest release, or for release vVERSION."""
1487
+ try:
1488
+ with fetch(f"{api}/tags/v{version}" if version else api + "/latest", timeout) as response:
1489
+ metadata = json.load(response)
1490
+ except urllib.error.HTTPError as error:
1491
+ if version and error.code == 404:
1492
+ raise ValueError(f"No published Raft release v{version}") from error
1493
+ raise
1494
+ metadata["version"] = metadata["tag_name"].removeprefix("v")
1495
+ parse_version(metadata["version"])
1496
+ return metadata
1497
+
1498
+
1499
+ def update_message(latest):
1500
+ return f"raft {latest} is available (you have {__version__}). Run: raft self-update"
1501
+
1502
+
1503
+ def update_cache():
1504
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "raft/update.json"
1505
+
1506
+
1507
+ def save_update_check():
1508
+ """Record a release check. A torn write only makes the next command check again."""
1509
+ path = update_cache()
1510
+ try:
1511
+ path.parent.mkdir(parents=True, exist_ok=True)
1512
+ path.write_text(json.dumps({"checkedAt": time.time()}))
1513
+ except OSError:
1514
+ pass
1515
+
1516
+
1517
+ def update_check(action, api=RELEASES_API):
1518
+ """Start the passive release check for a command; the returned function prints its notice.
1519
+
1520
+ At most once per UPDATE_CHECK_INTERVAL, a background thread asks GitHub for
1521
+ the latest release while the command runs, and only that command can print
1522
+ the notice, so a controller sees it at most once per interval. The notice is
1523
+ one stderr line, so stdout stays parseable. It never installs anything, and
1524
+ network, thread or cache failures stay silent. RAFT_NO_UPDATE_CHECK=1
1525
+ disables it.
1526
+ """
1527
+ if action == "self-update" or os.environ.get("RAFT_NO_UPDATE_CHECK"):
1528
+ return lambda: None
1529
+ try:
1530
+ checked = float(json.loads(update_cache().read_text())["checkedAt"])
1531
+ except (OSError, ValueError, KeyError, TypeError):
1532
+ checked = 0
1533
+ # A timestamp in the future, e.g. after a clock change, counts as stale.
1534
+ if 0 <= time.time() - checked < UPDATE_CHECK_INTERVAL:
1535
+ return lambda: None
1536
+ # Record the attempt first: an offline controller then waits for at most one
1537
+ # timeout per interval instead of one per command.
1538
+ save_update_check()
1539
+ found = {}
1540
+
1541
+ def check():
1542
+ try:
1543
+ found["latest"] = release(api, timeout=UPDATE_CHECK_TIMEOUT)["version"]
1544
+ except Exception:
1545
+ # A best-effort notice must not print tracebacks or fail commands.
1546
+ pass
1547
+
1548
+ thread = threading.Thread(target=check, daemon=True)
1549
+ try:
1550
+ thread.start()
1551
+ except RuntimeError:
1552
+ # The runtime could not create the thread; skip this interval's notice.
1553
+ return lambda: None
1554
+
1555
+ def notice():
1556
+ # Commands usually outlast the request; otherwise wait a bounded time.
1557
+ thread.join(UPDATE_CHECK_TIMEOUT)
1558
+ latest = found.get("latest")
1559
+ if latest is not None and parse_version(latest) > parse_version(__version__):
1560
+ print(update_message(latest), file=sys.stderr)
1561
+
1562
+ return notice
1563
+
1564
+
1565
+ def install_channel(source, prefix):
1566
+ """Return (channel, root) for the installation holding source, the resolved raft.py.
1567
+
1568
+ self-update manages npm global installs and uv tools. Any other layout raises
1569
+ ValueError with instructions, so an update never moves to another channel.
1570
+ """
1571
+ package = source.parent
1572
+ if (package / ".git").exists():
1573
+ raise ValueError(
1574
+ f"raft runs from the source checkout {package}; update it with "
1575
+ "`git fetch --tags && git checkout vX.Y.Z` and `uv sync --locked`"
1576
+ )
1577
+ # A global install puts the package at PREFIX/lib/node_modules/@microck/raft and
1578
+ # links PREFIX/bin/raft to raft.py. Project-local node_modules have no such link.
1579
+ if package.parts[-4:] == ("lib", "node_modules", "@microck", "raft"):
1580
+ npm_prefix = package.parents[3]
1581
+ if (npm_prefix / "bin/raft").resolve() == source:
1582
+ return "npm", npm_prefix
1583
+ # uv names each tool environment after its package and leaves a receipt in it.
1584
+ # Editable installs keep raft.py outside the environment and are excluded.
1585
+ if prefix.name == "raft" and (prefix / "uv-receipt.toml").exists():
1586
+ if source.is_relative_to(prefix):
1587
+ return "uv", prefix
1588
+ raise ValueError(
1589
+ f"self-update manages npm global installs and uv tools; {source} is neither. "
1590
+ "Reinstall with the tool you used, from a release verified against its SHA256SUMS: "
1591
+ "https://github.com/Microck/raft/releases"
1592
+ )
1593
+
1594
+
1595
+ def verified_wheel(metadata, directory):
1596
+ """Download the release wheel and check it against the release SHA256SUMS."""
1597
+ name = f"raft-{metadata['version']}-py3-none-any.whl"
1598
+ assets = {asset["name"]: asset["browser_download_url"] for asset in metadata["assets"]}
1599
+ for asset in [name, "SHA256SUMS"]:
1600
+ if asset not in assets:
1601
+ raise ValueError(
1602
+ f"Release {metadata['tag_name']} has no {asset}; nothing was installed"
1603
+ )
1604
+ with fetch(assets[asset], 60) as response, (directory / asset).open("wb") as file:
1605
+ shutil.copyfileobj(response, file)
1606
+ expected = [
1607
+ line.split()[0]
1608
+ for line in (directory / "SHA256SUMS").read_text().splitlines()
1609
+ if line.split()[1:] == [name]
1610
+ ]
1611
+ with (directory / name).open("rb") as file:
1612
+ digest = hashlib.file_digest(file, "sha256").hexdigest()
1613
+ if expected != [digest]:
1614
+ raise ValueError(f"{name} does not match the release SHA256SUMS; nothing was installed")
1615
+ return directory / name
1616
+
1617
+
1618
+ def self_update(args, api=RELEASES_API):
1619
+ """Install the latest or the requested release through the channel that installed raft."""
1620
+ if args.check:
1621
+ latest = release(api)["version"]
1622
+ save_update_check()
1623
+ if parse_version(latest) > parse_version(__version__):
1624
+ print(update_message(latest))
1625
+ return UPDATE_AVAILABLE
1626
+ print(f"raft {__version__} is up to date (latest release: {latest})")
1627
+ return 0
1628
+ if args.version is not None and parse_version(args.version) == parse_version(__version__):
1629
+ print(f"raft {__version__} is already installed")
1630
+ return 0
1631
+ # Refuse unmanaged layouts before any download.
1632
+ channel, root = install_channel(Path(__file__).resolve(), Path(sys.prefix).resolve())
1633
+ tool = shutil.which(channel)
1634
+ if tool is None:
1635
+ raise ValueError(f"{channel} installed raft but is not on PATH")
1636
+ metadata = release(api, args.version)
1637
+ target = metadata["version"]
1638
+ # Without --version, never downgrade a build that is newer than the latest release.
1639
+ if args.version is None and parse_version(target) <= parse_version(__version__):
1640
+ print(f"raft {__version__} is up to date (latest release: {target})")
1641
+ return 0
1642
+ # Installer output goes to stderr; stdout carries only the result below.
1643
+ if channel == "npm":
1644
+ # An explicit prefix keeps the update in this install even when another
1645
+ # Node.js installation provides npm. The registry checks package integrity.
1646
+ subprocess.run(
1647
+ [tool, "install", "--global", "--prefix", str(root), f"@microck/raft@{target}"],
1648
+ stdout=sys.stderr,
1649
+ check=True,
1650
+ )
1651
+ else:
1652
+ tools = subprocess.run([tool, "tool", "dir"], capture_output=True, text=True, check=True)
1653
+ if Path(tools.stdout.strip()).resolve() != root.parent:
1654
+ raise ValueError(
1655
+ f"uv now installs tools in {tools.stdout.strip()}, not {root.parent}; "
1656
+ "set UV_TOOL_DIR to the original directory, then retry"
1657
+ )
1658
+ with tempfile.TemporaryDirectory(prefix="raft-update-") as work:
1659
+ wheel = verified_wheel(metadata, Path(work))
1660
+ print(f"Verified {wheel.name} against the release SHA256SUMS", file=sys.stderr)
1661
+ subprocess.run(
1662
+ [tool, "tool", "install", "--force", str(wheel)], stdout=sys.stderr, check=True
1663
+ )
1664
+ installed = subprocess.run(
1665
+ [str(root / "bin/raft"), "--version"], capture_output=True, text=True, check=True
1666
+ )
1667
+ if installed.stdout.strip() != target:
1668
+ raise RuntimeError(f"{channel} installed {installed.stdout.strip()}, not {target}")
1669
+ print(f"raft {__version__} -> {target}")
1670
+ print("Release notes: " + metadata["html_url"])
1671
+ print(
1672
+ "Hosts were not changed. Run `raft doctor`: if a host's worker differs from this CLI, "
1673
+ f"redeploy that host from release {metadata['tag_name']}. "
1674
+ f"`raft host add` now imports {metadata['tag_name']} images by default."
1675
+ )
1676
+ return 0
1677
+
1678
+
1679
+ def note_options(command):
1680
+ """Add the box note template flags shared by new, note, fork and recover."""
1681
+ command.add_argument(
1682
+ "--purpose", help="Box note: what the box is for, e.g. 'Integration tests for PR 42'"
1683
+ )
1684
+ command.add_argument(
1685
+ "--owner", help="Box note: who uses the box, e.g. 'codex session in ~/work/app'"
1686
+ )
1687
+ command.add_argument(
1688
+ "--next", help="Box note: what happens next, e.g. 'Destroy after the tests pass'"
1689
+ )
1690
+
1691
+
852
1692
  def parser():
853
1693
  cli = argparse.ArgumentParser(description=__doc__)
854
1694
  cli.add_argument("--version", action="version", version=__version__)
855
1695
  commands = cli.add_subparsers(dest="action", required=True)
856
1696
  create = commands.add_parser("new")
857
1697
  create.add_argument("--location", default=None)
858
- create.add_argument("--ttl", type=ttl, default=600)
859
- create.add_argument("--cpu", type=int, choices=CPU_SIZES, default=1)
860
- create.add_argument("--memory", choices=MEMORY_SIZES, default="2GiB")
1698
+ # Omitted options take ~/.config/raft/config.json defaults, then NEW_DEFAULTS.
1699
+ # With --from, every omitted size comes from the snapshot instead of a default.
1700
+ create.add_argument("--ttl", type=ttl)
1701
+ create.add_argument(
1702
+ "--cpu",
1703
+ type=cpu_count,
1704
+ help=f"CPU limit (default: config, else {DEFAULT_CPU}; with --from, the snapshot's)",
1705
+ )
1706
+ create.add_argument(
1707
+ "--memory",
1708
+ type=gib_size,
1709
+ help="RAM limit, e.g. 16GiB "
1710
+ f"(default: config, else {DEFAULT_MEMORY}; with --from, the snapshot's)",
1711
+ )
1712
+ create.add_argument(
1713
+ "--disk",
1714
+ type=disk_size,
1715
+ metavar="SIZE",
1716
+ help="Give the box its own disk of SIZE, e.g. 20GiB, that it cannot grow past, "
1717
+ "or shared for the shared pool "
1718
+ "(default: config, else shared; with --from, the snapshot's disk)",
1719
+ )
861
1720
  create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
862
1721
  create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
1722
+ note_options(create)
863
1723
  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")
1724
+ defaults = commands.add_parser(
1725
+ "config", help="Show defaults for new and each location's note policy, or change defaults"
1726
+ )
1727
+ default_commands = defaults.add_subparsers(dest="config_action")
1728
+ default_set = default_commands.add_parser("set", help="Set a default for new")
1729
+ default_set.add_argument("key", choices=list(NEW_DEFAULTS))
1730
+ default_set.add_argument("value")
1731
+ default_unset = default_commands.add_parser("unset", help="Restore a built-in default")
1732
+ default_unset.add_argument("key", choices=list(NEW_DEFAULTS))
1733
+ policy = commands.add_parser(
1734
+ "policy", help="Set or clear the box note fields a location's host requires"
1735
+ )
1736
+ policy.add_argument("location")
1737
+ rule = policy.add_mutually_exclusive_group(required=True)
1738
+ rule.add_argument("--require", metavar="FIELDS", help="Note fields, e.g. purpose,owner")
1739
+ rule.add_argument("--clear", action="store_true", help="Stop requiring note fields")
1740
+ note = commands.add_parser("note", help="Set or clear the note of a location or a box")
1741
+ note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
1742
+ note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
1743
+ note.add_argument("--clear", action="store_true", help="Remove the note")
1744
+ note_options(note)
869
1745
  host = commands.add_parser("host", help="Onboard workspace hosts")
870
1746
  host_commands = host.add_subparsers(dest="host_action", required=True)
871
1747
  onboard = host_commands.add_parser(
@@ -888,11 +1764,18 @@ def parser():
888
1764
  "--dry-run", action="store_true", help="Check the host and print the plan; change nothing"
889
1765
  )
890
1766
  onboard.add_argument("--yes", action="store_true", help="Apply the plan without prompting")
1767
+ onboard.add_argument(
1768
+ "--disk-pool",
1769
+ type=gib_size,
1770
+ metavar="SIZE",
1771
+ help="Enable own box disks: set aside SIZE of host disk for them, e.g. 40GiB "
1772
+ "(default: not enabled)",
1773
+ )
891
1774
  listing = commands.add_parser("list")
892
1775
  listing.add_argument("--location")
893
- limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
894
- limits.add_argument("--location")
895
- limits.add_argument("--json", action="store_true")
1776
+ report = commands.add_parser("capacity", help=ADVISORY, description=ADVISORY)
1777
+ report.add_argument("--location")
1778
+ report.add_argument("--json", action="store_true")
896
1779
  commands.add_parser("doctor")
897
1780
  gc = commands.add_parser("gc", help="Enforce deadlines, including disposable deletion")
898
1781
  gc.add_argument("--location")
@@ -903,6 +1786,22 @@ def parser():
903
1786
  recovery = commands.add_parser("recover", help="Import a portable backup as a new stopped box")
904
1787
  recovery.add_argument("source")
905
1788
  recovery.add_argument("--location", required=True)
1789
+ note_options(recovery)
1790
+ update = commands.add_parser(
1791
+ "self-update", help="Upgrade this CLI to the latest or a chosen release"
1792
+ )
1793
+ target = update.add_mutually_exclusive_group()
1794
+ target.add_argument(
1795
+ "--check",
1796
+ action="store_true",
1797
+ help=f"Only report whether a newer release exists; exit {UPDATE_AVAILABLE} if one does",
1798
+ )
1799
+ target.add_argument(
1800
+ "--version",
1801
+ type=release_version,
1802
+ metavar="X.Y.Z",
1803
+ help="Install this release instead of the latest, e.g. to roll back",
1804
+ )
906
1805
  for action in [
907
1806
  "info",
908
1807
  "stop",
@@ -931,10 +1830,11 @@ def parser():
931
1830
  if action in ["resume", "fork", "extend"]:
932
1831
  command.add_argument("--ttl", type=ttl, required=True)
933
1832
  if action in ["resume", "fork"]:
934
- command.add_argument("--cpu", type=int, choices=CPU_SIZES)
935
- command.add_argument("--memory", choices=MEMORY_SIZES)
1833
+ command.add_argument("--cpu", type=cpu_count)
1834
+ command.add_argument("--memory", type=gib_size)
936
1835
  if action == "fork":
937
1836
  command.add_argument("--disposable", action="store_true")
1837
+ note_options(command)
938
1838
  if action == "exec":
939
1839
  command.add_argument("--detach", action="store_true")
940
1840
  command.add_argument("command", nargs="+")
@@ -957,6 +1857,8 @@ def parser():
957
1857
 
958
1858
 
959
1859
  def dispatch(args):
1860
+ if args.action == "self-update":
1861
+ return self_update(args)
960
1862
  if args.action == "recover":
961
1863
  recover(args)
962
1864
  return 0
@@ -966,27 +1868,72 @@ def dispatch(args):
966
1868
  if args.action == "host":
967
1869
  host_add(args)
968
1870
  return 0
1871
+ if args.action == "config":
1872
+ if args.config_action is None:
1873
+ report = {"file": str(DEFAULTS_CONFIG), "defaults": effective_defaults(), "policy": {}}
1874
+ for location in configuration():
1875
+ try:
1876
+ report["policy"][location] = note_policy(location)
1877
+ except RuntimeError as error:
1878
+ raise RuntimeError(f"{location}: {error}") from error
1879
+ print(json.dumps(report, indent=2))
1880
+ else:
1881
+ set_default(args.key, args.value if args.config_action == "set" else None)
1882
+ return 0
1883
+ if args.action == "policy":
1884
+ set_policy(args.location, None if args.clear else note_fields(args.require))
1885
+ return 0
969
1886
  if args.action == "note":
970
- set_note(args.location, None if args.clear else args.text)
1887
+ fields = box_note(args)
1888
+ # Location names cannot contain ":", so a colon always means a box handle.
1889
+ if ":" not in args.target:
1890
+ if fields is not None:
1891
+ raise ValueError("--purpose, --owner and --next describe boxes; pass a box handle")
1892
+ if args.clear == (args.text is not None):
1893
+ raise ValueError("Give the location note TEXT or --clear")
1894
+ set_note(args.target, None if args.clear else args.text)
1895
+ return 0
1896
+ if args.text is not None:
1897
+ raise ValueError("Describe boxes with --purpose, --owner and --next")
1898
+ if args.clear == (fields is not None):
1899
+ raise ValueError("Give --purpose (with optional --owner and --next) or --clear")
1900
+ location, name = parse_handle(args.target)
1901
+ instance(location, name)
1902
+ require_note(
1903
+ location,
1904
+ fields,
1905
+ f"note {args.target}",
1906
+ " Clearing would leave the box without them." if args.clear else "",
1907
+ )
1908
+ # Each call replaces the whole note; an empty value removes the key.
1909
+ transaction(
1910
+ location,
1911
+ 'note="$2"\n'
1912
+ + READ_NOTE_POLICY
1913
+ + CHECK_NOTE
1914
+ + '\nincus --project raft config set "$1" user.raft.note="$note"',
1915
+ name,
1916
+ "" if args.clear else json.dumps(fields),
1917
+ )
971
1918
  return 0
972
1919
  if args.action == "locations":
973
1920
  for index, (location, entry) in enumerate(configuration().items()):
974
1921
  record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
975
1922
  print(json.dumps(record))
976
1923
  return 0
977
- if args.action in ["list", "limits", "doctor", "gc", "prune"]:
1924
+ if args.action in ["list", "capacity", "doctor", "gc", "prune"]:
978
1925
  if args.action == "prune" and args.older_than < 1:
979
1926
  raise ValueError("Retention must be at least one day")
980
1927
  locations = configuration()
981
1928
  if getattr(args, "location", None):
982
1929
  locations = [args.location]
983
1930
  for location in locations:
984
- if args.action == "limits":
985
- report = host_limits(location)
1931
+ if args.action == "capacity":
1932
+ report = host_capacity(location)
986
1933
  if args.json:
987
1934
  print(json.dumps(report))
988
1935
  else:
989
- print_limits(report)
1936
+ print_capacity(report)
990
1937
  elif args.action in ["gc", "prune"]:
991
1938
  worker = ["sudo", "-n", "/usr/local/lib/raft/expire"]
992
1939
  if args.action == "prune":
@@ -1032,10 +1979,14 @@ def dispatch(args):
1032
1979
  "box": location + ":" + box["name"],
1033
1980
  "state": box["status"],
1034
1981
  "stopAt": box["config"].get("user.raft.expires", ""),
1982
+ "disk": box_disk(box),
1983
+ "note": read_box_note(box),
1035
1984
  }
1036
1985
  )
1037
1986
  )
1038
1987
  return 0
1988
+ # Only fork accepts note flags here; check them before contacting the host.
1989
+ fields = box_note(args)
1039
1990
  location, name = parse_handle(args.box)
1040
1991
  box = instance(location, name)
1041
1992
  if args.action == "info":
@@ -1047,7 +1998,12 @@ def dispatch(args):
1047
1998
  response = execute(location, name, ["python3", "-c", USAGE], capture=True)
1048
1999
  if response.returncode:
1049
2000
  raise RuntimeError(response.stderr.decode().strip())
1050
- print(response.stdout.decode().strip())
2001
+ usage = json.loads(response.stdout)
2002
+ disk = box_disk(box)
2003
+ usage["filesystemScope"] = (
2004
+ "shared Raft pool, not per-box usage" if disk == "shared" else f"own {disk} box disk"
2005
+ )
2006
+ print(json.dumps(usage))
1051
2007
  elif args.action == "destroy":
1052
2008
  incus(location, "delete", name, "--force", locked=True)
1053
2009
  if any(item["name"] == name for item in inventory(location)):
@@ -1071,7 +2027,7 @@ def dispatch(args):
1071
2027
  overrides.append(f"limits.{field}={value}")
1072
2028
  transaction(
1073
2029
  location,
1074
- REQUIRE_STOPPED + 'name="$1"; lifetime="$2"; shift 2; '
2030
+ REQUIRE_STOPPED + fits_host(args) + 'name="$1"; lifetime="$2"; shift 2; '
1075
2031
  'if [ "$#" -gt 0 ]; then incus --project raft config set "$name" "$@"; fi; '
1076
2032
  'incus --project raft start "$name"; '
1077
2033
  'incus --project raft config set "$name" user.raft.stopped-at=; '
@@ -1134,7 +2090,9 @@ def dispatch(args):
1134
2090
  print(
1135
2091
  transaction(
1136
2092
  location,
1137
- REQUIRE_STOPPED + 'incus --project raft snapshot create "$1" "$2"',
2093
+ REQUIRE_STOPPED
2094
+ + reserve_box_disk(location, None, name)
2095
+ + 'incus --project raft snapshot create "$1" "$2"',
1138
2096
  name,
1139
2097
  args.name,
1140
2098
  ).strip()
@@ -1148,6 +2106,9 @@ def dispatch(args):
1148
2106
  snapshot_name(args.name)
1149
2107
  # Imported/forked snapshots carry source configuration. Restore files,
1150
2108
  # keeping the destination's network identity and explicitly chosen limits.
2109
+ # On raft-disks Incus swaps in a thin snapshot of the snapshot for the box's
2110
+ # volume, so a restore needs no reservation: it writes no data, and the
2111
+ # reserved total is unchanged once the old volume is removed.
1151
2112
  transaction(
1152
2113
  location,
1153
2114
  REQUIRE_STOPPED + 'incus --project raft snapshot restore "$1" "$2" --diskonly',
@@ -1155,19 +2116,33 @@ def dispatch(args):
1155
2116
  args.name,
1156
2117
  )
1157
2118
  elif args.action == "fork":
2119
+ # Note flags replace the whole note, as with raft note; without them the
2120
+ # fork keeps the source note, which must then satisfy the host policy.
2121
+ require_note(
2122
+ location,
2123
+ fields or read_box_note(box),
2124
+ f"fork {args.box} --ttl {args.ttl}",
2125
+ "" if fields else " Forks keep the source note unless you give a new one.",
2126
+ )
1158
2127
  child = "rf-" + uuid.uuid4().hex[:16]
1159
2128
  transaction(
1160
2129
  location,
1161
2130
  (CHECK_WORKER if args.disposable else "")
1162
2131
  + REQUIRE_STOPPED
1163
- + ADMISSION
1164
- + 'name="$2"; lifetime="$3"; '
1165
- 'source="$1"; shift 3; incus --project raft copy "$source" "$name" --instance-only "$@"; '
2132
+ + fits_host(args)
2133
+ + reserve_box_disk(location, None, name)
2134
+ + 'name="$2"; lifetime="$3"; source="$1"; note="$4"; shift 4\n'
2135
+ # Read the source note under the lock, so the check sees what the copy keeps.
2136
+ 'if [ -z "$note" ]; then note=$(incus --project raft config get "$source" user.raft.note); fi\n'
2137
+ + READ_NOTE_POLICY
2138
+ + CHECK_NOTE
2139
+ + '\nincus --project raft copy "$source" "$name" --instance-only "$@" -c user.raft.note="$note"; '
1166
2140
  'incus --project raft start "$name"; '
1167
2141
  'incus --project raft config set "$name" user.raft.expires="$(( $(date +%s) + lifetime ))"',
1168
2142
  name,
1169
2143
  child,
1170
2144
  str(args.ttl),
2145
+ json.dumps(fields) if fields else "",
1171
2146
  "-c",
1172
2147
  "user.raft.expires=",
1173
2148
  "-c",
@@ -1234,8 +2209,11 @@ def main():
1234
2209
  print("raft: Python 3.11.8 or newer is required", file=sys.stderr)
1235
2210
  return 1
1236
2211
  os.umask(0o077)
2212
+ # Parsing exits for --version, --help and usage errors, before any release check.
2213
+ args = parser().parse_args()
2214
+ notice = update_check(args.action, RELEASES_API)
1237
2215
  try:
1238
- return dispatch(parser().parse_args())
2216
+ status = dispatch(args)
1239
2217
  except (
1240
2218
  OSError,
1241
2219
  ValueError,
@@ -1244,9 +2222,11 @@ def main():
1244
2222
  subprocess.CalledProcessError,
1245
2223
  ) as error:
1246
2224
  print(f"raft: {error}", file=sys.stderr)
1247
- return 1
2225
+ status = 1
1248
2226
  except KeyboardInterrupt:
1249
2227
  return 130
2228
+ notice()
2229
+ return status
1250
2230
 
1251
2231
 
1252
2232
  if __name__ == "__main__":