@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.
- package/README.md +13 -4
- package/deploy/build-image.py +4 -2
- package/deploy/verify-controls.py +5 -5
- package/deploy/verify-features.py +35 -2
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +20 -2
- package/deploy/verify-live.py +12 -3
- package/deploy/verify-output.py +208 -0
- package/deploy/verify-release.py +33 -4
- package/deploy/verify-run.py +571 -0
- package/deploy/verify-self-update.py +9 -1
- package/deploy/verify-sync.py +122 -0
- package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
- package/docs/boat-capabilities.md +60 -7
- package/docs/boat-parity-design.md +1 -0
- package/docs/boot-performance.mdx +21 -0
- package/docs/contract.md +76 -2
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +3 -2
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +1 -1
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +28 -9
- package/docs/guides/snapshots-forks.mdx +3 -1
- package/docs/guides/upgrades.mdx +7 -7
- package/docs/index.mdx +26 -28
- package/docs/maintainers/index.mdx +18 -0
- package/docs/maintainers/meta.json +12 -0
- package/docs/meta.json +5 -9
- package/docs/operations.mdx +7 -0
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +85 -19
- package/docs/reference/meta.json +3 -1
- package/docs/releases/0.2.0.md +1 -0
- package/docs/releases/0.2.1.md +1 -0
- package/docs/releases/0.3.0.md +1 -0
- package/docs/releases/0.4.0.md +1 -0
- package/docs/releases/0.5.0.md +1 -0
- package/docs/releases/0.6.0.md +1 -0
- package/docs/releases/0.7.0.md +23 -0
- package/docs/releases/index.mdx +21 -0
- package/docs/releases/meta.json +15 -0
- package/docs/requirements.mdx +1 -1
- package/docs/reviews/docs-audit-2026-10-24.md +92 -0
- package/docs/reviews/performance-audit-2026-10-29.md +90 -0
- package/docs/site-development.md +1 -0
- package/docs/verification.mdx +21 -3
- package/package.json +3 -1
- package/raft.py +380 -179
- package/raft_files.py +13 -1
- package/raft_jobs.py +390 -0
- package/raft_output.py +166 -0
- package/raft_run.py +330 -0
- 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.
|
|
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
|
|
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",
|
|
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(
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
if
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
(
|
|
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
|
-
'
|
|
1160
|
-
'incus --project raft
|
|
1161
|
-
|
|
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.
|
|
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",
|
|
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
|
|
1797
|
-
|
|
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
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
|
1840
|
-
|
|
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(
|
|
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
|
-
"
|
|
1942
|
-
"
|
|
1943
|
-
"
|
|
1944
|
-
"
|
|
1945
|
-
"
|
|
1946
|
-
"
|
|
1947
|
-
"
|
|
1948
|
-
"
|
|
1949
|
-
"
|
|
1950
|
-
"
|
|
1951
|
-
"
|
|
1952
|
-
"
|
|
1953
|
-
"
|
|
1954
|
-
"
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
command.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
2104
|
-
for box in
|
|
2279
|
+
policy, boxes = host_inventory(location)
|
|
2280
|
+
for box in boxes:
|
|
2105
2281
|
deletion = delete_at(box, policy)
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
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
|
-
|
|
2204
|
-
|
|
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
|
-
#
|
|
2345
|
-
|
|
2346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2360
|
-
|
|
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
|
|