@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
|
@@ -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:
|
package/deploy/verify-limits.py
CHANGED
|
@@ -1,136 +1,391 @@
|
|
|
1
|
-
"""Check capacity
|
|
1
|
+
"""Check advisory capacity and sizing, and optionally real boxes and own box disks."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import importlib.util
|
|
4
5
|
import json
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
8
11
|
|
|
9
12
|
ROOT = Path(__file__).resolve().parent.parent
|
|
10
13
|
sys.path.insert(0, str(ROOT))
|
|
11
|
-
from raft import capacity # noqa: E402
|
|
14
|
+
from raft import BOX_DISK_POOL, box_disk, capacity, gib, settings # noqa: E402
|
|
15
|
+
|
|
16
|
+
spec = importlib.util.spec_from_file_location("live", Path(__file__).with_name("verify-live.py"))
|
|
17
|
+
live = importlib.util.module_from_spec(spec)
|
|
18
|
+
spec.loader.exec_module(live)
|
|
12
19
|
|
|
13
20
|
GIB = 1024**3
|
|
14
21
|
CLI = [sys.executable, str(ROOT / "raft.py")]
|
|
22
|
+
# Fits the development image (about 5 GiB of files) with room to fill. It matches
|
|
23
|
+
# the 10 GiB volume Incus caches the image in, so creation is a pure thin snapshot.
|
|
24
|
+
DISK_GIB = 10
|
|
25
|
+
IMAGE_VOLUME_GIB = 10
|
|
15
26
|
|
|
16
27
|
|
|
17
28
|
def policy_checks():
|
|
18
29
|
# These are allocation fixtures, not mocked host services. Expected counts
|
|
19
30
|
# describe the documented policy independently of its arithmetic.
|
|
31
|
+
own = {
|
|
32
|
+
"totalBytes": 40 * GIB,
|
|
33
|
+
"reservedBytes": 20 * GIB,
|
|
34
|
+
"usedBytes": 6 * GIB,
|
|
35
|
+
"metadataUsedPercent": 3.0,
|
|
36
|
+
}
|
|
20
37
|
host = {
|
|
21
38
|
"cpus": 8,
|
|
22
39
|
"memoryTotalBytes": 16 * GIB,
|
|
23
40
|
"memoryAvailableBytes": 14 * GIB,
|
|
24
41
|
"hostDiskFreeBytes": 40 * GIB,
|
|
25
|
-
"
|
|
26
|
-
"
|
|
42
|
+
"sharedPool": {"totalBytes": 60 * GIB, "freeBytes": 30 * GIB},
|
|
43
|
+
"boxDiskPool": own,
|
|
27
44
|
}
|
|
28
|
-
|
|
45
|
+
|
|
46
|
+
def running(cpu, memory):
|
|
47
|
+
config = {"limits.cpu": str(cpu), "limits.memory": memory}
|
|
48
|
+
return {"name": "rf-fixture", "status": "Running", "expanded_config": config}
|
|
49
|
+
|
|
29
50
|
stopped = {"status": "Stopped"}
|
|
30
|
-
report = capacity(host, [running, stopped])
|
|
31
|
-
assert report["
|
|
51
|
+
report = capacity(host, [running(2, "4GiB"), stopped])
|
|
52
|
+
assert report["notice"].startswith("Advisory estimate only")
|
|
53
|
+
assert "enforced" not in json.dumps(report).lower()
|
|
54
|
+
assert report["boxes"] == 2 and report["activeBoxes"] == 1
|
|
32
55
|
assert report["allocated"] == {"cpus": 2, "memoryBytes": 4 * GIB}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert (by_size[2, 8]["totalRunning"], by_size[2, 8]["additionalRunning"]) == (1, 0)
|
|
46
|
-
assert by_size[1, 4]["additionalRunning"] == 1
|
|
56
|
+
assert report["headroom"] == {"cpus": 5, "memoryBytes": 10 * GIB}
|
|
57
|
+
# Shapes double from the default at 2 GiB per CPU while they fit an empty host.
|
|
58
|
+
shapes = [
|
|
59
|
+
(r["cpu"], r["memoryGiB"], r["fitEmptyHost"], r["fitNow"]) for r in report["estimates"]
|
|
60
|
+
]
|
|
61
|
+
assert shapes == [(1, 2, 7, 5), (2, 4, 3, 2), (4, 8, 1, 1)], shapes
|
|
62
|
+
# Any whole size counts, including sizes larger than the old 2 CPU / 8 GiB menu.
|
|
63
|
+
odd = capacity(host, [running(3, "12GiB")])
|
|
64
|
+
assert odd["allocated"] == {"cpus": 3, "memoryBytes": 12 * GIB}
|
|
65
|
+
assert odd["headroom"] == {"cpus": 4, "memoryBytes": 2 * GIB}
|
|
66
|
+
over = capacity(host, [running(8, "16GiB")])
|
|
67
|
+
assert over["warnings"] and all(r["fitNow"] == 0 for r in over["estimates"])
|
|
47
68
|
try:
|
|
48
|
-
capacity(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
"status": "Running",
|
|
53
|
-
"expanded_config": {"limits.cpu": "1", "limits.memory": "16GiB"},
|
|
54
|
-
}
|
|
55
|
-
],
|
|
56
|
-
)
|
|
57
|
-
except ValueError:
|
|
58
|
-
pass
|
|
69
|
+
capacity(host, [running(1, "512MiB")])
|
|
70
|
+
except ValueError as error:
|
|
71
|
+
assert "rf-fixture" in str(error)
|
|
59
72
|
else:
|
|
60
|
-
raise AssertionError("
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
assert
|
|
64
|
-
assert
|
|
65
|
-
frozen = capacity(host, [{**running, "status": "Frozen"}])
|
|
73
|
+
raise AssertionError("Sizing Raft never writes was counted")
|
|
74
|
+
# Saved boxes never reduce the estimates: there is no box count limit.
|
|
75
|
+
many = capacity(host, [stopped] * 12)
|
|
76
|
+
assert many["boxes"] == 12
|
|
77
|
+
assert many["estimates"] == capacity(host, [])["estimates"]
|
|
78
|
+
frozen = capacity(host, [{**running(2, "4GiB"), "status": "Frozen"}])
|
|
66
79
|
assert frozen["allocated"] == report["allocated"]
|
|
67
|
-
low = capacity({**host, "memoryAvailableBytes": GIB, "
|
|
68
|
-
assert all(r["
|
|
80
|
+
low = capacity({**host, "memoryAvailableBytes": GIB, "hostDiskFreeBytes": GIB}, [])
|
|
81
|
+
assert all(r["fitNow"] == 0 for r in low["estimates"])
|
|
69
82
|
assert len(low["warnings"]) == 2
|
|
70
|
-
|
|
71
|
-
assert
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
83
|
+
# Own box disks are opt-in: a host without their pool is healthy.
|
|
84
|
+
assert capacity({**host, "boxDiskPool": None}, [])["warnings"] == []
|
|
85
|
+
# An inactive thin pool reports no usage; that is not a warning either.
|
|
86
|
+
idle = {**own, "usedBytes": None, "metadataUsedPercent": None}
|
|
87
|
+
assert capacity({**host, "boxDiskPool": idle}, [])["warnings"] == []
|
|
88
|
+
full = capacity({**host, "boxDiskPool": {**own, "metadataUsedPercent": 85.0}}, [])
|
|
89
|
+
assert len(full["warnings"]) == 1 and "metadata" in full["warnings"][0], full["warnings"]
|
|
90
|
+
for tiny in [{**host, "cpus": 1}, {**host, "memoryTotalBytes": GIB}]:
|
|
91
|
+
estimates = capacity(tiny, [])["estimates"]
|
|
92
|
+
assert [(r["cpu"], r["fitEmptyHost"]) for r in estimates] == [(1, 0)], estimates
|
|
75
93
|
large = capacity({**host, "cpus": 64, "memoryTotalBytes": 128 * GIB}, [])
|
|
76
94
|
assert large["reserve"]["memoryBytes"] == 128 * GIB // 10
|
|
77
|
-
assert
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
for cpu, memory, expected in [(2, 8, 1), (4, 8, 3), (4, 16, 3), (8, 32, 4)]:
|
|
95
|
+
assert [r["cpu"] for r in large["estimates"]] == [1, 2, 4, 8, 16, 32]
|
|
96
|
+
assert large["estimates"][0]["fitEmptyHost"] == 57
|
|
97
|
+
for cpu, memory, expected in [(2, 8, 1), (4, 8, 3), (4, 16, 3), (8, 32, 7)]:
|
|
81
98
|
example = capacity({**host, "cpus": cpu, "memoryTotalBytes": memory * GIB}, [])
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
assert example["estimates"][0]["fitEmptyHost"] == expected
|
|
100
|
+
shared = {"expanded_devices": {"root": {"type": "disk", "path": "/", "pool": "raft-data"}}}
|
|
101
|
+
own = {"expanded_devices": {"root": {"pool": "raft-disks", "size": "20GiB"}}}
|
|
102
|
+
assert (box_disk(shared), box_disk(own)) == ("shared", "20GiB")
|
|
86
103
|
print(
|
|
87
|
-
"Capacity
|
|
104
|
+
"Capacity advice: arbitrary sizes, no box count, example shapes, stopped/frozen boxes, "
|
|
105
|
+
"low RAM/disk, optional own-disk pool, thin metadata and disk reporting passed",
|
|
88
106
|
flush=True,
|
|
89
107
|
)
|
|
90
108
|
|
|
91
109
|
|
|
92
110
|
def command(*args):
|
|
93
|
-
|
|
111
|
+
# The private controller HOME keeps this controller's defaults for new out.
|
|
112
|
+
return subprocess.check_output([*CLI, *args], text=True, env=live.CONTROLLER_ENV).strip()
|
|
94
113
|
|
|
95
114
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
def report(location):
|
|
116
|
+
return json.loads(command("capacity", "--location", location, "--json"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def listed(box):
|
|
120
|
+
location = box.partition(":")[0]
|
|
121
|
+
for line in command("list", "--location", location).splitlines():
|
|
122
|
+
record = json.loads(line)
|
|
123
|
+
if record["box"] == box:
|
|
124
|
+
return record
|
|
125
|
+
raise AssertionError(f"{box} is missing from raft list")
|
|
126
|
+
|
|
99
127
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
raise RuntimeError("Capacity E2E needs one free saved-box slot")
|
|
128
|
+
def live_checks(location):
|
|
129
|
+
baseline = report(location)
|
|
103
130
|
box = command("new", "--location", location, "--cpu", "1", "--memory", "1GiB", "--ttl", "600")
|
|
104
131
|
try:
|
|
105
|
-
active =
|
|
106
|
-
assert active["
|
|
132
|
+
active = report(location)
|
|
133
|
+
assert active["boxes"] == baseline["boxes"] + 1
|
|
107
134
|
assert active["activeBoxes"] == baseline["activeBoxes"] + 1
|
|
108
135
|
assert active["allocated"]["cpus"] == baseline["allocated"]["cpus"] + 1
|
|
109
136
|
assert active["allocated"]["memoryBytes"] == baseline["allocated"]["memoryBytes"] + GIB
|
|
110
|
-
assert
|
|
137
|
+
assert listed(box)["disk"] == "shared"
|
|
138
|
+
text = command("capacity", "--location", location)
|
|
139
|
+
assert text.startswith("Advisory estimate only") and "CPU RAM GiB" in text
|
|
140
|
+
assert "enforced" not in text.lower()
|
|
141
|
+
# Limits beyond the whole host fail before Incus would quietly clip them.
|
|
142
|
+
host = active["host"]
|
|
143
|
+
memory = -(-host["memoryTotalBytes"] // GIB) + 1
|
|
144
|
+
for arguments, message in [
|
|
145
|
+
(["--cpu", str(host["cpus"] + 1)], "CPUs, but this host has"),
|
|
146
|
+
(["--memory", f"{memory}GiB"], "RAM, but this host has"),
|
|
147
|
+
]:
|
|
148
|
+
rejected = live.raft("new", "--location", location, *arguments, check=False)
|
|
149
|
+
assert rejected.returncode == 1 and message in rejected.stderr, rejected
|
|
111
150
|
command("stop", box)
|
|
112
|
-
stopped =
|
|
113
|
-
assert stopped["
|
|
151
|
+
stopped = report(location)
|
|
152
|
+
assert stopped["boxes"] == active["boxes"]
|
|
114
153
|
assert stopped["activeBoxes"] == baseline["activeBoxes"]
|
|
115
154
|
assert stopped["allocated"] == baseline["allocated"]
|
|
155
|
+
rejected = live.raft("resume", box, "--ttl", "600", "--cpu", "100000", check=False)
|
|
156
|
+
assert rejected.returncode == 1 and "this host has" in rejected.stderr, rejected
|
|
116
157
|
command("resume", box, "--ttl", "600")
|
|
117
|
-
resumed =
|
|
158
|
+
resumed = report(location)
|
|
118
159
|
assert resumed["activeBoxes"] == active["activeBoxes"]
|
|
119
160
|
assert resumed["allocated"] == active["allocated"]
|
|
120
|
-
assert "CPU RAM GiB" in command("limits", "--location", location)
|
|
121
161
|
finally:
|
|
122
162
|
command("destroy", box)
|
|
123
|
-
final =
|
|
124
|
-
assert final["
|
|
163
|
+
final = report(location)
|
|
164
|
+
assert final["boxes"] == baseline["boxes"]
|
|
125
165
|
assert final["activeBoxes"] == baseline["activeBoxes"]
|
|
126
166
|
assert final["allocated"] == baseline["allocated"]
|
|
127
|
-
print(f"{location}: real capacity
|
|
167
|
+
print(f"{location}: real capacity reporting and oversized-request refusal passed", flush=True)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def disk_size(box):
|
|
171
|
+
"""Return the box filesystem's total and available bytes as seen inside the guest."""
|
|
172
|
+
output = live.execute(box, "df", "-B1", "--output=size,avail", "/")
|
|
173
|
+
total, available = output.splitlines()[-1].split()
|
|
174
|
+
return int(total), int(available)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def fits_disk(total, size):
|
|
178
|
+
# ext4 keeps a little of each volume for its own metadata.
|
|
179
|
+
return (gib(size) - 1) * GIB < total <= gib(size) * GIB
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def host(location, script):
|
|
183
|
+
return subprocess.check_output(
|
|
184
|
+
["ssh", "-T", "-o", "BatchMode=yes", settings(location)["ssh"], script], text=True
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def own_pool(location):
|
|
189
|
+
return report(location)["host"]["boxDiskPool"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def thin_origin(box):
|
|
193
|
+
"""Return the logical volume a box's own disk was snapshotted from, or "" for none.
|
|
194
|
+
|
|
195
|
+
Incus names a box's volume containers_<project>_<name> with each "-" doubled and
|
|
196
|
+
a snapshot's volume <box volume>-<snapshot>; the volume group is named after the
|
|
197
|
+
pool. These are Incus internals, read here only to prove copies are snapshots.
|
|
198
|
+
"""
|
|
199
|
+
location, _, name = box.partition(":")
|
|
200
|
+
output = host(location, f"sudo lvs --reportformat json -o lv_name,origin {BOX_DISK_POOL}")
|
|
201
|
+
volumes = json.loads(output)["report"][0]["lv"]
|
|
202
|
+
return next(v["origin"] for v in volumes if v["lv_name"] == volume_name(name))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def volume_name(name, snapshot=None):
|
|
206
|
+
return f"containers_raft_{name.replace('-', '--')}" + (f"-{snapshot}" if snapshot else "")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def disk_checks(location):
|
|
210
|
+
"""Prove own disks are hard caps, that copies are thin snapshots, and that the
|
|
211
|
+
pool is never promised more than it holds."""
|
|
212
|
+
size = f"{DISK_GIB}GiB"
|
|
213
|
+
fixtures = []
|
|
214
|
+
before = own_pool(location)
|
|
215
|
+
assert before is not None, "Own-disk checks need a raft-disks pool"
|
|
216
|
+
image = settings(location)["image"]
|
|
217
|
+
cached = host(
|
|
218
|
+
location,
|
|
219
|
+
f"sudo incus storage volume show {BOX_DISK_POOL} image/{image} >/dev/null 2>&1 "
|
|
220
|
+
"&& echo yes || echo no",
|
|
221
|
+
).strip()
|
|
222
|
+
saved = len(command("list", "--location", location).splitlines())
|
|
223
|
+
try:
|
|
224
|
+
box = command(
|
|
225
|
+
"new", "--location", location, "--disk", size, "--ttl", "1800", "--purpose", "Own disk"
|
|
226
|
+
)
|
|
227
|
+
fixtures.append(box)
|
|
228
|
+
live.wait_running(box)
|
|
229
|
+
assert listed(box)["disk"] == size
|
|
230
|
+
root = json.loads(command("info", box))["expanded_devices"]["root"]
|
|
231
|
+
assert (root["pool"], root["size"]) == (BOX_DISK_POOL, size), root
|
|
232
|
+
total, _ = disk_size(box)
|
|
233
|
+
assert fits_disk(total, size), total
|
|
234
|
+
usage = json.loads(command("usage", box))
|
|
235
|
+
assert usage["filesystemScope"] == f"own {size} box disk", usage
|
|
236
|
+
# The box reserves its full size; the first own-disk box also caches the image.
|
|
237
|
+
created_host = report(location)["host"]
|
|
238
|
+
created = created_host["boxDiskPool"]
|
|
239
|
+
image_gib = 0 if cached == "yes" else IMAGE_VOLUME_GIB
|
|
240
|
+
added = created["reservedBytes"] - before["reservedBytes"]
|
|
241
|
+
assert added == (DISK_GIB + image_gib) * GIB, added
|
|
242
|
+
assert thin_origin(box) == f"images_{image}", thin_origin(box)
|
|
243
|
+
|
|
244
|
+
# Refused requests leave no box and no reservation behind: a disk too small
|
|
245
|
+
# for the image fails in Incus, and a disk larger than the unreserved space
|
|
246
|
+
# fails the reservation check before Incus runs.
|
|
247
|
+
unreserved = created["totalBytes"] - created["reservedBytes"]
|
|
248
|
+
for disk, message in [
|
|
249
|
+
("1GiB", ""),
|
|
250
|
+
(f"{unreserved // GIB + 1}GiB", "GiB unreserved; nothing was created"),
|
|
251
|
+
]:
|
|
252
|
+
rejected = live.raft("new", "--location", location, "--disk", disk, check=False)
|
|
253
|
+
if rejected.returncode == 0:
|
|
254
|
+
fixtures.append(rejected.stdout.strip())
|
|
255
|
+
assert rejected.returncode == 1 and message in rejected.stderr, rejected
|
|
256
|
+
assert len(command("list", "--location", location).splitlines()) == saved + 1
|
|
257
|
+
assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
|
|
258
|
+
|
|
259
|
+
# Fill the disk from the guest: the write stops at the cap, and neither the
|
|
260
|
+
# own-disk pool's reservations nor the shared pool give up more space.
|
|
261
|
+
filled = live.raft(
|
|
262
|
+
"exec", box, "--", "dd", "if=/dev/zero", "of=/workspace/fill", "bs=1M", check=False
|
|
263
|
+
)
|
|
264
|
+
assert filled.returncode != 0 and "No space left" in filled.stderr, filled
|
|
265
|
+
written = int(live.execute(box, "stat", "-c", "%s", "/workspace/fill"))
|
|
266
|
+
# Guest root is not host root, so ext4's reserved blocks stay out of reach.
|
|
267
|
+
assert written > GIB and disk_size(box)[1] < 64 * 1024**2, (written, disk_size(box))
|
|
268
|
+
full = report(location)["host"]
|
|
269
|
+
assert full["boxDiskPool"]["reservedBytes"] == created["reservedBytes"]
|
|
270
|
+
assert full["boxDiskPool"]["usedBytes"] <= full["boxDiskPool"]["totalBytes"]
|
|
271
|
+
shared = created_host["sharedPool"]["freeBytes"]
|
|
272
|
+
assert full["sharedPool"]["freeBytes"] > shared - written // 2
|
|
273
|
+
live.execute(box, "rm", "/workspace/fill")
|
|
274
|
+
|
|
275
|
+
# Docker layers live inside the same filesystem, so a container hits the same cap.
|
|
276
|
+
# Docker keeps its own state on that filesystem too: once it is full the daemon
|
|
277
|
+
# can drop the client connection while dd still runs, so the container runs
|
|
278
|
+
# detached and the cap is proven from outside. The guest filesystem fills up
|
|
279
|
+
# and never grows past its size.
|
|
280
|
+
live.execute(box, "docker", "pull", "-q", "alpine:3.22")
|
|
281
|
+
live.execute(
|
|
282
|
+
box,
|
|
283
|
+
"docker",
|
|
284
|
+
"run",
|
|
285
|
+
"--detach",
|
|
286
|
+
"--log-driver",
|
|
287
|
+
"none",
|
|
288
|
+
"--name",
|
|
289
|
+
"raft-fill",
|
|
290
|
+
"alpine:3.22",
|
|
291
|
+
"dd",
|
|
292
|
+
"if=/dev/zero",
|
|
293
|
+
"of=/fill",
|
|
294
|
+
"bs=1M",
|
|
295
|
+
)
|
|
296
|
+
deadline = time.monotonic() + 300
|
|
297
|
+
while disk_size(box)[1] >= 64 * 1024**2:
|
|
298
|
+
assert time.monotonic() < deadline, disk_size(box)
|
|
299
|
+
time.sleep(2)
|
|
300
|
+
assert disk_size(box)[0] == total, disk_size(box)
|
|
301
|
+
# Removing the container frees the space; restart Docker in case it lost state.
|
|
302
|
+
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
303
|
+
live.execute(box, "systemctl", "restart", "docker")
|
|
304
|
+
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
305
|
+
assert disk_size(box)[1] > GIB, disk_size(box)
|
|
306
|
+
assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
|
|
307
|
+
print(f"{location}: {size} own disk capped guest and Docker writes", flush=True)
|
|
308
|
+
|
|
309
|
+
command("stop", box)
|
|
310
|
+
command("snapshot", box, "sized")
|
|
311
|
+
snapshotted = own_pool(location)["reservedBytes"]
|
|
312
|
+
assert snapshotted - created["reservedBytes"] == DISK_GIB * GIB
|
|
313
|
+
name = box.partition(":")[2]
|
|
314
|
+
# Forks and templates keep the source disk; --disk on a template sets a new
|
|
315
|
+
# size. Each copy reserves its full size but is a thin snapshot of its source.
|
|
316
|
+
for arguments, expected, origin in [
|
|
317
|
+
(["fork", box, "--ttl", "600"], size, volume_name(name)),
|
|
318
|
+
(
|
|
319
|
+
["new", "--from", box + "/sized", "--ttl", "600"],
|
|
320
|
+
size,
|
|
321
|
+
volume_name(name, "sized"),
|
|
322
|
+
),
|
|
323
|
+
(
|
|
324
|
+
["new", "--from", box + "/sized", "--disk", f"{DISK_GIB + 2}GiB"],
|
|
325
|
+
f"{DISK_GIB + 2}GiB",
|
|
326
|
+
volume_name(name, "sized"),
|
|
327
|
+
),
|
|
328
|
+
]:
|
|
329
|
+
copy = command(*arguments)
|
|
330
|
+
fixtures.append(copy)
|
|
331
|
+
assert listed(copy)["disk"] == expected, (arguments, listed(copy))
|
|
332
|
+
assert thin_origin(copy) == origin, (arguments, thin_origin(copy))
|
|
333
|
+
reserved = own_pool(location)["reservedBytes"] - snapshotted
|
|
334
|
+
assert reserved == gib(expected) * GIB, (arguments, reserved)
|
|
335
|
+
live.wait_running(copy)
|
|
336
|
+
assert fits_disk(disk_size(copy)[0], expected), disk_size(copy)
|
|
337
|
+
command("destroy", copy)
|
|
338
|
+
fixtures.remove(copy)
|
|
339
|
+
|
|
340
|
+
# A template copy of a running source writes almost nothing to the pool,
|
|
341
|
+
# where a file copy would write the image's 5 GiB again. The running source
|
|
342
|
+
# keeps the thin pool active, so LVM reports its usage on both sides.
|
|
343
|
+
command("resume", box, "--ttl", "600")
|
|
344
|
+
live.wait_running(box)
|
|
345
|
+
running = own_pool(location)["usedBytes"]
|
|
346
|
+
copy = command("new", "--from", box + "/sized", "--ttl", "600")
|
|
347
|
+
fixtures.append(copy)
|
|
348
|
+
written = own_pool(location)["usedBytes"] - running
|
|
349
|
+
assert written < GIB, written
|
|
350
|
+
command("destroy", copy)
|
|
351
|
+
fixtures.remove(copy)
|
|
352
|
+
command("stop", box)
|
|
353
|
+
print(
|
|
354
|
+
f"{location}: forks and templates of own disks are thin snapshots "
|
|
355
|
+
f"({written / 1024**2:.0f} MiB written by a template copy)",
|
|
356
|
+
flush=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
with tempfile.TemporaryDirectory(prefix="raft-disk-") as work:
|
|
360
|
+
archive = Path(work) / "own-disk.tar.gz"
|
|
361
|
+
command("backup", box, str(archive))
|
|
362
|
+
command("destroy", box)
|
|
363
|
+
fixtures.remove(box)
|
|
364
|
+
recovered = command("recover", str(archive), "--location", location)
|
|
365
|
+
fixtures.append(recovered)
|
|
366
|
+
assert listed(recovered)["disk"] == size
|
|
367
|
+
assert "sized" in command("snapshots", recovered)
|
|
368
|
+
# The recovered box and its snapshot each reserve the full size again.
|
|
369
|
+
reserved = own_pool(location)["reservedBytes"] - before["reservedBytes"]
|
|
370
|
+
assert reserved == (2 * DISK_GIB + image_gib) * GIB, reserved
|
|
371
|
+
command("resume", recovered, "--ttl", "600")
|
|
372
|
+
assert disk_size(recovered)[0] == total
|
|
373
|
+
print(
|
|
374
|
+
f"{location}: own disk kept by fork, template, snapshot and backup/recover", flush=True
|
|
375
|
+
)
|
|
376
|
+
finally:
|
|
377
|
+
for fixture in reversed(fixtures):
|
|
378
|
+
command("destroy", fixture)
|
|
379
|
+
# Only the cached image stays reserved after every own-disk box is gone.
|
|
380
|
+
final = own_pool(location)["reservedBytes"] - before["reservedBytes"]
|
|
381
|
+
assert final == image_gib * GIB, final
|
|
128
382
|
|
|
129
383
|
|
|
130
384
|
if __name__ == "__main__":
|
|
131
385
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
132
|
-
parser.add_argument("--location", help="Also test a real host
|
|
386
|
+
parser.add_argument("--location", help="Also test a real host with its own fixtures")
|
|
133
387
|
args = parser.parse_args()
|
|
134
388
|
policy_checks()
|
|
135
389
|
if args.location:
|
|
136
390
|
live_checks(args.location)
|
|
391
|
+
disk_checks(args.location)
|