@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.
Files changed (38) hide show
  1. package/README.md +30 -14
  2. package/deploy/deploy.py +112 -19
  3. package/deploy/install-incus.sh +10 -0
  4. package/deploy/raft-expire.py +109 -31
  5. package/deploy/verify-controls.py +34 -48
  6. package/deploy/verify-features.py +592 -3
  7. package/deploy/verify-fresh-host.py +64 -1
  8. package/deploy/verify-limits.py +327 -72
  9. package/deploy/verify-live.py +56 -31
  10. package/deploy/verify-self-update.py +372 -0
  11. package/docs/boat-capabilities.md +8 -7
  12. package/docs/boot-performance.mdx +1 -1
  13. package/docs/contract.md +33 -23
  14. package/docs/first-box.mdx +1 -1
  15. package/docs/guides/backup-recovery.mdx +2 -2
  16. package/docs/guides/config-policy.mdx +253 -0
  17. package/docs/guides/docker.mdx +9 -1
  18. package/docs/guides/long-running-services.mdx +10 -4
  19. package/docs/guides/meta.json +1 -0
  20. package/docs/guides/project-setup.mdx +4 -3
  21. package/docs/guides/scripting-ci.mdx +27 -10
  22. package/docs/guides/snapshots-forks.mdx +9 -3
  23. package/docs/guides/troubleshooting.mdx +37 -8
  24. package/docs/guides/upgrades.mdx +17 -5
  25. package/docs/index.mdx +1 -0
  26. package/docs/operations.mdx +16 -14
  27. package/docs/reference/capacity.mdx +198 -0
  28. package/docs/reference/cli.md +178 -35
  29. package/docs/reference/configuration.mdx +40 -2
  30. package/docs/reference/meta.json +1 -1
  31. package/docs/releases/0.5.0.md +19 -0
  32. package/docs/releases/0.6.0.md +16 -0
  33. package/docs/requirements.mdx +2 -2
  34. package/docs/verification.mdx +14 -8
  35. package/package.json +1 -1
  36. package/raft.py +1181 -128
  37. package/skills/raft-cli/SKILL.md +79 -13
  38. 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, oversize rejection and real host reboot passed",
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:
@@ -1,136 +1,391 @@
1
- """Check capacity boundaries and optionally real disposable box transitions."""
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
- "poolTotalBytes": 60 * GIB,
26
- "poolFreeBytes": 30 * GIB,
42
+ "sharedPool": {"totalBytes": 60 * GIB, "freeBytes": 30 * GIB},
43
+ "boxDiskPool": own,
27
44
  }
28
- running = {"status": "Running", "expanded_config": {"limits.cpu": "2", "limits.memory": "4GiB"}}
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["savedBoxes"] == 2 and report["activeBoxes"] == 1
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
- size = next(r for r in report["recommendations"] if r["cpu"] == 2 and r["memoryGiB"] == 4)
34
- assert (size["totalRunning"], size["additionalRunning"], size["newBoxes"]) == (3, 2, 2)
35
- assert len(report["recommendations"]) == 8, "Every CPU/RAM combination needs advice"
36
- # An 8 GiB box uses most of a 16 GiB host's 14 GiB budget: one fits on an empty
37
- # host, none more while it runs, but a 4 GiB box still does.
38
- large_box = {
39
- "status": "Running",
40
- "expanded_config": {"limits.cpu": "2", "limits.memory": "8GiB"},
41
- }
42
- eight = capacity(host, [large_box])
43
- assert eight["allocated"] == {"cpus": 2, "memoryBytes": 8 * GIB}
44
- by_size = {(r["cpu"], r["memoryGiB"]): r for r in eight["recommendations"]}
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
- host,
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("Unsupported box size was counted")
61
- full = capacity(host, [stopped] * 4)
62
- assert full["savedSlots"] == 0
63
- assert all(r["newBoxes"] == 0 for r in full["recommendations"])
64
- assert any(r["additionalRunning"] > 0 for r in full["recommendations"])
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, "poolFreeBytes": GIB}, [])
68
- assert all(r["additionalRunning"] == 0 for r in low["recommendations"])
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
- assert all(r["totalRunning"] == 0 for r in capacity({**host, "cpus": 1}, [])["recommendations"])
71
- assert all(
72
- r["totalRunning"] == 0
73
- for r in capacity({**host, "memoryTotalBytes": GIB}, [])["recommendations"]
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 all(r["totalRunning"] == 4 for r in large["recommendations"])
78
- over = capacity({**host, "cpus": 2}, [running])
79
- assert over["warnings"] and all(r["additionalRunning"] == 0 for r in over["recommendations"])
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
- default = next(
83
- r for r in example["recommendations"] if r["cpu"] == 1 and r["memoryGiB"] == 2
84
- )
85
- assert default["totalRunning"] == expected
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 policy: mixed and 8 GiB sizes, unsupported sizes, stopped/frozen boxes, low RAM/disk, reserves and saved cap passed",
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
- return subprocess.check_output([*CLI, *args], text=True).strip()
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 live_checks(location):
97
- def limits():
98
- return json.loads(command("limits", "--location", location, "--json"))
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
- baseline = limits()
101
- if baseline["savedSlots"] == 0:
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 = limits()
106
- assert active["savedBoxes"] == baseline["savedBoxes"] + 1
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 active["savedSlots"] == baseline["savedSlots"] - 1
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 = limits()
113
- assert stopped["savedBoxes"] == active["savedBoxes"]
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 = limits()
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 = limits()
124
- assert final["savedBoxes"] == baseline["savedBoxes"]
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 create/stop/resume/destroy reporting passed", flush=True)
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 using one disposable box")
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)