@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microck/raft",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Self-hosted Linux workspaces powered by Incus, an open-source alternative to boat.dev",
5
5
  "license": "MIT",
6
6
  "repository": {
package/raft.py CHANGED
@@ -25,7 +25,7 @@ import uuid
25
25
 
26
26
  import raft_files
27
27
 
28
- __version__ = "0.5.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
- POLICY_CHANGED_KEY = "user.raft.require.changed"
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 note_policy(location):
222
- """Return a location's required note fields and its last recorded policy change."""
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
- project = next(item for item in projects if item["name"] == PROJECT)
226
- stored = project["config"].get(POLICY_KEY, "")
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 --clear"
239
+ f"fix it with raft policy {location} --require FIELDS or --require none"
233
240
  )
234
- return {"require": required, "changed": project["config"].get(POLICY_CHANGED_KEY, "")}
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, note_policy(location)["require"], note, command, hint)
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 set_policy(location, fields):
275
- """Set (fields) or clear (None) a location's note policy and record the change on the host."""
276
- settings(location)
277
- value = ",".join(fields or [])
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
- before = transaction(
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 {POLICY_KEY})\n"
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 {POLICY_KEY}="$1" {POLICY_CHANGED_KEY}="$changed"\n'
285
- "logger -t raft \"Box note policy: required fields '$before' -> '$1' ($changed)\"\n"
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
- print(f"{location}: required note fields {before or 'none'} -> {value or 'none'}")
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') != 1:\n"
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
- return NEW_DEFAULTS[key][1](str(value))
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(f"defaults.{key}: {before} -> {'built-in default' if value is None else value}")
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.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
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", help="Set or clear the box note fields a location's host requires"
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
- rule = policy.add_mutually_exclusive_group(required=True)
1738
- rule.add_argument("--require", metavar="FIELDS", help="Note fields, e.g. purpose,owner")
1739
- rule.add_argument("--clear", action="store_true", help="Stop requiring note fields")
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("gc", help="Enforce deadlines, including disposable deletion")
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] = note_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
- set_policy(args.location, None if args.clear else note_fields(args.require))
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
  }
@@ -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 note policy: the box note fields its host requires.
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 controller, so
90
- pass `--location`, `--ttl`, `--cpu`, `--memory` and `--disk` (`--disk shared`
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. `raft stop "$box"` releases the
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