@microck/raft 0.3.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,
@@ -196,7 +202,21 @@ def verify_location_notes():
196
202
  ]:
197
203
  rejected = cli(*arguments)
198
204
  assert rejected.returncode == 1 and error in rejected.stderr, rejected
199
- assert cli("note", "lab", "x", "--clear").returncode == 2
205
+ # Box note mistakes are rejected before any host is contacted.
206
+ handle = "lab:rf-0123456789abcdef"
207
+ for arguments, error in [
208
+ (["note", "lab", "x", "--clear"], "TEXT or --clear"),
209
+ (["note", "lab"], "TEXT or --clear"),
210
+ (["note", "lab", "--purpose", "x"], "pass a box handle"),
211
+ (["note", handle, "x"], "--purpose, --owner and --next"),
212
+ (["note", handle], "--purpose (with optional"),
213
+ (["note", handle, "--purpose", "x", "--clear"], "--purpose (with optional"),
214
+ (["note", handle, "--owner", "agent"], "need --purpose"),
215
+ (["note", handle, "--purpose", " "], "--purpose cannot be empty"),
216
+ (["new", "--location", "lab", "--next", "later"], "need --purpose"),
217
+ ]:
218
+ rejected = cli(*arguments)
219
+ assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
200
220
  # Concurrent edits to different locations must all survive.
201
221
  many = {f"host{index}": {"ssh": f"host{index}"} for index in range(8)}
202
222
  saved = config.read_text()
@@ -221,6 +241,232 @@ def verify_location_notes():
221
241
  print("Location note editing, listing, ordering and permissions passed", flush=True)
222
242
 
223
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
+
224
470
  def verify_host_preflight():
225
471
  """Check onboarding decisions and refusals that need no host."""
226
472
  ready = {
@@ -239,6 +485,10 @@ def verify_host_preflight():
239
485
  assert host_problems(ready) == []
240
486
  # An existing deployment already owns its pool, so free disk no longer gates it.
241
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
242
492
  darwin = host_problems({"kernel": "Darwin", "sudo": "no"})
243
493
  assert len(darwin) == 1 and "macOS" in darwin[0], darwin
244
494
  for change, reason in [
@@ -329,6 +579,68 @@ with tempfile.TemporaryFile() as output:
329
579
  print(f"{location}: open stdin ignored and lifecycle lock released", flush=True)
330
580
 
331
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
+
332
644
  def verify(location, isolated_worker=False):
333
645
  if inventory(location):
334
646
  raise RuntimeError(
@@ -395,8 +707,18 @@ def verify(location, isolated_worker=False):
395
707
  return live.raft(*command).stdout
396
708
 
397
709
  def create(*arguments):
710
+ # Pin the sizes a controller's config.json could otherwise default.
398
711
  handle = live.raft(
399
- "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,
400
722
  ).stdout.strip()
401
723
  fixtures.append(handle)
402
724
  return handle
@@ -429,9 +751,28 @@ def verify(location, isolated_worker=False):
429
751
  live.raft("destroy", handle)
430
752
  fixtures.remove(handle)
431
753
  print(f"{location}: noted new kept stdout to the handle and the note on stderr", flush=True)
432
- box = create("--memory", "1GiB")
754
+ box = create("--memory", "1GiB", "--purpose", " Feature verification ", "--owner", "verify")
755
+
756
+ def listed_note(handle):
757
+ for line in live.raft("list", "--location", location).stdout.splitlines():
758
+ record = json.loads(line)
759
+ if record["box"] == handle:
760
+ return record["note"]
761
+ raise AssertionError(f"{handle} missing from list")
762
+
763
+ assert listed_note(box) == {"purpose": "Feature verification", "owner": "verify"}
764
+ # Each note edit replaces the whole note.
765
+ live.raft("note", box, "--purpose", "Feature verification", "--next", "Destroy at the end")
766
+ assert listed_note(box) == {"purpose": "Feature verification", "next": "Destroy at the end"}
767
+ live.raft("note", box, "--clear")
768
+ assert listed_note(box) is None
769
+ live.raft("note", box, "--purpose", "Feature verification")
433
770
  live.wait_running(box)
434
771
  _, name = parse_handle(box)
772
+ print(
773
+ f"{location}: box note template, listing, replacement and clearing passed", flush=True
774
+ )
775
+ verify_note_policy(location, create, listed_note, fixtures)
435
776
  with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
436
777
  root = Path(work)
437
778
  source = root / "source with spaces"
@@ -522,7 +863,10 @@ def verify(location, isolated_worker=False):
522
863
  != metadata(box)["config"]["volatile.eth0.hwaddr"]
523
864
  )
524
865
  assert metadata(template)["config"]["user.raft.disposable"] == "false"
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.
525
868
  assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
869
+ assert metadata(template)["expanded_config"]["limits.memory"] == "1GiB"
526
870
  live.raft("snapshot-delete", box, "prepared")
527
871
  assert live.execute(template, "cat", "/workspace/proof") == "original"
528
872
  live.raft("destroy", template)
@@ -536,6 +880,7 @@ def verify(location, isolated_worker=False):
536
880
  fork = live.raft(*fork_args).stdout.strip()
537
881
  fixtures.append(fork)
538
882
  assert metadata(fork)["expanded_config"]["limits.memory"] == "1GiB"
883
+ assert listed_note(fork) == {"purpose": "Feature verification"}, "fork lost the note"
539
884
  assert metadata(fork)["expanded_config"]["limits.cpu"] == "1"
540
885
  assert metadata(box)["expanded_config"]["limits.memory"] == "4GiB"
541
886
  if worker:
@@ -650,6 +995,7 @@ if __name__ == "__main__":
650
995
  parser.add_argument("--isolated-worker", action="store_true")
651
996
  args = parser.parse_args()
652
997
  verify_location_notes()
998
+ verify_controller_defaults()
653
999
  verify_host_preflight()
654
1000
  verify_archives()
655
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: