@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.
- 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 +350 -4
- 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 +28 -17
- 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 +25 -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 +166 -29
- package/docs/reference/configuration.mdx +36 -2
- package/docs/reference/meta.json +1 -1
- package/docs/releases/0.4.0.md +15 -0
- 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 +1105 -125
- package/skills/raft-cli/SKILL.md +76 -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,
|
|
@@ -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
|
-
|
|
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",
|
|
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,
|
|
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:
|