@microck/raft 0.4.0 → 0.6.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 +30 -14
- package/deploy/deploy.py +112 -19
- package/deploy/install-incus.sh +10 -0
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +34 -48
- package/deploy/verify-features.py +592 -3
- package/deploy/verify-fresh-host.py +64 -1
- package/deploy/verify-limits.py +327 -72
- package/deploy/verify-live.py +56 -31
- 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 +33 -23
- package/docs/first-box.mdx +1 -1
- package/docs/guides/backup-recovery.mdx +2 -2
- package/docs/guides/config-policy.mdx +253 -0
- package/docs/guides/docker.mdx +9 -1
- package/docs/guides/long-running-services.mdx +10 -4
- package/docs/guides/meta.json +1 -0
- package/docs/guides/project-setup.mdx +4 -3
- package/docs/guides/scripting-ci.mdx +27 -10
- package/docs/guides/snapshots-forks.mdx +9 -3
- package/docs/guides/troubleshooting.mdx +37 -8
- package/docs/guides/upgrades.mdx +17 -5
- package/docs/index.mdx +1 -0
- package/docs/operations.mdx +16 -14
- package/docs/reference/capacity.mdx +198 -0
- package/docs/reference/cli.md +178 -35
- package/docs/reference/configuration.mdx +40 -2
- package/docs/reference/meta.json +1 -1
- package/docs/releases/0.5.0.md +19 -0
- package/docs/releases/0.6.0.md +16 -0
- package/docs/requirements.mdx +2 -2
- package/docs/verification.mdx +14 -8
- package/package.json +1 -1
- package/raft.py +1181 -128
- package/skills/raft-cli/SKILL.md +79 -13
- 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,13 @@ 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
|
+
RETENTION_KEY,
|
|
32
|
+
RETENTION_SINCE_KEY,
|
|
33
|
+
disk_origin,
|
|
26
34
|
host_problems,
|
|
27
35
|
incus,
|
|
28
36
|
inventory,
|
|
@@ -235,6 +243,394 @@ def verify_location_notes():
|
|
|
235
243
|
print("Location note editing, listing, ordering and permissions passed", flush=True)
|
|
236
244
|
|
|
237
245
|
|
|
246
|
+
def verify_controller_defaults():
|
|
247
|
+
"""Edit config.json in a private controller home and resolve new's options offline."""
|
|
248
|
+
with tempfile.TemporaryDirectory() as home:
|
|
249
|
+
directory = Path(home) / ".config/raft"
|
|
250
|
+
directory.mkdir(parents=True)
|
|
251
|
+
(directory / "incus.json").write_text(
|
|
252
|
+
json.dumps({"lab": {"ssh": "lab-host", "image": ""}, "spare": {"ssh": "spare-host"}})
|
|
253
|
+
)
|
|
254
|
+
config = directory / "config.json"
|
|
255
|
+
|
|
256
|
+
def cli(*arguments):
|
|
257
|
+
return subprocess.run(
|
|
258
|
+
[sys.executable, str(ROOT / "raft.py"), *arguments],
|
|
259
|
+
env={**os.environ, "HOME": home},
|
|
260
|
+
capture_output=True,
|
|
261
|
+
text=True,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def resolved(*arguments):
|
|
265
|
+
# The real parser and resolver, reading the real files under HOME.
|
|
266
|
+
program = (
|
|
267
|
+
"import json, sys; import raft; args = raft.parser().parse_args(sys.argv[1:]); "
|
|
268
|
+
"used = raft.apply_new_defaults(args); "
|
|
269
|
+
"print(json.dumps([args.location, args.ttl, args.cpu, args.memory, args.disk, "
|
|
270
|
+
"args.disposable, sorted(used)]))"
|
|
271
|
+
)
|
|
272
|
+
response = subprocess.run(
|
|
273
|
+
[sys.executable, "-c", program, *arguments],
|
|
274
|
+
cwd=ROOT,
|
|
275
|
+
env={**os.environ, "HOME": home},
|
|
276
|
+
capture_output=True,
|
|
277
|
+
text=True,
|
|
278
|
+
)
|
|
279
|
+
if response.returncode:
|
|
280
|
+
return response.stderr
|
|
281
|
+
return json.loads(response.stdout)
|
|
282
|
+
|
|
283
|
+
# No disk is the shared pool.
|
|
284
|
+
assert resolved("new") == ["lab", 600, 1, "2GiB", None, False, []]
|
|
285
|
+
for arguments, printed in [
|
|
286
|
+
(["set", "cpu", "2"], "defaults.cpu: built-in default -> 2"),
|
|
287
|
+
(["set", "memory", "4GiB"], "defaults.memory: built-in default -> 4GiB"),
|
|
288
|
+
(["set", "ttl", "3600"], "defaults.ttl: built-in default -> 3600"),
|
|
289
|
+
(["set", "location", "spare"], "defaults.location: built-in default -> spare"),
|
|
290
|
+
(["set", "disk", "20GiB"], "defaults.disk: built-in default -> 20GiB"),
|
|
291
|
+
(["set", "disposable", "true"], "defaults.disposable: built-in default -> true"),
|
|
292
|
+
(["set", "cpu", "1"], "defaults.cpu: 2 -> 1"),
|
|
293
|
+
]:
|
|
294
|
+
response = cli("config", *arguments)
|
|
295
|
+
assert response.returncode == 0 and response.stdout == printed + "\n", response
|
|
296
|
+
assert json.loads(config.read_text()) == {
|
|
297
|
+
"defaults": {
|
|
298
|
+
"cpu": 1,
|
|
299
|
+
"memory": "4GiB",
|
|
300
|
+
"ttl": 3600,
|
|
301
|
+
"location": "spare",
|
|
302
|
+
"disk": "20GiB",
|
|
303
|
+
"disposable": True,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
assert config.stat().st_mode & 0o777 == 0o600, "Default editing widened permissions"
|
|
307
|
+
# Explicit flags win over config.json, which wins over built-in defaults.
|
|
308
|
+
everything = ["cpu", "disk", "disposable", "location", "memory", "ttl"]
|
|
309
|
+
assert resolved("new") == ["spare", 3600, 1, "4GiB", "20GiB", True, everything]
|
|
310
|
+
assert resolved("new", "--location", "lab", "--ttl", "60", "--cpu", "2") == [
|
|
311
|
+
"lab",
|
|
312
|
+
60,
|
|
313
|
+
2,
|
|
314
|
+
"4GiB",
|
|
315
|
+
"20GiB",
|
|
316
|
+
True,
|
|
317
|
+
["disk", "disposable", "memory"],
|
|
318
|
+
]
|
|
319
|
+
# --disk shared beats a configured own disk, and --persistent a disposable default.
|
|
320
|
+
assert resolved("new", "--disk", "shared", "--persistent")[4:] == [
|
|
321
|
+
None,
|
|
322
|
+
False,
|
|
323
|
+
["cpu", "location", "memory", "ttl"],
|
|
324
|
+
]
|
|
325
|
+
assert resolved("new", "--disposable")[5:] == [
|
|
326
|
+
True,
|
|
327
|
+
["cpu", "disk", "location", "memory", "ttl"],
|
|
328
|
+
]
|
|
329
|
+
rejected = cli("new", "--disposable", "--persistent")
|
|
330
|
+
assert rejected.returncode == 2 and "not allowed with" in rejected.stderr, rejected
|
|
331
|
+
# A box from a snapshot keeps the snapshot's location, CPU, RAM and disk:
|
|
332
|
+
# defaults, configured or built-in, never override them; flags still do.
|
|
333
|
+
# A snapshot has no lifecycle, so the TTL and disposable defaults apply.
|
|
334
|
+
template = "lab:rf-0123456789abcdef/snap"
|
|
335
|
+
assert resolved("new", "--from", template) == [
|
|
336
|
+
None,
|
|
337
|
+
3600,
|
|
338
|
+
None,
|
|
339
|
+
None,
|
|
340
|
+
None,
|
|
341
|
+
True,
|
|
342
|
+
["disposable", "ttl"],
|
|
343
|
+
]
|
|
344
|
+
assert resolved("new", "--from", template, "--persistent")[5] is False
|
|
345
|
+
assert resolved("new", "--from", template, "--cpu", "4", "--disk", "30GiB")[2:5] == [
|
|
346
|
+
4,
|
|
347
|
+
None,
|
|
348
|
+
"30GiB",
|
|
349
|
+
]
|
|
350
|
+
assert "--from keeps the snapshot's disk" in resolved(
|
|
351
|
+
"new", "--from", template, "--disk", "shared"
|
|
352
|
+
)
|
|
353
|
+
# fork has no defaults at all: omitted sizes stay the source's, and a fork
|
|
354
|
+
# is persistent without --disposable even under a disposable default.
|
|
355
|
+
forked = subprocess.check_output(
|
|
356
|
+
[
|
|
357
|
+
sys.executable,
|
|
358
|
+
"-c",
|
|
359
|
+
"import raft; args = raft.parser().parse_args(['fork', 'lab:rf-0123456789abcdef', "
|
|
360
|
+
"'--ttl', '600']); print(raft.sizing(args), hasattr(args, 'disk'), args.disposable)",
|
|
361
|
+
],
|
|
362
|
+
cwd=ROOT,
|
|
363
|
+
env={**os.environ, "HOME": home},
|
|
364
|
+
text=True,
|
|
365
|
+
)
|
|
366
|
+
assert forked == "[] False False\n", forked
|
|
367
|
+
for arguments, error in [
|
|
368
|
+
(["set", "cpu", "0"], "defaults.cpu='0' is invalid"),
|
|
369
|
+
(["set", "memory", "3GB"], "defaults.memory='3GB' is invalid"),
|
|
370
|
+
(["set", "disk", "20"], "defaults.disk='20' is invalid"),
|
|
371
|
+
(["set", "ttl", "59"], "TTL must be 60 seconds to 30 days"),
|
|
372
|
+
(
|
|
373
|
+
["set", "disposable", "yes"],
|
|
374
|
+
"defaults.disposable='yes' is invalid: Use true or false",
|
|
375
|
+
),
|
|
376
|
+
(["set", "disposable", "True"], "defaults.disposable='True' is invalid"),
|
|
377
|
+
(["set", "location", "missing"], "Unknown location"),
|
|
378
|
+
(["set", "location", "bad name"], "Location names"),
|
|
379
|
+
]:
|
|
380
|
+
rejected = cli("config", *arguments)
|
|
381
|
+
assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
|
|
382
|
+
assert str(config) in rejected.stderr or "location" in arguments, rejected
|
|
383
|
+
assert cli("config", "set", "colour", "red").returncode == 2
|
|
384
|
+
saved = config.read_text()
|
|
385
|
+
# A broken file fails new before any host is contacted, naming the file and key.
|
|
386
|
+
for document, error in [
|
|
387
|
+
({"defaults": {"cpu": 0}}, "defaults.cpu=0 is invalid"),
|
|
388
|
+
({"defaults": {"cpu": True}}, "defaults.cpu=True is invalid"),
|
|
389
|
+
({"defaults": {"disk": 20}}, "defaults.disk=20 is invalid"),
|
|
390
|
+
({"defaults": {"disposable": 1}}, "defaults.disposable=1 is invalid"),
|
|
391
|
+
({"defaults": {"colour": "red"}}, "unknown key defaults.colour"),
|
|
392
|
+
({"defaults": []}, '{"defaults"'),
|
|
393
|
+
({"lab": {"ssh": "x"}}, '{"defaults"'),
|
|
394
|
+
({"defaults": {"location": "gone"}}, "unknown location gone"),
|
|
395
|
+
]:
|
|
396
|
+
config.write_text(json.dumps(document))
|
|
397
|
+
rejected = cli("new", "--purpose", "x")
|
|
398
|
+
assert rejected.returncode == 1 and str(config) in rejected.stderr, rejected
|
|
399
|
+
assert error in rejected.stderr and "Traceback" not in rejected.stderr, rejected
|
|
400
|
+
config.write_text("{not json")
|
|
401
|
+
rejected = cli("new")
|
|
402
|
+
assert rejected.returncode == 1 and "invalid JSON" in rejected.stderr, rejected
|
|
403
|
+
# The broken key can be repaired through the CLI.
|
|
404
|
+
config.write_text(json.dumps({"defaults": {"cpu": 0, "ttl": 900}}))
|
|
405
|
+
assert cli("config", "unset", "cpu").stdout == "defaults.cpu: 0 -> built-in default\n"
|
|
406
|
+
assert json.loads(config.read_text()) == {"defaults": {"ttl": 900}}
|
|
407
|
+
config.write_text(saved)
|
|
408
|
+
# Concurrent edits to different keys must all survive.
|
|
409
|
+
writers = [
|
|
410
|
+
subprocess.Popen(
|
|
411
|
+
[sys.executable, str(ROOT / "raft.py"), "config", "set", key, value],
|
|
412
|
+
env={**os.environ, "HOME": home},
|
|
413
|
+
stdout=subprocess.DEVNULL,
|
|
414
|
+
)
|
|
415
|
+
for key, value in [
|
|
416
|
+
("cpu", "2"),
|
|
417
|
+
("memory", "8GiB"),
|
|
418
|
+
("ttl", "120"),
|
|
419
|
+
("location", "lab"),
|
|
420
|
+
("disk", "shared"),
|
|
421
|
+
("disposable", "false"),
|
|
422
|
+
]
|
|
423
|
+
]
|
|
424
|
+
assert all(writer.wait() == 0 for writer in writers)
|
|
425
|
+
assert json.loads(config.read_text())["defaults"] == {
|
|
426
|
+
"cpu": 2,
|
|
427
|
+
"memory": "8GiB",
|
|
428
|
+
"ttl": 120,
|
|
429
|
+
"location": "lab",
|
|
430
|
+
"disk": "shared",
|
|
431
|
+
"disposable": False,
|
|
432
|
+
}
|
|
433
|
+
# JSON booleans read back as the CLI's true and false.
|
|
434
|
+
assert cli("config", "set", "disposable", "true").stdout == (
|
|
435
|
+
"defaults.disposable: false -> true\n"
|
|
436
|
+
)
|
|
437
|
+
# raft config reports it with its source; the report's host half needs SSH.
|
|
438
|
+
effective = subprocess.check_output(
|
|
439
|
+
[
|
|
440
|
+
sys.executable,
|
|
441
|
+
"-c",
|
|
442
|
+
"import json, raft; print(json.dumps(raft.effective_defaults()['disposable']))",
|
|
443
|
+
],
|
|
444
|
+
cwd=ROOT,
|
|
445
|
+
env={**os.environ, "HOME": home},
|
|
446
|
+
text=True,
|
|
447
|
+
)
|
|
448
|
+
assert json.loads(effective) == {"value": True, "source": "config"}, effective
|
|
449
|
+
for key in ["cpu", "memory", "ttl", "location", "disk", "disposable"]:
|
|
450
|
+
assert cli("config", "unset", key).returncode == 0
|
|
451
|
+
assert not config.exists(), "Removing every default left an empty file"
|
|
452
|
+
assert not list(directory.glob(".raft-*")), "Staged configuration left behind"
|
|
453
|
+
# Policy and note flag mistakes are rejected before any host is contacted.
|
|
454
|
+
handle = "lab:rf-0123456789abcdef"
|
|
455
|
+
for arguments, error in [
|
|
456
|
+
(["policy", "lab", "--require", "owner"], "always need purpose"),
|
|
457
|
+
(["policy", "lab", "--require", "purpose,colour"], "Give note fields from"),
|
|
458
|
+
(["policy", "lab", "--require", ","], "Give note fields from"),
|
|
459
|
+
(["policy", "missing", "--require", "none"], "Unknown location"),
|
|
460
|
+
(["policy", "lab"], "Give --require FIELDS|none, --delete-stopped-after"),
|
|
461
|
+
(["policy", "lab", "--delete-stopped-after", "0"], "positive whole number of hours"),
|
|
462
|
+
(["policy", "lab", "--delete-stopped-after", "48h"], "positive whole number of hours"),
|
|
463
|
+
# Both options are parsed before either changes the host.
|
|
464
|
+
(
|
|
465
|
+
["policy", "lab", "--require", "none", "--delete-stopped-after", "-1"],
|
|
466
|
+
"positive whole number of hours",
|
|
467
|
+
),
|
|
468
|
+
(["fork", handle, "--ttl", "600", "--owner", "agent"], "need --purpose"),
|
|
469
|
+
(["recover", "archive", "--location", "lab", "--next", "x"], "need --purpose"),
|
|
470
|
+
]:
|
|
471
|
+
rejected = cli(*arguments)
|
|
472
|
+
assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
|
|
473
|
+
assert cli("policy", "lab", "--clear").returncode == 2
|
|
474
|
+
# The controller message names the missing flags and a complete command.
|
|
475
|
+
program = (
|
|
476
|
+
"import raft; print(raft.note_problem('lab', ['purpose', 'owner'], "
|
|
477
|
+
"{'purpose': 'x'}, 'new')); print(raft.note_problem('lab', ['purpose'], "
|
|
478
|
+
"{'purpose': 'x'}, 'new'))"
|
|
479
|
+
)
|
|
480
|
+
messages = subprocess.check_output([sys.executable, "-c", program], cwd=ROOT, text=True)
|
|
481
|
+
assert messages == (
|
|
482
|
+
'location lab requires --owner (host policy). Add it: raft new --purpose "..." '
|
|
483
|
+
'--owner "..." [--next "..."]\nNone\n'
|
|
484
|
+
), messages
|
|
485
|
+
# Run the exact host-side check locally; it needs only python3.
|
|
486
|
+
for required, note, accepted in [
|
|
487
|
+
("", "", True),
|
|
488
|
+
("purpose", json.dumps({"purpose": "x"}), True),
|
|
489
|
+
("purpose,owner", json.dumps({"purpose": "x", "owner": "y"}), True),
|
|
490
|
+
("purpose,owner", json.dumps({"purpose": "x"}), False),
|
|
491
|
+
("purpose", "", False),
|
|
492
|
+
]:
|
|
493
|
+
check = subprocess.run(
|
|
494
|
+
[sys.executable, "-I", "-c", CHECK_NOTE_CODE, required, note],
|
|
495
|
+
capture_output=True,
|
|
496
|
+
text=True,
|
|
497
|
+
)
|
|
498
|
+
assert (check.returncode == 0) == accepted, (required, note, check)
|
|
499
|
+
if not accepted:
|
|
500
|
+
assert "Nothing was changed" in check.stderr, check
|
|
501
|
+
# A configured disk on a host without own box disks gets the same refusal as
|
|
502
|
+
# --disk, plus where the size came from. Run the exact host check against a
|
|
503
|
+
# fake incus that reports no storage pools besides the shared one.
|
|
504
|
+
with tempfile.TemporaryDirectory() as bin_directory:
|
|
505
|
+
fake = Path(bin_directory) / "incus"
|
|
506
|
+
fake.write_text("#!/bin/sh\necho '[\"/1.0/storage-pools/raft-data\"]'\n")
|
|
507
|
+
fake.chmod(0o755)
|
|
508
|
+
refusals = [
|
|
509
|
+
subprocess.run(
|
|
510
|
+
[sys.executable, "-I", "-c", RESERVE_BOX_DISK, "lab", "20", "image:abc", origin],
|
|
511
|
+
env={**os.environ, "PATH": f"{bin_directory}:{os.environ['PATH']}"},
|
|
512
|
+
capture_output=True,
|
|
513
|
+
text=True,
|
|
514
|
+
)
|
|
515
|
+
for origin in ["", disk_origin("20GiB")]
|
|
516
|
+
]
|
|
517
|
+
flag, configured = (refusal.stderr.strip() for refusal in refusals)
|
|
518
|
+
assert all(refusal.returncode == 1 for refusal in refusals), refusals
|
|
519
|
+
assert flag.startswith("lab has no raft-disks pool") and "--disk-pool SIZE" in flag, flag
|
|
520
|
+
assert configured == flag + ". " + disk_origin("20GiB"), configured
|
|
521
|
+
assert "defaults.disk in " in configured and "raft config unset disk" in configured
|
|
522
|
+
print("Controller defaults, precedence, validation and note policy messages passed", flush=True)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# Stands in for incus and logger on PATH for the real host worker. It answers the
|
|
526
|
+
# worker's two reads from the state file and appends every call to the log file.
|
|
527
|
+
FAKE_HOST_TOOL = """import json, os, sys
|
|
528
|
+
from pathlib import Path
|
|
529
|
+
arguments = [Path(sys.argv[0]).name, *sys.argv[1:]]
|
|
530
|
+
with open(os.environ["RAFT_FAKE_LOG"], "a") as log:
|
|
531
|
+
log.write(json.dumps(arguments) + "\\n")
|
|
532
|
+
state = json.loads(Path(os.environ["RAFT_FAKE_STATE"]).read_text())
|
|
533
|
+
if arguments[1:] == ["--project", "raft", "list", "--fast", "--format", "json"]:
|
|
534
|
+
print(json.dumps(state["instances"]))
|
|
535
|
+
elif arguments[1:] == ["--project", "raft", "project", "list", "--format", "json"]:
|
|
536
|
+
print(json.dumps([{"name": "default", "config": {}}, {"name": "raft", "config": state["project"]}]))
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def verify_retention_worker():
|
|
541
|
+
"""Run the real host worker against fake incus fixtures and check what it changes."""
|
|
542
|
+
now = int(time.time())
|
|
543
|
+
|
|
544
|
+
def box(name, status, stopped_at=None, raft="true"):
|
|
545
|
+
config = {"user.raft": raft}
|
|
546
|
+
if stopped_at is not None:
|
|
547
|
+
config["user.raft.stopped-at"] = str(stopped_at)
|
|
548
|
+
return {"name": name, "status": status, "config": config}
|
|
549
|
+
|
|
550
|
+
instances = [
|
|
551
|
+
# Running boxes are never deleted; an old stop time left behind is cleared.
|
|
552
|
+
box("rf-running", "Running", now - 30 * 86400),
|
|
553
|
+
box("rf-old", "Stopped", now - 3 * 3600),
|
|
554
|
+
box("rf-recent", "Stopped", now - 3600),
|
|
555
|
+
box("rf-unrecorded", "Stopped"),
|
|
556
|
+
box("unrelated", "Stopped", now - 30 * 86400, raft="false"),
|
|
557
|
+
]
|
|
558
|
+
|
|
559
|
+
def run_worker(project, *arguments):
|
|
560
|
+
with tempfile.TemporaryDirectory() as work:
|
|
561
|
+
root = Path(work)
|
|
562
|
+
for tool in ["incus", "logger"]:
|
|
563
|
+
(root / tool).write_text(f"#!{sys.executable}\n" + FAKE_HOST_TOOL)
|
|
564
|
+
(root / tool).chmod(0o755)
|
|
565
|
+
(root / "state.json").write_text(
|
|
566
|
+
json.dumps({"instances": instances, "project": project})
|
|
567
|
+
)
|
|
568
|
+
started = int(time.time())
|
|
569
|
+
response = subprocess.run(
|
|
570
|
+
[sys.executable, "-I", str(ROOT / "deploy/raft-expire.py"), *arguments],
|
|
571
|
+
env={
|
|
572
|
+
**os.environ,
|
|
573
|
+
"PATH": f"{root}:{os.environ['PATH']}",
|
|
574
|
+
"RAFT_FAKE_STATE": str(root / "state.json"),
|
|
575
|
+
"RAFT_FAKE_LOG": str(root / "calls.jsonl"),
|
|
576
|
+
},
|
|
577
|
+
capture_output=True,
|
|
578
|
+
text=True,
|
|
579
|
+
)
|
|
580
|
+
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
|
|
581
|
+
# Leave out the two reads and the stale stop time cleared on the running box;
|
|
582
|
+
# normalize the recorded stop time to "now".
|
|
583
|
+
clear = ["incus", "--project", "raft", "config", "unset", "rf-running"]
|
|
584
|
+
cleared.append(clear + ["user.raft.stopped-at"] in calls)
|
|
585
|
+
changes = []
|
|
586
|
+
for call in calls:
|
|
587
|
+
if call[-2:] == ["--format", "json"] or call == clear + ["user.raft.stopped-at"]:
|
|
588
|
+
continue
|
|
589
|
+
if call[-1].startswith("user.raft.stopped-at="):
|
|
590
|
+
assert started <= int(call[-1].partition("=")[2]) <= int(time.time()), call
|
|
591
|
+
call[-1] = "user.raft.stopped-at=now"
|
|
592
|
+
changes.append(call)
|
|
593
|
+
return response, changes
|
|
594
|
+
|
|
595
|
+
cleared = []
|
|
596
|
+
record = ["incus", "--project", "raft", "config", "set", "rf-unrecorded"]
|
|
597
|
+
record.append("user.raft.stopped-at=now")
|
|
598
|
+
two_hours = {"user.raft.delete-stopped-after": "2", "user.raft.delete-stopped-after.since": ""}
|
|
599
|
+
# A 2-hour policy set long ago deletes the box stopped 3 hours ago and logs it.
|
|
600
|
+
response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": "0"})
|
|
601
|
+
assert response.returncode == 0, response
|
|
602
|
+
assert changes[0] == ["incus", "--project", "raft", "delete", "rf-old"], changes
|
|
603
|
+
assert changes[1][:3] == ["logger", "-t", "raft"] and "rf-old" in changes[1][3], changes
|
|
604
|
+
assert "under the 2-hour retention" in changes[1][3] and changes[2:] == [record], changes
|
|
605
|
+
assert cleared[-1], "the worker kept a stale stop time on a running box"
|
|
606
|
+
assert changes[1][3] in response.stdout, response
|
|
607
|
+
# Set an hour ago, the policy's clock starts then: nothing is deleted yet.
|
|
608
|
+
since = str(now - 3600)
|
|
609
|
+
response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after.since": since})
|
|
610
|
+
assert response.returncode == 0 and changes == [record], (response, changes)
|
|
611
|
+
# Without a policy nothing is deleted, but stop times are still recorded.
|
|
612
|
+
response, changes = run_worker({})
|
|
613
|
+
assert response.returncode == 0 and changes == [record], (response, changes)
|
|
614
|
+
# Prune mode only selects; it records and retains nothing else.
|
|
615
|
+
response, changes = run_worker(
|
|
616
|
+
{**two_hours, "user.raft.delete-stopped-after.since": "0"}, "--prune-days", "1"
|
|
617
|
+
)
|
|
618
|
+
assert response.returncode == 0 and changes == [], (response, changes)
|
|
619
|
+
# An invalid policy deletes nothing, still enforces deadlines, and fails the run.
|
|
620
|
+
instances.append(
|
|
621
|
+
{
|
|
622
|
+
"name": "rf-expired",
|
|
623
|
+
"status": "Running",
|
|
624
|
+
"config": {"user.raft": "true", "user.raft.expires": "1"},
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
response, changes = run_worker({**two_hours, "user.raft.delete-stopped-after": "48h"})
|
|
628
|
+
assert response.returncode == 1 and "Invalid retention" in response.stderr, response
|
|
629
|
+
assert ["incus", "--project", "raft", "stop", "rf-expired", "--timeout", "30"] in changes
|
|
630
|
+
assert not any("delete" in call for call in changes), changes
|
|
631
|
+
print("Host worker retention, stop-time recording and grace period passed", flush=True)
|
|
632
|
+
|
|
633
|
+
|
|
238
634
|
def verify_host_preflight():
|
|
239
635
|
"""Check onboarding decisions and refusals that need no host."""
|
|
240
636
|
ready = {
|
|
@@ -253,6 +649,10 @@ def verify_host_preflight():
|
|
|
253
649
|
assert host_problems(ready) == []
|
|
254
650
|
# An existing deployment already owns its pool, so free disk no longer gates it.
|
|
255
651
|
assert host_problems({**ready, "disk": "0", "deployed": "yes"}) == []
|
|
652
|
+
# Own box disks are opt-in; enabling them adds the pool size to the minimum.
|
|
653
|
+
assert host_problems({**ready, "disk": str(95 * 1024**2)}, "10GiB") == []
|
|
654
|
+
problems = host_problems(ready, "40GiB")
|
|
655
|
+
assert len(problems) == 1 and "120 GiB free disk" in problems[0], problems
|
|
256
656
|
darwin = host_problems({"kernel": "Darwin", "sudo": "no"})
|
|
257
657
|
assert len(darwin) == 1 and "macOS" in darwin[0], darwin
|
|
258
658
|
for change, reason in [
|
|
@@ -343,6 +743,179 @@ with tempfile.TemporaryFile() as output:
|
|
|
343
743
|
print(f"{location}: open stdin ignored and lifecycle lock released", flush=True)
|
|
344
744
|
|
|
345
745
|
|
|
746
|
+
def verify_note_policy(location, create, listed_note, fixtures):
|
|
747
|
+
"""Require note fields on the host, prove every note path honors it, then clear it."""
|
|
748
|
+
source = create("--memory", "1GiB", "--purpose", "Policy fork source")
|
|
749
|
+
live.raft("stop", source)
|
|
750
|
+
changed = live.raft("policy", location, "--require", "purpose,owner")
|
|
751
|
+
try:
|
|
752
|
+
assert changed.stdout == f"{location}: required note fields none -> purpose,owner\n"
|
|
753
|
+
report = json.loads(live.raft("config").stdout)
|
|
754
|
+
assert report["policy"][location]["require"] == ["purpose", "owner"], report
|
|
755
|
+
assert " by " in report["policy"][location]["requireChanged"], report
|
|
756
|
+
saved = len(inventory(location))
|
|
757
|
+
for arguments, error in [
|
|
758
|
+
(["new", "--location", location, "--purpose", "x"], "requires --owner (host policy)"),
|
|
759
|
+
(["new", "--location", location], "requires --purpose and --owner (host policy)"),
|
|
760
|
+
(["fork", source, "--ttl", "600"], "Forks keep the source note"),
|
|
761
|
+
(["fork", source, "--ttl", "600", "--purpose", "x"], "requires --owner"),
|
|
762
|
+
(["note", source, "--clear"], "Clearing would leave the box without them"),
|
|
763
|
+
(["note", source, "--purpose", "x", "--next", "y"], "requires --owner"),
|
|
764
|
+
# Rejected before the archive is read or uploaded.
|
|
765
|
+
(["recover", "missing.tar.gz", "--location", location, "--purpose", "x"], "--owner"),
|
|
766
|
+
]:
|
|
767
|
+
rejected = live.raft(*arguments, check=False)
|
|
768
|
+
assert rejected.returncode == 1 and error in rejected.stderr, (arguments, rejected)
|
|
769
|
+
assert "Add it: raft " in rejected.stderr or "Add them: raft " in rejected.stderr
|
|
770
|
+
assert len(inventory(location)) == saved, "A rejected note created a box"
|
|
771
|
+
assert listed_note(source) == {"purpose": "Policy fork source"}
|
|
772
|
+
# The host repeats the check under its lock, so a controller that skips the
|
|
773
|
+
# precheck still cannot write a note that lacks a required field.
|
|
774
|
+
try:
|
|
775
|
+
transaction(
|
|
776
|
+
location,
|
|
777
|
+
'note="$1"\n' + READ_NOTE_POLICY + CHECK_NOTE,
|
|
778
|
+
json.dumps({"purpose": "x"}),
|
|
779
|
+
)
|
|
780
|
+
except RuntimeError as error:
|
|
781
|
+
assert "requires box note fields purpose, owner" in str(error), error
|
|
782
|
+
else:
|
|
783
|
+
raise AssertionError("Host accepted a note without a required field")
|
|
784
|
+
owned = create("--memory", "1GiB", "--purpose", "Policy", "--owner", "verify")
|
|
785
|
+
assert listed_note(owned) == {"purpose": "Policy", "owner": "verify"}
|
|
786
|
+
live.raft("destroy", owned)
|
|
787
|
+
fixtures.remove(owned)
|
|
788
|
+
# Note flags replace the source note on the fork.
|
|
789
|
+
fork = live.raft(
|
|
790
|
+
"fork", source, "--ttl", "600", "--purpose", "Policy fork", "--owner", "verify"
|
|
791
|
+
).stdout.strip()
|
|
792
|
+
fixtures.append(fork)
|
|
793
|
+
assert listed_note(fork) == {"purpose": "Policy fork", "owner": "verify"}
|
|
794
|
+
live.raft("destroy", fork)
|
|
795
|
+
fixtures.remove(fork)
|
|
796
|
+
live.raft("note", source, "--purpose", "Policy fork source", "--owner", "verify")
|
|
797
|
+
assert listed_note(source) == {"purpose": "Policy fork source", "owner": "verify"}
|
|
798
|
+
finally:
|
|
799
|
+
cleared = live.raft("policy", location, "--require", "none")
|
|
800
|
+
assert cleared.stdout == f"{location}: required note fields purpose,owner -> none\n"
|
|
801
|
+
assert json.loads(live.raft("config").stdout)["policy"][location]["require"] == []
|
|
802
|
+
live.raft("note", source, "--clear")
|
|
803
|
+
live.raft("destroy", source)
|
|
804
|
+
fixtures.remove(source)
|
|
805
|
+
print(f"{location}: host note policy for new, fork, note and recover passed", flush=True)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def verify_retention(location, create, exists, worker):
|
|
809
|
+
"""Set a 1-hour stopped-box retention, prove the worker deletes only a box stopped
|
|
810
|
+
past it, records stop times Raft missed and logs to the journal, then clear it."""
|
|
811
|
+
host = settings(location)["ssh"]
|
|
812
|
+
started = int(time.time())
|
|
813
|
+
running = create("--memory", "1GiB", "--purpose", "Retention: running box")
|
|
814
|
+
kept = create("--memory", "1GiB", "--purpose", "Retention: recently stopped box")
|
|
815
|
+
expired = create("--memory", "1GiB", "--purpose", "Retention: long stopped box")
|
|
816
|
+
unrecorded = create("--memory", "1GiB", "--purpose", "Retention: box stopped outside Raft")
|
|
817
|
+
live.raft("stop", kept)
|
|
818
|
+
live.raft("stop", expired)
|
|
819
|
+
# A native stop records no stop time; the worker starts that box's clock.
|
|
820
|
+
incus(location, "stop", parse_handle(unrecorded)[1], "--timeout", "30", locked=True)
|
|
821
|
+
|
|
822
|
+
def stopped_at(handle):
|
|
823
|
+
return incus(
|
|
824
|
+
location, "config", "get", parse_handle(handle)[1], "user.raft.stopped-at"
|
|
825
|
+
).strip()
|
|
826
|
+
|
|
827
|
+
def tick(done):
|
|
828
|
+
# The staged worker runs once; the installed one runs on its 15-second timer.
|
|
829
|
+
if worker:
|
|
830
|
+
transaction(location, 'exec python3 "$1"', worker)
|
|
831
|
+
deadline = time.monotonic() + 50
|
|
832
|
+
while not done():
|
|
833
|
+
if time.monotonic() > deadline:
|
|
834
|
+
raise RuntimeError("Host expiry worker did not apply the retention policy")
|
|
835
|
+
time.sleep(1)
|
|
836
|
+
|
|
837
|
+
def listed():
|
|
838
|
+
lines = live.raft("list", "--location", location).stdout.splitlines()
|
|
839
|
+
return {record["box"]: record for record in map(json.loads, lines)}
|
|
840
|
+
|
|
841
|
+
if worker:
|
|
842
|
+
# Older workers ignore retention, so the CLI refuses to set it for them.
|
|
843
|
+
rejected = live.raft("policy", location, "--delete-stopped-after", "1", check=False)
|
|
844
|
+
assert rejected.returncode and "deploy the current Raft checkout" in rejected.stderr
|
|
845
|
+
incus(
|
|
846
|
+
location,
|
|
847
|
+
"project",
|
|
848
|
+
"set",
|
|
849
|
+
"raft",
|
|
850
|
+
f"{RETENTION_KEY}=1",
|
|
851
|
+
f"{RETENTION_SINCE_KEY}={int(time.time())}",
|
|
852
|
+
locked=True,
|
|
853
|
+
)
|
|
854
|
+
else:
|
|
855
|
+
changed = live.raft("policy", location, "--delete-stopped-after", "1")
|
|
856
|
+
assert changed.stdout == f"{location}: delete stopped boxes after never -> 1 hour\n"
|
|
857
|
+
try:
|
|
858
|
+
policy = json.loads(live.raft("config").stdout)["policy"][location]
|
|
859
|
+
assert policy["deleteStoppedAfterHours"] == 1, policy
|
|
860
|
+
since = policy["deleteStoppedAfterSince"]
|
|
861
|
+
assert started <= since <= time.time(), policy
|
|
862
|
+
# A box stopped before the policy gets the full hour from the change.
|
|
863
|
+
records = listed()
|
|
864
|
+
assert records[kept]["deleteAt"] == str(since + 3600), records[kept]
|
|
865
|
+
assert records[running]["deleteAt"] == "", records[running]
|
|
866
|
+
# Hours are the smallest unit, so backdate the policy and one stop by two
|
|
867
|
+
# hours instead of waiting: only that box is then past the retention.
|
|
868
|
+
backdated = int(time.time()) - 2 * 3600
|
|
869
|
+
incus(location, "project", "set", "raft", f"{RETENTION_SINCE_KEY}={backdated}", locked=True)
|
|
870
|
+
incus(
|
|
871
|
+
location,
|
|
872
|
+
"config",
|
|
873
|
+
"set",
|
|
874
|
+
parse_handle(expired)[1],
|
|
875
|
+
f"user.raft.stopped-at={backdated}",
|
|
876
|
+
locked=True,
|
|
877
|
+
)
|
|
878
|
+
tick(lambda: not exists(expired) and stopped_at(unrecorded))
|
|
879
|
+
assert exists(kept) and exists(running) and exists(unrecorded)
|
|
880
|
+
# The clock of a box stopped outside Raft starts when the worker sees it.
|
|
881
|
+
assert int(stopped_at(unrecorded)) >= started
|
|
882
|
+
assert listed()[unrecorded]["deleteAt"] == str(int(stopped_at(unrecorded)) + 3600)
|
|
883
|
+
deleted = f"Deleted box {parse_handle(expired)[1]}, stopped since "
|
|
884
|
+
command = ["sudo", "-n", "journalctl", "-t", "raft", "--since", f"@{started}", "-o", "cat"]
|
|
885
|
+
deadline = time.monotonic() + 10
|
|
886
|
+
while True:
|
|
887
|
+
journal = subprocess.check_output(["ssh", "-T", host, shlex.join(command)], text=True)
|
|
888
|
+
if deleted in journal or time.monotonic() > deadline:
|
|
889
|
+
break
|
|
890
|
+
time.sleep(1)
|
|
891
|
+
assert deleted in journal, journal
|
|
892
|
+
if not worker:
|
|
893
|
+
assert "Stopped-box retention: hours: '' -> '1'" in journal, journal
|
|
894
|
+
finally:
|
|
895
|
+
if worker:
|
|
896
|
+
incus(
|
|
897
|
+
location,
|
|
898
|
+
"project",
|
|
899
|
+
"set",
|
|
900
|
+
"raft",
|
|
901
|
+
f"{RETENTION_KEY}=",
|
|
902
|
+
f"{RETENTION_SINCE_KEY}=",
|
|
903
|
+
locked=True,
|
|
904
|
+
)
|
|
905
|
+
else:
|
|
906
|
+
cleared = live.raft("policy", location, "--delete-stopped-after", "never")
|
|
907
|
+
if not worker:
|
|
908
|
+
assert cleared.stdout == f"{location}: delete stopped boxes after 1 hour -> never\n"
|
|
909
|
+
policy = json.loads(live.raft("config").stdout)["policy"][location]
|
|
910
|
+
assert policy["deleteStoppedAfterHours"] is None, policy
|
|
911
|
+
assert listed()[kept]["deleteAt"] == ""
|
|
912
|
+
print(
|
|
913
|
+
f"{location}: stopped-box retention deleted only the expired box, "
|
|
914
|
+
"recorded missing stop times, logged and cleared",
|
|
915
|
+
flush=True,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
346
919
|
def verify(location, isolated_worker=False):
|
|
347
920
|
if inventory(location):
|
|
348
921
|
raise RuntimeError(
|
|
@@ -383,7 +956,7 @@ def verify(location, isolated_worker=False):
|
|
|
383
956
|
],
|
|
384
957
|
text=True,
|
|
385
958
|
).strip()
|
|
386
|
-
if protocol == "
|
|
959
|
+
if protocol == "2":
|
|
387
960
|
raise RuntimeError(
|
|
388
961
|
"--isolated-worker targets older deployments; use normal verification on a current host"
|
|
389
962
|
)
|
|
@@ -409,8 +982,18 @@ def verify(location, isolated_worker=False):
|
|
|
409
982
|
return live.raft(*command).stdout
|
|
410
983
|
|
|
411
984
|
def create(*arguments):
|
|
985
|
+
# Pin the sizes a controller's config.json could otherwise default.
|
|
412
986
|
handle = live.raft(
|
|
413
|
-
"new",
|
|
987
|
+
"new",
|
|
988
|
+
"--location",
|
|
989
|
+
location,
|
|
990
|
+
"--ttl",
|
|
991
|
+
"1800",
|
|
992
|
+
"--cpu",
|
|
993
|
+
"1",
|
|
994
|
+
"--disk",
|
|
995
|
+
"shared",
|
|
996
|
+
*arguments,
|
|
414
997
|
).stdout.strip()
|
|
415
998
|
fixtures.append(handle)
|
|
416
999
|
return handle
|
|
@@ -464,6 +1047,7 @@ def verify(location, isolated_worker=False):
|
|
|
464
1047
|
print(
|
|
465
1048
|
f"{location}: box note template, listing, replacement and clearing passed", flush=True
|
|
466
1049
|
)
|
|
1050
|
+
verify_note_policy(location, create, listed_note, fixtures)
|
|
467
1051
|
with tempfile.TemporaryDirectory(prefix="raft-tree-test-") as work:
|
|
468
1052
|
root = Path(work)
|
|
469
1053
|
source = root / "source with spaces"
|
|
@@ -555,7 +1139,9 @@ def verify(location, isolated_worker=False):
|
|
|
555
1139
|
)
|
|
556
1140
|
assert metadata(template)["config"]["user.raft.disposable"] == "false"
|
|
557
1141
|
assert listed_note(template) is None, "new --from inherited the source note"
|
|
1142
|
+
# A template keeps the snapshot's sizes, not the box's later ones or the defaults.
|
|
558
1143
|
assert metadata(template)["expanded_config"]["limits.cpu"] == "1"
|
|
1144
|
+
assert metadata(template)["expanded_config"]["limits.memory"] == "1GiB"
|
|
559
1145
|
live.raft("snapshot-delete", box, "prepared")
|
|
560
1146
|
assert live.execute(template, "cat", "/workspace/proof") == "original"
|
|
561
1147
|
live.raft("destroy", template)
|
|
@@ -654,6 +1240,7 @@ def verify(location, isolated_worker=False):
|
|
|
654
1240
|
f"{location}: disposable deletion, preview/apply retention and exclusions passed",
|
|
655
1241
|
flush=True,
|
|
656
1242
|
)
|
|
1243
|
+
verify_retention(location, create, exists, worker)
|
|
657
1244
|
finally:
|
|
658
1245
|
for handle in reversed(fixtures):
|
|
659
1246
|
if exists(handle):
|
|
@@ -684,6 +1271,8 @@ if __name__ == "__main__":
|
|
|
684
1271
|
parser.add_argument("--isolated-worker", action="store_true")
|
|
685
1272
|
args = parser.parse_args()
|
|
686
1273
|
verify_location_notes()
|
|
1274
|
+
verify_controller_defaults()
|
|
1275
|
+
verify_retention_worker()
|
|
687
1276
|
verify_host_preflight()
|
|
688
1277
|
verify_archives()
|
|
689
1278
|
if args.location:
|