@microck/raft 0.4.0 → 0.5.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.
@@ -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,11 @@ 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
+ disk_origin,
26
32
  host_problems,
27
33
  incus,
28
34
  inventory,
@@ -235,6 +241,232 @@ def verify_location_notes():
235
241
  print("Location note editing, listing, ordering and permissions passed", flush=True)
236
242
 
237
243
 
244
+ def verify_controller_defaults():
245
+ """Edit config.json in a private controller home and resolve new's options offline."""
246
+ with tempfile.TemporaryDirectory() as home:
247
+ directory = Path(home) / ".config/raft"
248
+ directory.mkdir(parents=True)
249
+ (directory / "incus.json").write_text(
250
+ json.dumps({"lab": {"ssh": "lab-host", "image": ""}, "spare": {"ssh": "spare-host"}})
251
+ )
252
+ config = directory / "config.json"
253
+
254
+ def cli(*arguments):
255
+ return subprocess.run(
256
+ [sys.executable, str(ROOT / "raft.py"), *arguments],
257
+ env={**os.environ, "HOME": home},
258
+ capture_output=True,
259
+ text=True,
260
+ )
261
+
262
+ def resolved(*arguments):
263
+ # The real parser and resolver, reading the real files under HOME.
264
+ program = (
265
+ "import json, sys; import raft; args = raft.parser().parse_args(sys.argv[1:]); "
266
+ "used = raft.apply_new_defaults(args); "
267
+ "print(json.dumps([args.location, args.ttl, args.cpu, args.memory, args.disk, "
268
+ "sorted(used)]))"
269
+ )
270
+ response = subprocess.run(
271
+ [sys.executable, "-c", program, *arguments],
272
+ cwd=ROOT,
273
+ env={**os.environ, "HOME": home},
274
+ capture_output=True,
275
+ text=True,
276
+ )
277
+ if response.returncode:
278
+ return response.stderr
279
+ return json.loads(response.stdout)
280
+
281
+ # No disk is the shared pool.
282
+ assert resolved("new") == ["lab", 600, 1, "2GiB", None, []]
283
+ for arguments, printed in [
284
+ (["set", "cpu", "2"], "defaults.cpu: built-in default -> 2"),
285
+ (["set", "memory", "4GiB"], "defaults.memory: built-in default -> 4GiB"),
286
+ (["set", "ttl", "3600"], "defaults.ttl: built-in default -> 3600"),
287
+ (["set", "location", "spare"], "defaults.location: built-in default -> spare"),
288
+ (["set", "disk", "20GiB"], "defaults.disk: built-in default -> 20GiB"),
289
+ (["set", "cpu", "1"], "defaults.cpu: 2 -> 1"),
290
+ ]:
291
+ response = cli("config", *arguments)
292
+ assert response.returncode == 0 and response.stdout == printed + "\n", response
293
+ assert json.loads(config.read_text()) == {
294
+ "defaults": {
295
+ "cpu": 1,
296
+ "memory": "4GiB",
297
+ "ttl": 3600,
298
+ "location": "spare",
299
+ "disk": "20GiB",
300
+ }
301
+ }
302
+ assert config.stat().st_mode & 0o777 == 0o600, "Default editing widened permissions"
303
+ # 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]
306
+ assert resolved("new", "--location", "lab", "--ttl", "60", "--cpu", "2") == [
307
+ "lab",
308
+ 60,
309
+ 2,
310
+ "4GiB",
311
+ "20GiB",
312
+ ["disk", "memory"],
313
+ ]
314
+ # --disk shared beats a configured own disk.
315
+ assert resolved("new", "--disk", "shared")[4:] == [
316
+ None,
317
+ ["cpu", "location", "memory", "ttl"],
318
+ ]
319
+ # A box from a snapshot keeps the snapshot's location, CPU, RAM and disk:
320
+ # defaults, configured or built-in, never override them; flags still do.
321
+ template = "lab:rf-0123456789abcdef/snap"
322
+ assert resolved("new", "--from", template) == [None, 3600, None, None, None, ["ttl"]]
323
+ assert resolved("new", "--from", template, "--cpu", "4", "--disk", "30GiB")[2:5] == [
324
+ 4,
325
+ None,
326
+ "30GiB",
327
+ ]
328
+ assert "--from keeps the snapshot's disk" in resolved(
329
+ "new", "--from", template, "--disk", "shared"
330
+ )
331
+ # fork has no defaults at all: omitted sizes stay the source's.
332
+ forked = subprocess.check_output(
333
+ [
334
+ sys.executable,
335
+ "-c",
336
+ "import raft; args = raft.parser().parse_args(['fork', 'lab:rf-0123456789abcdef', "
337
+ "'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'))",
338
+ ],
339
+ cwd=ROOT,
340
+ env={**os.environ, "HOME": home},
341
+ text=True,
342
+ )
343
+ assert forked == "[] False\n", forked
344
+ for arguments, error in [
345
+ (["set", "cpu", "0"], "defaults.cpu='0' is invalid"),
346
+ (["set", "memory", "3GB"], "defaults.memory='3GB' is invalid"),
347
+ (["set", "disk", "20"], "defaults.disk='20' is invalid"),
348
+ (["set", "ttl", "59"], "TTL must be 60 seconds to 30 days"),
349
+ (["set", "location", "missing"], "Unknown location"),
350
+ (["set", "location", "bad name"], "Location names"),
351
+ ]:
352
+ rejected = cli("config", *arguments)
353
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
354
+ assert str(config) in rejected.stderr or "location" in arguments, rejected
355
+ assert cli("config", "set", "colour", "red").returncode == 2
356
+ saved = config.read_text()
357
+ # A broken file fails new before any host is contacted, naming the file and key.
358
+ for document, error in [
359
+ ({"defaults": {"cpu": 0}}, "defaults.cpu=0 is invalid"),
360
+ ({"defaults": {"cpu": True}}, "defaults.cpu=True is invalid"),
361
+ ({"defaults": {"disk": 20}}, "defaults.disk=20 is invalid"),
362
+ ({"defaults": {"colour": "red"}}, "unknown key defaults.colour"),
363
+ ({"defaults": []}, '{"defaults"'),
364
+ ({"lab": {"ssh": "x"}}, '{"defaults"'),
365
+ ({"defaults": {"location": "gone"}}, "unknown location gone"),
366
+ ]:
367
+ config.write_text(json.dumps(document))
368
+ rejected = cli("new", "--purpose", "x")
369
+ assert rejected.returncode == 1 and str(config) in rejected.stderr, rejected
370
+ assert error in rejected.stderr and "Traceback" not in rejected.stderr, rejected
371
+ config.write_text("{not json")
372
+ rejected = cli("new")
373
+ assert rejected.returncode == 1 and "invalid JSON" in rejected.stderr, rejected
374
+ # The broken key can be repaired through the CLI.
375
+ config.write_text(json.dumps({"defaults": {"cpu": 0, "ttl": 900}}))
376
+ assert cli("config", "unset", "cpu").stdout == "defaults.cpu: 0 -> built-in default\n"
377
+ assert json.loads(config.read_text()) == {"defaults": {"ttl": 900}}
378
+ config.write_text(saved)
379
+ # Concurrent edits to different keys must all survive.
380
+ writers = [
381
+ subprocess.Popen(
382
+ [sys.executable, str(ROOT / "raft.py"), "config", "set", key, value],
383
+ env={**os.environ, "HOME": home},
384
+ stdout=subprocess.DEVNULL,
385
+ )
386
+ for key, value in [
387
+ ("cpu", "2"),
388
+ ("memory", "8GiB"),
389
+ ("ttl", "120"),
390
+ ("location", "lab"),
391
+ ("disk", "shared"),
392
+ ]
393
+ ]
394
+ assert all(writer.wait() == 0 for writer in writers)
395
+ assert json.loads(config.read_text())["defaults"] == {
396
+ "cpu": 2,
397
+ "memory": "8GiB",
398
+ "ttl": 120,
399
+ "location": "lab",
400
+ "disk": "shared",
401
+ }
402
+ for key in ["cpu", "memory", "ttl", "location", "disk"]:
403
+ assert cli("config", "unset", key).returncode == 0
404
+ assert not config.exists(), "Removing every default left an empty file"
405
+ assert not list(directory.glob(".raft-*")), "Staged configuration left behind"
406
+ # Policy and note flag mistakes are rejected before any host is contacted.
407
+ handle = "lab:rf-0123456789abcdef"
408
+ for arguments, error in [
409
+ (["policy", "lab", "--require", "owner"], "always need purpose"),
410
+ (["policy", "lab", "--require", "purpose,colour"], "Give note fields from"),
411
+ (["policy", "lab", "--require", ","], "Give note fields from"),
412
+ (["policy", "missing", "--clear"], "Unknown location"),
413
+ (["fork", handle, "--ttl", "600", "--owner", "agent"], "need --purpose"),
414
+ (["recover", "archive", "--location", "lab", "--next", "x"], "need --purpose"),
415
+ ]:
416
+ rejected = cli(*arguments)
417
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
418
+ assert cli("policy", "lab").returncode == 2
419
+ # The controller message names the missing flags and a complete command.
420
+ program = (
421
+ "import raft; print(raft.note_problem('lab', ['purpose', 'owner'], "
422
+ "{'purpose': 'x'}, 'new')); print(raft.note_problem('lab', ['purpose'], "
423
+ "{'purpose': 'x'}, 'new'))"
424
+ )
425
+ messages = subprocess.check_output([sys.executable, "-c", program], cwd=ROOT, text=True)
426
+ assert messages == (
427
+ 'location lab requires --owner (host policy). Add it: raft new --purpose "..." '
428
+ '--owner "..." [--next "..."]\nNone\n'
429
+ ), messages
430
+ # Run the exact host-side check locally; it needs only python3.
431
+ for required, note, accepted in [
432
+ ("", "", True),
433
+ ("purpose", json.dumps({"purpose": "x"}), True),
434
+ ("purpose,owner", json.dumps({"purpose": "x", "owner": "y"}), True),
435
+ ("purpose,owner", json.dumps({"purpose": "x"}), False),
436
+ ("purpose", "", False),
437
+ ]:
438
+ check = subprocess.run(
439
+ [sys.executable, "-I", "-c", CHECK_NOTE_CODE, required, note],
440
+ capture_output=True,
441
+ text=True,
442
+ )
443
+ assert (check.returncode == 0) == accepted, (required, note, check)
444
+ if not accepted:
445
+ assert "Nothing was changed" in check.stderr, check
446
+ # A configured disk on a host without own box disks gets the same refusal as
447
+ # --disk, plus where the size came from. Run the exact host check against a
448
+ # fake incus that reports no storage pools besides the shared one.
449
+ with tempfile.TemporaryDirectory() as bin_directory:
450
+ fake = Path(bin_directory) / "incus"
451
+ fake.write_text("#!/bin/sh\necho '[\"/1.0/storage-pools/raft-data\"]'\n")
452
+ fake.chmod(0o755)
453
+ refusals = [
454
+ subprocess.run(
455
+ [sys.executable, "-I", "-c", RESERVE_BOX_DISK, "lab", "20", "image:abc", origin],
456
+ env={**os.environ, "PATH": f"{bin_directory}:{os.environ['PATH']}"},
457
+ capture_output=True,
458
+ text=True,
459
+ )
460
+ for origin in ["", disk_origin("20GiB")]
461
+ ]
462
+ flag, configured = (refusal.stderr.strip() for refusal in refusals)
463
+ assert all(refusal.returncode == 1 for refusal in refusals), refusals
464
+ assert flag.startswith("lab has no raft-disks pool") and "--disk-pool SIZE" in flag, flag
465
+ assert configured == flag + ". " + disk_origin("20GiB"), configured
466
+ assert "defaults.disk in " in configured and "raft config unset disk" in configured
467
+ print("Controller defaults, precedence, validation and note policy messages passed", flush=True)
468
+
469
+
238
470
  def verify_host_preflight():
239
471
  """Check onboarding decisions and refusals that need no host."""
240
472
  ready = {
@@ -253,6 +485,10 @@ def verify_host_preflight():
253
485
  assert host_problems(ready) == []
254
486
  # An existing deployment already owns its pool, so free disk no longer gates it.
255
487
  assert host_problems({**ready, "disk": "0", "deployed": "yes"}) == []
488
+ # Own box disks are opt-in; enabling them adds the pool size to the minimum.
489
+ assert host_problems({**ready, "disk": str(95 * 1024**2)}, "10GiB") == []
490
+ problems = host_problems(ready, "40GiB")
491
+ assert len(problems) == 1 and "120 GiB free disk" in problems[0], problems
256
492
  darwin = host_problems({"kernel": "Darwin", "sudo": "no"})
257
493
  assert len(darwin) == 1 and "macOS" in darwin[0], darwin
258
494
  for change, reason in [
@@ -343,6 +579,68 @@ with tempfile.TemporaryFile() as output:
343
579
  print(f"{location}: open stdin ignored and lifecycle lock released", flush=True)
344
580
 
345
581
 
582
+ def verify_note_policy(location, create, listed_note, fixtures):
583
+ """Require note fields on the host, prove every note path honors it, then clear it."""
584
+ source = create("--memory", "1GiB", "--purpose", "Policy fork source")
585
+ live.raft("stop", source)
586
+ changed = live.raft("policy", location, "--require", "purpose,owner")
587
+ try:
588
+ assert changed.stdout == f"{location}: required note fields none -> purpose,owner\n"
589
+ report = json.loads(live.raft("config").stdout)
590
+ assert report["policy"][location]["require"] == ["purpose", "owner"], report
591
+ assert " by " in report["policy"][location]["changed"], report
592
+ saved = len(inventory(location))
593
+ for arguments, error in [
594
+ (["new", "--location", location, "--purpose", "x"], "requires --owner (host policy)"),
595
+ (["new", "--location", location], "requires --purpose and --owner (host policy)"),
596
+ (["fork", source, "--ttl", "600"], "Forks keep the source note"),
597
+ (["fork", source, "--ttl", "600", "--purpose", "x"], "requires --owner"),
598
+ (["note", source, "--clear"], "Clearing would leave the box without them"),
599
+ (["note", source, "--purpose", "x", "--next", "y"], "requires --owner"),
600
+ # Rejected before the archive is read or uploaded.
601
+ (["recover", "missing.tar.gz", "--location", location, "--purpose", "x"], "--owner"),
602
+ ]:
603
+ rejected = live.raft(*arguments, check=False)
604
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
605
+ assert "Add it: raft " in rejected.stderr or "Add them: raft " in rejected.stderr
606
+ assert len(inventory(location)) == saved, "A rejected note created a box"
607
+ assert listed_note(source) == {"purpose": "Policy fork source"}
608
+ # The host repeats the check under its lock, so a controller that skips the
609
+ # precheck still cannot write a note that lacks a required field.
610
+ try:
611
+ transaction(
612
+ location,
613
+ 'note="$1"\n' + READ_NOTE_POLICY + CHECK_NOTE,
614
+ json.dumps({"purpose": "x"}),
615
+ )
616
+ except RuntimeError as error:
617
+ assert "requires box note fields purpose, owner" in str(error), error
618
+ else:
619
+ raise AssertionError("Host accepted a note without a required field")
620
+ owned = create("--memory", "1GiB", "--purpose", "Policy", "--owner", "verify")
621
+ assert listed_note(owned) == {"purpose": "Policy", "owner": "verify"}
622
+ live.raft("destroy", owned)
623
+ fixtures.remove(owned)
624
+ # Note flags replace the source note on the fork.
625
+ fork = live.raft(
626
+ "fork", source, "--ttl", "600", "--purpose", "Policy fork", "--owner", "verify"
627
+ ).stdout.strip()
628
+ fixtures.append(fork)
629
+ assert listed_note(fork) == {"purpose": "Policy fork", "owner": "verify"}
630
+ live.raft("destroy", fork)
631
+ fixtures.remove(fork)
632
+ live.raft("note", source, "--purpose", "Policy fork source", "--owner", "verify")
633
+ assert listed_note(source) == {"purpose": "Policy fork source", "owner": "verify"}
634
+ finally:
635
+ cleared = live.raft("policy", location, "--clear")
636
+ assert cleared.stdout == f"{location}: required note fields purpose,owner -> none\n"
637
+ assert json.loads(live.raft("config").stdout)["policy"][location]["require"] == []
638
+ live.raft("note", source, "--clear")
639
+ live.raft("destroy", source)
640
+ fixtures.remove(source)
641
+ print(f"{location}: host note policy for new, fork, note and recover passed", flush=True)
642
+
643
+
346
644
  def verify(location, isolated_worker=False):
347
645
  if inventory(location):
348
646
  raise RuntimeError(
@@ -409,8 +707,18 @@ def verify(location, isolated_worker=False):
409
707
  return live.raft(*command).stdout
410
708
 
411
709
  def create(*arguments):
710
+ # Pin the sizes a controller's config.json could otherwise default.
412
711
  handle = live.raft(
413
- "new", "--location", location, "--ttl", "1800", *arguments
712
+ "new",
713
+ "--location",
714
+ location,
715
+ "--ttl",
716
+ "1800",
717
+ "--cpu",
718
+ "1",
719
+ "--disk",
720
+ "shared",
721
+ *arguments,
414
722
  ).stdout.strip()
415
723
  fixtures.append(handle)
416
724
  return handle
@@ -464,6 +772,7 @@ def verify(location, isolated_worker=False):
464
772
  print(
465
773
  f"{location}: box note template, listing, replacement and clearing passed", flush=True
466
774
  )
775
+ verify_note_policy(location, create, listed_note, fixtures)
467
776
  with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
468
777
  root = Path(work)
469
778
  source = root / "source with spaces"
@@ -555,7 +864,9 @@ def verify(location, isolated_worker=False):
555
864
  )
556
865
  assert metadata(template)["config"]["user.raft.disposable"] == "false"
557
866
  assert listed_note(template) is None, "new --from inherited the source note"
867
+ # A template keeps the snapshot's sizes, not the box's later ones or the defaults.
558
868
  assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
869
+ assert metadata(template)["expanded_config"]["limits.memory"] == "1GiB"
559
870
  live.raft("snapshot-delete", box, "prepared")
560
871
  assert live.execute(template, "cat", "/workspace/proof") == "original"
561
872
  live.raft("destroy", template)
@@ -684,6 +995,7 @@ if __name__ == "__main__":
684
995
  parser.add_argument("--isolated-worker", action="store_true")
685
996
  args = parser.parse_args()
686
997
  verify_location_notes()
998
+ verify_controller_defaults()
687
999
  verify_host_preflight()
688
1000
  verify_archives()
689
1001
  if args.location:
@@ -24,6 +24,67 @@ def run(*args, **kwargs):
24
24
  return subprocess.run(args, check=True, **kwargs)
25
25
 
26
26
 
27
+ def verify_box_disks(env):
28
+ """Own box disks are off after onboarding and a later redeploy can enable them."""
29
+ raft = [sys.executable, str(ROOT / "raft.py")]
30
+ deploy = [sys.executable, str(ROOT / "deploy/deploy.py"), "--location", "lab"]
31
+ host = ["ssh", "raft-fresh"]
32
+ doctor = subprocess.check_output([*raft, "doctor"], env=env, text=True)
33
+ assert "own box disks: not enabled (redeploy with --disk-pool SIZE)" in doctor, doctor
34
+ report = json.loads(subprocess.check_output([*raft, "capacity", "--json"], env=env))
35
+ assert report["host"]["boxDiskPool"] is None, report
36
+ assert not any("raft-disks" in warning for warning in report["warnings"]), report
37
+ # --disk fails before anything is created and names the command that enables it.
38
+ boxes = subprocess.check_output([*raft, "list"], env=env, text=True)
39
+ rejected = subprocess.run(
40
+ [*raft, "new", "--disk", "10GiB"], env=env, capture_output=True, text=True
41
+ )
42
+ assert rejected.returncode == 1, rejected
43
+ assert "--location lab --disk-pool SIZE" in rejected.stderr, rejected.stderr
44
+ assert subprocess.check_output([*raft, "list"], env=env, text=True) == boxes
45
+ # 24 GiB holds the 10 GiB cached image volume and one 10 GiB own disk.
46
+ run(*deploy, "--disk-pool", "24GiB", env=env)
47
+ pool = subprocess.check_output(
48
+ [
49
+ *host,
50
+ "sudo incus storage get raft-disks size; sudo incus storage get raft-disks "
51
+ "lvm.use_thinpool; sudo lvs --noheadings -o discards raft-disks/IncusThinPool",
52
+ ],
53
+ env=env,
54
+ text=True,
55
+ ).split()
56
+ assert pool == ["24GiB", "true", "nopassdown"], pool
57
+ allocated = subprocess.check_output(
58
+ [*host, "sudo stat -c '%b %B %s' /var/lib/incus/disks/raft-disks.img"],
59
+ env=env,
60
+ text=True,
61
+ ).split()
62
+ blocks, block_size, length = map(int, allocated)
63
+ assert blocks * block_size >= length, "Own-disk pool file is still sparse"
64
+ box = subprocess.check_output(
65
+ [*raft, "new", "--disk", "10GiB", "--ttl", "600"], env=env, text=True
66
+ ).strip()
67
+ try:
68
+ for _ in range(30):
69
+ if subprocess.run([*raft, "exec", box, "--", "true"], env=env).returncode == 0:
70
+ break
71
+ time.sleep(2)
72
+ else:
73
+ raise RuntimeError("Own-disk box did not become command-ready")
74
+ finally:
75
+ run(*raft, "destroy", box, env=env)
76
+ assert "own box disks: not enabled" not in subprocess.check_output(
77
+ [*raft, "doctor"], env=env, text=True
78
+ )
79
+ # The pool keeps its size; an explicit different size is refused.
80
+ resized = subprocess.run(
81
+ [*deploy, "--disk-pool", "25GiB"], env=env, capture_output=True, text=True
82
+ )
83
+ assert resized.returncode != 0 and "does not resize" in resized.stderr, resized
84
+ run(*deploy, env=env)
85
+ print("Own box disks: off by default, enabled by redeploy, thin and fully allocated")
86
+
87
+
27
88
  def verify_firewall(env):
28
89
  """Disrupt only this harness's disposable VM to prove the startup dependency."""
29
90
  ssh = ["ssh", "raft-fresh"]
@@ -270,6 +331,7 @@ def verify(archive):
270
331
  "--reboot-host",
271
332
  env=env,
272
333
  )
334
+ verify_box_disks(env)
273
335
  # Oversize only this disposable pool after all boxes have been removed.
274
336
  run("ssh", "raft-fresh", "sudo incus storage set raft-data size=61GiB", env=env)
275
337
  drift = subprocess.run(
@@ -286,7 +348,8 @@ def verify(archive):
286
348
  )
287
349
  assert size.strip() == "61GiB", "Deployment silently resized a drifting pool"
288
350
  print(
289
- "Fresh provisioning, firewall failure gating, redeployment, oversize rejection and real host reboot passed",
351
+ "Fresh provisioning, firewall failure gating, redeployment, opt-in own box "
352
+ "disks, pool size drift rejection and real host reboot passed",
290
353
  flush=True,
291
354
  )
292
355
  finally: