@microck/raft 0.4.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.
Files changed (38) hide show
  1. package/README.md +30 -14
  2. package/deploy/deploy.py +112 -19
  3. package/deploy/install-incus.sh +10 -0
  4. package/deploy/raft-expire.py +109 -31
  5. package/deploy/verify-controls.py +34 -48
  6. package/deploy/verify-features.py +592 -3
  7. package/deploy/verify-fresh-host.py +64 -1
  8. package/deploy/verify-limits.py +327 -72
  9. package/deploy/verify-live.py +56 -31
  10. package/deploy/verify-self-update.py +372 -0
  11. package/docs/boat-capabilities.md +8 -7
  12. package/docs/boot-performance.mdx +1 -1
  13. package/docs/contract.md +33 -23
  14. package/docs/first-box.mdx +1 -1
  15. package/docs/guides/backup-recovery.mdx +2 -2
  16. package/docs/guides/config-policy.mdx +253 -0
  17. package/docs/guides/docker.mdx +9 -1
  18. package/docs/guides/long-running-services.mdx +10 -4
  19. package/docs/guides/meta.json +1 -0
  20. package/docs/guides/project-setup.mdx +4 -3
  21. package/docs/guides/scripting-ci.mdx +27 -10
  22. package/docs/guides/snapshots-forks.mdx +9 -3
  23. package/docs/guides/troubleshooting.mdx +37 -8
  24. package/docs/guides/upgrades.mdx +17 -5
  25. package/docs/index.mdx +1 -0
  26. package/docs/operations.mdx +16 -14
  27. package/docs/reference/capacity.mdx +198 -0
  28. package/docs/reference/cli.md +178 -35
  29. package/docs/reference/configuration.mdx +40 -2
  30. package/docs/reference/meta.json +1 -1
  31. package/docs/releases/0.5.0.md +19 -0
  32. package/docs/releases/0.6.0.md +16 -0
  33. package/docs/requirements.mdx +2 -2
  34. package/docs/verification.mdx +14 -8
  35. package/package.json +1 -1
  36. package/raft.py +1181 -128
  37. package/skills/raft-cli/SKILL.md +79 -13
  38. package/docs/reference/capacity-limits.mdx +0 -92
@@ -1,4 +1,5 @@
1
- """Exercise disposable lifecycle, retention, templates, resizing and tree transfers.
1
+ """Exercise disposable lifecycle, retention, templates, resizing, tree transfers,
2
+ controller defaults and host note policies.
2
3
 
3
4
  --isolated-worker stages the current worker under a private temporary path for
4
5
  existing hosts. It never replaces their installed services. CI uses the installed
@@ -23,6 +24,13 @@ import time
23
24
  ROOT = Path(__file__).resolve().parent.parent
24
25
  sys.path.insert(0, str(ROOT))
25
26
  from raft import ( # noqa: E402
27
+ CHECK_NOTE,
28
+ CHECK_NOTE_CODE,
29
+ READ_NOTE_POLICY,
30
+ RESERVE_BOX_DISK,
31
+ RETENTION_KEY,
32
+ RETENTION_SINCE_KEY,
33
+ disk_origin,
26
34
  host_problems,
27
35
  incus,
28
36
  inventory,
@@ -235,6 +243,394 @@ def verify_location_notes():
235
243
  print("Location note editing, listing, ordering and permissions passed", flush=True)
236
244
 
237
245
 
246
+ def verify_controller_defaults():
247
+ """Edit config.json in a private controller home and resolve new's options offline."""
248
+ with tempfile.TemporaryDirectory() as home:
249
+ directory = Path(home) / ".config/raft"
250
+ directory.mkdir(parents=True)
251
+ (directory / "incus.json").write_text(
252
+ json.dumps({"lab": {"ssh": "lab-host", "image": ""}, "spare": {"ssh": "spare-host"}})
253
+ )
254
+ config = directory / "config.json"
255
+
256
+ def cli(*arguments):
257
+ return subprocess.run(
258
+ [sys.executable, str(ROOT / "raft.py"), *arguments],
259
+ env={**os.environ, "HOME": home},
260
+ capture_output=True,
261
+ text=True,
262
+ )
263
+
264
+ def resolved(*arguments):
265
+ # The real parser and resolver, reading the real files under HOME.
266
+ program = (
267
+ "import json, sys; import raft; args = raft.parser().parse_args(sys.argv[1:]); "
268
+ "used = raft.apply_new_defaults(args); "
269
+ "print(json.dumps([args.location, args.ttl, args.cpu, args.memory, args.disk, "
270
+ "args.disposable, sorted(used)]))"
271
+ )
272
+ response = subprocess.run(
273
+ [sys.executable, "-c", program, *arguments],
274
+ cwd=ROOT,
275
+ env={**os.environ, "HOME": home},
276
+ capture_output=True,
277
+ text=True,
278
+ )
279
+ if response.returncode:
280
+ return response.stderr
281
+ return json.loads(response.stdout)
282
+
283
+ # No disk is the shared pool.
284
+ assert resolved("new") == ["lab", 600, 1, "2GiB", None, False, []]
285
+ for arguments, printed in [
286
+ (["set", "cpu", "2"], "defaults.cpu: built-in default -> 2"),
287
+ (["set", "memory", "4GiB"], "defaults.memory: built-in default -> 4GiB"),
288
+ (["set", "ttl", "3600"], "defaults.ttl: built-in default -> 3600"),
289
+ (["set", "location", "spare"], "defaults.location: built-in default -> spare"),
290
+ (["set", "disk", "20GiB"], "defaults.disk: built-in default -> 20GiB"),
291
+ (["set", "disposable", "true"], "defaults.disposable: built-in default -> true"),
292
+ (["set", "cpu", "1"], "defaults.cpu: 2 -> 1"),
293
+ ]:
294
+ response = cli("config", *arguments)
295
+ assert response.returncode == 0 and response.stdout == printed + "\n", response
296
+ assert json.loads(config.read_text()) == {
297
+ "defaults": {
298
+ "cpu": 1,
299
+ "memory": "4GiB",
300
+ "ttl": 3600,
301
+ "location": "spare",
302
+ "disk": "20GiB",
303
+ "disposable": True,
304
+ }
305
+ }
306
+ assert config.stat().st_mode & 0o777 == 0o600, "Default editing widened permissions"
307
+ # Explicit flags win over config.json, which wins over built-in defaults.
308
+ everything = ["cpu", "disk", "disposable", "location", "memory", "ttl"]
309
+ assert resolved("new") == ["spare", 3600, 1, "4GiB", "20GiB", True, everything]
310
+ assert resolved("new", "--location", "lab", "--ttl", "60", "--cpu", "2") == [
311
+ "lab",
312
+ 60,
313
+ 2,
314
+ "4GiB",
315
+ "20GiB",
316
+ True,
317
+ ["disk", "disposable", "memory"],
318
+ ]
319
+ # --disk shared beats a configured own disk, and --persistent a disposable default.
320
+ assert resolved("new", "--disk", "shared", "--persistent")[4:] == [
321
+ None,
322
+ False,
323
+ ["cpu", "location", "memory", "ttl"],
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
331
+ # A box from a snapshot keeps the snapshot's location, CPU, RAM and disk:
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.
334
+ template = "lab:rf-0123456789abcdef/snap"
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
345
+ assert resolved("new", "--from", template, "--cpu", "4", "--disk", "30GiB")[2:5] == [
346
+ 4,
347
+ None,
348
+ "30GiB",
349
+ ]
350
+ assert "--from keeps the snapshot's disk" in resolved(
351
+ "new", "--from", template, "--disk", "shared"
352
+ )
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.
355
+ forked = subprocess.check_output(
356
+ [
357
+ sys.executable,
358
+ "-c",
359
+ "import raft; args = raft.parser().parse_args(['fork', 'lab:rf-0123456789abcdef', "
360
+ "'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'), args.disposable)",
361
+ ],
362
+ cwd=ROOT,
363
+ env={**os.environ, "HOME": home},
364
+ text=True,
365
+ )
366
+ assert forked == "[] False False\n", forked
367
+ for arguments, error in [
368
+ (["set", "cpu", "0"], "defaults.cpu='0' is invalid"),
369
+ (["set", "memory", "3GB"], "defaults.memory='3GB' is invalid"),
370
+ (["set", "disk", "20"], "defaults.disk='20' is invalid"),
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"),
377
+ (["set", "location", "missing"], "Unknown location"),
378
+ (["set", "location", "bad name"], "Location names"),
379
+ ]:
380
+ rejected = cli("config", *arguments)
381
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
382
+ assert str(config) in rejected.stderr or "location" in arguments, rejected
383
+ assert cli("config", "set", "colour", "red").returncode == 2
384
+ saved = config.read_text()
385
+ # A broken file fails new before any host is contacted, naming the file and key.
386
+ for document, error in [
387
+ ({"defaults": {"cpu": 0}}, "defaults.cpu=0 is invalid"),
388
+ ({"defaults": {"cpu": True}}, "defaults.cpu=True is invalid"),
389
+ ({"defaults": {"disk": 20}}, "defaults.disk=20 is invalid"),
390
+ ({"defaults": {"disposable": 1}}, "defaults.disposable=1 is invalid"),
391
+ ({"defaults": {"colour": "red"}}, "unknown key defaults.colour"),
392
+ ({"defaults": []}, '{"defaults"'),
393
+ ({"lab": {"ssh": "x"}}, '{"defaults"'),
394
+ ({"defaults": {"location": "gone"}}, "unknown location gone"),
395
+ ]:
396
+ config.write_text(json.dumps(document))
397
+ rejected = cli("new", "--purpose", "x")
398
+ assert rejected.returncode == 1 and str(config) in rejected.stderr, rejected
399
+ assert error in rejected.stderr and "Traceback" not in rejected.stderr, rejected
400
+ config.write_text("{not json")
401
+ rejected = cli("new")
402
+ assert rejected.returncode == 1 and "invalid JSON" in rejected.stderr, rejected
403
+ # The broken key can be repaired through the CLI.
404
+ config.write_text(json.dumps({"defaults": {"cpu": 0, "ttl": 900}}))
405
+ assert cli("config", "unset", "cpu").stdout == "defaults.cpu: 0 -> built-in default\n"
406
+ assert json.loads(config.read_text()) == {"defaults": {"ttl": 900}}
407
+ config.write_text(saved)
408
+ # Concurrent edits to different keys must all survive.
409
+ writers = [
410
+ subprocess.Popen(
411
+ [sys.executable, str(ROOT / "raft.py"), "config", "set", key, value],
412
+ env={**os.environ, "HOME": home},
413
+ stdout=subprocess.DEVNULL,
414
+ )
415
+ for key, value in [
416
+ ("cpu", "2"),
417
+ ("memory", "8GiB"),
418
+ ("ttl", "120"),
419
+ ("location", "lab"),
420
+ ("disk", "shared"),
421
+ ("disposable", "false"),
422
+ ]
423
+ ]
424
+ assert all(writer.wait() == 0 for writer in writers)
425
+ assert json.loads(config.read_text())["defaults"] == {
426
+ "cpu": 2,
427
+ "memory": "8GiB",
428
+ "ttl": 120,
429
+ "location": "lab",
430
+ "disk": "shared",
431
+ "disposable": False,
432
+ }
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"]:
450
+ assert cli("config", "unset", key).returncode == 0
451
+ assert not config.exists(), "Removing every default left an empty file"
452
+ assert not list(directory.glob(".raft-*")), "Staged configuration left behind"
453
+ # Policy and note flag mistakes are rejected before any host is contacted.
454
+ handle = "lab:rf-0123456789abcdef"
455
+ for arguments, error in [
456
+ (["policy", "lab", "--require", "owner"], "always need purpose"),
457
+ (["policy", "lab", "--require", "purpose,colour"], "Give note fields from"),
458
+ (["policy", "lab", "--require", ","], "Give note fields from"),
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
+ ),
468
+ (["fork", handle, "--ttl", "600", "--owner", "agent"], "need --purpose"),
469
+ (["recover", "archive", "--location", "lab", "--next", "x"], "need --purpose"),
470
+ ]:
471
+ rejected = cli(*arguments)
472
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
473
+ assert cli("policy", "lab", "--clear").returncode == 2
474
+ # The controller message names the missing flags and a complete command.
475
+ program = (
476
+ "import raft; print(raft.note_problem('lab', ['purpose', 'owner'], "
477
+ "{'purpose': 'x'}, 'new')); print(raft.note_problem('lab', ['purpose'], "
478
+ "{'purpose': 'x'}, 'new'))"
479
+ )
480
+ messages = subprocess.check_output([sys.executable, "-c", program], cwd=ROOT, text=True)
481
+ assert messages == (
482
+ 'location lab requires --owner (host policy). Add it: raft new --purpose "..." '
483
+ '--owner "..." [--next "..."]\nNone\n'
484
+ ), messages
485
+ # Run the exact host-side check locally; it needs only python3.
486
+ for required, note, accepted in [
487
+ ("", "", True),
488
+ ("purpose", json.dumps({"purpose": "x"}), True),
489
+ ("purpose,owner", json.dumps({"purpose": "x", "owner": "y"}), True),
490
+ ("purpose,owner", json.dumps({"purpose": "x"}), False),
491
+ ("purpose", "", False),
492
+ ]:
493
+ check = subprocess.run(
494
+ [sys.executable, "-I", "-c", CHECK_NOTE_CODE, required, note],
495
+ capture_output=True,
496
+ text=True,
497
+ )
498
+ assert (check.returncode == 0) == accepted, (required, note, check)
499
+ if not accepted:
500
+ assert "Nothing was changed" in check.stderr, check
501
+ # A configured disk on a host without own box disks gets the same refusal as
502
+ # --disk, plus where the size came from. Run the exact host check against a
503
+ # fake incus that reports no storage pools besides the shared one.
504
+ with tempfile.TemporaryDirectory() as bin_directory:
505
+ fake = Path(bin_directory) / "incus"
506
+ fake.write_text("#!/bin/sh\necho '[\"/1.0/storage-pools/raft-data\"]'\n")
507
+ fake.chmod(0o755)
508
+ refusals = [
509
+ subprocess.run(
510
+ [sys.executable, "-I", "-c", RESERVE_BOX_DISK, "lab", "20", "image:abc", origin],
511
+ env={**os.environ, "PATH": f"{bin_directory}:{os.environ['PATH']}"},
512
+ capture_output=True,
513
+ text=True,
514
+ )
515
+ for origin in ["", disk_origin("20GiB")]
516
+ ]
517
+ flag, configured = (refusal.stderr.strip() for refusal in refusals)
518
+ assert all(refusal.returncode == 1 for refusal in refusals), refusals
519
+ assert flag.startswith("lab has no raft-disks pool") and "--disk-pool SIZE" in flag, flag
520
+ assert configured == flag + ". " + disk_origin("20GiB"), configured
521
+ assert "defaults.disk in " in configured and "raft config unset disk" in configured
522
+ print("Controller defaults, precedence, validation and note policy messages passed", flush=True)
523
+
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
+
238
634
  def verify_host_preflight():
239
635
  """Check onboarding decisions and refusals that need no host."""
240
636
  ready = {
@@ -253,6 +649,10 @@ def verify_host_preflight():
253
649
  assert host_problems(ready) == []
254
650
  # An existing deployment already owns its pool, so free disk no longer gates it.
255
651
  assert host_problems({**ready, "disk": "0", "deployed": "yes"}) == []
652
+ # Own box disks are opt-in; enabling them adds the pool size to the minimum.
653
+ assert host_problems({**ready, "disk": str(95 * 1024**2)}, "10GiB") == []
654
+ problems = host_problems(ready, "40GiB")
655
+ assert len(problems) == 1 and "120 GiB free disk" in problems[0], problems
256
656
  darwin = host_problems({"kernel": "Darwin", "sudo": "no"})
257
657
  assert len(darwin) == 1 and "macOS" in darwin[0], darwin
258
658
  for change, reason in [
@@ -343,6 +743,179 @@ with tempfile.TemporaryFile() as output:
343
743
  print(f"{location}: open stdin ignored and lifecycle lock released", flush=True)
344
744
 
345
745
 
746
+ def verify_note_policy(location, create, listed_note, fixtures):
747
+ """Require note fields on the host, prove every note path honors it, then clear it."""
748
+ source = create("--memory", "1GiB", "--purpose", "Policy fork source")
749
+ live.raft("stop", source)
750
+ changed = live.raft("policy", location, "--require", "purpose,owner")
751
+ try:
752
+ assert changed.stdout == f"{location}: required note fields none -> purpose,owner\n"
753
+ report = json.loads(live.raft("config").stdout)
754
+ assert report["policy"][location]["require"] == ["purpose", "owner"], report
755
+ assert " by " in report["policy"][location]["requireChanged"], report
756
+ saved = len(inventory(location))
757
+ for arguments, error in [
758
+ (["new", "--location", location, "--purpose", "x"], "requires --owner (host policy)"),
759
+ (["new", "--location", location], "requires --purpose and --owner (host policy)"),
760
+ (["fork", source, "--ttl", "600"], "Forks keep the source note"),
761
+ (["fork", source, "--ttl", "600", "--purpose", "x"], "requires --owner"),
762
+ (["note", source, "--clear"], "Clearing would leave the box without them"),
763
+ (["note", source, "--purpose", "x", "--next", "y"], "requires --owner"),
764
+ # Rejected before the archive is read or uploaded.
765
+ (["recover", "missing.tar.gz", "--location", location, "--purpose", "x"], "--owner"),
766
+ ]:
767
+ rejected = live.raft(*arguments, check=False)
768
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
769
+ assert "Add it: raft " in rejected.stderr or "Add them: raft " in rejected.stderr
770
+ assert len(inventory(location)) == saved, "A rejected note created a box"
771
+ assert listed_note(source) == {"purpose": "Policy fork source"}
772
+ # The host repeats the check under its lock, so a controller that skips the
773
+ # precheck still cannot write a note that lacks a required field.
774
+ try:
775
+ transaction(
776
+ location,
777
+ 'note="$1"\n' + READ_NOTE_POLICY + CHECK_NOTE,
778
+ json.dumps({"purpose": "x"}),
779
+ )
780
+ except RuntimeError as error:
781
+ assert "requires box note fields purpose, owner" in str(error), error
782
+ else:
783
+ raise AssertionError("Host accepted a note without a required field")
784
+ owned = create("--memory", "1GiB", "--purpose", "Policy", "--owner", "verify")
785
+ assert listed_note(owned) == {"purpose": "Policy", "owner": "verify"}
786
+ live.raft("destroy", owned)
787
+ fixtures.remove(owned)
788
+ # Note flags replace the source note on the fork.
789
+ fork = live.raft(
790
+ "fork", source, "--ttl", "600", "--purpose", "Policy fork", "--owner", "verify"
791
+ ).stdout.strip()
792
+ fixtures.append(fork)
793
+ assert listed_note(fork) == {"purpose": "Policy fork", "owner": "verify"}
794
+ live.raft("destroy", fork)
795
+ fixtures.remove(fork)
796
+ live.raft("note", source, "--purpose", "Policy fork source", "--owner", "verify")
797
+ assert listed_note(source) == {"purpose": "Policy fork source", "owner": "verify"}
798
+ finally:
799
+ cleared = live.raft("policy", location, "--require", "none")
800
+ assert cleared.stdout == f"{location}: required note fields purpose,owner -> none\n"
801
+ assert json.loads(live.raft("config").stdout)["policy"][location]["require"] == []
802
+ live.raft("note", source, "--clear")
803
+ live.raft("destroy", source)
804
+ fixtures.remove(source)
805
+ print(f"{location}: host note policy for new, fork, note and recover passed", flush=True)
806
+
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
+
346
919
  def verify(location, isolated_worker=False):
347
920
  if inventory(location):
348
921
  raise RuntimeError(
@@ -383,7 +956,7 @@ def verify(location, isolated_worker=False):
383
956
  ],
384
957
  text=True,
385
958
  ).strip()
386
- if protocol == "1":
959
+ if protocol == "2":
387
960
  raise RuntimeError(
388
961
  "--isolated-worker targets older deployments; use normal verification on a current host"
389
962
  )
@@ -409,8 +982,18 @@ def verify(location, isolated_worker=False):
409
982
  return live.raft(*command).stdout
410
983
 
411
984
  def create(*arguments):
985
+ # Pin the sizes a controller's config.json could otherwise default.
412
986
  handle = live.raft(
413
- "new", "--location", location, "--ttl", "1800", *arguments
987
+ "new",
988
+ "--location",
989
+ location,
990
+ "--ttl",
991
+ "1800",
992
+ "--cpu",
993
+ "1",
994
+ "--disk",
995
+ "shared",
996
+ *arguments,
414
997
  ).stdout.strip()
415
998
  fixtures.append(handle)
416
999
  return handle
@@ -464,6 +1047,7 @@ def verify(location, isolated_worker=False):
464
1047
  print(
465
1048
  f"{location}: box note template, listing, replacement and clearing passed", flush=True
466
1049
  )
1050
+ verify_note_policy(location, create, listed_note, fixtures)
467
1051
  with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
468
1052
  root = Path(work)
469
1053
  source = root / "source with spaces"
@@ -555,7 +1139,9 @@ def verify(location, isolated_worker=False):
555
1139
  )
556
1140
  assert metadata(template)["config"]["user.raft.disposable"] == "false"
557
1141
  assert listed_note(template) is None, "new --from inherited the source note"
1142
+ # A template keeps the snapshot's sizes, not the box's later ones or the defaults.
558
1143
  assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
1144
+ assert metadata(template)["expanded_config"]["limits.memory"] == "1GiB"
559
1145
  live.raft("snapshot-delete", box, "prepared")
560
1146
  assert live.execute(template, "cat", "/workspace/proof") == "original"
561
1147
  live.raft("destroy", template)
@@ -654,6 +1240,7 @@ def verify(location, isolated_worker=False):
654
1240
  f"{location}: disposable deletion, preview/apply retention and exclusions passed",
655
1241
  flush=True,
656
1242
  )
1243
+ verify_retention(location, create, exists, worker)
657
1244
  finally:
658
1245
  for handle in reversed(fixtures):
659
1246
  if exists(handle):
@@ -684,6 +1271,8 @@ if __name__ == "__main__":
684
1271
  parser.add_argument("--isolated-worker", action="store_true")
685
1272
  args = parser.parse_args()
686
1273
  verify_location_notes()
1274
+ verify_controller_defaults()
1275
+ verify_retention_worker()
687
1276
  verify_host_preflight()
688
1277
  verify_archives()
689
1278
  if args.location: