@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.
- package/README.md +23 -10
- package/deploy/deploy.py +112 -19
- package/deploy/install-incus.sh +10 -0
- package/deploy/verify-controls.py +34 -48
- package/deploy/verify-features.py +314 -2
- package/deploy/verify-fresh-host.py +64 -1
- package/deploy/verify-limits.py +325 -71
- package/deploy/verify-live.py +38 -29
- package/deploy/verify-self-update.py +372 -0
- package/docs/boat-capabilities.md +8 -7
- package/docs/boot-performance.mdx +1 -1
- package/docs/contract.md +27 -18
- package/docs/guides/backup-recovery.mdx +1 -1
- package/docs/guides/config-policy.mdx +179 -0
- package/docs/guides/docker.mdx +9 -1
- package/docs/guides/long-running-services.mdx +2 -2
- package/docs/guides/meta.json +1 -0
- package/docs/guides/project-setup.mdx +4 -3
- package/docs/guides/scripting-ci.mdx +24 -10
- package/docs/guides/snapshots-forks.mdx +9 -3
- package/docs/guides/troubleshooting.mdx +18 -6
- package/docs/guides/upgrades.mdx +13 -3
- package/docs/index.mdx +1 -0
- package/docs/operations.mdx +16 -14
- package/docs/reference/capacity.mdx +198 -0
- package/docs/reference/cli.md +148 -31
- package/docs/reference/configuration.mdx +36 -2
- package/docs/reference/meta.json +1 -1
- package/docs/releases/0.5.0.md +19 -0
- package/docs/requirements.mdx +2 -2
- package/docs/verification.mdx +14 -8
- package/package.json +1 -1
- package/raft.py +1046 -125
- package/skills/raft-cli/SKILL.md +63 -12
- package/docs/reference/capacity-limits.mdx +0 -92
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Exercise disposable lifecycle, retention, templates, resizing
|
|
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",
|
|
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,
|
|
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:
|