@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.
Files changed (63) hide show
  1. package/README.md +27 -15
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/raft-expire.py +109 -31
  4. package/deploy/verify-controls.py +6 -6
  5. package/deploy/verify-features.py +329 -19
  6. package/deploy/verify-jobs.py +264 -0
  7. package/deploy/verify-limits.py +22 -3
  8. package/deploy/verify-live.py +31 -6
  9. package/deploy/verify-output.py +208 -0
  10. package/deploy/verify-release.py +33 -4
  11. package/deploy/verify-run.py +571 -0
  12. package/deploy/verify-self-update.py +9 -1
  13. package/deploy/verify-sync.py +122 -0
  14. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  15. package/docs/boat-capabilities.md +60 -7
  16. package/docs/boat-parity-design.md +1 -0
  17. package/docs/boot-performance.mdx +21 -0
  18. package/docs/contract.md +88 -13
  19. package/docs/first-box.mdx +22 -19
  20. package/docs/guides/background-jobs.mdx +38 -31
  21. package/docs/guides/backup-recovery.mdx +4 -3
  22. package/docs/guides/ci-jobs.mdx +209 -0
  23. package/docs/guides/config-policy.mdx +109 -35
  24. package/docs/guides/files.mdx +2 -2
  25. package/docs/guides/index.mdx +31 -0
  26. package/docs/guides/long-running-services.mdx +9 -3
  27. package/docs/guides/meta.json +7 -2
  28. package/docs/guides/project-setup.mdx +1 -1
  29. package/docs/guides/scripting-ci.mdx +36 -14
  30. package/docs/guides/snapshots-forks.mdx +4 -2
  31. package/docs/guides/troubleshooting.mdx +19 -2
  32. package/docs/guides/upgrades.mdx +11 -9
  33. package/docs/index.mdx +26 -28
  34. package/docs/maintainers/index.mdx +18 -0
  35. package/docs/maintainers/meta.json +12 -0
  36. package/docs/meta.json +5 -9
  37. package/docs/operations.mdx +17 -10
  38. package/docs/publishing.md +14 -4
  39. package/docs/reference/capacity.mdx +2 -1
  40. package/docs/reference/cli.md +135 -43
  41. package/docs/reference/configuration.mdx +10 -6
  42. package/docs/reference/meta.json +3 -1
  43. package/docs/releases/0.2.0.md +1 -0
  44. package/docs/releases/0.2.1.md +1 -0
  45. package/docs/releases/0.3.0.md +1 -0
  46. package/docs/releases/0.4.0.md +1 -0
  47. package/docs/releases/0.5.0.md +1 -0
  48. package/docs/releases/0.6.0.md +17 -0
  49. package/docs/releases/0.7.0.md +23 -0
  50. package/docs/releases/index.mdx +21 -0
  51. package/docs/releases/meta.json +15 -0
  52. package/docs/requirements.mdx +1 -1
  53. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  54. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  55. package/docs/site-development.md +1 -0
  56. package/docs/verification.mdx +22 -4
  57. package/package.json +3 -1
  58. package/raft.py +526 -193
  59. package/raft_files.py +13 -1
  60. package/raft_jobs.py +390 -0
  61. package/raft_output.py +166 -0
  62. package/raft_run.py +330 -0
  63. package/skills/raft-cli/SKILL.md +78 -182
@@ -28,8 +28,12 @@ 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,
35
+ host_inventory,
36
+ host_policy,
33
37
  incus,
34
38
  inventory,
35
39
  parse_handle,
@@ -265,7 +269,7 @@ def verify_controller_defaults():
265
269
  "import json, sys; import raft; args = raft.parser().parse_args(sys.argv[1:]); "
266
270
  "used = raft.apply_new_defaults(args); "
267
271
  "print(json.dumps([args.location, args.ttl, args.cpu, args.memory, args.disk, "
268
- "sorted(used)]))"
272
+ "args.disposable, sorted(used)]))"
269
273
  )
270
274
  response = subprocess.run(
271
275
  [sys.executable, "-c", program, *arguments],
@@ -279,13 +283,14 @@ def verify_controller_defaults():
279
283
  return json.loads(response.stdout)
280
284
 
281
285
  # No disk is the shared pool.
282
- assert resolved("new") == ["lab", 600, 1, "2GiB", None, []]
286
+ assert resolved("new") == ["lab", 600, 1, "2GiB", None, False, []]
283
287
  for arguments, printed in [
284
288
  (["set", "cpu", "2"], "defaults.cpu: built-in default -> 2"),
285
289
  (["set", "memory", "4GiB"], "defaults.memory: built-in default -> 4GiB"),
286
290
  (["set", "ttl", "3600"], "defaults.ttl: built-in default -> 3600"),
287
291
  (["set", "location", "spare"], "defaults.location: built-in default -> spare"),
288
292
  (["set", "disk", "20GiB"], "defaults.disk: built-in default -> 20GiB"),
293
+ (["set", "disposable", "true"], "defaults.disposable: built-in default -> true"),
289
294
  (["set", "cpu", "1"], "defaults.cpu: 2 -> 1"),
290
295
  ]:
291
296
  response = cli("config", *arguments)
@@ -297,29 +302,48 @@ def verify_controller_defaults():
297
302
  "ttl": 3600,
298
303
  "location": "spare",
299
304
  "disk": "20GiB",
305
+ "disposable": True,
300
306
  }
301
307
  }
302
308
  assert config.stat().st_mode & 0o777 == 0o600, "Default editing widened permissions"
303
309
  # 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]
310
+ everything = ["cpu", "disk", "disposable", "location", "memory", "ttl"]
311
+ assert resolved("new") == ["spare", 3600, 1, "4GiB", "20GiB", True, everything]
306
312
  assert resolved("new", "--location", "lab", "--ttl", "60", "--cpu", "2") == [
307
313
  "lab",
308
314
  60,
309
315
  2,
310
316
  "4GiB",
311
317
  "20GiB",
312
- ["disk", "memory"],
318
+ True,
319
+ ["disk", "disposable", "memory"],
313
320
  ]
314
- # --disk shared beats a configured own disk.
315
- assert resolved("new", "--disk", "shared")[4:] == [
321
+ # --disk shared beats a configured own disk, and --persistent a disposable default.
322
+ assert resolved("new", "--disk", "shared", "--persistent")[4:] == [
316
323
  None,
324
+ False,
317
325
  ["cpu", "location", "memory", "ttl"],
318
326
  ]
327
+ assert resolved("new", "--disposable")[5:] == [
328
+ True,
329
+ ["cpu", "disk", "location", "memory", "ttl"],
330
+ ]
331
+ rejected = cli("new", "--disposable", "--persistent")
332
+ assert rejected.returncode == 2 and "not allowed with" in rejected.stderr, rejected
319
333
  # A box from a snapshot keeps the snapshot's location, CPU, RAM and disk:
320
334
  # defaults, configured or built-in, never override them; flags still do.
335
+ # A snapshot has no lifecycle, so the TTL and disposable defaults apply.
321
336
  template = "lab:rf-0123456789abcdef/snap"
322
- assert resolved("new", "--from", template) == [None, 3600, None, None, None, ["ttl"]]
337
+ assert resolved("new", "--from", template) == [
338
+ None,
339
+ 3600,
340
+ None,
341
+ None,
342
+ None,
343
+ True,
344
+ ["disposable", "ttl"],
345
+ ]
346
+ assert resolved("new", "--from", template, "--persistent")[5] is False
323
347
  assert resolved("new", "--from", template, "--cpu", "4", "--disk", "30GiB")[2:5] == [
324
348
  4,
325
349
  None,
@@ -328,24 +352,30 @@ def verify_controller_defaults():
328
352
  assert "--from keeps the snapshot's disk" in resolved(
329
353
  "new", "--from", template, "--disk", "shared"
330
354
  )
331
- # fork has no defaults at all: omitted sizes stay the source's.
355
+ # fork has no defaults at all: omitted sizes stay the source's, and a fork
356
+ # is persistent without --disposable even under a disposable default.
332
357
  forked = subprocess.check_output(
333
358
  [
334
359
  sys.executable,
335
360
  "-c",
336
361
  "import raft; args = raft.parser().parse_args(['fork', 'lab:rf-0123456789abcdef', "
337
- "'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'))",
362
+ "'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'), args.disposable)",
338
363
  ],
339
364
  cwd=ROOT,
340
365
  env={**os.environ, "HOME": home},
341
366
  text=True,
342
367
  )
343
- assert forked == "[] False\n", forked
368
+ assert forked == "[] False False\n", forked
344
369
  for arguments, error in [
345
370
  (["set", "cpu", "0"], "defaults.cpu='0' is invalid"),
346
371
  (["set", "memory", "3GB"], "defaults.memory='3GB' is invalid"),
347
372
  (["set", "disk", "20"], "defaults.disk='20' is invalid"),
348
373
  (["set", "ttl", "59"], "TTL must be 60 seconds to 30 days"),
374
+ (
375
+ ["set", "disposable", "yes"],
376
+ "defaults.disposable='yes' is invalid: Use true or false",
377
+ ),
378
+ (["set", "disposable", "True"], "defaults.disposable='True' is invalid"),
349
379
  (["set", "location", "missing"], "Unknown location"),
350
380
  (["set", "location", "bad name"], "Location names"),
351
381
  ]:
@@ -359,6 +389,7 @@ def verify_controller_defaults():
359
389
  ({"defaults": {"cpu": 0}}, "defaults.cpu=0 is invalid"),
360
390
  ({"defaults": {"cpu": True}}, "defaults.cpu=True is invalid"),
361
391
  ({"defaults": {"disk": 20}}, "defaults.disk=20 is invalid"),
392
+ ({"defaults": {"disposable": 1}}, "defaults.disposable=1 is invalid"),
362
393
  ({"defaults": {"colour": "red"}}, "unknown key defaults.colour"),
363
394
  ({"defaults": []}, '{"defaults"'),
364
395
  ({"lab": {"ssh": "x"}}, '{"defaults"'),
@@ -389,6 +420,7 @@ def verify_controller_defaults():
389
420
  ("ttl", "120"),
390
421
  ("location", "lab"),
391
422
  ("disk", "shared"),
423
+ ("disposable", "false"),
392
424
  ]
393
425
  ]
394
426
  assert all(writer.wait() == 0 for writer in writers)
@@ -398,8 +430,25 @@ def verify_controller_defaults():
398
430
  "ttl": 120,
399
431
  "location": "lab",
400
432
  "disk": "shared",
433
+ "disposable": False,
401
434
  }
402
- for key in ["cpu", "memory", "ttl", "location", "disk"]:
435
+ # JSON booleans read back as the CLI's true and false.
436
+ assert cli("config", "set", "disposable", "true").stdout == (
437
+ "defaults.disposable: false -> true\n"
438
+ )
439
+ # raft config reports it with its source; the report's host half needs SSH.
440
+ effective = subprocess.check_output(
441
+ [
442
+ sys.executable,
443
+ "-c",
444
+ "import json, raft; print(json.dumps(raft.effective_defaults()['disposable']))",
445
+ ],
446
+ cwd=ROOT,
447
+ env={**os.environ, "HOME": home},
448
+ text=True,
449
+ )
450
+ assert json.loads(effective) == {"value": True, "source": "config"}, effective
451
+ for key in ["cpu", "memory", "ttl", "location", "disk", "disposable"]:
403
452
  assert cli("config", "unset", key).returncode == 0
404
453
  assert not config.exists(), "Removing every default left an empty file"
405
454
  assert not list(directory.glob(".raft-*")), "Staged configuration left behind"
@@ -409,13 +458,21 @@ def verify_controller_defaults():
409
458
  (["policy", "lab", "--require", "owner"], "always need purpose"),
410
459
  (["policy", "lab", "--require", "purpose,colour"], "Give note fields from"),
411
460
  (["policy", "lab", "--require", ","], "Give note fields from"),
412
- (["policy", "missing", "--clear"], "Unknown location"),
461
+ (["policy", "missing", "--require", "none"], "Unknown location"),
462
+ (["policy", "lab"], "Give --require FIELDS|none, --delete-stopped-after"),
463
+ (["policy", "lab", "--delete-stopped-after", "0"], "positive whole number of hours"),
464
+ (["policy", "lab", "--delete-stopped-after", "48h"], "positive whole number of hours"),
465
+ # Both options are parsed before either changes the host.
466
+ (
467
+ ["policy", "lab", "--require", "none", "--delete-stopped-after", "-1"],
468
+ "positive whole number of hours",
469
+ ),
413
470
  (["fork", handle, "--ttl", "600", "--owner", "agent"], "need --purpose"),
414
471
  (["recover", "archive", "--location", "lab", "--next", "x"], "need --purpose"),
415
472
  ]:
416
473
  rejected = cli(*arguments)
417
474
  assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
418
- assert cli("policy", "lab").returncode == 2
475
+ assert cli("policy", "lab", "--clear").returncode == 2
419
476
  # The controller message names the missing flags and a complete command.
420
477
  program = (
421
478
  "import raft; print(raft.note_problem('lab', ['purpose', 'owner'], "
@@ -467,6 +524,115 @@ def verify_controller_defaults():
467
524
  print("Controller defaults, precedence, validation and note policy messages passed", flush=True)
468
525
 
469
526
 
527
+ # Stands in for incus and logger on PATH for the real host worker. It answers the
528
+ # worker's two reads from the state file and appends every call to the log file.
529
+ FAKE_HOST_TOOL = """import json, os, sys
530
+ from pathlib import Path
531
+ arguments = [Path(sys.argv[0]).name, *sys.argv[1:]]
532
+ with open(os.environ["RAFT_FAKE_LOG"], "a") as log:
533
+ log.write(json.dumps(arguments) + "\\n")
534
+ state = json.loads(Path(os.environ["RAFT_FAKE_STATE"]).read_text())
535
+ if arguments[1:] == ["--project", "raft", "list", "--fast", "--format", "json"]:
536
+ print(json.dumps(state["instances"]))
537
+ elif arguments[1:] == ["--project", "raft", "project", "list", "--format", "json"]:
538
+ print(json.dumps([{"name": "default", "config": {}}, {"name": "raft", "config": state["project"]}]))
539
+ """
540
+
541
+
542
+ def verify_retention_worker():
543
+ """Run the real host worker against fake incus fixtures and check what it changes."""
544
+ now = int(time.time())
545
+
546
+ def box(name, status, stopped_at=None, raft="true"):
547
+ config = {"user.raft": raft}
548
+ if stopped_at is not None:
549
+ config["user.raft.stopped-at"] = str(stopped_at)
550
+ return {"name": name, "status": status, "config": config}
551
+
552
+ instances = [
553
+ # Running boxes are never deleted; an old stop time left behind is cleared.
554
+ box("rf-running", "Running", now - 30 * 86400),
555
+ box("rf-old", "Stopped", now - 3 * 3600),
556
+ box("rf-recent", "Stopped", now - 3600),
557
+ box("rf-unrecorded", "Stopped"),
558
+ box("unrelated", "Stopped", now - 30 * 86400, raft="false"),
559
+ ]
560
+
561
+ def run_worker(project, *arguments):
562
+ with tempfile.TemporaryDirectory() as work:
563
+ root = Path(work)
564
+ for tool in ["incus", "logger"]:
565
+ (root / tool).write_text(f"#!{sys.executable}\n" + FAKE_HOST_TOOL)
566
+ (root / tool).chmod(0o755)
567
+ (root / "state.json").write_text(
568
+ json.dumps({"instances": instances, "project": project})
569
+ )
570
+ started = int(time.time())
571
+ response = subprocess.run(
572
+ [sys.executable, "-I", str(ROOT / "deploy/raft-expire.py"), *arguments],
573
+ env={
574
+ **os.environ,
575
+ "PATH": f"{root}:{os.environ['PATH']}",
576
+ "RAFT_FAKE_STATE": str(root / "state.json"),
577
+ "RAFT_FAKE_LOG": str(root / "calls.jsonl"),
578
+ },
579
+ capture_output=True,
580
+ text=True,
581
+ )
582
+ calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
583
+ # Leave out the two reads and the stale stop time cleared on the running box;
584
+ # normalize the recorded stop time to "now".
585
+ clear = ["incus", "--project", "raft", "config", "unset", "rf-running"]
586
+ cleared.append(clear + ["user.raft.stopped-at"] in calls)
587
+ changes = []
588
+ for call in calls:
589
+ if call[-2:] == ["--format", "json"] or call == clear + ["user.raft.stopped-at"]:
590
+ continue
591
+ if call[-1].startswith("user.raft.stopped-at="):
592
+ assert started <= int(call[-1].partition("=")[2]) <= int(time.time()), call
593
+ call[-1] = "user.raft.stopped-at=now"
594
+ changes.append(call)
595
+ return response, changes
596
+
597
+ cleared = []
598
+ record = ["incus", "--project", "raft", "config", "set", "rf-unrecorded"]
599
+ record.append("user.raft.stopped-at=now")
600
+ two_hours = {"user.raft.delete-stopped-after": "2", "user.raft.delete-stopped-after.since": ""}
601
+ # A 2-hour policy set long ago deletes the box stopped 3 hours ago and logs it.
602
+ response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": "0"})
603
+ assert response.returncode == 0, response
604
+ assert changes[0] == ["incus", "--project", "raft", "delete", "rf-old"], changes
605
+ assert changes[1][:3] == ["logger", "-t", "raft"] and "rf-old" in changes[1][3], changes
606
+ assert "under the 2-hour retention" in changes[1][3] and changes[2:] == [record], changes
607
+ assert cleared[-1], "the worker kept a stale stop time on a running box"
608
+ assert changes[1][3] in response.stdout, response
609
+ # Set an hour ago, the policy's clock starts then: nothing is deleted yet.
610
+ since = str(now - 3600)
611
+ response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": since})
612
+ assert response.returncode == 0 and changes == [record], (response, changes)
613
+ # Without a policy nothing is deleted, but stop times are still recorded.
614
+ response, changes = run_worker({})
615
+ assert response.returncode == 0 and changes == [record], (response, changes)
616
+ # Prune mode only selects; it records and retains nothing else.
617
+ response, changes = run_worker(
618
+ {**two_hours, "user.raft.delete-stopped-after.since": "0"}, "--prune-days", "1"
619
+ )
620
+ assert response.returncode == 0 and changes == [], (response, changes)
621
+ # An invalid policy deletes nothing, still enforces deadlines, and fails the run.
622
+ instances.append(
623
+ {
624
+ "name": "rf-expired",
625
+ "status": "Running",
626
+ "config": {"user.raft": "true", "user.raft.expires": "1"},
627
+ }
628
+ )
629
+ response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after": "48h"})
630
+ assert response.returncode == 1 and "Invalid retention" in response.stderr, response
631
+ assert ["incus", "--project", "raft", "stop", "rf-expired", "--timeout", "30"] in changes
632
+ assert not any("delete" in call for call in changes), changes
633
+ print("Host worker retention, stop-time recording and grace period passed", flush=True)
634
+
635
+
470
636
  def verify_host_preflight():
471
637
  """Check onboarding decisions and refusals that need no host."""
472
638
  ready = {
@@ -530,8 +696,12 @@ def verify_readiness(location, expect_stale=False):
530
696
  (home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
531
697
  config = home / ".config/raft/incus.json"
532
698
  config.parent.mkdir(parents=True)
533
- config.write_text(json.dumps({location: settings(location)}))
534
- response = live.raft("doctor", check=False, env={**os.environ, "HOME": str(home)})
699
+ config.write_text(
700
+ json.dumps({"offline": {"ssh": "raft-offline.invalid"}, location: settings(location)})
701
+ )
702
+ response = live.raft(
703
+ "doctor", "--location", location, check=False, env={**os.environ, "HOME": str(home)}
704
+ )
535
705
  assert location + ": " in response.stdout, response
536
706
  if expect_stale:
537
707
  assert response.returncode == 1, response
@@ -545,6 +715,32 @@ def verify_readiness(location, expect_stale=False):
545
715
  print(f"{location}: doctor {'rejected stale worker' if expect_stale else 'passed'}", flush=True)
546
716
 
547
717
 
718
+ def verify_read_reports(location):
719
+ """Compare batched reads with independent native reads on a quiescent host."""
720
+ policy, boxes = host_inventory(location)
721
+ assert policy == host_policy(location)
722
+ assert sorted(boxes, key=lambda box: box["name"]) == sorted(
723
+ inventory(location), key=lambda box: box["name"]
724
+ )
725
+ with tempfile.TemporaryDirectory(prefix="raft-config-test-") as work:
726
+ home = Path(work)
727
+ (home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
728
+ config = home / ".config/raft/incus.json"
729
+ config.parent.mkdir(parents=True)
730
+ config.write_text(
731
+ json.dumps({"offline": {"ssh": "raft-offline.invalid"}, location: settings(location)})
732
+ )
733
+ env = {**os.environ, "HOME": str(home), "RAFT_NO_UPDATE_CHECK": "1"}
734
+ report = live.raft("config", "--location", location, env=env)
735
+ assert json.loads(report.stdout)["policy"] == {location: policy}
736
+ rejected = live.raft(
737
+ "config", "--location", location, "set", "cpu", "2", env=env, check=False
738
+ )
739
+ assert rejected.returncode == 1 and "controller-wide" in rejected.stderr
740
+ assert not (config.parent / "config.json").exists()
741
+ print(f"{location}: batched metadata and offline-host isolation passed", flush=True)
742
+
743
+
548
744
  def verify_transaction_stdin(location):
549
745
  program = """import sys, tempfile
550
746
  from raft import transaction
@@ -588,7 +784,7 @@ def verify_note_policy(location, create, listed_note, fixtures):
588
784
  assert changed.stdout == f"{location}: required note fields none -> purpose,owner\n"
589
785
  report = json.loads(live.raft("config").stdout)
590
786
  assert report["policy"][location]["require"] == ["purpose", "owner"], report
591
- assert " by " in report["policy"][location]["changed"], report
787
+ assert " by " in report["policy"][location]["requireChanged"], report
592
788
  saved = len(inventory(location))
593
789
  for arguments, error in [
594
790
  (["new", "--location", location, "--purpose", "x"], "requires --owner (host policy)"),
@@ -632,7 +828,7 @@ def verify_note_policy(location, create, listed_note, fixtures):
632
828
  live.raft("note", source, "--purpose", "Policy fork source", "--owner", "verify")
633
829
  assert listed_note(source) == {"purpose": "Policy fork source", "owner": "verify"}
634
830
  finally:
635
- cleared = live.raft("policy", location, "--clear")
831
+ cleared = live.raft("policy", location, "--require", "none")
636
832
  assert cleared.stdout == f"{location}: required note fields purpose,owner -> none\n"
637
833
  assert json.loads(live.raft("config").stdout)["policy"][location]["require"] == []
638
834
  live.raft("note", source, "--clear")
@@ -641,6 +837,117 @@ def verify_note_policy(location, create, listed_note, fixtures):
641
837
  print(f"{location}: host note policy for new, fork, note and recover passed", flush=True)
642
838
 
643
839
 
840
+ def verify_retention(location, create, exists, worker):
841
+ """Set a 1-hour stopped-box retention, prove the worker deletes only a box stopped
842
+ past it, records stop times Raft missed and logs to the journal, then clear it."""
843
+ host = settings(location)["ssh"]
844
+ started = int(time.time())
845
+ running = create("--memory", "1GiB", "--purpose", "Retention: running box")
846
+ kept = create("--memory", "1GiB", "--purpose", "Retention: recently stopped box")
847
+ expired = create("--memory", "1GiB", "--purpose", "Retention: long stopped box")
848
+ unrecorded = create("--memory", "1GiB", "--purpose", "Retention: box stopped outside Raft")
849
+ live.raft("stop", kept)
850
+ live.raft("stop", expired)
851
+ # A native stop records no stop time; the worker starts that box's clock.
852
+ incus(location, "stop", parse_handle(unrecorded)[1], "--timeout", "30", locked=True)
853
+
854
+ def stopped_at(handle):
855
+ return incus(
856
+ location, "config", "get", parse_handle(handle)[1], "user.raft.stopped-at"
857
+ ).strip()
858
+
859
+ def tick(done):
860
+ # The staged worker runs once; the installed one runs on its 15-second timer.
861
+ if worker:
862
+ transaction(location, 'exec python3 "$1"', worker)
863
+ deadline = time.monotonic() + 50
864
+ while not done():
865
+ if time.monotonic() > deadline:
866
+ raise RuntimeError("Host expiry worker did not apply the retention policy")
867
+ time.sleep(1)
868
+
869
+ def listed():
870
+ lines = live.raft("list", "--location", location).stdout.splitlines()
871
+ return {record["box"]: record for record in map(json.loads, lines)}
872
+
873
+ if worker:
874
+ # Older workers ignore retention, so the CLI refuses to set it for them.
875
+ rejected = live.raft("policy", location, "--delete-stopped-after", "1", check=False)
876
+ assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
877
+ incus(
878
+ location,
879
+ "project",
880
+ "set",
881
+ "raft",
882
+ f"{RETENTION_KEY}=1",
883
+ f"{RETENTION_SINCE_KEY}={int(time.time())}",
884
+ locked=True,
885
+ )
886
+ else:
887
+ changed = live.raft("policy", location, "--delete-stopped-after", "1")
888
+ assert changed.stdout == f"{location}: delete stopped boxes after never -> 1 hour\n"
889
+ try:
890
+ policy = json.loads(live.raft("config").stdout)["policy"][location]
891
+ assert policy["deleteStoppedAfterHours"] == 1, policy
892
+ since = policy["deleteStoppedAfterSince"]
893
+ assert started <= since <= time.time(), policy
894
+ # A box stopped before the policy gets the full hour from the change.
895
+ records = listed()
896
+ assert records[kept]["deleteAt"] == str(since + 3600), records[kept]
897
+ assert records[running]["deleteAt"] == "", records[running]
898
+ # Hours are the smallest unit, so backdate the policy and one stop by two
899
+ # hours instead of waiting: only that box is then past the retention.
900
+ backdated = int(time.time()) - 2 * 3600
901
+ incus(location, "project", "set", "raft", f"{RETENTION_SINCE_KEY}={backdated}", locked=True)
902
+ incus(
903
+ location,
904
+ "config",
905
+ "set",
906
+ parse_handle(expired)[1],
907
+ f"user.raft.stopped-at={backdated}",
908
+ locked=True,
909
+ )
910
+ tick(lambda: not exists(expired) and stopped_at(unrecorded))
911
+ assert exists(kept) and exists(running) and exists(unrecorded)
912
+ # The clock of a box stopped outside Raft starts when the worker sees it.
913
+ assert int(stopped_at(unrecorded)) >= started
914
+ assert listed()[unrecorded]["deleteAt"] == str(int(stopped_at(unrecorded)) + 3600)
915
+ deleted = f"Deleted box {parse_handle(expired)[1]}, stopped since "
916
+ command = ["sudo", "-n", "journalctl", "-t", "raft", "--since", f"@{started}", "-o", "cat"]
917
+ deadline = time.monotonic() + 10
918
+ while True:
919
+ journal = subprocess.check_output(["ssh", "-T", host, shlex.join(command)], text=True)
920
+ if deleted in journal or time.monotonic() > deadline:
921
+ break
922
+ time.sleep(1)
923
+ assert deleted in journal, journal
924
+ if not worker:
925
+ assert "Stopped-box retention: hours: '' -> '1'" in journal, journal
926
+ finally:
927
+ if worker:
928
+ incus(
929
+ location,
930
+ "project",
931
+ "set",
932
+ "raft",
933
+ f"{RETENTION_KEY}=",
934
+ f"{RETENTION_SINCE_KEY}=",
935
+ locked=True,
936
+ )
937
+ else:
938
+ cleared = live.raft("policy", location, "--delete-stopped-after", "never")
939
+ if not worker:
940
+ assert cleared.stdout == f"{location}: delete stopped boxes after 1 hour -> never\n"
941
+ policy = json.loads(live.raft("config").stdout)["policy"][location]
942
+ assert policy["deleteStoppedAfterHours"] is None, policy
943
+ assert listed()[kept]["deleteAt"] == ""
944
+ print(
945
+ f"{location}: stopped-box retention deleted only the expired box, "
946
+ "recorded missing stop times, logged and cleared",
947
+ flush=True,
948
+ )
949
+
950
+
644
951
  def verify(location, isolated_worker=False):
645
952
  if inventory(location):
646
953
  raise RuntimeError(
@@ -681,7 +988,7 @@ def verify(location, isolated_worker=False):
681
988
  ],
682
989
  text=True,
683
990
  ).strip()
684
- if protocol == "1":
991
+ if protocol == "2":
685
992
  raise RuntimeError(
686
993
  "--isolated-worker targets older deployments; use normal verification on a current host"
687
994
  )
@@ -965,6 +1272,7 @@ def verify(location, isolated_worker=False):
965
1272
  f"{location}: disposable deletion, preview/apply retention and exclusions passed",
966
1273
  flush=True,
967
1274
  )
1275
+ verify_retention(location, create, exists, worker)
968
1276
  finally:
969
1277
  for handle in reversed(fixtures):
970
1278
  if exists(handle):
@@ -996,7 +1304,9 @@ if __name__ == "__main__":
996
1304
  args = parser.parse_args()
997
1305
  verify_location_notes()
998
1306
  verify_controller_defaults()
1307
+ verify_retention_worker()
999
1308
  verify_host_preflight()
1000
1309
  verify_archives()
1001
1310
  if args.location:
1311
+ verify_read_reports(args.location)
1002
1312
  verify(args.location, args.isolated_worker)