@microck/raft 0.5.0 → 0.6.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 -10
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +1 -1
- package/deploy/verify-features.py +294 -17
- package/deploy/verify-limits.py +2 -1
- package/deploy/verify-live.py +19 -3
- package/docs/contract.md +12 -11
- package/docs/first-box.mdx +1 -1
- package/docs/guides/backup-recovery.mdx +1 -1
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/long-running-services.mdx +8 -2
- package/docs/guides/scripting-ci.mdx +8 -5
- package/docs/guides/snapshots-forks.mdx +1 -1
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +4 -2
- package/docs/index.mdx +1 -1
- package/docs/operations.mdx +10 -10
- package/docs/reference/cli.md +51 -25
- package/docs/reference/configuration.mdx +10 -6
- package/docs/releases/0.6.0.md +16 -0
- package/docs/verification.mdx +1 -1
- package/package.json +1 -1
- package/raft.py +163 -31
- package/skills/raft-cli/SKILL.md +21 -6
package/package.json
CHANGED
package/raft.py
CHANGED
|
@@ -25,7 +25,7 @@ import uuid
|
|
|
25
25
|
|
|
26
26
|
import raft_files
|
|
27
27
|
|
|
28
|
-
__version__ = "0.
|
|
28
|
+
__version__ = "0.6.0"
|
|
29
29
|
|
|
30
30
|
CONFIG = Path.home() / ".config/raft/incus.json"
|
|
31
31
|
# Controller defaults for new live apart from the location inventory, so location
|
|
@@ -195,7 +195,14 @@ def box_disk(box):
|
|
|
195
195
|
# guardrail for agents: SSH and sudo on the host bypass it, and controllers
|
|
196
196
|
# older than this check never run it.
|
|
197
197
|
POLICY_KEY = "user.raft.require"
|
|
198
|
-
|
|
198
|
+
# Stopped-box retention, the second host policy: the host expiry worker deletes
|
|
199
|
+
# raft boxes that have been stopped for this many hours. Its .since key holds
|
|
200
|
+
# when the policy was last set, in epoch seconds: the retention clock starts at
|
|
201
|
+
# the later of a box's stop and that time, so setting or shortening a policy
|
|
202
|
+
# never deletes a box sooner than the given hours after the change. Mirrors
|
|
203
|
+
# RETENTION_KEY in deploy/raft-expire.py, which enforces it.
|
|
204
|
+
RETENTION_KEY = "user.raft.delete-stopped-after"
|
|
205
|
+
RETENTION_SINCE_KEY = RETENTION_KEY + ".since"
|
|
199
206
|
|
|
200
207
|
# Host half of the check, run with the required fields and the note the box
|
|
201
208
|
# will carry. Kept short; the controller builds the detailed message.
|
|
@@ -218,20 +225,48 @@ READ_NOTE_POLICY = f"require=$(incus project get raft {POLICY_KEY})\n"
|
|
|
218
225
|
CHECK_NOTE = "python3 -c " + shlex.quote(CHECK_NOTE_CODE) + ' "$require" "$note"'
|
|
219
226
|
|
|
220
227
|
|
|
221
|
-
def
|
|
222
|
-
"""Return a location's
|
|
228
|
+
def host_policy(location):
|
|
229
|
+
"""Return a location's host policies and their last recorded changes, as raft config shows them."""
|
|
223
230
|
# `incus query` rejects the --project flag that remote() always passes.
|
|
224
231
|
projects = json.loads(incus(location, "project", "list", "--format", "json"))
|
|
225
|
-
|
|
226
|
-
stored =
|
|
232
|
+
config = next(item for item in projects if item["name"] == PROJECT)["config"]
|
|
233
|
+
stored = config.get(POLICY_KEY, "")
|
|
227
234
|
required = [field for field in stored.split(",") if field]
|
|
228
235
|
unknown = [field for field in required if field not in BOX_NOTE_FIELDS]
|
|
229
236
|
if unknown:
|
|
230
237
|
raise ValueError(
|
|
231
238
|
f"{location}: host policy {POLICY_KEY}={stored!r} names unknown note fields; "
|
|
232
|
-
f"fix it with raft policy {location} --require FIELDS or --
|
|
239
|
+
f"fix it with raft policy {location} --require FIELDS or --require none"
|
|
233
240
|
)
|
|
234
|
-
|
|
241
|
+
hours = config.get(RETENTION_KEY, "")
|
|
242
|
+
since = config.get(RETENTION_SINCE_KEY, "")
|
|
243
|
+
# Raft writes both as whole numbers; anything else came from a native Incus edit.
|
|
244
|
+
if hours and not (re.fullmatch(r"[1-9][0-9]*", hours) and re.fullmatch(r"[0-9]+", since)):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"{location}: host policy {RETENTION_KEY}={hours!r} ({RETENTION_SINCE_KEY}={since!r}) "
|
|
247
|
+
f"is not a whole number of hours; fix it with raft policy {location} "
|
|
248
|
+
"--delete-stopped-after HOURS or --delete-stopped-after never"
|
|
249
|
+
)
|
|
250
|
+
return {
|
|
251
|
+
"require": required,
|
|
252
|
+
"requireChanged": config.get(POLICY_KEY + ".changed", ""),
|
|
253
|
+
"deleteStoppedAfterHours": int(hours) if hours else None,
|
|
254
|
+
"deleteStoppedAfterSince": int(since) if hours else None,
|
|
255
|
+
"deleteStoppedAfterChanged": config.get(RETENTION_KEY + ".changed", ""),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def delete_at(box, policy):
|
|
260
|
+
"""When the host worker deletes a stopped box under the retention policy, or None.
|
|
261
|
+
|
|
262
|
+
Must match the deletion rule in deploy/raft-expire.py. A stopped box without
|
|
263
|
+
a recorded stop time has no deadline until the worker records one.
|
|
264
|
+
"""
|
|
265
|
+
hours = policy["deleteStoppedAfterHours"]
|
|
266
|
+
stopped = box["config"].get("user.raft.stopped-at")
|
|
267
|
+
if box["status"] != "Stopped" or hours is None or not stopped:
|
|
268
|
+
return None
|
|
269
|
+
return max(int(stopped), policy["deleteStoppedAfterSince"]) + hours * 3600
|
|
235
270
|
|
|
236
271
|
|
|
237
272
|
def note_problem(location, required, note, command, hint=""):
|
|
@@ -252,13 +287,15 @@ def note_problem(location, required, note, command, hint=""):
|
|
|
252
287
|
|
|
253
288
|
def require_note(location, note, command, hint=""):
|
|
254
289
|
"""Fail before any host change when note lacks a field the location requires."""
|
|
255
|
-
problem = note_problem(location,
|
|
290
|
+
problem = note_problem(location, host_policy(location)["require"], note, command, hint)
|
|
256
291
|
if problem:
|
|
257
292
|
raise ValueError(problem)
|
|
258
293
|
|
|
259
294
|
|
|
260
295
|
def note_fields(text):
|
|
261
|
-
"""Parse --require FIELDS into BOX_NOTE_FIELDS order."""
|
|
296
|
+
"""Parse --require FIELDS into BOX_NOTE_FIELDS order; none means no required fields."""
|
|
297
|
+
if text == "none":
|
|
298
|
+
return []
|
|
262
299
|
fields = [field.strip() for field in text.split(",") if field.strip()]
|
|
263
300
|
unknown = [field for field in fields if field not in BOX_NOTE_FIELDS]
|
|
264
301
|
if unknown or not fields:
|
|
@@ -271,23 +308,69 @@ def note_fields(text):
|
|
|
271
308
|
return [field for field in BOX_NOTE_FIELDS if field in fields]
|
|
272
309
|
|
|
273
310
|
|
|
274
|
-
def
|
|
275
|
-
"""
|
|
276
|
-
|
|
277
|
-
|
|
311
|
+
def retention_hours(text):
|
|
312
|
+
"""Parse --delete-stopped-after HOURS: a positive whole number, or never for no retention."""
|
|
313
|
+
if text == "never":
|
|
314
|
+
return None
|
|
315
|
+
if not re.fullmatch(r"[1-9][0-9]*", text):
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"Give a positive whole number of hours, e.g. --delete-stopped-after 48, or never"
|
|
318
|
+
)
|
|
319
|
+
return int(text)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def set_policy(location, key, value, journal, check=""):
|
|
323
|
+
"""Set one host policy key to value, empty to clear it, and record the change.
|
|
324
|
+
|
|
325
|
+
Returns the previous value. journal names the policy in the host journal entry;
|
|
326
|
+
check is shell that must pass first, under the same lock.
|
|
327
|
+
"""
|
|
278
328
|
# Who and when is recorded on the host beside the policy and in its journal.
|
|
279
329
|
# The controller identity is self-reported; SUDO_USER is the SSH account.
|
|
280
|
-
|
|
330
|
+
# Retention also records when it was set, for the worker's clock (see RETENTION_KEY).
|
|
331
|
+
since = f' {RETENTION_SINCE_KEY}="$([ -z "$1" ] || date +%s)"' if key == RETENTION_KEY else ""
|
|
332
|
+
return transaction(
|
|
281
333
|
location,
|
|
282
|
-
f"before=$(incus project get raft {
|
|
334
|
+
check + f"before=$(incus project get raft {key})\n"
|
|
283
335
|
'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 \"
|
|
336
|
+
f'incus project set raft {key}="$1" {key}.changed="$changed"{since}\n'
|
|
337
|
+
f"logger -t raft \"{journal}: '$before' -> '$1' ($changed)\"\n"
|
|
286
338
|
"printf '%s\\n' \"$before\"",
|
|
287
339
|
value,
|
|
288
340
|
f"{getpass.getuser()}@{socket.gethostname()}",
|
|
289
341
|
).strip()
|
|
290
|
-
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def change_policy(args):
|
|
345
|
+
"""Apply raft policy: each given option sets or clears its own policy independently."""
|
|
346
|
+
if args.require is None and args.delete_stopped_after is None:
|
|
347
|
+
raise ValueError("Give --require FIELDS|none, --delete-stopped-after HOURS|never, or both")
|
|
348
|
+
settings(args.location)
|
|
349
|
+
# Parse every option before the first host change. Retention goes first: its
|
|
350
|
+
# worker check is the one step that can still refuse.
|
|
351
|
+
fields = None if args.require is None else note_fields(args.require)
|
|
352
|
+
hours = (
|
|
353
|
+
None if args.delete_stopped_after is None else retention_hours(args.delete_stopped_after)
|
|
354
|
+
)
|
|
355
|
+
if args.delete_stopped_after is not None:
|
|
356
|
+
value = "" if hours is None else str(hours)
|
|
357
|
+
before = set_policy(
|
|
358
|
+
args.location,
|
|
359
|
+
RETENTION_KEY,
|
|
360
|
+
value,
|
|
361
|
+
"Stopped-box retention: hours",
|
|
362
|
+
# Only a current worker enforces retention; clearing is safe on any worker.
|
|
363
|
+
check=CHECK_WORKER if hours else "",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def shown(stored):
|
|
367
|
+
return "never" if not stored else stored + (" hour" if stored == "1" else " hours")
|
|
368
|
+
|
|
369
|
+
print(f"{args.location}: delete stopped boxes after {shown(before)} -> {shown(value)}")
|
|
370
|
+
if fields is not None:
|
|
371
|
+
value = ",".join(fields)
|
|
372
|
+
before = set_policy(args.location, POLICY_KEY, value, "Box note policy: required fields")
|
|
373
|
+
print(f"{args.location}: required note fields {before or 'none'} -> {value or 'none'}")
|
|
291
374
|
|
|
292
375
|
|
|
293
376
|
def receive(command, destination, *, stdin=None):
|
|
@@ -576,12 +659,15 @@ test "$state" = Stopped || { echo 'Stop the source box before resume, snapshot,
|
|
|
576
659
|
"""
|
|
577
660
|
|
|
578
661
|
|
|
662
|
+
# Refuses a host whose expiry worker speaks another PROTOCOL than this CLI. Bump
|
|
663
|
+
# PROTOCOL here and in deploy/raft-expire.py together whenever the CLI starts to
|
|
664
|
+
# rely on new worker behavior; protocol 2 added stopped-box retention.
|
|
579
665
|
CHECK_WORKER = (
|
|
580
666
|
"python3 -c "
|
|
581
667
|
+ shlex.quote(
|
|
582
668
|
"import runpy; "
|
|
583
669
|
"worker = runpy.run_path('/usr/local/lib/raft/expire-worker'); "
|
|
584
|
-
"\nif worker.get('PROTOCOL') !=
|
|
670
|
+
"\nif worker.get('PROTOCOL') != 2:\n"
|
|
585
671
|
" raise SystemExit('Host expiry worker differs from this CLI; deploy the current Raft checkout first')"
|
|
586
672
|
)
|
|
587
673
|
+ "\n"
|
|
@@ -1308,6 +1394,18 @@ def location_name(value):
|
|
|
1308
1394
|
return value
|
|
1309
1395
|
|
|
1310
1396
|
|
|
1397
|
+
def boolean(value):
|
|
1398
|
+
"""Parse a true or false setting, e.g. raft config set disposable true."""
|
|
1399
|
+
if value not in {"true", "false"}:
|
|
1400
|
+
raise argparse.ArgumentTypeError("Use true or false")
|
|
1401
|
+
return value == "true"
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def shown_default(value):
|
|
1405
|
+
"""Print a default as it is typed: true and false, not Python's True and False."""
|
|
1406
|
+
return json.dumps(value) if isinstance(value, bool) else value
|
|
1407
|
+
|
|
1408
|
+
|
|
1311
1409
|
# Options of new that ~/.config/raft/config.json can default, with their built-in
|
|
1312
1410
|
# defaults and value checks. Values go through str() first, so a configured value
|
|
1313
1411
|
# is accepted exactly when the same text passed as the flag would be. Adding a
|
|
@@ -1319,6 +1417,9 @@ NEW_DEFAULTS = {
|
|
|
1319
1417
|
"cpu": (DEFAULT_CPU, cpu_count),
|
|
1320
1418
|
"memory": (DEFAULT_MEMORY, gib_size),
|
|
1321
1419
|
"disk": ("shared", disk_size),
|
|
1420
|
+
# new --disposable and --persistent override it. Unlike the sizes it applies
|
|
1421
|
+
# to new --from too: a snapshot carries no lifecycle, new always sets one.
|
|
1422
|
+
"disposable": (False, boolean),
|
|
1322
1423
|
}
|
|
1323
1424
|
# A box from a snapshot keeps the snapshot's size, so these defaults, configured
|
|
1324
1425
|
# or built-in, apply only to boxes created from the image.
|
|
@@ -1333,7 +1434,8 @@ def default_value(key, value):
|
|
|
1333
1434
|
"supported keys: " + ", ".join(NEW_DEFAULTS)
|
|
1334
1435
|
)
|
|
1335
1436
|
try:
|
|
1336
|
-
|
|
1437
|
+
# JSON true and false read back as the true and false text the CLI accepts.
|
|
1438
|
+
return NEW_DEFAULTS[key][1](str(shown_default(value)))
|
|
1337
1439
|
except (ValueError, argparse.ArgumentTypeError) as error:
|
|
1338
1440
|
raise ValueError(
|
|
1339
1441
|
f"{DEFAULTS_CONFIG}: defaults.{key}={value!r} is invalid: {error}"
|
|
@@ -1445,8 +1547,10 @@ def set_default(key, value):
|
|
|
1445
1547
|
document.pop("defaults")
|
|
1446
1548
|
|
|
1447
1549
|
edit_json(DEFAULTS_CONFIG, lambda: {"defaults": configured_defaults(key)}, change)
|
|
1448
|
-
before = previous.get(key, "built-in default")
|
|
1449
|
-
print(
|
|
1550
|
+
before = shown_default(previous.get(key, "built-in default"))
|
|
1551
|
+
print(
|
|
1552
|
+
f"defaults.{key}: {before} -> {'built-in default' if value is None else shown_default(value)}"
|
|
1553
|
+
)
|
|
1450
1554
|
|
|
1451
1555
|
|
|
1452
1556
|
# GitHub releases are the one source of version truth. The release workflow
|
|
@@ -1717,7 +1821,21 @@ def parser():
|
|
|
1717
1821
|
"or shared for the shared pool "
|
|
1718
1822
|
"(default: config, else shared; with --from, the snapshot's disk)",
|
|
1719
1823
|
)
|
|
1720
|
-
create.
|
|
1824
|
+
lifecycle = create.add_mutually_exclusive_group()
|
|
1825
|
+
lifecycle.add_argument(
|
|
1826
|
+
"--disposable",
|
|
1827
|
+
dest="disposable",
|
|
1828
|
+
action="store_const",
|
|
1829
|
+
const=True,
|
|
1830
|
+
help="Delete on stop or expiry (default: config, else persistent)",
|
|
1831
|
+
)
|
|
1832
|
+
lifecycle.add_argument(
|
|
1833
|
+
"--persistent",
|
|
1834
|
+
dest="disposable",
|
|
1835
|
+
action="store_const",
|
|
1836
|
+
const=False,
|
|
1837
|
+
help="Keep the disk on stop or expiry, despite a disposable default",
|
|
1838
|
+
)
|
|
1721
1839
|
create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
|
|
1722
1840
|
note_options(create)
|
|
1723
1841
|
commands.add_parser("locations", help="List configured locations and their notes")
|
|
@@ -1731,12 +1849,20 @@ def parser():
|
|
|
1731
1849
|
default_unset = default_commands.add_parser("unset", help="Restore a built-in default")
|
|
1732
1850
|
default_unset.add_argument("key", choices=list(NEW_DEFAULTS))
|
|
1733
1851
|
policy = commands.add_parser(
|
|
1734
|
-
"policy",
|
|
1852
|
+
"policy",
|
|
1853
|
+
help="Set a location's required box note fields and stopped-box retention",
|
|
1735
1854
|
)
|
|
1736
1855
|
policy.add_argument("location")
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1856
|
+
policy.add_argument(
|
|
1857
|
+
"--require",
|
|
1858
|
+
metavar="FIELDS|none",
|
|
1859
|
+
help="Note fields every box must carry, e.g. purpose,owner; none stops requiring them",
|
|
1860
|
+
)
|
|
1861
|
+
policy.add_argument(
|
|
1862
|
+
"--delete-stopped-after",
|
|
1863
|
+
metavar="HOURS|never",
|
|
1864
|
+
help="Delete boxes stopped for HOURS, e.g. 48; never keeps them",
|
|
1865
|
+
)
|
|
1740
1866
|
note = commands.add_parser("note", help="Set or clear the note of a location or a box")
|
|
1741
1867
|
note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
|
|
1742
1868
|
note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
|
|
@@ -1777,7 +1903,9 @@ def parser():
|
|
|
1777
1903
|
report.add_argument("--location")
|
|
1778
1904
|
report.add_argument("--json", action="store_true")
|
|
1779
1905
|
commands.add_parser("doctor")
|
|
1780
|
-
gc = commands.add_parser(
|
|
1906
|
+
gc = commands.add_parser(
|
|
1907
|
+
"gc", help="Enforce deadlines and retention, including disposable deletion"
|
|
1908
|
+
)
|
|
1781
1909
|
gc.add_argument("--location")
|
|
1782
1910
|
prune = commands.add_parser("prune", help="Preview deletion of old stopped boxes")
|
|
1783
1911
|
prune.add_argument("--location")
|
|
@@ -1873,7 +2001,7 @@ def dispatch(args):
|
|
|
1873
2001
|
report = {"file": str(DEFAULTS_CONFIG), "defaults": effective_defaults(), "policy": {}}
|
|
1874
2002
|
for location in configuration():
|
|
1875
2003
|
try:
|
|
1876
|
-
report["policy"][location] =
|
|
2004
|
+
report["policy"][location] = host_policy(location)
|
|
1877
2005
|
except RuntimeError as error:
|
|
1878
2006
|
raise RuntimeError(f"{location}: {error}") from error
|
|
1879
2007
|
print(json.dumps(report, indent=2))
|
|
@@ -1881,7 +2009,7 @@ def dispatch(args):
|
|
|
1881
2009
|
set_default(args.key, args.value if args.config_action == "set" else None)
|
|
1882
2010
|
return 0
|
|
1883
2011
|
if args.action == "policy":
|
|
1884
|
-
|
|
2012
|
+
change_policy(args)
|
|
1885
2013
|
return 0
|
|
1886
2014
|
if args.action == "note":
|
|
1887
2015
|
fields = box_note(args)
|
|
@@ -1972,13 +2100,17 @@ def dispatch(args):
|
|
|
1972
2100
|
elif args.action == "doctor":
|
|
1973
2101
|
doctor(location)
|
|
1974
2102
|
else:
|
|
2103
|
+
policy = host_policy(location)
|
|
1975
2104
|
for box in inventory(location):
|
|
2105
|
+
deletion = delete_at(box, policy)
|
|
1976
2106
|
print(
|
|
1977
2107
|
json.dumps(
|
|
1978
2108
|
{
|
|
1979
2109
|
"box": location + ":" + box["name"],
|
|
1980
2110
|
"state": box["status"],
|
|
1981
2111
|
"stopAt": box["config"].get("user.raft.expires", ""),
|
|
2112
|
+
# Epoch text like stopAt; empty when no retention deletion is due.
|
|
2113
|
+
"deleteAt": "" if deletion is None else str(deletion),
|
|
1982
2114
|
"disk": box_disk(box),
|
|
1983
2115
|
"note": read_box_note(box),
|
|
1984
2116
|
}
|
package/skills/raft-cli/SKILL.md
CHANGED
|
@@ -18,7 +18,8 @@ instead of acting on it. For pinned or scripted runs, set
|
|
|
18
18
|
|
|
19
19
|
Run `raft locations`, `raft config`, `raft doctor` and `raft list` before allocating.
|
|
20
20
|
`raft config` shows the defaults `new` will use on this controller and each
|
|
21
|
-
location's
|
|
21
|
+
location's host policies: the box note fields it requires (`require`) and how
|
|
22
|
+
long it keeps stopped boxes (`deleteStoppedAfterHours`, `null` for no limit).
|
|
22
23
|
Follow each location's `note`: the operator uses it to mark preferred hosts and
|
|
23
24
|
hosts to avoid for some work. Pass `--location` explicitly when a note rules out
|
|
24
25
|
the default. Do not change notes unless the user asks; `raft note LOCATION TEXT`
|
|
@@ -37,7 +38,7 @@ changes; each call replaces the whole note. If a command fails with
|
|
|
37
38
|
`requires --owner (host policy)` or similar, rerun it with the fields it names;
|
|
38
39
|
the error prints the corrected command. Never change or clear a policy with
|
|
39
40
|
`raft policy`, or the defaults with `raft config set/unset`, to get past an
|
|
40
|
-
error; only do so when the user asks. `fork` and `recover` accept the same note
|
|
41
|
+
error or to keep a box from retention; only do so when the user asks. `fork` and `recover` accept the same note
|
|
41
42
|
flags, which replace the whole note on the copy. `raft list` shows every box's
|
|
42
43
|
`note`. Read it before you resume, stop, fork or destroy a box. Treat a box whose
|
|
43
44
|
note names another owner or a job unrelated to your task as not yours. Forks
|
|
@@ -86,9 +87,9 @@ box=$(raft new --cpu 4 --memory 16GiB --disk 20GiB --ttl 3600 --purpose "Build a
|
|
|
86
87
|
```
|
|
87
88
|
|
|
88
89
|
Built-in defaults are the first configured location, one CPU, 2 GiB RAM, the
|
|
89
|
-
shared pool and 600 seconds; the operator can change them per
|
|
90
|
-
pass `--location`, `--ttl`, `--cpu`, `--memory` and `--disk`
|
|
91
|
-
for the shared pool) when the values matter. `new --from` and `fork` keep the
|
|
90
|
+
shared pool, a persistent box and 600 seconds; the operator can change them per
|
|
91
|
+
controller, so pass `--location`, `--ttl`, `--cpu`, `--memory` and `--disk`
|
|
92
|
+
(`--disk shared` for the shared pool) when the values matter. `new --from` and `fork` keep the
|
|
92
93
|
source's CPU, RAM and disk instead of any default; each flag overrides only its
|
|
93
94
|
own value. TTL accepts 60 seconds to 30
|
|
94
95
|
days. A request larger than the whole host fails with the host's real size;
|
|
@@ -105,7 +106,11 @@ zero means new boxes may run slower, not that they are refused.
|
|
|
105
106
|
## Persistence and cleanup
|
|
106
107
|
|
|
107
108
|
Persistent boxes stop and retain disk at expiry. `new --disposable` deletes a
|
|
108
|
-
box and its snapshots on stop or expiry.
|
|
109
|
+
box and its snapshots on stop or expiry. The operator can make disposable the
|
|
110
|
+
default for `new` (`raft config` shows `disposable`); then pass `--persistent`
|
|
111
|
+
for a box you plan to stop and resume, snapshot, fork or back up, because
|
|
112
|
+
stopping a disposable box deletes it. Forks stay persistent unless you pass
|
|
113
|
+
`--disposable`. `raft stop "$box"` releases the
|
|
109
114
|
container's running processes; `raft resume "$box" --ttl 600` starts it again.
|
|
110
115
|
Stop is not pause and does not keep guest RAM allocated. Installed dependencies,
|
|
111
116
|
files and secrets remain on disk. For a temporary run, completion requires
|
|
@@ -118,6 +123,16 @@ boxes. `raft prune --older-than 7` previews stopped boxes older than seven days;
|
|
|
118
123
|
`--yes` deletes them. It skips running boxes and boxes without a recorded stop
|
|
119
124
|
time. Never apply prune outside the task-owned scope without explicit permission. A powered-off host enforces expiry when it returns.
|
|
120
125
|
|
|
126
|
+
A location can also delete boxes that stay stopped longer than its retention,
|
|
127
|
+
`deleteStoppedAfterHours` in `raft config`, with their snapshots. The clock
|
|
128
|
+
starts when the box stops, including a stop by TTL expiry, and resume resets
|
|
129
|
+
it. `raft list` shows each stopped box's `deleteAt` (Unix time, empty when
|
|
130
|
+
nothing is due). Do not rely on a stopped box surviving past it: copy out
|
|
131
|
+
results with `raft download`, or keep the whole box with `raft backup`, before
|
|
132
|
+
then. Snapshots are deleted with the box, so they protect nothing against
|
|
133
|
+
retention. Resume a box only when its task still needs it, not just to reset
|
|
134
|
+
the clock.
|
|
135
|
+
|
|
121
136
|
Stop a box before a consistent disk snapshot or fork:
|
|
122
137
|
|
|
123
138
|
```sh
|