@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.
- package/README.md +27 -15
- package/deploy/build-image.py +4 -2
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +6 -6
- package/deploy/verify-features.py +329 -19
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +22 -3
- package/deploy/verify-live.py +31 -6
- 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 +88 -13
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +4 -3
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +9 -3
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +36 -14
- package/docs/guides/snapshots-forks.mdx +4 -2
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +11 -9
- 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 +17 -10
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +135 -43
- package/docs/reference/configuration.mdx +10 -6
- 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 +17 -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 +22 -4
- package/package.json +3 -1
- package/raft.py +526 -193
- 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 -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.
|
|
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
|
-
|
|
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
|
|
222
|
-
"""Return a location's
|
|
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
|
-
|
|
226
|
-
|
|
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 --
|
|
247
|
+
f"fix it with raft policy {location} --require FIELDS or --require none"
|
|
233
248
|
)
|
|
234
|
-
|
|
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,
|
|
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
|
|
275
|
-
"""
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
285
|
-
"logger -t raft \"
|
|
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
|
-
|
|
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') !=
|
|
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
|
|
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",
|
|
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(
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
(
|
|
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
|
-
'
|
|
1074
|
-
'incus --project raft
|
|
1075
|
-
|
|
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.
|
|
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",
|
|
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
|
-
|
|
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(
|
|
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
|
|
1693
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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",
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
"
|
|
1814
|
-
"
|
|
1815
|
-
"
|
|
1816
|
-
"
|
|
1817
|
-
"
|
|
1818
|
-
"
|
|
1819
|
-
"
|
|
1820
|
-
"
|
|
1821
|
-
"
|
|
1822
|
-
"
|
|
1823
|
-
"
|
|
1824
|
-
"
|
|
1825
|
-
"
|
|
1826
|
-
"
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
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")
|
|
1830
2109
|
if action in ["resume", "fork", "extend"]:
|
|
1831
|
-
command.add_argument(
|
|
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
|
-
|
|
2173
|
+
locations = [args.location] if args.location else configuration()
|
|
2174
|
+
for location in locations:
|
|
1875
2175
|
try:
|
|
1876
|
-
report["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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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
|
-
|
|
2072
|
-
|
|
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
|
-
#
|
|
2213
|
-
|
|
2214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2228
|
-
|
|
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
|
|