@microck/raft 0.5.0 → 0.7.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 +27 -15
- package/deploy/build-image.py +4 -2
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +6 -6
- package/deploy/verify-features.py +329 -19
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +22 -3
- package/deploy/verify-live.py +31 -6
- package/deploy/verify-output.py +208 -0
- package/deploy/verify-release.py +33 -4
- package/deploy/verify-run.py +571 -0
- package/deploy/verify-self-update.py +9 -1
- package/deploy/verify-sync.py +122 -0
- package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
- package/docs/boat-capabilities.md +60 -7
- package/docs/boat-parity-design.md +1 -0
- package/docs/boot-performance.mdx +21 -0
- package/docs/contract.md +88 -13
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +4 -3
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +9 -3
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +36 -14
- package/docs/guides/snapshots-forks.mdx +4 -2
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +11 -9
- package/docs/index.mdx +26 -28
- package/docs/maintainers/index.mdx +18 -0
- package/docs/maintainers/meta.json +12 -0
- package/docs/meta.json +5 -9
- package/docs/operations.mdx +17 -10
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +135 -43
- package/docs/reference/configuration.mdx +10 -6
- package/docs/reference/meta.json +3 -1
- package/docs/releases/0.2.0.md +1 -0
- package/docs/releases/0.2.1.md +1 -0
- package/docs/releases/0.3.0.md +1 -0
- package/docs/releases/0.4.0.md +1 -0
- package/docs/releases/0.5.0.md +1 -0
- package/docs/releases/0.6.0.md +17 -0
- package/docs/releases/0.7.0.md +23 -0
- package/docs/releases/index.mdx +21 -0
- package/docs/releases/meta.json +15 -0
- package/docs/requirements.mdx +1 -1
- package/docs/reviews/docs-audit-2026-10-24.md +92 -0
- package/docs/reviews/performance-audit-2026-10-29.md +90 -0
- package/docs/site-development.md +1 -0
- package/docs/verification.mdx +22 -4
- package/package.json +3 -1
- package/raft.py +526 -193
- package/raft_files.py +13 -1
- package/raft_jobs.py +390 -0
- package/raft_output.py +166 -0
- package/raft_run.py +330 -0
- package/skills/raft-cli/SKILL.md +78 -182
|
@@ -28,8 +28,12 @@ from raft import ( # noqa: E402
|
|
|
28
28
|
CHECK_NOTE_CODE,
|
|
29
29
|
READ_NOTE_POLICY,
|
|
30
30
|
RESERVE_BOX_DISK,
|
|
31
|
+
RETENTION_KEY,
|
|
32
|
+
RETENTION_SINCE_KEY,
|
|
31
33
|
disk_origin,
|
|
32
34
|
host_problems,
|
|
35
|
+
host_inventory,
|
|
36
|
+
host_policy,
|
|
33
37
|
incus,
|
|
34
38
|
inventory,
|
|
35
39
|
parse_handle,
|
|
@@ -265,7 +269,7 @@ def verify_controller_defaults():
|
|
|
265
269
|
"import json, sys; import raft; args = raft.parser().parse_args(sys.argv[1:]); "
|
|
266
270
|
"used = raft.apply_new_defaults(args); "
|
|
267
271
|
"print(json.dumps([args.location, args.ttl, args.cpu, args.memory, args.disk, "
|
|
268
|
-
"sorted(used)]))"
|
|
272
|
+
"args.disposable, sorted(used)]))"
|
|
269
273
|
)
|
|
270
274
|
response = subprocess.run(
|
|
271
275
|
[sys.executable, "-c", program, *arguments],
|
|
@@ -279,13 +283,14 @@ def verify_controller_defaults():
|
|
|
279
283
|
return json.loads(response.stdout)
|
|
280
284
|
|
|
281
285
|
# No disk is the shared pool.
|
|
282
|
-
assert resolved("new") == ["lab", 600, 1, "2GiB", None, []]
|
|
286
|
+
assert resolved("new") == ["lab", 600, 1, "2GiB", None, False, []]
|
|
283
287
|
for arguments, printed in [
|
|
284
288
|
(["set", "cpu", "2"], "defaults.cpu: built-in default -> 2"),
|
|
285
289
|
(["set", "memory", "4GiB"], "defaults.memory: built-in default -> 4GiB"),
|
|
286
290
|
(["set", "ttl", "3600"], "defaults.ttl: built-in default -> 3600"),
|
|
287
291
|
(["set", "location", "spare"], "defaults.location: built-in default -> spare"),
|
|
288
292
|
(["set", "disk", "20GiB"], "defaults.disk: built-in default -> 20GiB"),
|
|
293
|
+
(["set", "disposable", "true"], "defaults.disposable: built-in default -> true"),
|
|
289
294
|
(["set", "cpu", "1"], "defaults.cpu: 2 -> 1"),
|
|
290
295
|
]:
|
|
291
296
|
response = cli("config", *arguments)
|
|
@@ -297,29 +302,48 @@ def verify_controller_defaults():
|
|
|
297
302
|
"ttl": 3600,
|
|
298
303
|
"location": "spare",
|
|
299
304
|
"disk": "20GiB",
|
|
305
|
+
"disposable": True,
|
|
300
306
|
}
|
|
301
307
|
}
|
|
302
308
|
assert config.stat().st_mode & 0o777 == 0o600, "Default editing widened permissions"
|
|
303
309
|
# 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]
|
|
310
|
+
everything = ["cpu", "disk", "disposable", "location", "memory", "ttl"]
|
|
311
|
+
assert resolved("new") == ["spare", 3600, 1, "4GiB", "20GiB", True, everything]
|
|
306
312
|
assert resolved("new", "--location", "lab", "--ttl", "60", "--cpu", "2") == [
|
|
307
313
|
"lab",
|
|
308
314
|
60,
|
|
309
315
|
2,
|
|
310
316
|
"4GiB",
|
|
311
317
|
"20GiB",
|
|
312
|
-
|
|
318
|
+
True,
|
|
319
|
+
["disk", "disposable", "memory"],
|
|
313
320
|
]
|
|
314
|
-
# --disk shared beats a configured own disk.
|
|
315
|
-
assert resolved("new", "--disk", "shared")[4:] == [
|
|
321
|
+
# --disk shared beats a configured own disk, and --persistent a disposable default.
|
|
322
|
+
assert resolved("new", "--disk", "shared", "--persistent")[4:] == [
|
|
316
323
|
None,
|
|
324
|
+
False,
|
|
317
325
|
["cpu", "location", "memory", "ttl"],
|
|
318
326
|
]
|
|
327
|
+
assert resolved("new", "--disposable")[5:] == [
|
|
328
|
+
True,
|
|
329
|
+
["cpu", "disk", "location", "memory", "ttl"],
|
|
330
|
+
]
|
|
331
|
+
rejected = cli("new", "--disposable", "--persistent")
|
|
332
|
+
assert rejected.returncode == 2 and "not allowed with" in rejected.stderr, rejected
|
|
319
333
|
# A box from a snapshot keeps the snapshot's location, CPU, RAM and disk:
|
|
320
334
|
# defaults, configured or built-in, never override them; flags still do.
|
|
335
|
+
# A snapshot has no lifecycle, so the TTL and disposable defaults apply.
|
|
321
336
|
template = "lab:rf-0123456789abcdef/snap"
|
|
322
|
-
assert resolved("new", "--from", template) == [
|
|
337
|
+
assert resolved("new", "--from", template) == [
|
|
338
|
+
None,
|
|
339
|
+
3600,
|
|
340
|
+
None,
|
|
341
|
+
None,
|
|
342
|
+
None,
|
|
343
|
+
True,
|
|
344
|
+
["disposable", "ttl"],
|
|
345
|
+
]
|
|
346
|
+
assert resolved("new", "--from", template, "--persistent")[5] is False
|
|
323
347
|
assert resolved("new", "--from", template, "--cpu", "4", "--disk", "30GiB")[2:5] == [
|
|
324
348
|
4,
|
|
325
349
|
None,
|
|
@@ -328,24 +352,30 @@ def verify_controller_defaults():
|
|
|
328
352
|
assert "--from keeps the snapshot's disk" in resolved(
|
|
329
353
|
"new", "--from", template, "--disk", "shared"
|
|
330
354
|
)
|
|
331
|
-
# fork has no defaults at all: omitted sizes stay the source's
|
|
355
|
+
# fork has no defaults at all: omitted sizes stay the source's, and a fork
|
|
356
|
+
# is persistent without --disposable even under a disposable default.
|
|
332
357
|
forked = subprocess.check_output(
|
|
333
358
|
[
|
|
334
359
|
sys.executable,
|
|
335
360
|
"-c",
|
|
336
361
|
"import raft; args = raft.parser().parse_args(['fork', 'lab:rf-0123456789abcdef', "
|
|
337
|
-
"'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'))",
|
|
362
|
+
"'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'), args.disposable)",
|
|
338
363
|
],
|
|
339
364
|
cwd=ROOT,
|
|
340
365
|
env={**os.environ, "HOME": home},
|
|
341
366
|
text=True,
|
|
342
367
|
)
|
|
343
|
-
assert forked == "[] False\n", forked
|
|
368
|
+
assert forked == "[] False False\n", forked
|
|
344
369
|
for arguments, error in [
|
|
345
370
|
(["set", "cpu", "0"], "defaults.cpu='0' is invalid"),
|
|
346
371
|
(["set", "memory", "3GB"], "defaults.memory='3GB' is invalid"),
|
|
347
372
|
(["set", "disk", "20"], "defaults.disk='20' is invalid"),
|
|
348
373
|
(["set", "ttl", "59"], "TTL must be 60 seconds to 30 days"),
|
|
374
|
+
(
|
|
375
|
+
["set", "disposable", "yes"],
|
|
376
|
+
"defaults.disposable='yes' is invalid: Use true or false",
|
|
377
|
+
),
|
|
378
|
+
(["set", "disposable", "True"], "defaults.disposable='True' is invalid"),
|
|
349
379
|
(["set", "location", "missing"], "Unknown location"),
|
|
350
380
|
(["set", "location", "bad name"], "Location names"),
|
|
351
381
|
]:
|
|
@@ -359,6 +389,7 @@ def verify_controller_defaults():
|
|
|
359
389
|
({"defaults": {"cpu": 0}}, "defaults.cpu=0 is invalid"),
|
|
360
390
|
({"defaults": {"cpu": True}}, "defaults.cpu=True is invalid"),
|
|
361
391
|
({"defaults": {"disk": 20}}, "defaults.disk=20 is invalid"),
|
|
392
|
+
({"defaults": {"disposable": 1}}, "defaults.disposable=1 is invalid"),
|
|
362
393
|
({"defaults": {"colour": "red"}}, "unknown key defaults.colour"),
|
|
363
394
|
({"defaults": []}, '{"defaults"'),
|
|
364
395
|
({"lab": {"ssh": "x"}}, '{"defaults"'),
|
|
@@ -389,6 +420,7 @@ def verify_controller_defaults():
|
|
|
389
420
|
("ttl", "120"),
|
|
390
421
|
("location", "lab"),
|
|
391
422
|
("disk", "shared"),
|
|
423
|
+
("disposable", "false"),
|
|
392
424
|
]
|
|
393
425
|
]
|
|
394
426
|
assert all(writer.wait() == 0 for writer in writers)
|
|
@@ -398,8 +430,25 @@ def verify_controller_defaults():
|
|
|
398
430
|
"ttl": 120,
|
|
399
431
|
"location": "lab",
|
|
400
432
|
"disk": "shared",
|
|
433
|
+
"disposable": False,
|
|
401
434
|
}
|
|
402
|
-
|
|
435
|
+
# JSON booleans read back as the CLI's true and false.
|
|
436
|
+
assert cli("config", "set", "disposable", "true").stdout == (
|
|
437
|
+
"defaults.disposable: false -> true\n"
|
|
438
|
+
)
|
|
439
|
+
# raft config reports it with its source; the report's host half needs SSH.
|
|
440
|
+
effective = subprocess.check_output(
|
|
441
|
+
[
|
|
442
|
+
sys.executable,
|
|
443
|
+
"-c",
|
|
444
|
+
"import json, raft; print(json.dumps(raft.effective_defaults()['disposable']))",
|
|
445
|
+
],
|
|
446
|
+
cwd=ROOT,
|
|
447
|
+
env={**os.environ, "HOME": home},
|
|
448
|
+
text=True,
|
|
449
|
+
)
|
|
450
|
+
assert json.loads(effective) == {"value": True, "source": "config"}, effective
|
|
451
|
+
for key in ["cpu", "memory", "ttl", "location", "disk", "disposable"]:
|
|
403
452
|
assert cli("config", "unset", key).returncode == 0
|
|
404
453
|
assert not config.exists(), "Removing every default left an empty file"
|
|
405
454
|
assert not list(directory.glob(".raft-*")), "Staged configuration left behind"
|
|
@@ -409,13 +458,21 @@ def verify_controller_defaults():
|
|
|
409
458
|
(["policy", "lab", "--require", "owner"], "always need purpose"),
|
|
410
459
|
(["policy", "lab", "--require", "purpose,colour"], "Give note fields from"),
|
|
411
460
|
(["policy", "lab", "--require", ","], "Give note fields from"),
|
|
412
|
-
(["policy", "missing", "--
|
|
461
|
+
(["policy", "missing", "--require", "none"], "Unknown location"),
|
|
462
|
+
(["policy", "lab"], "Give --require FIELDS|none, --delete-stopped-after"),
|
|
463
|
+
(["policy", "lab", "--delete-stopped-after", "0"], "positive whole number of hours"),
|
|
464
|
+
(["policy", "lab", "--delete-stopped-after", "48h"], "positive whole number of hours"),
|
|
465
|
+
# Both options are parsed before either changes the host.
|
|
466
|
+
(
|
|
467
|
+
["policy", "lab", "--require", "none", "--delete-stopped-after", "-1"],
|
|
468
|
+
"positive whole number of hours",
|
|
469
|
+
),
|
|
413
470
|
(["fork", handle, "--ttl", "600", "--owner", "agent"], "need --purpose"),
|
|
414
471
|
(["recover", "archive", "--location", "lab", "--next", "x"], "need --purpose"),
|
|
415
472
|
]:
|
|
416
473
|
rejected = cli(*arguments)
|
|
417
474
|
assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
|
|
418
|
-
assert cli("policy", "lab").returncode == 2
|
|
475
|
+
assert cli("policy", "lab", "--clear").returncode == 2
|
|
419
476
|
# The controller message names the missing flags and a complete command.
|
|
420
477
|
program = (
|
|
421
478
|
"import raft; print(raft.note_problem('lab', ['purpose', 'owner'], "
|
|
@@ -467,6 +524,115 @@ def verify_controller_defaults():
|
|
|
467
524
|
print("Controller defaults, precedence, validation and note policy messages passed", flush=True)
|
|
468
525
|
|
|
469
526
|
|
|
527
|
+
# Stands in for incus and logger on PATH for the real host worker. It answers the
|
|
528
|
+
# worker's two reads from the state file and appends every call to the log file.
|
|
529
|
+
FAKE_HOST_TOOL = """import json, os, sys
|
|
530
|
+
from pathlib import Path
|
|
531
|
+
arguments = [Path(sys.argv[0]).name, *sys.argv[1:]]
|
|
532
|
+
with open(os.environ["RAFT_FAKE_LOG"], "a") as log:
|
|
533
|
+
log.write(json.dumps(arguments) + "\\n")
|
|
534
|
+
state = json.loads(Path(os.environ["RAFT_FAKE_STATE"]).read_text())
|
|
535
|
+
if arguments[1:] == ["--project", "raft", "list", "--fast", "--format", "json"]:
|
|
536
|
+
print(json.dumps(state["instances"]))
|
|
537
|
+
elif arguments[1:] == ["--project", "raft", "project", "list", "--format", "json"]:
|
|
538
|
+
print(json.dumps([{"name": "default", "config": {}}, {"name": "raft", "config": state["project"]}]))
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def verify_retention_worker():
|
|
543
|
+
"""Run the real host worker against fake incus fixtures and check what it changes."""
|
|
544
|
+
now = int(time.time())
|
|
545
|
+
|
|
546
|
+
def box(name, status, stopped_at=None, raft="true"):
|
|
547
|
+
config = {"user.raft": raft}
|
|
548
|
+
if stopped_at is not None:
|
|
549
|
+
config["user.raft.stopped-at"] = str(stopped_at)
|
|
550
|
+
return {"name": name, "status": status, "config": config}
|
|
551
|
+
|
|
552
|
+
instances = [
|
|
553
|
+
# Running boxes are never deleted; an old stop time left behind is cleared.
|
|
554
|
+
box("rf-running", "Running", now - 30 * 86400),
|
|
555
|
+
box("rf-old", "Stopped", now - 3 * 3600),
|
|
556
|
+
box("rf-recent", "Stopped", now - 3600),
|
|
557
|
+
box("rf-unrecorded", "Stopped"),
|
|
558
|
+
box("unrelated", "Stopped", now - 30 * 86400, raft="false"),
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
def run_worker(project, *arguments):
|
|
562
|
+
with tempfile.TemporaryDirectory() as work:
|
|
563
|
+
root = Path(work)
|
|
564
|
+
for tool in ["incus", "logger"]:
|
|
565
|
+
(root / tool).write_text(f"#!{sys.executable}\n" + FAKE_HOST_TOOL)
|
|
566
|
+
(root / tool).chmod(0o755)
|
|
567
|
+
(root / "state.json").write_text(
|
|
568
|
+
json.dumps({"instances": instances, "project": project})
|
|
569
|
+
)
|
|
570
|
+
started = int(time.time())
|
|
571
|
+
response = subprocess.run(
|
|
572
|
+
[sys.executable, "-I", str(ROOT / "deploy/raft-expire.py"), *arguments],
|
|
573
|
+
env={
|
|
574
|
+
**os.environ,
|
|
575
|
+
"PATH": f"{root}:{os.environ['PATH']}",
|
|
576
|
+
"RAFT_FAKE_STATE": str(root / "state.json"),
|
|
577
|
+
"RAFT_FAKE_LOG": str(root / "calls.jsonl"),
|
|
578
|
+
},
|
|
579
|
+
capture_output=True,
|
|
580
|
+
text=True,
|
|
581
|
+
)
|
|
582
|
+
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
|
|
583
|
+
# Leave out the two reads and the stale stop time cleared on the running box;
|
|
584
|
+
# normalize the recorded stop time to "now".
|
|
585
|
+
clear = ["incus", "--project", "raft", "config", "unset", "rf-running"]
|
|
586
|
+
cleared.append(clear + ["user.raft.stopped-at"] in calls)
|
|
587
|
+
changes = []
|
|
588
|
+
for call in calls:
|
|
589
|
+
if call[-2:] == ["--format", "json"] or call == clear + ["user.raft.stopped-at"]:
|
|
590
|
+
continue
|
|
591
|
+
if call[-1].startswith("user.raft.stopped-at="):
|
|
592
|
+
assert started <= int(call[-1].partition("=")[2]) <= int(time.time()), call
|
|
593
|
+
call[-1] = "user.raft.stopped-at=now"
|
|
594
|
+
changes.append(call)
|
|
595
|
+
return response, changes
|
|
596
|
+
|
|
597
|
+
cleared = []
|
|
598
|
+
record = ["incus", "--project", "raft", "config", "set", "rf-unrecorded"]
|
|
599
|
+
record.append("user.raft.stopped-at=now")
|
|
600
|
+
two_hours = {"user.raft.delete-stopped-after": "2", "user.raft.delete-stopped-after.since": ""}
|
|
601
|
+
# A 2-hour policy set long ago deletes the box stopped 3 hours ago and logs it.
|
|
602
|
+
response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": "0"})
|
|
603
|
+
assert response.returncode == 0, response
|
|
604
|
+
assert changes[0] == ["incus", "--project", "raft", "delete", "rf-old"], changes
|
|
605
|
+
assert changes[1][:3] == ["logger", "-t", "raft"] and "rf-old" in changes[1][3], changes
|
|
606
|
+
assert "under the 2-hour retention" in changes[1][3] and changes[2:] == [record], changes
|
|
607
|
+
assert cleared[-1], "the worker kept a stale stop time on a running box"
|
|
608
|
+
assert changes[1][3] in response.stdout, response
|
|
609
|
+
# Set an hour ago, the policy's clock starts then: nothing is deleted yet.
|
|
610
|
+
since = str(now - 3600)
|
|
611
|
+
response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": since})
|
|
612
|
+
assert response.returncode == 0 and changes == [record], (response, changes)
|
|
613
|
+
# Without a policy nothing is deleted, but stop times are still recorded.
|
|
614
|
+
response, changes = run_worker({})
|
|
615
|
+
assert response.returncode == 0 and changes == [record], (response, changes)
|
|
616
|
+
# Prune mode only selects; it records and retains nothing else.
|
|
617
|
+
response, changes = run_worker(
|
|
618
|
+
{**two_hours, "user.raft.delete-stopped-after.since": "0"}, "--prune-days", "1"
|
|
619
|
+
)
|
|
620
|
+
assert response.returncode == 0 and changes == [], (response, changes)
|
|
621
|
+
# An invalid policy deletes nothing, still enforces deadlines, and fails the run.
|
|
622
|
+
instances.append(
|
|
623
|
+
{
|
|
624
|
+
"name": "rf-expired",
|
|
625
|
+
"status": "Running",
|
|
626
|
+
"config": {"user.raft": "true", "user.raft.expires": "1"},
|
|
627
|
+
}
|
|
628
|
+
)
|
|
629
|
+
response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after": "48h"})
|
|
630
|
+
assert response.returncode == 1 and "Invalid retention" in response.stderr, response
|
|
631
|
+
assert ["incus", "--project", "raft", "stop", "rf-expired", "--timeout", "30"] in changes
|
|
632
|
+
assert not any("delete" in call for call in changes), changes
|
|
633
|
+
print("Host worker retention, stop-time recording and grace period passed", flush=True)
|
|
634
|
+
|
|
635
|
+
|
|
470
636
|
def verify_host_preflight():
|
|
471
637
|
"""Check onboarding decisions and refusals that need no host."""
|
|
472
638
|
ready = {
|
|
@@ -530,8 +696,12 @@ def verify_readiness(location, expect_stale=False):
|
|
|
530
696
|
(home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
|
|
531
697
|
config = home / ".config/raft/incus.json"
|
|
532
698
|
config.parent.mkdir(parents=True)
|
|
533
|
-
config.write_text(
|
|
534
|
-
|
|
699
|
+
config.write_text(
|
|
700
|
+
json.dumps({"offline": {"ssh": "raft-offline.invalid"}, location: settings(location)})
|
|
701
|
+
)
|
|
702
|
+
response = live.raft(
|
|
703
|
+
"doctor", "--location", location, check=False, env={**os.environ, "HOME": str(home)}
|
|
704
|
+
)
|
|
535
705
|
assert location + ": " in response.stdout, response
|
|
536
706
|
if expect_stale:
|
|
537
707
|
assert response.returncode == 1, response
|
|
@@ -545,6 +715,32 @@ def verify_readiness(location, expect_stale=False):
|
|
|
545
715
|
print(f"{location}: doctor {'rejected stale worker' if expect_stale else 'passed'}", flush=True)
|
|
546
716
|
|
|
547
717
|
|
|
718
|
+
def verify_read_reports(location):
|
|
719
|
+
"""Compare batched reads with independent native reads on a quiescent host."""
|
|
720
|
+
policy, boxes = host_inventory(location)
|
|
721
|
+
assert policy == host_policy(location)
|
|
722
|
+
assert sorted(boxes, key=lambda box: box["name"]) == sorted(
|
|
723
|
+
inventory(location), key=lambda box: box["name"]
|
|
724
|
+
)
|
|
725
|
+
with tempfile.TemporaryDirectory(prefix="raft-config-test-") as work:
|
|
726
|
+
home = Path(work)
|
|
727
|
+
(home / ".ssh").symlink_to(Path.home() / ".ssh", target_is_directory=True)
|
|
728
|
+
config = home / ".config/raft/incus.json"
|
|
729
|
+
config.parent.mkdir(parents=True)
|
|
730
|
+
config.write_text(
|
|
731
|
+
json.dumps({"offline": {"ssh": "raft-offline.invalid"}, location: settings(location)})
|
|
732
|
+
)
|
|
733
|
+
env = {**os.environ, "HOME": str(home), "RAFT_NO_UPDATE_CHECK": "1"}
|
|
734
|
+
report = live.raft("config", "--location", location, env=env)
|
|
735
|
+
assert json.loads(report.stdout)["policy"] == {location: policy}
|
|
736
|
+
rejected = live.raft(
|
|
737
|
+
"config", "--location", location, "set", "cpu", "2", env=env, check=False
|
|
738
|
+
)
|
|
739
|
+
assert rejected.returncode == 1 and "controller-wide" in rejected.stderr
|
|
740
|
+
assert not (config.parent / "config.json").exists()
|
|
741
|
+
print(f"{location}: batched metadata and offline-host isolation passed", flush=True)
|
|
742
|
+
|
|
743
|
+
|
|
548
744
|
def verify_transaction_stdin(location):
|
|
549
745
|
program = """import sys, tempfile
|
|
550
746
|
from raft import transaction
|
|
@@ -588,7 +784,7 @@ def verify_note_policy(location, create, listed_note, fixtures):
|
|
|
588
784
|
assert changed.stdout == f"{location}: required note fields none -> purpose,owner\n"
|
|
589
785
|
report = json.loads(live.raft("config").stdout)
|
|
590
786
|
assert report["policy"][location]["require"] == ["purpose", "owner"], report
|
|
591
|
-
assert " by " in report["policy"][location]["
|
|
787
|
+
assert " by " in report["policy"][location]["requireChanged"], report
|
|
592
788
|
saved = len(inventory(location))
|
|
593
789
|
for arguments, error in [
|
|
594
790
|
(["new", "--location", location, "--purpose", "x"], "requires --owner (host policy)"),
|
|
@@ -632,7 +828,7 @@ def verify_note_policy(location, create, listed_note, fixtures):
|
|
|
632
828
|
live.raft("note", source, "--purpose", "Policy fork source", "--owner", "verify")
|
|
633
829
|
assert listed_note(source) == {"purpose": "Policy fork source", "owner": "verify"}
|
|
634
830
|
finally:
|
|
635
|
-
cleared = live.raft("policy", location, "--
|
|
831
|
+
cleared = live.raft("policy", location, "--require", "none")
|
|
636
832
|
assert cleared.stdout == f"{location}: required note fields purpose,owner -> none\n"
|
|
637
833
|
assert json.loads(live.raft("config").stdout)["policy"][location]["require"] == []
|
|
638
834
|
live.raft("note", source, "--clear")
|
|
@@ -641,6 +837,117 @@ def verify_note_policy(location, create, listed_note, fixtures):
|
|
|
641
837
|
print(f"{location}: host note policy for new, fork, note and recover passed", flush=True)
|
|
642
838
|
|
|
643
839
|
|
|
840
|
+
def verify_retention(location, create, exists, worker):
|
|
841
|
+
"""Set a 1-hour stopped-box retention, prove the worker deletes only a box stopped
|
|
842
|
+
past it, records stop times Raft missed and logs to the journal, then clear it."""
|
|
843
|
+
host = settings(location)["ssh"]
|
|
844
|
+
started = int(time.time())
|
|
845
|
+
running = create("--memory", "1GiB", "--purpose", "Retention: running box")
|
|
846
|
+
kept = create("--memory", "1GiB", "--purpose", "Retention: recently stopped box")
|
|
847
|
+
expired = create("--memory", "1GiB", "--purpose", "Retention: long stopped box")
|
|
848
|
+
unrecorded = create("--memory", "1GiB", "--purpose", "Retention: box stopped outside Raft")
|
|
849
|
+
live.raft("stop", kept)
|
|
850
|
+
live.raft("stop", expired)
|
|
851
|
+
# A native stop records no stop time; the worker starts that box's clock.
|
|
852
|
+
incus(location, "stop", parse_handle(unrecorded)[1], "--timeout", "30", locked=True)
|
|
853
|
+
|
|
854
|
+
def stopped_at(handle):
|
|
855
|
+
return incus(
|
|
856
|
+
location, "config", "get", parse_handle(handle)[1], "user.raft.stopped-at"
|
|
857
|
+
).strip()
|
|
858
|
+
|
|
859
|
+
def tick(done):
|
|
860
|
+
# The staged worker runs once; the installed one runs on its 15-second timer.
|
|
861
|
+
if worker:
|
|
862
|
+
transaction(location, 'exec python3 "$1"', worker)
|
|
863
|
+
deadline = time.monotonic() + 50
|
|
864
|
+
while not done():
|
|
865
|
+
if time.monotonic() > deadline:
|
|
866
|
+
raise RuntimeError("Host expiry worker did not apply the retention policy")
|
|
867
|
+
time.sleep(1)
|
|
868
|
+
|
|
869
|
+
def listed():
|
|
870
|
+
lines = live.raft("list", "--location", location).stdout.splitlines()
|
|
871
|
+
return {record["box"]: record for record in map(json.loads, lines)}
|
|
872
|
+
|
|
873
|
+
if worker:
|
|
874
|
+
# Older workers ignore retention, so the CLI refuses to set it for them.
|
|
875
|
+
rejected = live.raft("policy", location, "--delete-stopped-after", "1", check=False)
|
|
876
|
+
assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
|
|
877
|
+
incus(
|
|
878
|
+
location,
|
|
879
|
+
"project",
|
|
880
|
+
"set",
|
|
881
|
+
"raft",
|
|
882
|
+
f"{RETENTION_KEY}=1",
|
|
883
|
+
f"{RETENTION_SINCE_KEY}={int(time.time())}",
|
|
884
|
+
locked=True,
|
|
885
|
+
)
|
|
886
|
+
else:
|
|
887
|
+
changed = live.raft("policy", location, "--delete-stopped-after", "1")
|
|
888
|
+
assert changed.stdout == f"{location}: delete stopped boxes after never -> 1 hour\n"
|
|
889
|
+
try:
|
|
890
|
+
policy = json.loads(live.raft("config").stdout)["policy"][location]
|
|
891
|
+
assert policy["deleteStoppedAfterHours"] == 1, policy
|
|
892
|
+
since = policy["deleteStoppedAfterSince"]
|
|
893
|
+
assert started <= since <= time.time(), policy
|
|
894
|
+
# A box stopped before the policy gets the full hour from the change.
|
|
895
|
+
records = listed()
|
|
896
|
+
assert records[kept]["deleteAt"] == str(since + 3600), records[kept]
|
|
897
|
+
assert records[running]["deleteAt"] == "", records[running]
|
|
898
|
+
# Hours are the smallest unit, so backdate the policy and one stop by two
|
|
899
|
+
# hours instead of waiting: only that box is then past the retention.
|
|
900
|
+
backdated = int(time.time()) - 2 * 3600
|
|
901
|
+
incus(location, "project", "set", "raft", f"{RETENTION_SINCE_KEY}={backdated}", locked=True)
|
|
902
|
+
incus(
|
|
903
|
+
location,
|
|
904
|
+
"config",
|
|
905
|
+
"set",
|
|
906
|
+
parse_handle(expired)[1],
|
|
907
|
+
f"user.raft.stopped-at={backdated}",
|
|
908
|
+
locked=True,
|
|
909
|
+
)
|
|
910
|
+
tick(lambda: not exists(expired) and stopped_at(unrecorded))
|
|
911
|
+
assert exists(kept) and exists(running) and exists(unrecorded)
|
|
912
|
+
# The clock of a box stopped outside Raft starts when the worker sees it.
|
|
913
|
+
assert int(stopped_at(unrecorded)) >= started
|
|
914
|
+
assert listed()[unrecorded]["deleteAt"] == str(int(stopped_at(unrecorded)) + 3600)
|
|
915
|
+
deleted = f"Deleted box {parse_handle(expired)[1]}, stopped since "
|
|
916
|
+
command = ["sudo", "-n", "journalctl", "-t", "raft", "--since", f"@{started}", "-o", "cat"]
|
|
917
|
+
deadline = time.monotonic() + 10
|
|
918
|
+
while True:
|
|
919
|
+
journal = subprocess.check_output(["ssh", "-T", host, shlex.join(command)], text=True)
|
|
920
|
+
if deleted in journal or time.monotonic() > deadline:
|
|
921
|
+
break
|
|
922
|
+
time.sleep(1)
|
|
923
|
+
assert deleted in journal, journal
|
|
924
|
+
if not worker:
|
|
925
|
+
assert "Stopped-box retention: hours: '' -> '1'" in journal, journal
|
|
926
|
+
finally:
|
|
927
|
+
if worker:
|
|
928
|
+
incus(
|
|
929
|
+
location,
|
|
930
|
+
"project",
|
|
931
|
+
"set",
|
|
932
|
+
"raft",
|
|
933
|
+
f"{RETENTION_KEY}=",
|
|
934
|
+
f"{RETENTION_SINCE_KEY}=",
|
|
935
|
+
locked=True,
|
|
936
|
+
)
|
|
937
|
+
else:
|
|
938
|
+
cleared = live.raft("policy", location, "--delete-stopped-after", "never")
|
|
939
|
+
if not worker:
|
|
940
|
+
assert cleared.stdout == f"{location}: delete stopped boxes after 1 hour -> never\n"
|
|
941
|
+
policy = json.loads(live.raft("config").stdout)["policy"][location]
|
|
942
|
+
assert policy["deleteStoppedAfterHours"] is None, policy
|
|
943
|
+
assert listed()[kept]["deleteAt"] == ""
|
|
944
|
+
print(
|
|
945
|
+
f"{location}: stopped-box retention deleted only the expired box, "
|
|
946
|
+
"recorded missing stop times, logged and cleared",
|
|
947
|
+
flush=True,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
|
|
644
951
|
def verify(location, isolated_worker=False):
|
|
645
952
|
if inventory(location):
|
|
646
953
|
raise RuntimeError(
|
|
@@ -681,7 +988,7 @@ def verify(location, isolated_worker=False):
|
|
|
681
988
|
],
|
|
682
989
|
text=True,
|
|
683
990
|
).strip()
|
|
684
|
-
if protocol == "
|
|
991
|
+
if protocol == "2":
|
|
685
992
|
raise RuntimeError(
|
|
686
993
|
"--isolated-worker targets older deployments; use normal verification on a current host"
|
|
687
994
|
)
|
|
@@ -965,6 +1272,7 @@ def verify(location, isolated_worker=False):
|
|
|
965
1272
|
f"{location}: disposable deletion, preview/apply retention and exclusions passed",
|
|
966
1273
|
flush=True,
|
|
967
1274
|
)
|
|
1275
|
+
verify_retention(location, create, exists, worker)
|
|
968
1276
|
finally:
|
|
969
1277
|
for handle in reversed(fixtures):
|
|
970
1278
|
if exists(handle):
|
|
@@ -996,7 +1304,9 @@ if __name__ == "__main__":
|
|
|
996
1304
|
args = parser.parse_args()
|
|
997
1305
|
verify_location_notes()
|
|
998
1306
|
verify_controller_defaults()
|
|
1307
|
+
verify_retention_worker()
|
|
999
1308
|
verify_host_preflight()
|
|
1000
1309
|
verify_archives()
|
|
1001
1310
|
if args.location:
|
|
1311
|
+
verify_read_reports(args.location)
|
|
1002
1312
|
verify(args.location, args.isolated_worker)
|