@microck/raft 0.6.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 (59) hide show
  1. package/README.md +13 -4
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/verify-controls.py +5 -5
  4. package/deploy/verify-features.py +35 -2
  5. package/deploy/verify-jobs.py +264 -0
  6. package/deploy/verify-limits.py +20 -2
  7. package/deploy/verify-live.py +12 -3
  8. package/deploy/verify-output.py +208 -0
  9. package/deploy/verify-release.py +33 -4
  10. package/deploy/verify-run.py +571 -0
  11. package/deploy/verify-self-update.py +9 -1
  12. package/deploy/verify-sync.py +122 -0
  13. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  14. package/docs/boat-capabilities.md +60 -7
  15. package/docs/boat-parity-design.md +1 -0
  16. package/docs/boot-performance.mdx +21 -0
  17. package/docs/contract.md +76 -2
  18. package/docs/first-box.mdx +22 -19
  19. package/docs/guides/background-jobs.mdx +38 -31
  20. package/docs/guides/backup-recovery.mdx +3 -2
  21. package/docs/guides/ci-jobs.mdx +209 -0
  22. package/docs/guides/files.mdx +2 -2
  23. package/docs/guides/index.mdx +31 -0
  24. package/docs/guides/long-running-services.mdx +1 -1
  25. package/docs/guides/meta.json +7 -2
  26. package/docs/guides/project-setup.mdx +1 -1
  27. package/docs/guides/scripting-ci.mdx +28 -9
  28. package/docs/guides/snapshots-forks.mdx +3 -1
  29. package/docs/guides/upgrades.mdx +7 -7
  30. package/docs/index.mdx +26 -28
  31. package/docs/maintainers/index.mdx +18 -0
  32. package/docs/maintainers/meta.json +12 -0
  33. package/docs/meta.json +5 -9
  34. package/docs/operations.mdx +7 -0
  35. package/docs/publishing.md +14 -4
  36. package/docs/reference/capacity.mdx +2 -1
  37. package/docs/reference/cli.md +85 -19
  38. package/docs/reference/meta.json +3 -1
  39. package/docs/releases/0.2.0.md +1 -0
  40. package/docs/releases/0.2.1.md +1 -0
  41. package/docs/releases/0.3.0.md +1 -0
  42. package/docs/releases/0.4.0.md +1 -0
  43. package/docs/releases/0.5.0.md +1 -0
  44. package/docs/releases/0.6.0.md +1 -0
  45. package/docs/releases/0.7.0.md +23 -0
  46. package/docs/releases/index.mdx +21 -0
  47. package/docs/releases/meta.json +15 -0
  48. package/docs/requirements.mdx +1 -1
  49. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  50. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  51. package/docs/site-development.md +1 -0
  52. package/docs/verification.mdx +21 -3
  53. package/package.json +3 -1
  54. package/raft.py +380 -179
  55. package/raft_files.py +13 -1
  56. package/raft_jobs.py +390 -0
  57. package/raft_output.py +166 -0
  58. package/raft_run.py +330 -0
  59. package/skills/raft-cli/SKILL.md +78 -197
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.6.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
@@ -230,6 +233,11 @@ def host_policy(location):
230
233
  # `incus query` rejects the --project flag that remote() always passes.
231
234
  projects = json.loads(incus(location, "project", "list", "--format", "json"))
232
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."""
233
241
  stored = config.get(POLICY_KEY, "")
234
242
  required = [field for field in stored.split(",") if field]
235
243
  unknown = [field for field in required if field not in BOX_NOTE_FIELDS]
@@ -381,11 +389,11 @@ def receive(command, destination, *, stdin=None):
381
389
  ) as process:
382
390
  try:
383
391
  shutil.copyfileobj(process.stdout, destination)
392
+ returncode = process.wait()
384
393
  except BaseException:
385
394
  process.kill()
386
395
  process.wait()
387
396
  raise
388
- returncode = process.wait()
389
397
  errors.seek(0)
390
398
  return subprocess.CompletedProcess(command, returncode, None, errors.read())
391
399
 
@@ -654,6 +662,30 @@ def inventory(location):
654
662
  ]
655
663
 
656
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
+
657
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")
658
690
  test "$state" = Stopped || { echo 'Stop the source box before resume, snapshot, restore, fork or backup' >&2; exit 1; }
659
691
  """
@@ -917,26 +949,44 @@ def capacity(host, boxes):
917
949
  }
918
950
 
919
951
 
920
- def host_capacity(location):
952
+ def host_read(location, script):
953
+ """Run a read-only host probe without taking the lifecycle/expiry lock."""
921
954
  response = subprocess.run(
922
955
  [
923
956
  "ssh",
957
+ "-n",
924
958
  "-T",
925
959
  "-o",
926
960
  "BatchMode=yes",
927
961
  "-o",
928
962
  "ConnectTimeout=10",
929
963
  settings(location)["ssh"],
930
- shlex.join(["sudo", "-n", "python3", "-c", HOST_RESOURCES]),
964
+ shlex.join(["sudo", "-n", "python3", "-c", script]),
931
965
  ],
932
966
  capture_output=True,
933
967
  text=True,
934
- check=True,
935
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"]
936
986
  return {
937
987
  "location": location,
938
988
  "note": settings(location).get("note", ""),
939
- **capacity(json.loads(response.stdout), inventory(location)),
989
+ **capacity(json.loads(resources), boxes),
940
990
  }
941
991
 
942
992
 
@@ -986,34 +1036,34 @@ def print_capacity(report):
986
1036
 
987
1037
 
988
1038
  def doctor(location):
989
- print(location + ": " + incus(location, "version").strip())
990
- try:
991
- transaction(location, CHECK_WORKER)
992
- except RuntimeError as error:
993
- raise RuntimeError(f"{location}: {error}") from error
994
- response = subprocess.run(
995
- [
996
- "ssh",
997
- "-T",
998
- "-o",
999
- "BatchMode=yes",
1000
- "-o",
1001
- "ConnectTimeout=10",
1002
- settings(location)["ssh"],
1003
- "systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
1004
- ],
1005
- capture_output=True,
1006
- text=True,
1007
- 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"
1008
1044
  )
1009
- print(response.stdout.strip())
1010
- print(incus(location, "storage", "info", SHARED_POOL).strip())
1011
- # Own box disks are opt-in, so a host without their pool is healthy.
1012
- if BOX_DISK_POOL in incus(location, "storage", "list", "--format", "csv", "-c", "n").split():
1013
- print(incus(location, "storage", "info", BOX_DISK_POOL).strip())
1014
- else:
1015
- print("own box disks: not enabled (redeploy with --disk-pool SIZE)")
1016
- 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="")
1017
1067
 
1018
1068
 
1019
1069
  def transaction(location, script, *arguments, stdout=None):
@@ -1048,6 +1098,57 @@ def transaction(location, script, *arguments, stdout=None):
1048
1098
  return response.stdout.decode() if response.stdout is not None else ""
1049
1099
 
1050
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
+
1051
1152
  def snapshot_name(value):
1052
1153
  if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", value):
1053
1154
  raise ValueError("Invalid snapshot name")
@@ -1104,7 +1205,7 @@ def own_disk(size):
1104
1205
  ]
1105
1206
 
1106
1207
 
1107
- def new(args):
1208
+ def new(args, *, run_id=None, on_allocate=None):
1108
1209
  box_fields = box_note(args)
1109
1210
  # Sizes are filled here only for boxes from the image; a box from a snapshot
1110
1211
  # keeps the snapshot's CPU, RAM and disk unless a flag overrides them.
@@ -1133,10 +1234,13 @@ def new(args):
1133
1234
  print(f"{args.location} note: {note}", file=sys.stderr)
1134
1235
  require_note(args.location, box_fields, "new")
1135
1236
  name = "rf-" + uuid.uuid4().hex[:16]
1237
+ if on_allocate is not None:
1238
+ on_allocate(args.location, name)
1136
1239
  # Initialize before starting, so a failed start still has its intended policy.
1137
1240
  transaction(
1138
1241
  args.location,
1139
- (CHECK_WORKER if args.disposable else "")
1242
+ (raft_run.creation_guard(run_id) if run_id else "")
1243
+ + (CHECK_WORKER if args.disposable else "")
1140
1244
  + fits_host(args)
1141
1245
  # Plain boxes from the image stay off raft-disks and skip the check.
1142
1246
  + (
@@ -1155,10 +1259,15 @@ def new(args):
1155
1259
  + CHECK_NOTE
1156
1260
  # Always set the note, so a box created from a snapshot never inherits the
1157
1261
  # source note. The host checks the same value it writes.
1158
- + '\n"$@" -c user.raft.note="$note"; '
1159
- 'incus --project raft start "$name"; '
1160
- 'incus --project raft config set "$name" '
1161
- '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
+ ),
1162
1271
  str(args.ttl),
1163
1272
  name,
1164
1273
  json.dumps(box_fields) if box_fields else "",
@@ -1170,8 +1279,8 @@ def new(args):
1170
1279
  name,
1171
1280
  "-c",
1172
1281
  "user.raft=true",
1173
- "-c",
1174
- "user.raft.expires=",
1282
+ *([] if run_id else ["-c", "user.raft.expires="]),
1283
+ *(["-c", f"user.raft.run={run_id}"] if run_id else []),
1175
1284
  "-c",
1176
1285
  "user.raft.stopped-at=",
1177
1286
  "-c",
@@ -1187,15 +1296,15 @@ def new(args):
1187
1296
  return f"{args.location}:{name}"
1188
1297
 
1189
1298
 
1190
- def execute(location, name, command, *, capture=False):
1191
- 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)
1192
1301
 
1193
1302
 
1194
1303
  def transfer_tree(args, location, name):
1195
1304
  worker = Path(raft_files.__file__).read_text()
1196
1305
  with tempfile.TemporaryFile() as archive:
1197
1306
  if args.action == "upload":
1198
- raft_files.pack_directory(args.source, archive)
1307
+ raft_files.pack_directory(args.source, archive, include=getattr(args, "include", None))
1199
1308
  size = archive.tell()
1200
1309
  archive.seek(0)
1201
1310
  response = remote(
@@ -1793,27 +1902,26 @@ def note_options(command):
1793
1902
  )
1794
1903
 
1795
1904
 
1796
- def parser():
1797
- cli = argparse.ArgumentParser(description=__doc__)
1798
- cli.add_argument("--version", action="version", version=__version__)
1799
- commands = cli.add_subparsers(dest="action", required=True)
1800
- create = commands.add_parser("new")
1801
- create.add_argument("--location", default=None)
1905
+ def creation_options(command, *, lifecycle=True):
1906
+ command.add_argument("--location", default=None)
1802
1907
  # Omitted options take ~/.config/raft/config.json defaults, then NEW_DEFAULTS.
1803
1908
  # With --from, every omitted size comes from the snapshot instead of a default.
1804
- create.add_argument("--ttl", type=ttl)
1805
- 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(
1806
1914
  "--cpu",
1807
1915
  type=cpu_count,
1808
1916
  help=f"CPU limit (default: config, else {DEFAULT_CPU}; with --from, the snapshot's)",
1809
1917
  )
1810
- create.add_argument(
1918
+ command.add_argument(
1811
1919
  "--memory",
1812
1920
  type=gib_size,
1813
1921
  help="RAM limit, e.g. 16GiB "
1814
1922
  f"(default: config, else {DEFAULT_MEMORY}; with --from, the snapshot's)",
1815
1923
  )
1816
- create.add_argument(
1924
+ command.add_argument(
1817
1925
  "--disk",
1818
1926
  type=disk_size,
1819
1927
  metavar="SIZE",
@@ -1821,27 +1929,62 @@ def parser():
1821
1929
  "or shared for the shared pool "
1822
1930
  "(default: config, else shared; with --from, the snapshot's disk)",
1823
1931
  )
1824
- lifecycle = create.add_mutually_exclusive_group()
1825
- lifecycle.add_argument(
1826
- "--disposable",
1827
- dest="disposable",
1828
- action="store_const",
1829
- const=True,
1830
- help="Delete on stop or expiry (default: config, else persistent)",
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",
1831
1953
  )
1832
- lifecycle.add_argument(
1833
- "--persistent",
1834
- dest="disposable",
1835
- action="store_const",
1836
- const=False,
1837
- help="Keep the disk on stop or expiry, despite a disposable default",
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.",
1838
1978
  )
1839
- create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
1840
- note_options(create)
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)
1841
1983
  commands.add_parser("locations", help="List configured locations and their notes")
1842
1984
  defaults = commands.add_parser(
1843
1985
  "config", help="Show defaults for new and each location's note policy, or change defaults"
1844
1986
  )
1987
+ defaults.add_argument("--location", help="Read policy only from this configured host")
1845
1988
  default_commands = defaults.add_subparsers(dest="config_action")
1846
1989
  default_set = default_commands.add_parser("set", help="Set a default for new")
1847
1990
  default_set.add_argument("key", choices=list(NEW_DEFAULTS))
@@ -1897,12 +2040,17 @@ def parser():
1897
2040
  help="Enable own box disks: set aside SIZE of host disk for them, e.g. 40GiB "
1898
2041
  "(default: not enabled)",
1899
2042
  )
1900
- 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
+ )
1901
2048
  listing.add_argument("--location")
1902
2049
  report = commands.add_parser("capacity", help=ADVISORY, description=ADVISORY)
1903
2050
  report.add_argument("--location")
1904
2051
  report.add_argument("--json", action="store_true")
1905
- commands.add_parser("doctor")
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")
1906
2054
  gc = commands.add_parser(
1907
2055
  "gc", help="Enforce deadlines and retention, including disposable deletion"
1908
2056
  )
@@ -1930,33 +2078,38 @@ def parser():
1930
2078
  metavar="X.Y.Z",
1931
2079
  help="Install this release instead of the latest, e.g. to roll back",
1932
2080
  )
1933
- for action in [
1934
- "info",
1935
- "stop",
1936
- "resume",
1937
- "extend",
1938
- "destroy",
1939
- "ssh",
1940
- "exec",
1941
- "upload",
1942
- "download",
1943
- "snapshot",
1944
- "snapshots",
1945
- "snapshot-delete",
1946
- "restore",
1947
- "fork",
1948
- "forward",
1949
- "desktop",
1950
- "logs",
1951
- "status",
1952
- "cancel",
1953
- "backup",
1954
- "usage",
1955
- ]:
1956
- command = commands.add_parser(action)
1957
- 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")
1958
2109
  if action in ["resume", "fork", "extend"]:
1959
- 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
+ )
1960
2113
  if action in ["resume", "fork"]:
1961
2114
  command.add_argument("--cpu", type=cpu_count)
1962
2115
  command.add_argument("--memory", type=gib_size)
@@ -1966,6 +2119,17 @@ def parser():
1966
2119
  if action == "exec":
1967
2120
  command.add_argument("--detach", action="store_true")
1968
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
+ )
1969
2133
  if action in ["upload", "download"]:
1970
2134
  command.add_argument("source")
1971
2135
  command.add_argument("destination")
@@ -1985,6 +2149,13 @@ def parser():
1985
2149
 
1986
2150
 
1987
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__])
1988
2159
  if args.action == "self-update":
1989
2160
  return self_update(args)
1990
2161
  if args.action == "recover":
@@ -1999,13 +2170,18 @@ def dispatch(args):
1999
2170
  if args.action == "config":
2000
2171
  if args.config_action is None:
2001
2172
  report = {"file": str(DEFAULTS_CONFIG), "defaults": effective_defaults(), "policy": {}}
2002
- for location in configuration():
2173
+ locations = [args.location] if args.location else configuration()
2174
+ for location in locations:
2003
2175
  try:
2004
2176
  report["policy"][location] = host_policy(location)
2005
2177
  except RuntimeError as error:
2006
2178
  raise RuntimeError(f"{location}: {error}") from error
2007
- print(json.dumps(report, indent=2))
2179
+ raft_output.data(report, indent=2)
2008
2180
  else:
2181
+ if args.location:
2182
+ raise ValueError(
2183
+ "--location selects a host policy report; defaults are controller-wide"
2184
+ )
2009
2185
  set_default(args.key, args.value if args.config_action == "set" else None)
2010
2186
  return 0
2011
2187
  if args.action == "policy":
@@ -2047,7 +2223,7 @@ def dispatch(args):
2047
2223
  if args.action == "locations":
2048
2224
  for index, (location, entry) in enumerate(configuration().items()):
2049
2225
  record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
2050
- print(json.dumps(record))
2226
+ raft_output.data(record)
2051
2227
  return 0
2052
2228
  if args.action in ["list", "capacity", "doctor", "gc", "prune"]:
2053
2229
  if args.action == "prune" and args.older_than < 1:
@@ -2058,8 +2234,8 @@ def dispatch(args):
2058
2234
  for location in locations:
2059
2235
  if args.action == "capacity":
2060
2236
  report = host_capacity(location)
2061
- if args.json:
2062
- print(json.dumps(report))
2237
+ if args.json or args.json_output:
2238
+ raft_output.data(report)
2063
2239
  else:
2064
2240
  print_capacity(report)
2065
2241
  elif args.action in ["gc", "prune"]:
@@ -2094,35 +2270,70 @@ def dispatch(args):
2094
2270
  for line in output.splitlines():
2095
2271
  record = json.loads(line)
2096
2272
  record["box"] = location + ":" + record["box"]
2097
- print(json.dumps(record))
2273
+ raft_output.data(record)
2098
2274
  else:
2099
2275
  print(output, end="")
2100
2276
  elif args.action == "doctor":
2101
2277
  doctor(location)
2102
2278
  else:
2103
- policy = host_policy(location)
2104
- for box in inventory(location):
2279
+ policy, boxes = host_inventory(location)
2280
+ for box in boxes:
2105
2281
  deletion = delete_at(box, policy)
2106
- print(
2107
- json.dumps(
2108
- {
2109
- "box": location + ":" + box["name"],
2110
- "state": box["status"],
2111
- "stopAt": box["config"].get("user.raft.expires", ""),
2112
- # Epoch text like stopAt; empty when no retention deletion is due.
2113
- "deleteAt": "" if deletion is None else str(deletion),
2114
- "disk": box_disk(box),
2115
- "note": read_box_note(box),
2116
- }
2117
- )
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
+ }
2118
2291
  )
2119
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
2120
2331
  # Only fork accepts note flags here; check them before contacting the host.
2121
2332
  fields = box_note(args)
2122
2333
  location, name = parse_handle(args.box)
2123
2334
  box = instance(location, name)
2124
2335
  if args.action == "info":
2125
- print(json.dumps(box, indent=2))
2336
+ raft_output.data(box, indent=2)
2126
2337
  elif args.action == "backup":
2127
2338
  backup(args, location, name)
2128
2339
  elif args.action == "usage":
@@ -2135,7 +2346,7 @@ def dispatch(args):
2135
2346
  usage["filesystemScope"] = (
2136
2347
  "shared Raft pool, not per-box usage" if disk == "shared" else f"own {disk} box disk"
2137
2348
  )
2138
- print(json.dumps(usage))
2349
+ raft_output.data(usage)
2139
2350
  elif args.action == "destroy":
2140
2351
  incus(location, "delete", name, "--force", locked=True)
2141
2352
  if any(item["name"] == name for item in inventory(location)):
@@ -2192,26 +2403,21 @@ def dispatch(args):
2192
2403
  raise ValueError("Supply a command after --")
2193
2404
  if args.detach:
2194
2405
  job = "rfcmd-" + uuid.uuid4().hex
2195
- response = execute(
2196
- location,
2197
- name,
2198
- [
2199
- "systemd-run",
2200
- "--quiet",
2201
- "--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,
2202
2413
  job,
2203
- "--property=WorkingDirectory=/workspace",
2204
- "--property=RemainAfterExit=yes",
2205
- "--",
2206
- *payload,
2207
- ],
2208
- capture=True,
2414
+ json.dumps(payload),
2415
+ ).strip()
2209
2416
  )
2210
- if response.returncode:
2211
- raise RuntimeError(response.stderr.decode().strip())
2212
- print(job)
2213
2417
  else:
2214
2418
  return execute(location, name, payload).returncode
2419
+ elif args.action == "sync":
2420
+ return sync_source(args, location, name)
2215
2421
  elif args.action in ["upload", "download"]:
2216
2422
  guest_path = args.destination if args.action == "upload" else args.source
2217
2423
  if not guest_path.startswith("/"):
@@ -2286,44 +2492,6 @@ def dispatch(args):
2286
2492
  *sizing(args),
2287
2493
  )
2288
2494
  print(location + ":" + child)
2289
- elif args.action in ["logs", "status", "cancel"]:
2290
- if not re.fullmatch(r"rfcmd-[a-f0-9]{32}", args.job):
2291
- raise ValueError("Use the job ID returned by exec --detach")
2292
- loaded = execute(
2293
- location,
2294
- name,
2295
- ["systemctl", "show", args.job, "-p", "LoadState", "--value"],
2296
- capture=True,
2297
- )
2298
- if loaded.returncode or loaded.stdout.strip() != b"loaded":
2299
- # Cancelled transient units unload, but their journal keeps history.
2300
- # Live units remain recognizable even after old logs are rotated away.
2301
- recorded = execute(
2302
- location,
2303
- name,
2304
- ["journalctl", "--no-pager", "--quiet", "-o", "json", "-n", "1", "-u", args.job],
2305
- capture=True,
2306
- )
2307
- if recorded.returncode or not recorded.stdout.strip():
2308
- raise ValueError("Detached job not found in this running box")
2309
- if args.action == "cancel":
2310
- return execute(location, name, ["systemctl", "stop", args.job]).returncode
2311
- command = (
2312
- ["journalctl", "--no-pager", "-o", "cat", "-u", args.job]
2313
- if args.action == "logs"
2314
- else [
2315
- "systemctl",
2316
- "show",
2317
- args.job,
2318
- "-p",
2319
- "ActiveState",
2320
- "-p",
2321
- "SubState",
2322
- "-p",
2323
- "ExecMainStatus",
2324
- ]
2325
- )
2326
- return execute(location, name, command).returncode
2327
2495
  elif args.action in ["forward", "desktop"]:
2328
2496
  port = args.remote if args.action == "forward" else 6080
2329
2497
  if not 1 <= port <= 65535 or not 1 <= args.local <= 65535:
@@ -2336,16 +2504,42 @@ def dispatch(args):
2336
2504
  return 0
2337
2505
 
2338
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
+
2339
2512
  def main():
2340
2513
  if sys.version_info < (3, 11, 8):
2341
2514
  print("raft: Python 3.11.8 or newer is required", file=sys.stderr)
2342
2515
  return 1
2343
2516
  os.umask(0o077)
2344
- # Parsing exits for --version, --help and usage errors, before any release check.
2345
- args = parser().parse_args()
2346
- 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
2347
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
+ )
2348
2539
  status = dispatch(args)
2540
+ except raft_output.UsageError as error:
2541
+ raft_output.error("usage", error)
2542
+ status = 2
2349
2543
  except (
2350
2544
  OSError,
2351
2545
  ValueError,
@@ -2353,11 +2547,18 @@ def main():
2353
2547
  tarfile.TarError,
2354
2548
  subprocess.CalledProcessError,
2355
2549
  ) as error:
2356
- print(f"raft: {error}", file=sys.stderr)
2550
+ raft_output.error("operation", error)
2357
2551
  status = 1
2552
+ except SystemExit as error:
2553
+ status = error.code if isinstance(error.code, int) else 1
2358
2554
  except KeyboardInterrupt:
2359
- return 130
2360
- 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)
2361
2562
  return status
2362
2563
 
2363
2564