@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.
@@ -28,6 +28,8 @@ from raft import ( # noqa: E402
28
28
  CHECK_NOTE_CODE,
29
29
  READ_NOTE_POLICY,
30
30
  RESERVE_BOX_DISK,
31
+ RETENTION_KEY,
32
+ RETENTION_SINCE_KEY,
31
33
  disk_origin,
32
34
  host_problems,
33
35
  incus,
@@ -265,7 +267,7 @@ def verify_controller_defaults():
265
267
  "import json, sys; import raft; args = raft.parser().parse_args(sys.argv[1:]); "
266
268
  "used = raft.apply_new_defaults(args); "
267
269
  "print(json.dumps([args.location, args.ttl, args.cpu, args.memory, args.disk, "
268
- "sorted(used)]))"
270
+ "args.disposable, sorted(used)]))"
269
271
  )
270
272
  response = subprocess.run(
271
273
  [sys.executable, "-c", program, *arguments],
@@ -279,13 +281,14 @@ def verify_controller_defaults():
279
281
  return json.loads(response.stdout)
280
282
 
281
283
  # No disk is the shared pool.
282
- assert resolved("new") == ["lab", 600, 1, "2GiB", None, []]
284
+ assert resolved("new") == ["lab", 600, 1, "2GiB", None, False, []]
283
285
  for arguments, printed in [
284
286
  (["set", "cpu", "2"], "defaults.cpu: built-in default -> 2"),
285
287
  (["set", "memory", "4GiB"], "defaults.memory: built-in default -> 4GiB"),
286
288
  (["set", "ttl", "3600"], "defaults.ttl: built-in default -> 3600"),
287
289
  (["set", "location", "spare"], "defaults.location: built-in default -> spare"),
288
290
  (["set", "disk", "20GiB"], "defaults.disk: built-in default -> 20GiB"),
291
+ (["set", "disposable", "true"], "defaults.disposable: built-in default -> true"),
289
292
  (["set", "cpu", "1"], "defaults.cpu: 2 -> 1"),
290
293
  ]:
291
294
  response = cli("config", *arguments)
@@ -297,29 +300,48 @@ def verify_controller_defaults():
297
300
  "ttl": 3600,
298
301
  "location": "spare",
299
302
  "disk": "20GiB",
303
+ "disposable": True,
300
304
  }
301
305
  }
302
306
  assert config.stat().st_mode & 0o777 == 0o600, "Default editing widened permissions"
303
307
  # Explicit flags win over config.json, which wins over built-in defaults.
304
- everything = ["cpu", "disk", "location", "memory", "ttl"]
305
- assert resolved("new") == ["spare", 3600, 1, "4GiB", "20GiB", everything]
308
+ everything = ["cpu", "disk", "disposable", "location", "memory", "ttl"]
309
+ assert resolved("new") == ["spare", 3600, 1, "4GiB", "20GiB", True, everything]
306
310
  assert resolved("new", "--location", "lab", "--ttl", "60", "--cpu", "2") == [
307
311
  "lab",
308
312
  60,
309
313
  2,
310
314
  "4GiB",
311
315
  "20GiB",
312
- ["disk", "memory"],
316
+ True,
317
+ ["disk", "disposable", "memory"],
313
318
  ]
314
- # --disk shared beats a configured own disk.
315
- assert resolved("new", "--disk", "shared")[4:] == [
319
+ # --disk shared beats a configured own disk, and --persistent a disposable default.
320
+ assert resolved("new", "--disk", "shared", "--persistent")[4:] == [
316
321
  None,
322
+ False,
317
323
  ["cpu", "location", "memory", "ttl"],
318
324
  ]
325
+ assert resolved("new", "--disposable")[5:] == [
326
+ True,
327
+ ["cpu", "disk", "location", "memory", "ttl"],
328
+ ]
329
+ rejected = cli("new", "--disposable", "--persistent")
330
+ assert rejected.returncode == 2 and "not allowed with" in rejected.stderr, rejected
319
331
  # A box from a snapshot keeps the snapshot's location, CPU, RAM and disk:
320
332
  # defaults, configured or built-in, never override them; flags still do.
333
+ # A snapshot has no lifecycle, so the TTL and disposable defaults apply.
321
334
  template = "lab:rf-0123456789abcdef/snap"
322
- assert resolved("new", "--from", template) == [None, 3600, None, None, None, ["ttl"]]
335
+ assert resolved("new", "--from", template) == [
336
+ None,
337
+ 3600,
338
+ None,
339
+ None,
340
+ None,
341
+ True,
342
+ ["disposable", "ttl"],
343
+ ]
344
+ assert resolved("new", "--from", template, "--persistent")[5] is False
323
345
  assert resolved("new", "--from", template, "--cpu", "4", "--disk", "30GiB")[2:5] == [
324
346
  4,
325
347
  None,
@@ -328,24 +350,30 @@ def verify_controller_defaults():
328
350
  assert "--from keeps the snapshot's disk" in resolved(
329
351
  "new", "--from", template, "--disk", "shared"
330
352
  )
331
- # fork has no defaults at all: omitted sizes stay the source's.
353
+ # fork has no defaults at all: omitted sizes stay the source's, and a fork
354
+ # is persistent without --disposable even under a disposable default.
332
355
  forked = subprocess.check_output(
333
356
  [
334
357
  sys.executable,
335
358
  "-c",
336
359
  "import raft; args = raft.parser().parse_args(['fork', 'lab:rf-0123456789abcdef', "
337
- "'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'))",
360
+ "'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'), args.disposable)",
338
361
  ],
339
362
  cwd=ROOT,
340
363
  env={**os.environ, "HOME": home},
341
364
  text=True,
342
365
  )
343
- assert forked == "[] False\n", forked
366
+ assert forked == "[] False False\n", forked
344
367
  for arguments, error in [
345
368
  (["set", "cpu", "0"], "defaults.cpu='0' is invalid"),
346
369
  (["set", "memory", "3GB"], "defaults.memory='3GB' is invalid"),
347
370
  (["set", "disk", "20"], "defaults.disk='20' is invalid"),
348
371
  (["set", "ttl", "59"], "TTL must be 60 seconds to 30 days"),
372
+ (
373
+ ["set", "disposable", "yes"],
374
+ "defaults.disposable='yes' is invalid: Use true or false",
375
+ ),
376
+ (["set", "disposable", "True"], "defaults.disposable='True' is invalid"),
349
377
  (["set", "location", "missing"], "Unknown location"),
350
378
  (["set", "location", "bad name"], "Location names"),
351
379
  ]:
@@ -359,6 +387,7 @@ def verify_controller_defaults():
359
387
  ({"defaults": {"cpu": 0}}, "defaults.cpu=0 is invalid"),
360
388
  ({"defaults": {"cpu": True}}, "defaults.cpu=True is invalid"),
361
389
  ({"defaults": {"disk": 20}}, "defaults.disk=20 is invalid"),
390
+ ({"defaults": {"disposable": 1}}, "defaults.disposable=1 is invalid"),
362
391
  ({"defaults": {"colour": "red"}}, "unknown key defaults.colour"),
363
392
  ({"defaults": []}, '{"defaults"'),
364
393
  ({"lab": {"ssh": "x"}}, '{"defaults"'),
@@ -389,6 +418,7 @@ def verify_controller_defaults():
389
418
  ("ttl", "120"),
390
419
  ("location", "lab"),
391
420
  ("disk", "shared"),
421
+ ("disposable", "false"),
392
422
  ]
393
423
  ]
394
424
  assert all(writer.wait() == 0 for writer in writers)
@@ -398,8 +428,25 @@ def verify_controller_defaults():
398
428
  "ttl": 120,
399
429
  "location": "lab",
400
430
  "disk": "shared",
431
+ "disposable": False,
401
432
  }
402
- for key in ["cpu", "memory", "ttl", "location", "disk"]:
433
+ # JSON booleans read back as the CLI's true and false.
434
+ assert cli("config", "set", "disposable", "true").stdout == (
435
+ "defaults.disposable: false -> true\n"
436
+ )
437
+ # raft config reports it with its source; the report's host half needs SSH.
438
+ effective = subprocess.check_output(
439
+ [
440
+ sys.executable,
441
+ "-c",
442
+ "import json, raft; print(json.dumps(raft.effective_defaults()['disposable']))",
443
+ ],
444
+ cwd=ROOT,
445
+ env={**os.environ, "HOME": home},
446
+ text=True,
447
+ )
448
+ assert json.loads(effective) == {"value": True, "source": "config"}, effective
449
+ for key in ["cpu", "memory", "ttl", "location", "disk", "disposable"]:
403
450
  assert cli("config", "unset", key).returncode == 0
404
451
  assert not config.exists(), "Removing every default left an empty file"
405
452
  assert not list(directory.glob(".raft-*")), "Staged configuration left behind"
@@ -409,13 +456,21 @@ def verify_controller_defaults():
409
456
  (["policy", "lab", "--require", "owner"], "always need purpose"),
410
457
  (["policy", "lab", "--require", "purpose,colour"], "Give note fields from"),
411
458
  (["policy", "lab", "--require", ","], "Give note fields from"),
412
- (["policy", "missing", "--clear"], "Unknown location"),
459
+ (["policy", "missing", "--require", "none"], "Unknown location"),
460
+ (["policy", "lab"], "Give --require FIELDS|none, --delete-stopped-after"),
461
+ (["policy", "lab", "--delete-stopped-after", "0"], "positive whole number of hours"),
462
+ (["policy", "lab", "--delete-stopped-after", "48h"], "positive whole number of hours"),
463
+ # Both options are parsed before either changes the host.
464
+ (
465
+ ["policy", "lab", "--require", "none", "--delete-stopped-after", "-1"],
466
+ "positive whole number of hours",
467
+ ),
413
468
  (["fork", handle, "--ttl", "600", "--owner", "agent"], "need --purpose"),
414
469
  (["recover", "archive", "--location", "lab", "--next", "x"], "need --purpose"),
415
470
  ]:
416
471
  rejected = cli(*arguments)
417
472
  assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
418
- assert cli("policy", "lab").returncode == 2
473
+ assert cli("policy", "lab", "--clear").returncode == 2
419
474
  # The controller message names the missing flags and a complete command.
420
475
  program = (
421
476
  "import raft; print(raft.note_problem('lab', ['purpose', 'owner'], "
@@ -467,6 +522,115 @@ def verify_controller_defaults():
467
522
  print("Controller defaults, precedence, validation and note policy messages passed", flush=True)
468
523
 
469
524
 
525
+ # Stands in for incus and logger on PATH for the real host worker. It answers the
526
+ # worker's two reads from the state file and appends every call to the log file.
527
+ FAKE_HOST_TOOL = """import json, os, sys
528
+ from pathlib import Path
529
+ arguments = [Path(sys.argv[0]).name, *sys.argv[1:]]
530
+ with open(os.environ["RAFT_FAKE_LOG"], "a") as log:
531
+ log.write(json.dumps(arguments) + "\\n")
532
+ state = json.loads(Path(os.environ["RAFT_FAKE_STATE"]).read_text())
533
+ if arguments[1:] == ["--project", "raft", "list", "--fast", "--format", "json"]:
534
+ print(json.dumps(state["instances"]))
535
+ elif arguments[1:] == ["--project", "raft", "project", "list", "--format", "json"]:
536
+ print(json.dumps([{"name": "default", "config": {}}, {"name": "raft", "config": state["project"]}]))
537
+ """
538
+
539
+
540
+ def verify_retention_worker():
541
+ """Run the real host worker against fake incus fixtures and check what it changes."""
542
+ now = int(time.time())
543
+
544
+ def box(name, status, stopped_at=None, raft="true"):
545
+ config = {"user.raft": raft}
546
+ if stopped_at is not None:
547
+ config["user.raft.stopped-at"] = str(stopped_at)
548
+ return {"name": name, "status": status, "config": config}
549
+
550
+ instances = [
551
+ # Running boxes are never deleted; an old stop time left behind is cleared.
552
+ box("rf-running", "Running", now - 30 * 86400),
553
+ box("rf-old", "Stopped", now - 3 * 3600),
554
+ box("rf-recent", "Stopped", now - 3600),
555
+ box("rf-unrecorded", "Stopped"),
556
+ box("unrelated", "Stopped", now - 30 * 86400, raft="false"),
557
+ ]
558
+
559
+ def run_worker(project, *arguments):
560
+ with tempfile.TemporaryDirectory() as work:
561
+ root = Path(work)
562
+ for tool in ["incus", "logger"]:
563
+ (root / tool).write_text(f"#!{sys.executable}\n" + FAKE_HOST_TOOL)
564
+ (root / tool).chmod(0o755)
565
+ (root / "state.json").write_text(
566
+ json.dumps({"instances": instances, "project": project})
567
+ )
568
+ started = int(time.time())
569
+ response = subprocess.run(
570
+ [sys.executable, "-I", str(ROOT / "deploy/raft-expire.py"), *arguments],
571
+ env={
572
+ **os.environ,
573
+ "PATH": f"{root}:{os.environ['PATH']}",
574
+ "RAFT_FAKE_STATE": str(root / "state.json"),
575
+ "RAFT_FAKE_LOG": str(root / "calls.jsonl"),
576
+ },
577
+ capture_output=True,
578
+ text=True,
579
+ )
580
+ calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
581
+ # Leave out the two reads and the stale stop time cleared on the running box;
582
+ # normalize the recorded stop time to "now".
583
+ clear = ["incus", "--project", "raft", "config", "unset", "rf-running"]
584
+ cleared.append(clear + ["user.raft.stopped-at"] in calls)
585
+ changes = []
586
+ for call in calls:
587
+ if call[-2:] == ["--format", "json"] or call == clear + ["user.raft.stopped-at"]:
588
+ continue
589
+ if call[-1].startswith("user.raft.stopped-at="):
590
+ assert started <= int(call[-1].partition("=")[2]) <= int(time.time()), call
591
+ call[-1] = "user.raft.stopped-at=now"
592
+ changes.append(call)
593
+ return response, changes
594
+
595
+ cleared = []
596
+ record = ["incus", "--project", "raft", "config", "set", "rf-unrecorded"]
597
+ record.append("user.raft.stopped-at=now")
598
+ two_hours = {"user.raft.delete-stopped-after": "2", "user.raft.delete-stopped-after.since": ""}
599
+ # A 2-hour policy set long ago deletes the box stopped 3 hours ago and logs it.
600
+ response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": "0"})
601
+ assert response.returncode == 0, response
602
+ assert changes[0] == ["incus", "--project", "raft", "delete", "rf-old"], changes
603
+ assert changes[1][:3] == ["logger", "-t", "raft"] and "rf-old" in changes[1][3], changes
604
+ assert "under the 2-hour retention" in changes[1][3] and changes[2:] == [record], changes
605
+ assert cleared[-1], "the worker kept a stale stop time on a running box"
606
+ assert changes[1][3] in response.stdout, response
607
+ # Set an hour ago, the policy's clock starts then: nothing is deleted yet.
608
+ since = str(now - 3600)
609
+ response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": since})
610
+ assert response.returncode == 0 and changes == [record], (response, changes)
611
+ # Without a policy nothing is deleted, but stop times are still recorded.
612
+ response, changes = run_worker({})
613
+ assert response.returncode == 0 and changes == [record], (response, changes)
614
+ # Prune mode only selects; it records and retains nothing else.
615
+ response, changes = run_worker(
616
+ {**two_hours, "user.raft.delete-stopped-after.since": "0"}, "--prune-days", "1"
617
+ )
618
+ assert response.returncode == 0 and changes == [], (response, changes)
619
+ # An invalid policy deletes nothing, still enforces deadlines, and fails the run.
620
+ instances.append(
621
+ {
622
+ "name": "rf-expired",
623
+ "status": "Running",
624
+ "config": {"user.raft": "true", "user.raft.expires": "1"},
625
+ }
626
+ )
627
+ response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after": "48h"})
628
+ assert response.returncode == 1 and "Invalid retention" in response.stderr, response
629
+ assert ["incus", "--project", "raft", "stop", "rf-expired", "--timeout", "30"] in changes
630
+ assert not any("delete" in call for call in changes), changes
631
+ print("Host worker retention, stop-time recording and grace period passed", flush=True)
632
+
633
+
470
634
  def verify_host_preflight():
471
635
  """Check onboarding decisions and refusals that need no host."""
472
636
  ready = {
@@ -588,7 +752,7 @@ def verify_note_policy(location, create, listed_note, fixtures):
588
752
  assert changed.stdout == f"{location}: required note fields none -> purpose,owner\n"
589
753
  report = json.loads(live.raft("config").stdout)
590
754
  assert report["policy"][location]["require"] == ["purpose", "owner"], report
591
- assert " by " in report["policy"][location]["changed"], report
755
+ assert " by " in report["policy"][location]["requireChanged"], report
592
756
  saved = len(inventory(location))
593
757
  for arguments, error in [
594
758
  (["new", "--location", location, "--purpose", "x"], "requires --owner (host policy)"),
@@ -632,7 +796,7 @@ def verify_note_policy(location, create, listed_note, fixtures):
632
796
  live.raft("note", source, "--purpose", "Policy fork source", "--owner", "verify")
633
797
  assert listed_note(source) == {"purpose": "Policy fork source", "owner": "verify"}
634
798
  finally:
635
- cleared = live.raft("policy", location, "--clear")
799
+ cleared = live.raft("policy", location, "--require", "none")
636
800
  assert cleared.stdout == f"{location}: required note fields purpose,owner -> none\n"
637
801
  assert json.loads(live.raft("config").stdout)["policy"][location]["require"] == []
638
802
  live.raft("note", source, "--clear")
@@ -641,6 +805,117 @@ def verify_note_policy(location, create, listed_note, fixtures):
641
805
  print(f"{location}: host note policy for new, fork, note and recover passed", flush=True)
642
806
 
643
807
 
808
+ def verify_retention(location, create, exists, worker):
809
+ """Set a 1-hour stopped-box retention, prove the worker deletes only a box stopped
810
+ past it, records stop times Raft missed and logs to the journal, then clear it."""
811
+ host = settings(location)["ssh"]
812
+ started = int(time.time())
813
+ running = create("--memory", "1GiB", "--purpose", "Retention: running box")
814
+ kept = create("--memory", "1GiB", "--purpose", "Retention: recently stopped box")
815
+ expired = create("--memory", "1GiB", "--purpose", "Retention: long stopped box")
816
+ unrecorded = create("--memory", "1GiB", "--purpose", "Retention: box stopped outside Raft")
817
+ live.raft("stop", kept)
818
+ live.raft("stop", expired)
819
+ # A native stop records no stop time; the worker starts that box's clock.
820
+ incus(location, "stop", parse_handle(unrecorded)[1], "--timeout", "30", locked=True)
821
+
822
+ def stopped_at(handle):
823
+ return incus(
824
+ location, "config", "get", parse_handle(handle)[1], "user.raft.stopped-at"
825
+ ).strip()
826
+
827
+ def tick(done):
828
+ # The staged worker runs once; the installed one runs on its 15-second timer.
829
+ if worker:
830
+ transaction(location, 'exec python3 "$1"', worker)
831
+ deadline = time.monotonic() + 50
832
+ while not done():
833
+ if time.monotonic() > deadline:
834
+ raise RuntimeError("Host expiry worker did not apply the retention policy")
835
+ time.sleep(1)
836
+
837
+ def listed():
838
+ lines = live.raft("list", "--location", location).stdout.splitlines()
839
+ return {record["box"]: record for record in map(json.loads, lines)}
840
+
841
+ if worker:
842
+ # Older workers ignore retention, so the CLI refuses to set it for them.
843
+ rejected = live.raft("policy", location, "--delete-stopped-after", "1", check=False)
844
+ assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
845
+ incus(
846
+ location,
847
+ "project",
848
+ "set",
849
+ "raft",
850
+ f"{RETENTION_KEY}=1",
851
+ f"{RETENTION_SINCE_KEY}={int(time.time())}",
852
+ locked=True,
853
+ )
854
+ else:
855
+ changed = live.raft("policy", location, "--delete-stopped-after", "1")
856
+ assert changed.stdout == f"{location}: delete stopped boxes after never -> 1 hour\n"
857
+ try:
858
+ policy = json.loads(live.raft("config").stdout)["policy"][location]
859
+ assert policy["deleteStoppedAfterHours"] == 1, policy
860
+ since = policy["deleteStoppedAfterSince"]
861
+ assert started <= since <= time.time(), policy
862
+ # A box stopped before the policy gets the full hour from the change.
863
+ records = listed()
864
+ assert records[kept]["deleteAt"] == str(since + 3600), records[kept]
865
+ assert records[running]["deleteAt"] == "", records[running]
866
+ # Hours are the smallest unit, so backdate the policy and one stop by two
867
+ # hours instead of waiting: only that box is then past the retention.
868
+ backdated = int(time.time()) - 2 * 3600
869
+ incus(location, "project", "set", "raft", f"{RETENTION_SINCE_KEY}={backdated}", locked=True)
870
+ incus(
871
+ location,
872
+ "config",
873
+ "set",
874
+ parse_handle(expired)[1],
875
+ f"user.raft.stopped-at={backdated}",
876
+ locked=True,
877
+ )
878
+ tick(lambda: not exists(expired) and stopped_at(unrecorded))
879
+ assert exists(kept) and exists(running) and exists(unrecorded)
880
+ # The clock of a box stopped outside Raft starts when the worker sees it.
881
+ assert int(stopped_at(unrecorded)) >= started
882
+ assert listed()[unrecorded]["deleteAt"] == str(int(stopped_at(unrecorded)) + 3600)
883
+ deleted = f"Deleted box {parse_handle(expired)[1]}, stopped since "
884
+ command = ["sudo", "-n", "journalctl", "-t", "raft", "--since", f"@{started}", "-o", "cat"]
885
+ deadline = time.monotonic() + 10
886
+ while True:
887
+ journal = subprocess.check_output(["ssh", "-T", host, shlex.join(command)], text=True)
888
+ if deleted in journal or time.monotonic() > deadline:
889
+ break
890
+ time.sleep(1)
891
+ assert deleted in journal, journal
892
+ if not worker:
893
+ assert "Stopped-box retention: hours: '' -> '1'" in journal, journal
894
+ finally:
895
+ if worker:
896
+ incus(
897
+ location,
898
+ "project",
899
+ "set",
900
+ "raft",
901
+ f"{RETENTION_KEY}=",
902
+ f"{RETENTION_SINCE_KEY}=",
903
+ locked=True,
904
+ )
905
+ else:
906
+ cleared = live.raft("policy", location, "--delete-stopped-after", "never")
907
+ if not worker:
908
+ assert cleared.stdout == f"{location}: delete stopped boxes after 1 hour -> never\n"
909
+ policy = json.loads(live.raft("config").stdout)["policy"][location]
910
+ assert policy["deleteStoppedAfterHours"] is None, policy
911
+ assert listed()[kept]["deleteAt"] == ""
912
+ print(
913
+ f"{location}: stopped-box retention deleted only the expired box, "
914
+ "recorded missing stop times, logged and cleared",
915
+ flush=True,
916
+ )
917
+
918
+
644
919
  def verify(location, isolated_worker=False):
645
920
  if inventory(location):
646
921
  raise RuntimeError(
@@ -681,7 +956,7 @@ def verify(location, isolated_worker=False):
681
956
  ],
682
957
  text=True,
683
958
  ).strip()
684
- if protocol == "1":
959
+ if protocol == "2":
685
960
  raise RuntimeError(
686
961
  "--isolated-worker targets older deployments; use normal verification on a current host"
687
962
  )
@@ -965,6 +1240,7 @@ def verify(location, isolated_worker=False):
965
1240
  f"{location}: disposable deletion, preview/apply retention and exclusions passed",
966
1241
  flush=True,
967
1242
  )
1243
+ verify_retention(location, create, exists, worker)
968
1244
  finally:
969
1245
  for handle in reversed(fixtures):
970
1246
  if exists(handle):
@@ -996,6 +1272,7 @@ if __name__ == "__main__":
996
1272
  args = parser.parse_args()
997
1273
  verify_location_notes()
998
1274
  verify_controller_defaults()
1275
+ verify_retention_worker()
999
1276
  verify_host_preflight()
1000
1277
  verify_archives()
1001
1278
  if args.location:
@@ -108,7 +108,8 @@ def policy_checks():
108
108
 
109
109
 
110
110
  def command(*args):
111
- return subprocess.check_output([*CLI, *args], text=True).strip()
111
+ # The private controller HOME keeps this controller's defaults for new out.
112
+ return subprocess.check_output([*CLI, *args], text=True, env=live.CONTROLLER_ENV).strip()
112
113
 
113
114
 
114
115
  def report(location):
@@ -7,6 +7,7 @@ import os
7
7
  import pty
8
8
  import select
9
9
  import shlex
10
+ import shutil
10
11
  from pathlib import Path
11
12
  import socket
12
13
  import subprocess
@@ -19,11 +20,24 @@ ROOT = Path(__file__).resolve().parent.parent
19
20
  CLI = [sys.executable, str(ROOT / "raft.py")]
20
21
  USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
21
22
  sys.path.insert(0, str(ROOT))
22
- from raft import configuration, incus, remote, settings, transaction # noqa: E402
23
+ from raft import CONFIG, configuration, incus, remote, settings, transaction # noqa: E402
24
+
25
+ # Suites run the CLI in a private HOME that holds only the location inventory, so
26
+ # this controller's defaults for new (config.json) never reach them: a disposable
27
+ # default would delete boxes a suite stops and expects to keep, and size defaults
28
+ # would change asserted limits. OpenSSH reads keys and aliases from the passwd
29
+ # home, not HOME. The inventory is copied once; suites that edit it use their own.
30
+ CONTROLLER_HOME = tempfile.TemporaryDirectory(prefix="raft-verify-home-")
31
+ if CONFIG.exists():
32
+ (Path(CONTROLLER_HOME.name) / ".config/raft").mkdir(parents=True)
33
+ shutil.copy2(CONFIG, Path(CONTROLLER_HOME.name) / ".config/raft/incus.json")
34
+ CONTROLLER_ENV = {**os.environ, "HOME": CONTROLLER_HOME.name}
23
35
 
24
36
 
25
37
  def raft(*arguments, check=True, env=None):
26
- response = subprocess.run([*CLI, *arguments], capture_output=True, text=True, env=env)
38
+ response = subprocess.run(
39
+ [*CLI, *arguments], capture_output=True, text=True, env=env or CONTROLLER_ENV
40
+ )
27
41
  if check and response.returncode:
28
42
  raise RuntimeError(f"Raft {arguments!r} failed: {response.stderr.strip()}")
29
43
  return response
@@ -352,7 +366,9 @@ def browser_session(box):
352
366
  assert.equal(response.result.errorText, undefined);
353
367
  socket.send(JSON.stringify(viewerProof));
354
368
  } else if (response.id === 4) {
355
- const viewer = JSON.parse(response.result.result.value);
369
+ // While noVNC is still loading, the evaluation can return no value.
370
+ const value = response.result.result.value;
371
+ const viewer = typeof value === 'string' ? JSON.parse(value) : {connected: false};
356
372
  if (!viewer.connected) {
357
373
  setTimeout(() => socket.send(JSON.stringify(viewerProof)), 200); return;
358
374
  }
package/docs/contract.md CHANGED
@@ -35,7 +35,7 @@ Raft manages persistent unprivileged native-architecture Incus system containers
35
35
  | Host identification | Configuration requires named SSH targets using letters, digits, underscores, or hyphens. |
36
36
  | Qualified handles | Every handle includes location and instance name (`<location>:<name>`). |
37
37
  | Image pinning | Workspace creation requires the immutable 64-hex image fingerprint, not an updating alias. |
38
- | Lifecycle defaults | Built-in defaults are the first configured location, 1 CPU, 2 GiB RAM, the shared pool, and 600-second lifetime. `~/.config/raft/config.json` can override them for `new` only; explicit flags win. Boxes from a snapshot (`new --from`) and forks keep the source's CPU, RAM and disk instead of any default, configured or built-in; each flag overrides only its own value. `--cpu` takes any positive whole number, `--memory` any positive whole GiB (`16GiB`), `--disk` a positive whole GiB or `shared`, and TTL 60 to 2,592,000 seconds (30 days). |
38
+ | Lifecycle defaults | Built-in defaults are the first configured location, 1 CPU, 2 GiB RAM, the shared pool, a persistent box and 600-second lifetime. `~/.config/raft/config.json` can override them for `new` only; explicit flags win, and `--persistent` overrides a `disposable` default. Boxes from a snapshot (`new --from`) and forks keep the source's CPU, RAM and disk instead of any default, configured or built-in; each flag overrides only its own value. `--cpu` takes any positive whole number, `--memory` any positive whole GiB (`16GiB`), `--disk` a positive whole GiB or `shared`, and TTL 60 to 2,592,000 seconds (30 days). |
39
39
  | Box count and size | Raft limits neither how many boxes a host holds nor their size. CPU or RAM limits larger than the whole host (CPU count, or `MemTotal` rounded up to whole GiB) are refused under the host lock before any change, because Incus would clip them silently. Other failures come from Incus, such as too little pool space. |
40
40
  | TTL calculation | Running TTL begins after launch completes, using the host clock. Cold image preparation does not consume TTL. |
41
41
  | Host locking | Creation, lifecycle transitions, and expiry are serialized by host file lock (`/run/lock/raft-incus.lock`). Noninteractive transactions close controller stdin so commands cannot wait for piped input while holding this lock. Exec, SSH, and file-transfer paths remain unchanged. Piped input to `raft exec` is unsupported. Native Incus admin calls bypass CLI policy. |
@@ -44,14 +44,15 @@ Raft manages persistent unprivileged native-architecture Incus system containers
44
44
 
45
45
  `raft locations` lists configured locations as JSONL with `location`, `default` and `note` fields without contacting hosts. `raft note LOCATION TEXT` and `raft note LOCATION --clear` edit the optional `note` field in the controller configuration atomically, preserving location order and mode `0600`. Notes are advisory guidance; Raft shows them in `locations`, `capacity` and on stderr from `new`, and never enforces them.
46
46
 
47
- Boxes accept an optional note with a fixed template: `purpose` (required), `owner` and `next`, all nonempty strings. `raft new --purpose TEXT [--owner TEXT] [--next TEXT]` and `raft note BOX ...` store it as JSON in the box's `user.raft.note` Incus key; `raft note BOX --clear` removes it. Each edit replaces the whole note. `raft list` reports it as `note`, or `null`, and reports each box's `disk` as `"shared"` or its own disk size. `new` always writes the key, so `new --from` never inherits a snapshot's note. Forks and recovered backups keep it unless `fork` or `recover` gets note flags, which replace the whole note. Box notes never change lifecycle behavior.
47
+ Boxes accept an optional note with a fixed template: `purpose` (required), `owner` and `next`, all nonempty strings. `raft new --purpose TEXT [--owner TEXT] [--next TEXT]` and `raft note BOX ...` store it as JSON in the box's `user.raft.note` Incus key; `raft note BOX --clear` removes it. Each edit replaces the whole note. `raft list` reports it as `note`, or `null`, reports each box's `disk` as `"shared"` or its own disk size, and reports `deleteAt`, the Unix time retention deletes a stopped box, or `""`. `new` always writes the key, so `new --from` never inherits a snapshot's note. Forks and recovered backups keep it unless `fork` or `recover` gets note flags, which replace the whole note. Box notes never change lifecycle behavior.
48
48
 
49
- ## Controller defaults and note policies
49
+ ## Controller defaults and host policies
50
50
 
51
- - **Controller defaults**: `~/.config/raft/config.json` holds `{"defaults": {...}}` with optional `location`, `ttl`, `cpu`, `memory` and `disk` for `new`. Precedence is explicit flag, then configured default, then built-in default. `cpu`, `memory` and `disk` apply only to boxes from the image: `new --from` keeps the snapshot's location, CPU, RAM and disk unless a flag overrides them, and `--disk shared` is refused there. A `disk` default on a host without `raft-disks`, or larger than its unreserved space, fails like `--disk` and names `defaults.disk` as the source. Other commands take no defaults. A value is valid exactly when the same text is valid for the flag. Unknown keys, bad values and malformed JSON fail `new` with the file path and key, before any host access. `raft config set/unset` validate first, write atomically under a controller lock, keep mode `0600`, print the change and can repair the key they edit. The file is separate from `incus.json`, so location names never collide with setting names.
52
- - **Note policy storage**: `raft policy LOCATION --require FIELDS` stores required box note fields, which must include `purpose`, in the host's Incus project key `user.raft.require`; `--clear` empties it. Each change runs under the host lock, prints the old and new fields, records UTC time, the SSH account (`SUDO_USER`) and the self-reported controller user and hostname in `user.raft.require.changed`, and logs to the host journal with tag `raft`. Redeployment leaves these keys unchanged.
51
+ - **Controller defaults**: `~/.config/raft/config.json` holds `{"defaults": {...}}` with optional `location`, `ttl`, `cpu`, `memory`, `disk` and `disposable` for `new`. Precedence is explicit flag, then configured default, then built-in default. `cpu`, `memory` and `disk` apply only to boxes from the image: `new --from` keeps the snapshot's location, CPU, RAM and disk unless a flag overrides them, and `--disk shared` is refused there. `ttl` and `disposable` apply to `new --from` as well, because a snapshot carries no lifecycle; `new --disposable` and `--persistent` are mutually exclusive overrides. `fork` takes no defaults and is persistent without `--disposable`. A `disk` default on a host without `raft-disks`, or larger than its unreserved space, fails like `--disk` and names `defaults.disk` as the source. Other commands take no defaults. A value is valid exactly when the same text is valid for the flag. Unknown keys, bad values and malformed JSON fail `new` with the file path and key, before any host access. `raft config set/unset` validate first, write atomically under a controller lock, keep mode `0600`, print the change and can repair the key they edit. The file is separate from `incus.json`, so location names never collide with setting names.
52
+ - **Policy storage**: `raft policy LOCATION [--require FIELDS|none] [--delete-stopped-after HOURS|never]` sets either host policy or both; each option changes only its own. Required box note fields, which must include `purpose`, live in the Incus project key `user.raft.require`, and `--require none` empties it. Stopped-box retention lives in `user.raft.delete-stopped-after` as a positive whole number of hours, with the time it was set in `user.raft.delete-stopped-after.since`; `never` empties both. Both values are validated before any host change. Each change runs under the host lock, prints the old and new value, records UTC time, the SSH account (`SUDO_USER`) and the self-reported controller user and hostname in the policy's `.changed` key, and logs to the host journal with tag `raft`. Redeployment leaves these keys unchanged.
53
53
  - **Note policy enforcement**: `new` (including `--from`), `fork`, `recover` and `raft note BOX` check the note the box will carry. The controller reads the policy first and fails with the missing flags and a complete corrected command before changing anything. The host repeats the check inside the transaction that writes the note, under the host lock, so a concurrent policy change cannot be missed. That transaction also holds the oversize and own-disk reservation checks; all of them run before Incus creates anything. `fork` checks the given note or, without one, the source note read under the lock. `recover` checks a given note before upload; an archived note is checked after import, and a failing import is deleted in the same transaction. `note --clear` and replacement notes that drop a required field are rejected.
54
- - **Policy scope**: The policy is a guardrail for agents, not an access control. Anyone with host SSH and sudo can change it or create boxes with native Incus commands, and controllers without policy support do not check it. `raft config` reports every location's policy and last change.
54
+ - **Retention enforcement**: The host expiry worker deletes a stopped Raft box, with its snapshots, once the hours have passed since the later of its `user.raft.stopped-at` and the policy's `.since` time, so setting, shortening or renewing a policy never deletes a box sooner than its hours after the change. It runs on the 15-second timer and on `raft gc`, under the host lock, and writes each deletion to the host journal with tag `raft`. Running boxes are never touched. A stopped Raft box without `user.raft.stopped-at`, from a guest poweroff, a native stop, a failed start or `raft recover`, gets the worker's current time as its stop time, never an earlier guess, whether or not a policy is set. A malformed retention value makes the worker delete nothing by retention, still enforce deadlines, and exit nonzero; the CLI reports it with the fix. Setting retention requires the current worker protocol; clearing it does not.
55
+ - **Policy scope**: Policies are guardrails for agents, not access controls. Anyone with host SSH and sudo can change them or create boxes with native Incus commands, and controllers without note policy support do not check it. A native `incus start` leaves a stale stop time that retention trusts. `raft config` reports every location's policies and last changes.
55
56
 
56
57
  `raft capacity [--location NAME] [--json]` inspects hosts without modifying state. Text and JSON (`notice`) lead with: "Advisory estimate only. Raft enforces no box count or size budget; create the boxes your task needs." It reports effective CPUs, total and available RAM, saved and active box counts, active resource limits, headroom, free space in the host disk and the shared pool, and for `raft-disks` its data space, reserved and used bytes and metadata use (`boxDiskPool`, `null` when own box disks are not enabled).
57
58
 
@@ -64,12 +65,12 @@ Boxes accept an optional note with a fixed template: `purpose` (required), `owne
64
65
 
65
66
  - **Stop and resume**: Stop terminates guest processes and preserves disk contents; stopped boxes retain disk but do not reserve guest RAM. Resume starts the container anew with an explicit TTL without restoring process memory.
66
67
  - **Lifetime extension**: `raft extend --ttl` resets a running box's deadline from current host time without restarting; stopped boxes require `raft resume`.
67
- - **Host worker contract**: `doctor`, disposable creation/fork, and retention cleanup require the current worker protocol. `doctor` checks it without running expiry or changing boxes, and reports the failing location. A mismatch fails before executing deletion policy or creating a disposable box; deploy the current checkout explicitly. Installing a new CLI does not update host workers.
68
- - **Disposable lifecycle**: `new --disposable` opts a box into deletion on stop or TTL expiry, including its snapshots. Clones and recovered boxes default to persistent; fork accepts an explicit `--disposable`.
69
- - **Retention cleanup**: `prune --older-than DAYS` previews persistent stopped boxes with a recorded stop timestamp. `--yes` deletes matching boxes under the host lifecycle lock. Running boxes and boxes without a recorded stop time are excluded. No automatic persistent-box deletion is enabled.
68
+ - **Host worker contract**: `doctor`, disposable creation/fork, prune and setting a retention policy require the current worker protocol (2 since 0.6.0, which added retention). `doctor` checks it without running expiry or changing boxes, and reports the failing location. A mismatch fails before executing deletion policy or creating a disposable box; deploy the current checkout explicitly. Installing a new CLI does not update host workers.
69
+ - **Disposable lifecycle**: `new --disposable`, or a configured `disposable` default without `--persistent`, opts a box into deletion on stop or TTL expiry, including its snapshots. Clones and recovered boxes default to persistent whatever the default; fork accepts an explicit `--disposable`.
70
+ - **Manual cleanup**: `prune --older-than DAYS` previews persistent stopped boxes with a recorded stop timestamp. `--yes` deletes matching boxes under the host lifecycle lock. Running boxes and boxes without a recorded stop time are excluded. Automatic deletion of persistent stopped boxes happens only under a host retention policy.
70
71
  - **Resizing**: Resume and fork accept optional CPU/RAM overrides, preserving omitted values. Resume validates stopped state under the lock before changing sizing. Neither changes a box's disk.
71
- - **Expiration cleanup**: The host timer checks expirations every 15 seconds, stopping expired persistent containers and deleting expired disposable containers. Expiry may be delayed by graceful shutdown, long locked transactions, and host outage until startup.
72
- - **Destroy**: Removes the container instance and its snapshots, releasing storage. Destroy confirms absence; SSH failure is not proof of deletion. Disposable boxes created with `new --disposable` are deleted on explicit stop or expiry. Persistent boxes still require explicit destroy for deletion.
72
+ - **Expiration cleanup**: The host timer checks expirations every 15 seconds, stopping expired persistent containers, deleting expired disposable containers and applying stopped-box retention. Expiry may be delayed by graceful shutdown, long locked transactions, and host outage until startup.
73
+ - **Destroy**: Removes the container instance and its snapshots, releasing storage. Destroy confirms absence; SSH failure is not proof of deletion. Disposable boxes created with `new --disposable` are deleted on explicit stop or expiry. Persistent boxes still require explicit destroy, prune or a retention policy for deletion.
73
74
  - **Execution transport**: Incus exec and file APIs travel over authenticated host SSH without a public Incus listener or injected credentials. Interactive SSH runs a PTY over host SSH and Incus exec.
74
75
  - **State inspection and tunnels**: `raft list` and `raft info` read native metadata without guest process counters during shutdown; tunnels use the managed NIC.
75
76
  - **Resource reporting**: `raft usage` reports running cgroup memory, CPU microseconds, CPU affinity, and filesystem space with `filesystemScope`: the shared pool, or `own SIZE box disk`. It provides no billing.
@@ -83,4 +83,4 @@ raft destroy "$box"
83
83
  - [Troubleshooting](./guides/troubleshooting.mdx): Diagnose common errors and inspect host services.
84
84
  - [Upgrades and image changes](./guides/upgrades.mdx): Update the controller, hosts, and image pins.
85
85
 
86
- For temporary work, create with `--disposable`. Stopping or expiry then deletes the box and its snapshots. For persistent boxes, `raft prune --older-than 7` previews old stopped workspaces; add `--yes` only after reviewing the candidates.
86
+ For temporary work, create with `--disposable`. Stopping or expiry then deletes the box and its snapshots. For persistent boxes, `raft prune --older-than 7` previews old stopped workspaces; add `--yes` only after reviewing the candidates. A host can also delete boxes left stopped automatically; `raft config` shows each location's `deleteStoppedAfterHours`.
@@ -53,7 +53,7 @@ echo "Recovered workspace: $recovered"
53
53
 
54
54
  - **Architecture compatibility**: Target host must match the native CPU architecture (ARM64 to ARM64, AMD64 to AMD64). Cross-architecture recovery is not supported.
55
55
  - **Fresh network identity**: The container receives a new name in project `raft`, a new MAC address on `rfbr0`, and no inherited TTL deadline.
56
- - **Stopped initial state**: Recovered workspaces remain stopped until started with `raft resume "$recovered" --ttl <SECONDS>`.
56
+ - **Stopped initial state**: Recovered workspaces remain stopped until started with `raft resume "$recovered" --ttl <SECONDS>`. Under a host [retention policy](./config-policy.mdx#delete-boxes-left-stopped), the recovered box's clock starts when the host worker first sees it, within about 15 seconds; resume it before the policy's hours pass.
57
57
  - **Disk placement**: A backup of a box with its own disk returns to an own disk of the same size in `raft-disks`. The destination needs own box disks enabled and enough unreserved pool space for the box and each of its snapshots; otherwise recovery fails before importing. Other backups land in the shared pool.
58
58
  - **Interrupted recovery**: Follow [Host crash during recovery](./troubleshooting.mdx#host-crash-during-recovery). Inspect the reported handle and container state before deleting any staging file.
59
59