@microck/raft 0.5.0 → 0.7.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.
Files changed (63) hide show
  1. package/README.md +27 -15
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/raft-expire.py +109 -31
  4. package/deploy/verify-controls.py +6 -6
  5. package/deploy/verify-features.py +329 -19
  6. package/deploy/verify-jobs.py +264 -0
  7. package/deploy/verify-limits.py +22 -3
  8. package/deploy/verify-live.py +31 -6
  9. package/deploy/verify-output.py +208 -0
  10. package/deploy/verify-release.py +33 -4
  11. package/deploy/verify-run.py +571 -0
  12. package/deploy/verify-self-update.py +9 -1
  13. package/deploy/verify-sync.py +122 -0
  14. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  15. package/docs/boat-capabilities.md +60 -7
  16. package/docs/boat-parity-design.md +1 -0
  17. package/docs/boot-performance.mdx +21 -0
  18. package/docs/contract.md +88 -13
  19. package/docs/first-box.mdx +22 -19
  20. package/docs/guides/background-jobs.mdx +38 -31
  21. package/docs/guides/backup-recovery.mdx +4 -3
  22. package/docs/guides/ci-jobs.mdx +209 -0
  23. package/docs/guides/config-policy.mdx +109 -35
  24. package/docs/guides/files.mdx +2 -2
  25. package/docs/guides/index.mdx +31 -0
  26. package/docs/guides/long-running-services.mdx +9 -3
  27. package/docs/guides/meta.json +7 -2
  28. package/docs/guides/project-setup.mdx +1 -1
  29. package/docs/guides/scripting-ci.mdx +36 -14
  30. package/docs/guides/snapshots-forks.mdx +4 -2
  31. package/docs/guides/troubleshooting.mdx +19 -2
  32. package/docs/guides/upgrades.mdx +11 -9
  33. package/docs/index.mdx +26 -28
  34. package/docs/maintainers/index.mdx +18 -0
  35. package/docs/maintainers/meta.json +12 -0
  36. package/docs/meta.json +5 -9
  37. package/docs/operations.mdx +17 -10
  38. package/docs/publishing.md +14 -4
  39. package/docs/reference/capacity.mdx +2 -1
  40. package/docs/reference/cli.md +135 -43
  41. package/docs/reference/configuration.mdx +10 -6
  42. package/docs/reference/meta.json +3 -1
  43. package/docs/releases/0.2.0.md +1 -0
  44. package/docs/releases/0.2.1.md +1 -0
  45. package/docs/releases/0.3.0.md +1 -0
  46. package/docs/releases/0.4.0.md +1 -0
  47. package/docs/releases/0.5.0.md +1 -0
  48. package/docs/releases/0.6.0.md +17 -0
  49. package/docs/releases/0.7.0.md +23 -0
  50. package/docs/releases/index.mdx +21 -0
  51. package/docs/releases/meta.json +15 -0
  52. package/docs/requirements.mdx +1 -1
  53. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  54. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  55. package/docs/site-development.md +1 -0
  56. package/docs/verification.mdx +22 -4
  57. package/package.json +3 -1
  58. package/raft.py +526 -193
  59. package/raft_files.py +13 -1
  60. package/raft_jobs.py +390 -0
  61. package/raft_output.py +166 -0
  62. package/raft_run.py +330 -0
  63. package/skills/raft-cli/SKILL.md +78 -182
package/raft.py CHANGED
@@ -24,8 +24,11 @@ import urllib.request
24
24
  import uuid
25
25
 
26
26
  import raft_files
27
+ import raft_run
28
+ import raft_jobs
29
+ import raft_output
27
30
 
28
- __version__ = "0.5.0"
31
+ __version__ = "0.7.0"
29
32
 
30
33
  CONFIG = Path.home() / ".config/raft/incus.json"
31
34
  # Controller defaults for new live apart from the location inventory, so location
@@ -195,7 +198,14 @@ def box_disk(box):
195
198
  # guardrail for agents: SSH and sudo on the host bypass it, and controllers
196
199
  # older than this check never run it.
197
200
  POLICY_KEY = "user.raft.require"
198
- POLICY_CHANGED_KEY = "user.raft.require.changed"
201
+ # Stopped-box retention, the second host policy: the host expiry worker deletes
202
+ # raft boxes that have been stopped for this many hours. Its .since key holds
203
+ # when the policy was last set, in epoch seconds: the retention clock starts at
204
+ # the later of a box's stop and that time, so setting or shortening a policy
205
+ # never deletes a box sooner than the given hours after the change. Mirrors
206
+ # RETENTION_KEY in deploy/raft-expire.py, which enforces it.
207
+ RETENTION_KEY = "user.raft.delete-stopped-after"
208
+ RETENTION_SINCE_KEY = RETENTION_KEY + ".since"
199
209
 
200
210
  # Host half of the check, run with the required fields and the note the box
201
211
  # will carry. Kept short; the controller builds the detailed message.
@@ -218,20 +228,53 @@ READ_NOTE_POLICY = f"require=$(incus project get raft {POLICY_KEY})\n"
218
228
  CHECK_NOTE = "python3 -c " + shlex.quote(CHECK_NOTE_CODE) + ' "$require" "$note"'
219
229
 
220
230
 
221
- def note_policy(location):
222
- """Return a location's required note fields and its last recorded policy change."""
231
+ def host_policy(location):
232
+ """Return a location's host policies and their last recorded changes, as raft config shows them."""
223
233
  # `incus query` rejects the --project flag that remote() always passes.
224
234
  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, "")
235
+ config = next(item for item in projects if item["name"] == PROJECT)["config"]
236
+ return parse_host_policy(location, config)
237
+
238
+
239
+ def parse_host_policy(location, config):
240
+ """Validate native policy values before displaying or enforcing them."""
241
+ stored = config.get(POLICY_KEY, "")
227
242
  required = [field for field in stored.split(",") if field]
228
243
  unknown = [field for field in required if field not in BOX_NOTE_FIELDS]
229
244
  if unknown:
230
245
  raise ValueError(
231
246
  f"{location}: host policy {POLICY_KEY}={stored!r} names unknown note fields; "
232
- f"fix it with raft policy {location} --require FIELDS or --clear"
247
+ f"fix it with raft policy {location} --require FIELDS or --require none"
233
248
  )
234
- return {"require": required, "changed": project["config"].get(POLICY_CHANGED_KEY, "")}
249
+ hours = config.get(RETENTION_KEY, "")
250
+ since = config.get(RETENTION_SINCE_KEY, "")
251
+ # Raft writes both as whole numbers; anything else came from a native Incus edit.
252
+ if hours and not (re.fullmatch(r"[1-9][0-9]*", hours) and re.fullmatch(r"[0-9]+", since)):
253
+ raise ValueError(
254
+ f"{location}: host policy {RETENTION_KEY}={hours!r} ({RETENTION_SINCE_KEY}={since!r}) "
255
+ f"is not a whole number of hours; fix it with raft policy {location} "
256
+ "--delete-stopped-after HOURS or --delete-stopped-after never"
257
+ )
258
+ return {
259
+ "require": required,
260
+ "requireChanged": config.get(POLICY_KEY + ".changed", ""),
261
+ "deleteStoppedAfterHours": int(hours) if hours else None,
262
+ "deleteStoppedAfterSince": int(since) if hours else None,
263
+ "deleteStoppedAfterChanged": config.get(RETENTION_KEY + ".changed", ""),
264
+ }
265
+
266
+
267
+ def delete_at(box, policy):
268
+ """When the host worker deletes a stopped box under the retention policy, or None.
269
+
270
+ Must match the deletion rule in deploy/raft-expire.py. A stopped box without
271
+ a recorded stop time has no deadline until the worker records one.
272
+ """
273
+ hours = policy["deleteStoppedAfterHours"]
274
+ stopped = box["config"].get("user.raft.stopped-at")
275
+ if box["status"] != "Stopped" or hours is None or not stopped:
276
+ return None
277
+ return max(int(stopped), policy["deleteStoppedAfterSince"]) + hours * 3600
235
278
 
236
279
 
237
280
  def note_problem(location, required, note, command, hint=""):
@@ -252,13 +295,15 @@ def note_problem(location, required, note, command, hint=""):
252
295
 
253
296
  def require_note(location, note, command, hint=""):
254
297
  """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)
298
+ problem = note_problem(location, host_policy(location)["require"], note, command, hint)
256
299
  if problem:
257
300
  raise ValueError(problem)
258
301
 
259
302
 
260
303
  def note_fields(text):
261
- """Parse --require FIELDS into BOX_NOTE_FIELDS order."""
304
+ """Parse --require FIELDS into BOX_NOTE_FIELDS order; none means no required fields."""
305
+ if text == "none":
306
+ return []
262
307
  fields = [field.strip() for field in text.split(",") if field.strip()]
263
308
  unknown = [field for field in fields if field not in BOX_NOTE_FIELDS]
264
309
  if unknown or not fields:
@@ -271,23 +316,69 @@ def note_fields(text):
271
316
  return [field for field in BOX_NOTE_FIELDS if field in fields]
272
317
 
273
318
 
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 [])
319
+ def retention_hours(text):
320
+ """Parse --delete-stopped-after HOURS: a positive whole number, or never for no retention."""
321
+ if text == "never":
322
+ return None
323
+ if not re.fullmatch(r"[1-9][0-9]*", text):
324
+ raise ValueError(
325
+ "Give a positive whole number of hours, e.g. --delete-stopped-after 48, or never"
326
+ )
327
+ return int(text)
328
+
329
+
330
+ def set_policy(location, key, value, journal, check=""):
331
+ """Set one host policy key to value, empty to clear it, and record the change.
332
+
333
+ Returns the previous value. journal names the policy in the host journal entry;
334
+ check is shell that must pass first, under the same lock.
335
+ """
278
336
  # Who and when is recorded on the host beside the policy and in its journal.
279
337
  # The controller identity is self-reported; SUDO_USER is the SSH account.
280
- before = transaction(
338
+ # Retention also records when it was set, for the worker's clock (see RETENTION_KEY).
339
+ since = f' {RETENTION_SINCE_KEY}="$([ -z "$1" ] || date +%s)"' if key == RETENTION_KEY else ""
340
+ return transaction(
281
341
  location,
282
- f"before=$(incus project get raft {POLICY_KEY})\n"
342
+ check + f"before=$(incus project get raft {key})\n"
283
343
  '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"
344
+ f'incus project set raft {key}="$1" {key}.changed="$changed"{since}\n'
345
+ f"logger -t raft \"{journal}: '$before' -> '$1' ($changed)\"\n"
286
346
  "printf '%s\\n' \"$before\"",
287
347
  value,
288
348
  f"{getpass.getuser()}@{socket.gethostname()}",
289
349
  ).strip()
290
- print(f"{location}: required note fields {before or 'none'} -> {value or 'none'}")
350
+
351
+
352
+ def change_policy(args):
353
+ """Apply raft policy: each given option sets or clears its own policy independently."""
354
+ if args.require is None and args.delete_stopped_after is None:
355
+ raise ValueError("Give --require FIELDS|none, --delete-stopped-after HOURS|never, or both")
356
+ settings(args.location)
357
+ # Parse every option before the first host change. Retention goes first: its
358
+ # worker check is the one step that can still refuse.
359
+ fields = None if args.require is None else note_fields(args.require)
360
+ hours = (
361
+ None if args.delete_stopped_after is None else retention_hours(args.delete_stopped_after)
362
+ )
363
+ if args.delete_stopped_after is not None:
364
+ value = "" if hours is None else str(hours)
365
+ before = set_policy(
366
+ args.location,
367
+ RETENTION_KEY,
368
+ value,
369
+ "Stopped-box retention: hours",
370
+ # Only a current worker enforces retention; clearing is safe on any worker.
371
+ check=CHECK_WORKER if hours else "",
372
+ )
373
+
374
+ def shown(stored):
375
+ return "never" if not stored else stored + (" hour" if stored == "1" else " hours")
376
+
377
+ print(f"{args.location}: delete stopped boxes after {shown(before)} -> {shown(value)}")
378
+ if fields is not None:
379
+ value = ",".join(fields)
380
+ before = set_policy(args.location, POLICY_KEY, value, "Box note policy: required fields")
381
+ print(f"{args.location}: required note fields {before or 'none'} -> {value or 'none'}")
291
382
 
292
383
 
293
384
  def receive(command, destination, *, stdin=None):
@@ -298,11 +389,11 @@ def receive(command, destination, *, stdin=None):
298
389
  ) as process:
299
390
  try:
300
391
  shutil.copyfileobj(process.stdout, destination)
392
+ returncode = process.wait()
301
393
  except BaseException:
302
394
  process.kill()
303
395
  process.wait()
304
396
  raise
305
- returncode = process.wait()
306
397
  errors.seek(0)
307
398
  return subprocess.CompletedProcess(command, returncode, None, errors.read())
308
399
 
@@ -571,17 +662,44 @@ def inventory(location):
571
662
  ]
572
663
 
573
664
 
665
+ # These probes retain --fast so stopped/stopping guests never need live counters.
666
+ HOST_BOXES = (
667
+ "project = "
668
+ + repr(PROJECT)
669
+ + "\n"
670
+ + """
671
+ print(json.dumps(json.loads(subprocess.run(['incus', '--project', project, 'list', '--fast', '--format', 'json'],
672
+ check=True, capture_output=True).stdout)))
673
+ """
674
+ )
675
+ HOST_INVENTORY = (
676
+ "project = "
677
+ + repr(PROJECT)
678
+ + "\n"
679
+ + """import json, subprocess
680
+ projects = json.loads(subprocess.run(['incus', '--project', project, 'project', 'list', '--format', 'json'],
681
+ check=True, capture_output=True).stdout)
682
+ boxes = json.loads(subprocess.run(['incus', '--project', project, 'list', '--fast', '--format', 'json'],
683
+ check=True, capture_output=True).stdout)
684
+ print(json.dumps({'policy': next(p for p in projects if p['name'] == project)['config'], 'boxes': boxes}))
685
+ """
686
+ )
687
+
688
+
574
689
  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")
575
690
  test "$state" = Stopped || { echo 'Stop the source box before resume, snapshot, restore, fork or backup' >&2; exit 1; }
576
691
  """
577
692
 
578
693
 
694
+ # Refuses a host whose expiry worker speaks another PROTOCOL than this CLI. Bump
695
+ # PROTOCOL here and in deploy/raft-expire.py together whenever the CLI starts to
696
+ # rely on new worker behavior; protocol 2 added stopped-box retention.
579
697
  CHECK_WORKER = (
580
698
  "python3 -c "
581
699
  + shlex.quote(
582
700
  "import runpy; "
583
701
  "worker = runpy.run_path('/usr/local/lib/raft/expire-worker'); "
584
- "\nif worker.get('PROTOCOL') != 1:\n"
702
+ "\nif worker.get('PROTOCOL') != 2:\n"
585
703
  " raise SystemExit('Host expiry worker differs from this CLI; deploy the current Raft checkout first')"
586
704
  )
587
705
  + "\n"
@@ -831,26 +949,44 @@ def capacity(host, boxes):
831
949
  }
832
950
 
833
951
 
834
- def host_capacity(location):
952
+ def host_read(location, script):
953
+ """Run a read-only host probe without taking the lifecycle/expiry lock."""
835
954
  response = subprocess.run(
836
955
  [
837
956
  "ssh",
957
+ "-n",
838
958
  "-T",
839
959
  "-o",
840
960
  "BatchMode=yes",
841
961
  "-o",
842
962
  "ConnectTimeout=10",
843
963
  settings(location)["ssh"],
844
- shlex.join(["sudo", "-n", "python3", "-c", HOST_RESOURCES]),
964
+ shlex.join(["sudo", "-n", "python3", "-c", script]),
845
965
  ],
846
966
  capture_output=True,
847
967
  text=True,
848
- check=True,
849
968
  )
969
+ if response.returncode:
970
+ raise RuntimeError(f"{location}: {response.stderr.strip()}")
971
+ return response.stdout
972
+
973
+
974
+ def host_inventory(location):
975
+ # Keep both native reads on the host: network latency costs more than JSON
976
+ # decoding. Do not lock read-only inventory behind a long backup or creation.
977
+ report = json.loads(host_read(location, HOST_INVENTORY))
978
+ policy = parse_host_policy(location, report["policy"])
979
+ boxes = [box for box in report["boxes"] if box["config"].get("user.raft") == "true"]
980
+ return policy, boxes
981
+
982
+
983
+ def host_capacity(location):
984
+ resources, boxes = host_read(location, HOST_RESOURCES + HOST_BOXES).splitlines()
985
+ boxes = [box for box in json.loads(boxes) if box["config"].get("user.raft") == "true"]
850
986
  return {
851
987
  "location": location,
852
988
  "note": settings(location).get("note", ""),
853
- **capacity(json.loads(response.stdout), inventory(location)),
989
+ **capacity(json.loads(resources), boxes),
854
990
  }
855
991
 
856
992
 
@@ -900,34 +1036,34 @@ def print_capacity(report):
900
1036
 
901
1037
 
902
1038
  def doctor(location):
903
- print(location + ": " + incus(location, "version").strip())
904
- try:
905
- transaction(location, CHECK_WORKER)
906
- except RuntimeError as error:
907
- raise RuntimeError(f"{location}: {error}") from error
908
- response = subprocess.run(
909
- [
910
- "ssh",
911
- "-T",
912
- "-o",
913
- "BatchMode=yes",
914
- "-o",
915
- "ConnectTimeout=10",
916
- settings(location)["ssh"],
917
- "systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
918
- ],
919
- capture_output=True,
920
- text=True,
921
- check=True,
1039
+ # Health checks are sequential on the host, not separate SSH handshakes.
1040
+ # Only the worker compatibility check takes the lifecycle lock, as before.
1041
+ script = "import json, subprocess, sys\nworker_check = " + repr(CHECK_WORKER) + "\n"
1042
+ script += (
1043
+ f"project = {PROJECT!r}\nshared_pool = {SHARED_POOL!r}\nbox_disk_pool = {BOX_DISK_POOL!r}\n"
922
1044
  )
923
- print(response.stdout.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)")
930
- print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
1045
+ script += """
1046
+ def checked(*command):
1047
+ response = subprocess.run(command, capture_output=True, text=True)
1048
+ if response.returncode:
1049
+ sys.exit(response.stderr.strip() or response.stdout.strip())
1050
+ return response.stdout.strip()
1051
+ print(checked('incus', '--project', project, 'version'))
1052
+ checked('flock', '/run/lock/raft-incus.lock', 'sh', '-ec', worker_check)
1053
+ print(checked('systemctl', 'is-active', 'incus', 'raft-expire.timer', 'raft-network.service'))
1054
+ print(checked('df', '-h', '/'))
1055
+ print(checked('incus', '--project', project, 'storage', 'info', shared_pool))
1056
+ pools = checked('incus', '--project', project, 'storage', 'list', '--format', 'csv', '-c', 'n').split()
1057
+ if box_disk_pool in pools:
1058
+ print(checked('incus', '--project', project, 'storage', 'info', box_disk_pool))
1059
+ else:
1060
+ print('own box disks: not enabled (redeploy with --disk-pool SIZE)')
1061
+ boxes = json.loads(checked('incus', '--project', project, 'list', '--fast', '--format', 'json'))
1062
+ print(str(sum(box['config'].get('user.raft') == 'true' for box in boxes))
1063
+ + ' saved boxes; system containers (shared host kernel)')
1064
+ """
1065
+ print(location + ": ", end="", flush=True)
1066
+ print(host_read(location, script), end="")
931
1067
 
932
1068
 
933
1069
  def transaction(location, script, *arguments, stdout=None):
@@ -962,6 +1098,57 @@ def transaction(location, script, *arguments, stdout=None):
962
1098
  return response.stdout.decode() if response.stdout is not None else ""
963
1099
 
964
1100
 
1101
+ def sync_source(args, location, name):
1102
+ """Use rsync's checksum/delta protocol inside the guest, never on the host tree."""
1103
+ if shutil.which("rsync") is None:
1104
+ raise ValueError("sync requires rsync on the controller; install rsync first")
1105
+ source = Path(args.source).resolve(strict=True)
1106
+ if not source.is_dir():
1107
+ raise ValueError("Sync source must be a directory")
1108
+ destination = raft_run.guest_path(args.destination)
1109
+ if destination == "/":
1110
+ raise ValueError("Sync destination must not be the guest root directory")
1111
+ selected = None if args.all else raft_run.git_files(source, all_option="--all")
1112
+ filters = None
1113
+ if selected is not None:
1114
+ # Recursive traversal never follows directory symlinks. A files-from list
1115
+ # would follow symlink ancestors when a tracked directory was replaced.
1116
+ allowed = set(selected)
1117
+ for path in selected:
1118
+ allowed.update(str(parent) for parent in Path(path).parents if str(parent) != ".")
1119
+ rules = []
1120
+ for path in sorted(allowed):
1121
+ if any(char in path for char in "*?["):
1122
+ path = re.sub(r"([\\*?\[])", r"\\\1", path)
1123
+ rules.append(b"+ /" + os.fsencode(path))
1124
+ filters = b"\0".join([*rules, b"- *", b""])
1125
+ receiver = shlex.join(
1126
+ ["sudo", "-n", "incus", "--project", PROJECT, "exec", name, "--", "rsync"]
1127
+ )
1128
+ command = [
1129
+ "rsync",
1130
+ "--recursive",
1131
+ "--links",
1132
+ "--safe-links",
1133
+ "--times",
1134
+ "--perms",
1135
+ "--checksum",
1136
+ "--omit-dir-times",
1137
+ "--itemize-changes",
1138
+ "--secluded-args",
1139
+ "--rsync-path",
1140
+ receiver,
1141
+ "-e",
1142
+ "ssh -T -o BatchMode=yes -o ConnectTimeout=10",
1143
+ ]
1144
+ if filters is not None:
1145
+ command += ["--from0", "--filter=merge -"]
1146
+ if args.dry_run:
1147
+ command.append("--dry-run")
1148
+ command += ["--", str(source) + "/", settings(location)["ssh"] + ":" + destination + "/"]
1149
+ return subprocess.run(command, input=filters).returncode
1150
+
1151
+
965
1152
  def snapshot_name(value):
966
1153
  if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", value):
967
1154
  raise ValueError("Invalid snapshot name")
@@ -1018,7 +1205,7 @@ def own_disk(size):
1018
1205
  ]
1019
1206
 
1020
1207
 
1021
- def new(args):
1208
+ def new(args, *, run_id=None, on_allocate=None):
1022
1209
  box_fields = box_note(args)
1023
1210
  # Sizes are filled here only for boxes from the image; a box from a snapshot
1024
1211
  # keeps the snapshot's CPU, RAM and disk unless a flag overrides them.
@@ -1047,10 +1234,13 @@ def new(args):
1047
1234
  print(f"{args.location} note: {note}", file=sys.stderr)
1048
1235
  require_note(args.location, box_fields, "new")
1049
1236
  name = "rf-" + uuid.uuid4().hex[:16]
1237
+ if on_allocate is not None:
1238
+ on_allocate(args.location, name)
1050
1239
  # Initialize before starting, so a failed start still has its intended policy.
1051
1240
  transaction(
1052
1241
  args.location,
1053
- (CHECK_WORKER if args.disposable else "")
1242
+ (raft_run.creation_guard(run_id) if run_id else "")
1243
+ + (CHECK_WORKER if args.disposable else "")
1054
1244
  + fits_host(args)
1055
1245
  # Plain boxes from the image stay off raft-disks and skip the check.
1056
1246
  + (
@@ -1069,10 +1259,15 @@ def new(args):
1069
1259
  + CHECK_NOTE
1070
1260
  # Always set the note, so a box created from a snapshot never inherits the
1071
1261
  # source note. The host checks the same value it writes.
1072
- + '\n"$@" -c user.raft.note="$note"; '
1073
- 'incus --project raft start "$name"; '
1074
- 'incus --project raft config set "$name" '
1075
- 'user.raft.expires="$(( $(date +%s) + lifetime ))"',
1262
+ + '\n"$@" -c user.raft.note="$note"'
1263
+ + (' -c user.raft.expires="$(( $(date +%s) + lifetime ))"' if run_id else "")
1264
+ + '; incus --project raft start "$name"; '
1265
+ + (
1266
+ ""
1267
+ if run_id
1268
+ else 'incus --project raft config set "$name" '
1269
+ 'user.raft.expires="$(( $(date +%s) + lifetime ))"'
1270
+ ),
1076
1271
  str(args.ttl),
1077
1272
  name,
1078
1273
  json.dumps(box_fields) if box_fields else "",
@@ -1084,8 +1279,8 @@ def new(args):
1084
1279
  name,
1085
1280
  "-c",
1086
1281
  "user.raft=true",
1087
- "-c",
1088
- "user.raft.expires=",
1282
+ *([] if run_id else ["-c", "user.raft.expires="]),
1283
+ *(["-c", f"user.raft.run={run_id}"] if run_id else []),
1089
1284
  "-c",
1090
1285
  "user.raft.stopped-at=",
1091
1286
  "-c",
@@ -1101,15 +1296,15 @@ def new(args):
1101
1296
  return f"{args.location}:{name}"
1102
1297
 
1103
1298
 
1104
- def execute(location, name, command, *, capture=False):
1105
- return remote(location, ["exec", name, "--cwd", "/workspace", "--", *command], capture=capture)
1299
+ def execute(location, name, command, *, capture=False, cwd="/workspace"):
1300
+ return remote(location, ["exec", name, "--cwd", cwd, "--", *command], capture=capture)
1106
1301
 
1107
1302
 
1108
1303
  def transfer_tree(args, location, name):
1109
1304
  worker = Path(raft_files.__file__).read_text()
1110
1305
  with tempfile.TemporaryFile() as archive:
1111
1306
  if args.action == "upload":
1112
- raft_files.pack_directory(args.source, archive)
1307
+ raft_files.pack_directory(args.source, archive, include=getattr(args, "include", None))
1113
1308
  size = archive.tell()
1114
1309
  archive.seek(0)
1115
1310
  response = remote(
@@ -1308,6 +1503,18 @@ def location_name(value):
1308
1503
  return value
1309
1504
 
1310
1505
 
1506
+ def boolean(value):
1507
+ """Parse a true or false setting, e.g. raft config set disposable true."""
1508
+ if value not in {"true", "false"}:
1509
+ raise argparse.ArgumentTypeError("Use true or false")
1510
+ return value == "true"
1511
+
1512
+
1513
+ def shown_default(value):
1514
+ """Print a default as it is typed: true and false, not Python's True and False."""
1515
+ return json.dumps(value) if isinstance(value, bool) else value
1516
+
1517
+
1311
1518
  # Options of new that ~/.config/raft/config.json can default, with their built-in
1312
1519
  # defaults and value checks. Values go through str() first, so a configured value
1313
1520
  # is accepted exactly when the same text passed as the flag would be. Adding a
@@ -1319,6 +1526,9 @@ NEW_DEFAULTS = {
1319
1526
  "cpu": (DEFAULT_CPU, cpu_count),
1320
1527
  "memory": (DEFAULT_MEMORY, gib_size),
1321
1528
  "disk": ("shared", disk_size),
1529
+ # new --disposable and --persistent override it. Unlike the sizes it applies
1530
+ # to new --from too: a snapshot carries no lifecycle, new always sets one.
1531
+ "disposable": (False, boolean),
1322
1532
  }
1323
1533
  # A box from a snapshot keeps the snapshot's size, so these defaults, configured
1324
1534
  # or built-in, apply only to boxes created from the image.
@@ -1333,7 +1543,8 @@ def default_value(key, value):
1333
1543
  "supported keys: " + ", ".join(NEW_DEFAULTS)
1334
1544
  )
1335
1545
  try:
1336
- return NEW_DEFAULTS[key][1](str(value))
1546
+ # JSON true and false read back as the true and false text the CLI accepts.
1547
+ return NEW_DEFAULTS[key][1](str(shown_default(value)))
1337
1548
  except (ValueError, argparse.ArgumentTypeError) as error:
1338
1549
  raise ValueError(
1339
1550
  f"{DEFAULTS_CONFIG}: defaults.{key}={value!r} is invalid: {error}"
@@ -1445,8 +1656,10 @@ def set_default(key, value):
1445
1656
  document.pop("defaults")
1446
1657
 
1447
1658
  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}")
1659
+ before = shown_default(previous.get(key, "built-in default"))
1660
+ print(
1661
+ f"defaults.{key}: {before} -> {'built-in default' if value is None else shown_default(value)}"
1662
+ )
1450
1663
 
1451
1664
 
1452
1665
  # GitHub releases are the one source of version truth. The release workflow
@@ -1689,27 +1902,26 @@ def note_options(command):
1689
1902
  )
1690
1903
 
1691
1904
 
1692
- def parser():
1693
- cli = argparse.ArgumentParser(description=__doc__)
1694
- cli.add_argument("--version", action="version", version=__version__)
1695
- commands = cli.add_subparsers(dest="action", required=True)
1696
- create = commands.add_parser("new")
1697
- create.add_argument("--location", default=None)
1905
+ def creation_options(command, *, lifecycle=True):
1906
+ command.add_argument("--location", default=None)
1698
1907
  # Omitted options take ~/.config/raft/config.json defaults, then NEW_DEFAULTS.
1699
1908
  # With --from, every omitted size comes from the snapshot instead of a default.
1700
- create.add_argument("--ttl", type=ttl)
1701
- create.add_argument(
1909
+ if lifecycle:
1910
+ command.add_argument(
1911
+ "--ttl", type=ttl, help="Lifetime in seconds, 60 to 2592000 (default: config, else 600)"
1912
+ )
1913
+ command.add_argument(
1702
1914
  "--cpu",
1703
1915
  type=cpu_count,
1704
1916
  help=f"CPU limit (default: config, else {DEFAULT_CPU}; with --from, the snapshot's)",
1705
1917
  )
1706
- create.add_argument(
1918
+ command.add_argument(
1707
1919
  "--memory",
1708
1920
  type=gib_size,
1709
1921
  help="RAM limit, e.g. 16GiB "
1710
1922
  f"(default: config, else {DEFAULT_MEMORY}; with --from, the snapshot's)",
1711
1923
  )
1712
- create.add_argument(
1924
+ command.add_argument(
1713
1925
  "--disk",
1714
1926
  type=disk_size,
1715
1927
  metavar="SIZE",
@@ -1717,13 +1929,62 @@ def parser():
1717
1929
  "or shared for the shared pool "
1718
1930
  "(default: config, else shared; with --from, the snapshot's disk)",
1719
1931
  )
1720
- create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
1721
- create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
1722
- note_options(create)
1932
+ if lifecycle:
1933
+ policy = command.add_mutually_exclusive_group()
1934
+ policy.add_argument(
1935
+ "--disposable",
1936
+ dest="disposable",
1937
+ action="store_const",
1938
+ const=True,
1939
+ help="Delete on stop or expiry (default: config, else persistent)",
1940
+ )
1941
+ policy.add_argument(
1942
+ "--persistent",
1943
+ dest="disposable",
1944
+ action="store_const",
1945
+ const=False,
1946
+ help="Keep the disk on stop or expiry, despite a disposable default",
1947
+ )
1948
+ command.add_argument(
1949
+ "--from",
1950
+ dest="template",
1951
+ metavar="LOCATION:BOX/SNAPSHOT",
1952
+ help="Copy a named snapshot on its source host, including filesystem secrets",
1953
+ )
1954
+ note_options(command)
1955
+
1956
+
1957
+ def parser():
1958
+ cli = raft_output.Parser(description=__doc__)
1959
+ cli.add_argument(
1960
+ "--json",
1961
+ dest="json_output",
1962
+ action="store_true",
1963
+ help="Emit JSONL data, byte-stream, error and exit events; use before the command",
1964
+ )
1965
+ cli.add_argument("--version", action="version", version=__version__)
1966
+ commands = cli.add_subparsers(dest="action", required=True)
1967
+ complete = commands.add_parser("completions", help="Print parser-driven shell completion setup")
1968
+ complete.add_argument("shell", choices=["bash", "zsh", "fish"])
1969
+ query = commands.add_parser("_complete")
1970
+ query.add_argument("words", nargs=argparse.REMAINDER)
1971
+ create = commands.add_parser(
1972
+ "new",
1973
+ help="Create a workspace and print its location-qualified handle",
1974
+ description="Create from the pinned image or a same-host named snapshot. "
1975
+ "Prints the handle before guest services are necessarily ready. "
1976
+ "Inspect config for defaults and required notes, and capacity for host resources. "
1977
+ "Disposable boxes are deleted on stop or expiry; persistent disks remain subject to host retention.",
1978
+ )
1979
+ creation_options(create)
1980
+ run = commands.add_parser("run", help="Run a command in a temporary CI box and clean up")
1981
+ creation_options(run, lifecycle=False)
1982
+ raft_run.add_options(run)
1723
1983
  commands.add_parser("locations", help="List configured locations and their notes")
1724
1984
  defaults = commands.add_parser(
1725
1985
  "config", help="Show defaults for new and each location's note policy, or change defaults"
1726
1986
  )
1987
+ defaults.add_argument("--location", help="Read policy only from this configured host")
1727
1988
  default_commands = defaults.add_subparsers(dest="config_action")
1728
1989
  default_set = default_commands.add_parser("set", help="Set a default for new")
1729
1990
  default_set.add_argument("key", choices=list(NEW_DEFAULTS))
@@ -1731,12 +1992,20 @@ def parser():
1731
1992
  default_unset = default_commands.add_parser("unset", help="Restore a built-in default")
1732
1993
  default_unset.add_argument("key", choices=list(NEW_DEFAULTS))
1733
1994
  policy = commands.add_parser(
1734
- "policy", help="Set or clear the box note fields a location's host requires"
1995
+ "policy",
1996
+ help="Set a location's required box note fields and stopped-box retention",
1735
1997
  )
1736
1998
  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")
1999
+ policy.add_argument(
2000
+ "--require",
2001
+ metavar="FIELDS|none",
2002
+ help="Note fields every box must carry, e.g. purpose,owner; none stops requiring them",
2003
+ )
2004
+ policy.add_argument(
2005
+ "--delete-stopped-after",
2006
+ metavar="HOURS|never",
2007
+ help="Delete boxes stopped for HOURS, e.g. 48; never keeps them",
2008
+ )
1740
2009
  note = commands.add_parser("note", help="Set or clear the note of a location or a box")
1741
2010
  note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
1742
2011
  note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
@@ -1771,13 +2040,20 @@ def parser():
1771
2040
  help="Enable own box disks: set aside SIZE of host disk for them, e.g. 40GiB "
1772
2041
  "(default: not enabled)",
1773
2042
  )
1774
- listing = commands.add_parser("list")
2043
+ listing = commands.add_parser(
2044
+ "list",
2045
+ help="List boxes as JSONL, including ownership notes and retention deadlines",
2046
+ description="List boxes as JSONL. Read note before using a box; deleteAt is its stopped-box deletion deadline, or empty when none is due.",
2047
+ )
1775
2048
  listing.add_argument("--location")
1776
2049
  report = commands.add_parser("capacity", help=ADVISORY, description=ADVISORY)
1777
2050
  report.add_argument("--location")
1778
2051
  report.add_argument("--json", action="store_true")
1779
- commands.add_parser("doctor")
1780
- gc = commands.add_parser("gc", help="Enforce deadlines, including disposable deletion")
2052
+ doctor_command = commands.add_parser("doctor", help="Check host services, worker and storage")
2053
+ doctor_command.add_argument("--location", help="Inspect only this configured host")
2054
+ gc = commands.add_parser(
2055
+ "gc", help="Enforce deadlines and retention, including disposable deletion"
2056
+ )
1781
2057
  gc.add_argument("--location")
1782
2058
  prune = commands.add_parser("prune", help="Preview deletion of old stopped boxes")
1783
2059
  prune.add_argument("--location")
@@ -1802,33 +2078,38 @@ def parser():
1802
2078
  metavar="X.Y.Z",
1803
2079
  help="Install this release instead of the latest, e.g. to roll back",
1804
2080
  )
1805
- for action in [
1806
- "info",
1807
- "stop",
1808
- "resume",
1809
- "extend",
1810
- "destroy",
1811
- "ssh",
1812
- "exec",
1813
- "upload",
1814
- "download",
1815
- "snapshot",
1816
- "snapshots",
1817
- "snapshot-delete",
1818
- "restore",
1819
- "fork",
1820
- "forward",
1821
- "desktop",
1822
- "logs",
1823
- "status",
1824
- "cancel",
1825
- "backup",
1826
- "usage",
1827
- ]:
1828
- command = commands.add_parser(action)
1829
- command.add_argument("box")
2081
+ for action, description in {
2082
+ "info": "Read native box metadata as JSON without starting guest processes.",
2083
+ "stop": "Stop processes in a persistent box and retain its disk. Disposable boxes and their snapshots are deleted. Inspect info before stopping.",
2084
+ "resume": "Start a stopped persistent box with a new lifetime. Optional CPU and memory replace only those limits. Process memory is not restored.",
2085
+ "extend": "Reset the deadline of a running box from the host clock without restarting it. Use resume for stopped boxes.",
2086
+ "destroy": "Delete a box and all its snapshots, then confirm absence. Only destroy boxes owned by this task unless explicitly authorized.",
2087
+ "ssh": "Open a root shell through host SSH and Incus exec. This does not expose a guest SSH port.",
2088
+ "exec": "Run literal command arguments in /workspace and retain the exit status. Use an explicit shell for pipelines or expansions. Detached execution returns a job ID for status, logs and cancel.",
2089
+ "sync": "Incrementally sync source into a running guest. Matching files are replaced; guest-only files remain. Requires rsync on controller and guest. Git selects tracked and unignored files by default; --all includes ignored files. Unsafe symlinks and special files are skipped by rsync. Updates are atomic per file, not per tree.",
2090
+ "upload": "Copy a local file to an absolute guest path. Recursive transfers require a running guest and publish a new directory without merging or overwriting; escaping links and special files are rejected.",
2091
+ "download": "Copy an absolute guest path to a local file. Recursive transfers require a running guest and a new destination directory; the parent must exist. Failed downloads do not replace existing files.",
2092
+ "snapshot": "Save a named disk snapshot of a stopped persistent box. Snapshots disappear when their box is deleted, including by retention.",
2093
+ "snapshots": "List named snapshots through native Incus info.",
2094
+ "snapshot-delete": "Delete one named snapshot permanently without deleting its box.",
2095
+ "restore": "Replace disk contents from a named snapshot of a stopped box. Keep current instance configuration, resource limits and MAC address. Requires an explicitly requested rollback.",
2096
+ "fork": "Copy a stopped box on the same host, including files, secrets and note. Defaults to persistent. CPU and memory retain source limits unless overridden. Update the copy note for its new task.",
2097
+ "forward": "Open a foreground SSH tunnel bound to controller 127.0.0.1. Guest service must listen on its managed NIC or 0.0.0.0. Ctrl+C closes the tunnel.",
2098
+ "desktop": "Start the full guest desktop and open its private foreground tunnel. Closing the tunnel leaves the desktop service running until explicitly stopped or the box stops.",
2099
+ "logs": "Print retained detached-job output, including after workspace stop or deletion. First 1 MiB retained for seven days.",
2100
+ "status": "Print retained job state, exit code, timestamps and dropped bytes as JSON, even after workspace deletion.",
2101
+ "cancel": "Stop the guest job control group. Host-retained output remains available for seven days.",
2102
+ "backup": "Export a stopped persistent box and its snapshots to a new private archive. Includes credentials. The host lifecycle lock delays expiry while exporting.",
2103
+ "usage": "Report running guest cgroup CPU and memory counters and filesystem space as JSON. filesystemScope distinguishes shared storage from an own disk.",
2104
+ }.items():
2105
+ command = commands.add_parser(
2106
+ action, help=description.split(". ")[0], description=description
2107
+ )
2108
+ command.add_argument("box", help="Exact LOCATION:BOX handle returned by creation")
1830
2109
  if action in ["resume", "fork", "extend"]:
1831
- command.add_argument("--ttl", type=ttl, required=True)
2110
+ command.add_argument(
2111
+ "--ttl", type=ttl, required=True, help="Lifetime in seconds, 60 to 2592000"
2112
+ )
1832
2113
  if action in ["resume", "fork"]:
1833
2114
  command.add_argument("--cpu", type=cpu_count)
1834
2115
  command.add_argument("--memory", type=gib_size)
@@ -1838,6 +2119,17 @@ def parser():
1838
2119
  if action == "exec":
1839
2120
  command.add_argument("--detach", action="store_true")
1840
2121
  command.add_argument("command", nargs="+")
2122
+ if action == "sync":
2123
+ command.add_argument("source")
2124
+ command.add_argument("destination")
2125
+ command.add_argument(
2126
+ "--all",
2127
+ action="store_true",
2128
+ help="Include ignored and hidden files; inspect for secrets first",
2129
+ )
2130
+ command.add_argument(
2131
+ "--dry-run", action="store_true", help="Report changes without writing guest files"
2132
+ )
1841
2133
  if action in ["upload", "download"]:
1842
2134
  command.add_argument("source")
1843
2135
  command.add_argument("destination")
@@ -1857,6 +2149,13 @@ def parser():
1857
2149
 
1858
2150
 
1859
2151
  def dispatch(args):
2152
+ if getattr(args, "json_output", False) and args.action in {"ssh", "forward", "desktop"}:
2153
+ raise ValueError("JSON mode does not support interactive shells or foreground tunnels")
2154
+ if args.action == "completions":
2155
+ print(raft_output.completions(args.shell), end="")
2156
+ return 0
2157
+ if args.action == "run":
2158
+ return raft_run.run(args, sys.modules[__name__])
1860
2159
  if args.action == "self-update":
1861
2160
  return self_update(args)
1862
2161
  if args.action == "recover":
@@ -1871,17 +2170,22 @@ def dispatch(args):
1871
2170
  if args.action == "config":
1872
2171
  if args.config_action is None:
1873
2172
  report = {"file": str(DEFAULTS_CONFIG), "defaults": effective_defaults(), "policy": {}}
1874
- for location in configuration():
2173
+ locations = [args.location] if args.location else configuration()
2174
+ for location in locations:
1875
2175
  try:
1876
- report["policy"][location] = note_policy(location)
2176
+ report["policy"][location] = host_policy(location)
1877
2177
  except RuntimeError as error:
1878
2178
  raise RuntimeError(f"{location}: {error}") from error
1879
- print(json.dumps(report, indent=2))
2179
+ raft_output.data(report, indent=2)
1880
2180
  else:
2181
+ if args.location:
2182
+ raise ValueError(
2183
+ "--location selects a host policy report; defaults are controller-wide"
2184
+ )
1881
2185
  set_default(args.key, args.value if args.config_action == "set" else None)
1882
2186
  return 0
1883
2187
  if args.action == "policy":
1884
- set_policy(args.location, None if args.clear else note_fields(args.require))
2188
+ change_policy(args)
1885
2189
  return 0
1886
2190
  if args.action == "note":
1887
2191
  fields = box_note(args)
@@ -1919,7 +2223,7 @@ def dispatch(args):
1919
2223
  if args.action == "locations":
1920
2224
  for index, (location, entry) in enumerate(configuration().items()):
1921
2225
  record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
1922
- print(json.dumps(record))
2226
+ raft_output.data(record)
1923
2227
  return 0
1924
2228
  if args.action in ["list", "capacity", "doctor", "gc", "prune"]:
1925
2229
  if args.action == "prune" and args.older_than < 1:
@@ -1930,8 +2234,8 @@ def dispatch(args):
1930
2234
  for location in locations:
1931
2235
  if args.action == "capacity":
1932
2236
  report = host_capacity(location)
1933
- if args.json:
1934
- print(json.dumps(report))
2237
+ if args.json or args.json_output:
2238
+ raft_output.data(report)
1935
2239
  else:
1936
2240
  print_capacity(report)
1937
2241
  elif args.action in ["gc", "prune"]:
@@ -1966,31 +2270,70 @@ def dispatch(args):
1966
2270
  for line in output.splitlines():
1967
2271
  record = json.loads(line)
1968
2272
  record["box"] = location + ":" + record["box"]
1969
- print(json.dumps(record))
2273
+ raft_output.data(record)
1970
2274
  else:
1971
2275
  print(output, end="")
1972
2276
  elif args.action == "doctor":
1973
2277
  doctor(location)
1974
2278
  else:
1975
- for box in inventory(location):
1976
- print(
1977
- json.dumps(
1978
- {
1979
- "box": location + ":" + box["name"],
1980
- "state": box["status"],
1981
- "stopAt": box["config"].get("user.raft.expires", ""),
1982
- "disk": box_disk(box),
1983
- "note": read_box_note(box),
1984
- }
1985
- )
2279
+ policy, boxes = host_inventory(location)
2280
+ for box in boxes:
2281
+ deletion = delete_at(box, policy)
2282
+ raft_output.data(
2283
+ {
2284
+ "box": location + ":" + box["name"],
2285
+ "state": box["status"],
2286
+ "stopAt": box["config"].get("user.raft.expires", ""),
2287
+ "deleteAt": "" if deletion is None else str(deletion),
2288
+ "disk": box_disk(box),
2289
+ "note": read_box_note(box),
2290
+ }
1986
2291
  )
1987
2292
  return 0
2293
+ if args.action in {"logs", "status", "cancel"}:
2294
+ location, name = parse_handle(args.box)
2295
+ if not re.fullmatch(r"rfcmd-[a-f0-9]{32}", args.job):
2296
+ raise ValueError("Use the job ID returned by exec --detach")
2297
+ command = shlex.join(
2298
+ [
2299
+ "sudo",
2300
+ "-n",
2301
+ "python3",
2302
+ "-c",
2303
+ Path(raft_jobs.__file__).read_text(),
2304
+ args.action,
2305
+ name,
2306
+ args.job,
2307
+ ]
2308
+ )
2309
+ response = subprocess.run(
2310
+ [
2311
+ "ssh",
2312
+ "-T",
2313
+ "-o",
2314
+ "BatchMode=yes",
2315
+ "-o",
2316
+ "ConnectTimeout=10",
2317
+ settings(location)["ssh"],
2318
+ command,
2319
+ ],
2320
+ capture_output=True,
2321
+ )
2322
+ if response.returncode:
2323
+ raise RuntimeError(response.stderr.decode(errors="replace").strip())
2324
+ if args.action == "status":
2325
+ record = json.loads(response.stdout)
2326
+ record["box"] = args.box
2327
+ print(json.dumps(record))
2328
+ else:
2329
+ sys.stdout.buffer.write(response.stdout)
2330
+ return 0
1988
2331
  # Only fork accepts note flags here; check them before contacting the host.
1989
2332
  fields = box_note(args)
1990
2333
  location, name = parse_handle(args.box)
1991
2334
  box = instance(location, name)
1992
2335
  if args.action == "info":
1993
- print(json.dumps(box, indent=2))
2336
+ raft_output.data(box, indent=2)
1994
2337
  elif args.action == "backup":
1995
2338
  backup(args, location, name)
1996
2339
  elif args.action == "usage":
@@ -2003,7 +2346,7 @@ def dispatch(args):
2003
2346
  usage["filesystemScope"] = (
2004
2347
  "shared Raft pool, not per-box usage" if disk == "shared" else f"own {disk} box disk"
2005
2348
  )
2006
- print(json.dumps(usage))
2349
+ raft_output.data(usage)
2007
2350
  elif args.action == "destroy":
2008
2351
  incus(location, "delete", name, "--force", locked=True)
2009
2352
  if any(item["name"] == name for item in inventory(location)):
@@ -2060,26 +2403,21 @@ def dispatch(args):
2060
2403
  raise ValueError("Supply a command after --")
2061
2404
  if args.detach:
2062
2405
  job = "rfcmd-" + uuid.uuid4().hex
2063
- response = execute(
2064
- location,
2065
- name,
2066
- [
2067
- "systemd-run",
2068
- "--quiet",
2069
- "--unit",
2406
+ source = Path(raft_jobs.__file__).read_text()
2407
+ print(
2408
+ transaction(
2409
+ location,
2410
+ 'exec python3 -c "$1" start "$2" "$3" "$4" "$1"',
2411
+ source,
2412
+ name,
2070
2413
  job,
2071
- "--property=WorkingDirectory=/workspace",
2072
- "--property=RemainAfterExit=yes",
2073
- "--",
2074
- *payload,
2075
- ],
2076
- capture=True,
2414
+ json.dumps(payload),
2415
+ ).strip()
2077
2416
  )
2078
- if response.returncode:
2079
- raise RuntimeError(response.stderr.decode().strip())
2080
- print(job)
2081
2417
  else:
2082
2418
  return execute(location, name, payload).returncode
2419
+ elif args.action == "sync":
2420
+ return sync_source(args, location, name)
2083
2421
  elif args.action in ["upload", "download"]:
2084
2422
  guest_path = args.destination if args.action == "upload" else args.source
2085
2423
  if not guest_path.startswith("/"):
@@ -2154,44 +2492,6 @@ def dispatch(args):
2154
2492
  *sizing(args),
2155
2493
  )
2156
2494
  print(location + ":" + child)
2157
- elif args.action in ["logs", "status", "cancel"]:
2158
- if not re.fullmatch(r"rfcmd-[a-f0-9]{32}", args.job):
2159
- raise ValueError("Use the job ID returned by exec --detach")
2160
- loaded = execute(
2161
- location,
2162
- name,
2163
- ["systemctl", "show", args.job, "-p", "LoadState", "--value"],
2164
- capture=True,
2165
- )
2166
- if loaded.returncode or loaded.stdout.strip() != b"loaded":
2167
- # Cancelled transient units unload, but their journal keeps history.
2168
- # Live units remain recognizable even after old logs are rotated away.
2169
- recorded = execute(
2170
- location,
2171
- name,
2172
- ["journalctl", "--no-pager", "--quiet", "-o", "json", "-n", "1", "-u", args.job],
2173
- capture=True,
2174
- )
2175
- if recorded.returncode or not recorded.stdout.strip():
2176
- raise ValueError("Detached job not found in this running box")
2177
- if args.action == "cancel":
2178
- return execute(location, name, ["systemctl", "stop", args.job]).returncode
2179
- command = (
2180
- ["journalctl", "--no-pager", "-o", "cat", "-u", args.job]
2181
- if args.action == "logs"
2182
- else [
2183
- "systemctl",
2184
- "show",
2185
- args.job,
2186
- "-p",
2187
- "ActiveState",
2188
- "-p",
2189
- "SubState",
2190
- "-p",
2191
- "ExecMainStatus",
2192
- ]
2193
- )
2194
- return execute(location, name, command).returncode
2195
2495
  elif args.action in ["forward", "desktop"]:
2196
2496
  port = args.remote if args.action == "forward" else 6080
2197
2497
  if not 1 <= port <= 65535 or not 1 <= args.local <= 65535:
@@ -2204,16 +2504,42 @@ def dispatch(args):
2204
2504
  return 0
2205
2505
 
2206
2506
 
2507
+ def exit_status(returncode):
2508
+ """Translate Python subprocess signals into shell-compatible process statuses."""
2509
+ return 128 - returncode if returncode < 0 else returncode
2510
+
2511
+
2207
2512
  def main():
2208
2513
  if sys.version_info < (3, 11, 8):
2209
2514
  print("raft: Python 3.11.8 or newer is required", file=sys.stderr)
2210
2515
  return 1
2211
2516
  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)
2517
+ # Only root options select this mode. A payload's --json is its own argument.
2518
+ root_options = []
2519
+ for argument in sys.argv[1:]:
2520
+ if not argument.startswith("-") or argument == "--":
2521
+ break
2522
+ root_options.append(argument)
2523
+ output = raft_output.JsonOutput().start() if "--json" in root_options else None
2524
+ status = 1
2525
+ notice = None
2215
2526
  try:
2527
+ cli = parser()
2528
+ args = cli.parse_args()
2529
+ if args.action == "_complete":
2530
+ words = args.words[1:] if args.words[:1] == ["--"] else args.words
2531
+ print("\n".join(raft_output.candidates(cli, words)))
2532
+ status = 0
2533
+ return status
2534
+ notice = (
2535
+ None
2536
+ if output or args.action in {"completions", "_complete"}
2537
+ else update_check(args.action, RELEASES_API)
2538
+ )
2216
2539
  status = dispatch(args)
2540
+ except raft_output.UsageError as error:
2541
+ raft_output.error("usage", error)
2542
+ status = 2
2217
2543
  except (
2218
2544
  OSError,
2219
2545
  ValueError,
@@ -2221,11 +2547,18 @@ def main():
2221
2547
  tarfile.TarError,
2222
2548
  subprocess.CalledProcessError,
2223
2549
  ) as error:
2224
- print(f"raft: {error}", file=sys.stderr)
2550
+ raft_output.error("operation", error)
2225
2551
  status = 1
2552
+ except SystemExit as error:
2553
+ status = error.code if isinstance(error.code, int) else 1
2226
2554
  except KeyboardInterrupt:
2227
- return 130
2228
- notice()
2555
+ status = 130
2556
+ finally:
2557
+ status = exit_status(status)
2558
+ if notice and status != 130:
2559
+ notice()
2560
+ if output:
2561
+ output.finish(status)
2229
2562
  return status
2230
2563
 
2231
2564