@microck/raft 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -10
- package/deploy/deploy.py +112 -19
- package/deploy/install-incus.sh +10 -0
- package/deploy/verify-controls.py +34 -48
- package/deploy/verify-features.py +350 -4
- package/deploy/verify-fresh-host.py +64 -1
- package/deploy/verify-limits.py +325 -71
- package/deploy/verify-live.py +38 -29
- package/deploy/verify-self-update.py +372 -0
- package/docs/boat-capabilities.md +8 -7
- package/docs/boot-performance.mdx +1 -1
- package/docs/contract.md +28 -17
- package/docs/guides/backup-recovery.mdx +1 -1
- package/docs/guides/config-policy.mdx +179 -0
- package/docs/guides/docker.mdx +9 -1
- package/docs/guides/long-running-services.mdx +2 -2
- package/docs/guides/meta.json +1 -0
- package/docs/guides/project-setup.mdx +4 -3
- package/docs/guides/scripting-ci.mdx +25 -10
- package/docs/guides/snapshots-forks.mdx +9 -3
- package/docs/guides/troubleshooting.mdx +18 -6
- package/docs/guides/upgrades.mdx +13 -3
- package/docs/index.mdx +1 -0
- package/docs/operations.mdx +16 -14
- package/docs/reference/capacity.mdx +198 -0
- package/docs/reference/cli.md +166 -29
- package/docs/reference/configuration.mdx +36 -2
- package/docs/reference/meta.json +1 -1
- package/docs/releases/0.4.0.md +15 -0
- package/docs/releases/0.5.0.md +19 -0
- package/docs/requirements.mdx +2 -2
- package/docs/verification.mdx +14 -8
- package/package.json +1 -1
- package/raft.py +1105 -125
- package/skills/raft-cli/SKILL.md +76 -12
- package/docs/reference/capacity-limits.mdx +0 -92
package/deploy/verify-limits.py
CHANGED
|
@@ -1,90 +1,108 @@
|
|
|
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
|
|
|
@@ -93,44 +111,280 @@ def command(*args):
|
|
|
93
111
|
return subprocess.check_output([*CLI, *args], text=True).strip()
|
|
94
112
|
|
|
95
113
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
def report(location):
|
|
115
|
+
return json.loads(command("capacity", "--location", location, "--json"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def listed(box):
|
|
119
|
+
location = box.partition(":")[0]
|
|
120
|
+
for line in command("list", "--location", location).splitlines():
|
|
121
|
+
record = json.loads(line)
|
|
122
|
+
if record["box"] == box:
|
|
123
|
+
return record
|
|
124
|
+
raise AssertionError(f"{box} is missing from raft list")
|
|
125
|
+
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
raise RuntimeError("Capacity E2E needs one free saved-box slot")
|
|
127
|
+
def live_checks(location):
|
|
128
|
+
baseline = report(location)
|
|
103
129
|
box = command("new", "--location", location, "--cpu", "1", "--memory", "1GiB", "--ttl", "600")
|
|
104
130
|
try:
|
|
105
|
-
active =
|
|
106
|
-
assert active["
|
|
131
|
+
active = report(location)
|
|
132
|
+
assert active["boxes"] == baseline["boxes"] + 1
|
|
107
133
|
assert active["activeBoxes"] == baseline["activeBoxes"] + 1
|
|
108
134
|
assert active["allocated"]["cpus"] == baseline["allocated"]["cpus"] + 1
|
|
109
135
|
assert active["allocated"]["memoryBytes"] == baseline["allocated"]["memoryBytes"] + GIB
|
|
110
|
-
assert
|
|
136
|
+
assert listed(box)["disk"] == "shared"
|
|
137
|
+
text = command("capacity", "--location", location)
|
|
138
|
+
assert text.startswith("Advisory estimate only") and "CPU RAM GiB" in text
|
|
139
|
+
assert "enforced" not in text.lower()
|
|
140
|
+
# Limits beyond the whole host fail before Incus would quietly clip them.
|
|
141
|
+
host = active["host"]
|
|
142
|
+
memory = -(-host["memoryTotalBytes"] // GIB) + 1
|
|
143
|
+
for arguments, message in [
|
|
144
|
+
(["--cpu", str(host["cpus"] + 1)], "CPUs, but this host has"),
|
|
145
|
+
(["--memory", f"{memory}GiB"], "RAM, but this host has"),
|
|
146
|
+
]:
|
|
147
|
+
rejected = live.raft("new", "--location", location, *arguments, check=False)
|
|
148
|
+
assert rejected.returncode == 1 and message in rejected.stderr, rejected
|
|
111
149
|
command("stop", box)
|
|
112
|
-
stopped =
|
|
113
|
-
assert stopped["
|
|
150
|
+
stopped = report(location)
|
|
151
|
+
assert stopped["boxes"] == active["boxes"]
|
|
114
152
|
assert stopped["activeBoxes"] == baseline["activeBoxes"]
|
|
115
153
|
assert stopped["allocated"] == baseline["allocated"]
|
|
154
|
+
rejected = live.raft("resume", box, "--ttl", "600", "--cpu", "100000", check=False)
|
|
155
|
+
assert rejected.returncode == 1 and "this host has" in rejected.stderr, rejected
|
|
116
156
|
command("resume", box, "--ttl", "600")
|
|
117
|
-
resumed =
|
|
157
|
+
resumed = report(location)
|
|
118
158
|
assert resumed["activeBoxes"] == active["activeBoxes"]
|
|
119
159
|
assert resumed["allocated"] == active["allocated"]
|
|
120
|
-
assert "CPU RAM GiB" in command("limits", "--location", location)
|
|
121
160
|
finally:
|
|
122
161
|
command("destroy", box)
|
|
123
|
-
final =
|
|
124
|
-
assert final["
|
|
162
|
+
final = report(location)
|
|
163
|
+
assert final["boxes"] == baseline["boxes"]
|
|
125
164
|
assert final["activeBoxes"] == baseline["activeBoxes"]
|
|
126
165
|
assert final["allocated"] == baseline["allocated"]
|
|
127
|
-
print(f"{location}: real capacity
|
|
166
|
+
print(f"{location}: real capacity reporting and oversized-request refusal passed", flush=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def disk_size(box):
|
|
170
|
+
"""Return the box filesystem's total and available bytes as seen inside the guest."""
|
|
171
|
+
output = live.execute(box, "df", "-B1", "--output=size,avail", "/")
|
|
172
|
+
total, available = output.splitlines()[-1].split()
|
|
173
|
+
return int(total), int(available)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def fits_disk(total, size):
|
|
177
|
+
# ext4 keeps a little of each volume for its own metadata.
|
|
178
|
+
return (gib(size) - 1) * GIB < total <= gib(size) * GIB
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def host(location, script):
|
|
182
|
+
return subprocess.check_output(
|
|
183
|
+
["ssh", "-T", "-o", "BatchMode=yes", settings(location)["ssh"], script], text=True
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def own_pool(location):
|
|
188
|
+
return report(location)["host"]["boxDiskPool"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def thin_origin(box):
|
|
192
|
+
"""Return the logical volume a box's own disk was snapshotted from, or "" for none.
|
|
193
|
+
|
|
194
|
+
Incus names a box's volume containers_<project>_<name> with each "-" doubled and
|
|
195
|
+
a snapshot's volume <box volume>-<snapshot>; the volume group is named after the
|
|
196
|
+
pool. These are Incus internals, read here only to prove copies are snapshots.
|
|
197
|
+
"""
|
|
198
|
+
location, _, name = box.partition(":")
|
|
199
|
+
output = host(location, f"sudo lvs --reportformat json -o lv_name,origin {BOX_DISK_POOL}")
|
|
200
|
+
volumes = json.loads(output)["report"][0]["lv"]
|
|
201
|
+
return next(v["origin"] for v in volumes if v["lv_name"] == volume_name(name))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def volume_name(name, snapshot=None):
|
|
205
|
+
return f"containers_raft_{name.replace('-', '--')}" + (f"-{snapshot}" if snapshot else "")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def disk_checks(location):
|
|
209
|
+
"""Prove own disks are hard caps, that copies are thin snapshots, and that the
|
|
210
|
+
pool is never promised more than it holds."""
|
|
211
|
+
size = f"{DISK_GIB}GiB"
|
|
212
|
+
fixtures = []
|
|
213
|
+
before = own_pool(location)
|
|
214
|
+
assert before is not None, "Own-disk checks need a raft-disks pool"
|
|
215
|
+
image = settings(location)["image"]
|
|
216
|
+
cached = host(
|
|
217
|
+
location,
|
|
218
|
+
f"sudo incus storage volume show {BOX_DISK_POOL} image/{image} >/dev/null 2>&1 "
|
|
219
|
+
"&& echo yes || echo no",
|
|
220
|
+
).strip()
|
|
221
|
+
saved = len(command("list", "--location", location).splitlines())
|
|
222
|
+
try:
|
|
223
|
+
box = command(
|
|
224
|
+
"new", "--location", location, "--disk", size, "--ttl", "1800", "--purpose", "Own disk"
|
|
225
|
+
)
|
|
226
|
+
fixtures.append(box)
|
|
227
|
+
live.wait_running(box)
|
|
228
|
+
assert listed(box)["disk"] == size
|
|
229
|
+
root = json.loads(command("info", box))["expanded_devices"]["root"]
|
|
230
|
+
assert (root["pool"], root["size"]) == (BOX_DISK_POOL, size), root
|
|
231
|
+
total, _ = disk_size(box)
|
|
232
|
+
assert fits_disk(total, size), total
|
|
233
|
+
usage = json.loads(command("usage", box))
|
|
234
|
+
assert usage["filesystemScope"] == f"own {size} box disk", usage
|
|
235
|
+
# The box reserves its full size; the first own-disk box also caches the image.
|
|
236
|
+
created_host = report(location)["host"]
|
|
237
|
+
created = created_host["boxDiskPool"]
|
|
238
|
+
image_gib = 0 if cached == "yes" else IMAGE_VOLUME_GIB
|
|
239
|
+
added = created["reservedBytes"] - before["reservedBytes"]
|
|
240
|
+
assert added == (DISK_GIB + image_gib) * GIB, added
|
|
241
|
+
assert thin_origin(box) == f"images_{image}", thin_origin(box)
|
|
242
|
+
|
|
243
|
+
# Refused requests leave no box and no reservation behind: a disk too small
|
|
244
|
+
# for the image fails in Incus, and a disk larger than the unreserved space
|
|
245
|
+
# fails the reservation check before Incus runs.
|
|
246
|
+
unreserved = created["totalBytes"] - created["reservedBytes"]
|
|
247
|
+
for disk, message in [
|
|
248
|
+
("1GiB", ""),
|
|
249
|
+
(f"{unreserved // GIB + 1}GiB", "GiB unreserved; nothing was created"),
|
|
250
|
+
]:
|
|
251
|
+
rejected = live.raft("new", "--location", location, "--disk", disk, check=False)
|
|
252
|
+
if rejected.returncode == 0:
|
|
253
|
+
fixtures.append(rejected.stdout.strip())
|
|
254
|
+
assert rejected.returncode == 1 and message in rejected.stderr, rejected
|
|
255
|
+
assert len(command("list", "--location", location).splitlines()) == saved + 1
|
|
256
|
+
assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
|
|
257
|
+
|
|
258
|
+
# Fill the disk from the guest: the write stops at the cap, and neither the
|
|
259
|
+
# own-disk pool's reservations nor the shared pool give up more space.
|
|
260
|
+
filled = live.raft(
|
|
261
|
+
"exec", box, "--", "dd", "if=/dev/zero", "of=/workspace/fill", "bs=1M", check=False
|
|
262
|
+
)
|
|
263
|
+
assert filled.returncode != 0 and "No space left" in filled.stderr, filled
|
|
264
|
+
written = int(live.execute(box, "stat", "-c", "%s", "/workspace/fill"))
|
|
265
|
+
# Guest root is not host root, so ext4's reserved blocks stay out of reach.
|
|
266
|
+
assert written > GIB and disk_size(box)[1] < 64 * 1024**2, (written, disk_size(box))
|
|
267
|
+
full = report(location)["host"]
|
|
268
|
+
assert full["boxDiskPool"]["reservedBytes"] == created["reservedBytes"]
|
|
269
|
+
assert full["boxDiskPool"]["usedBytes"] <= full["boxDiskPool"]["totalBytes"]
|
|
270
|
+
shared = created_host["sharedPool"]["freeBytes"]
|
|
271
|
+
assert full["sharedPool"]["freeBytes"] > shared - written // 2
|
|
272
|
+
live.execute(box, "rm", "/workspace/fill")
|
|
273
|
+
|
|
274
|
+
# Docker layers live inside the same filesystem, so a container hits the same cap.
|
|
275
|
+
# Docker keeps its own state on that filesystem too: once it is full the daemon
|
|
276
|
+
# can drop the client connection while dd still runs, so the container runs
|
|
277
|
+
# detached and the cap is proven from outside. The guest filesystem fills up
|
|
278
|
+
# and never grows past its size.
|
|
279
|
+
live.execute(box, "docker", "pull", "-q", "alpine:3.22")
|
|
280
|
+
live.execute(
|
|
281
|
+
box,
|
|
282
|
+
"docker",
|
|
283
|
+
"run",
|
|
284
|
+
"--detach",
|
|
285
|
+
"--log-driver",
|
|
286
|
+
"none",
|
|
287
|
+
"--name",
|
|
288
|
+
"raft-fill",
|
|
289
|
+
"alpine:3.22",
|
|
290
|
+
"dd",
|
|
291
|
+
"if=/dev/zero",
|
|
292
|
+
"of=/fill",
|
|
293
|
+
"bs=1M",
|
|
294
|
+
)
|
|
295
|
+
deadline = time.monotonic() + 300
|
|
296
|
+
while disk_size(box)[1] >= 64 * 1024**2:
|
|
297
|
+
assert time.monotonic() < deadline, disk_size(box)
|
|
298
|
+
time.sleep(2)
|
|
299
|
+
assert disk_size(box)[0] == total, disk_size(box)
|
|
300
|
+
# Removing the container frees the space; restart Docker in case it lost state.
|
|
301
|
+
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
302
|
+
live.execute(box, "systemctl", "restart", "docker")
|
|
303
|
+
live.raft("exec", box, "--", "docker", "rm", "-f", "raft-fill", check=False)
|
|
304
|
+
assert disk_size(box)[1] > GIB, disk_size(box)
|
|
305
|
+
assert own_pool(location)["reservedBytes"] == created["reservedBytes"]
|
|
306
|
+
print(f"{location}: {size} own disk capped guest and Docker writes", flush=True)
|
|
307
|
+
|
|
308
|
+
command("stop", box)
|
|
309
|
+
command("snapshot", box, "sized")
|
|
310
|
+
snapshotted = own_pool(location)["reservedBytes"]
|
|
311
|
+
assert snapshotted - created["reservedBytes"] == DISK_GIB * GIB
|
|
312
|
+
name = box.partition(":")[2]
|
|
313
|
+
# Forks and templates keep the source disk; --disk on a template sets a new
|
|
314
|
+
# size. Each copy reserves its full size but is a thin snapshot of its source.
|
|
315
|
+
for arguments, expected, origin in [
|
|
316
|
+
(["fork", box, "--ttl", "600"], size, volume_name(name)),
|
|
317
|
+
(
|
|
318
|
+
["new", "--from", box + "/sized", "--ttl", "600"],
|
|
319
|
+
size,
|
|
320
|
+
volume_name(name, "sized"),
|
|
321
|
+
),
|
|
322
|
+
(
|
|
323
|
+
["new", "--from", box + "/sized", "--disk", f"{DISK_GIB + 2}GiB"],
|
|
324
|
+
f"{DISK_GIB + 2}GiB",
|
|
325
|
+
volume_name(name, "sized"),
|
|
326
|
+
),
|
|
327
|
+
]:
|
|
328
|
+
copy = command(*arguments)
|
|
329
|
+
fixtures.append(copy)
|
|
330
|
+
assert listed(copy)["disk"] == expected, (arguments, listed(copy))
|
|
331
|
+
assert thin_origin(copy) == origin, (arguments, thin_origin(copy))
|
|
332
|
+
reserved = own_pool(location)["reservedBytes"] - snapshotted
|
|
333
|
+
assert reserved == gib(expected) * GIB, (arguments, reserved)
|
|
334
|
+
live.wait_running(copy)
|
|
335
|
+
assert fits_disk(disk_size(copy)[0], expected), disk_size(copy)
|
|
336
|
+
command("destroy", copy)
|
|
337
|
+
fixtures.remove(copy)
|
|
338
|
+
|
|
339
|
+
# A template copy of a running source writes almost nothing to the pool,
|
|
340
|
+
# where a file copy would write the image's 5 GiB again. The running source
|
|
341
|
+
# keeps the thin pool active, so LVM reports its usage on both sides.
|
|
342
|
+
command("resume", box, "--ttl", "600")
|
|
343
|
+
live.wait_running(box)
|
|
344
|
+
running = own_pool(location)["usedBytes"]
|
|
345
|
+
copy = command("new", "--from", box + "/sized", "--ttl", "600")
|
|
346
|
+
fixtures.append(copy)
|
|
347
|
+
written = own_pool(location)["usedBytes"] - running
|
|
348
|
+
assert written < GIB, written
|
|
349
|
+
command("destroy", copy)
|
|
350
|
+
fixtures.remove(copy)
|
|
351
|
+
command("stop", box)
|
|
352
|
+
print(
|
|
353
|
+
f"{location}: forks and templates of own disks are thin snapshots "
|
|
354
|
+
f"({written / 1024**2:.0f} MiB written by a template copy)",
|
|
355
|
+
flush=True,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
with tempfile.TemporaryDirectory(prefix="raft-disk-") as work:
|
|
359
|
+
archive = Path(work) / "own-disk.tar.gz"
|
|
360
|
+
command("backup", box, str(archive))
|
|
361
|
+
command("destroy", box)
|
|
362
|
+
fixtures.remove(box)
|
|
363
|
+
recovered = command("recover", str(archive), "--location", location)
|
|
364
|
+
fixtures.append(recovered)
|
|
365
|
+
assert listed(recovered)["disk"] == size
|
|
366
|
+
assert "sized" in command("snapshots", recovered)
|
|
367
|
+
# The recovered box and its snapshot each reserve the full size again.
|
|
368
|
+
reserved = own_pool(location)["reservedBytes"] - before["reservedBytes"]
|
|
369
|
+
assert reserved == (2 * DISK_GIB + image_gib) * GIB, reserved
|
|
370
|
+
command("resume", recovered, "--ttl", "600")
|
|
371
|
+
assert disk_size(recovered)[0] == total
|
|
372
|
+
print(
|
|
373
|
+
f"{location}: own disk kept by fork, template, snapshot and backup/recover", flush=True
|
|
374
|
+
)
|
|
375
|
+
finally:
|
|
376
|
+
for fixture in reversed(fixtures):
|
|
377
|
+
command("destroy", fixture)
|
|
378
|
+
# Only the cached image stays reserved after every own-disk box is gone.
|
|
379
|
+
final = own_pool(location)["reservedBytes"] - before["reservedBytes"]
|
|
380
|
+
assert final == image_gib * GIB, final
|
|
128
381
|
|
|
129
382
|
|
|
130
383
|
if __name__ == "__main__":
|
|
131
384
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
132
|
-
parser.add_argument("--location", help="Also test a real host
|
|
385
|
+
parser.add_argument("--location", help="Also test a real host with its own fixtures")
|
|
133
386
|
args = parser.parse_args()
|
|
134
387
|
policy_checks()
|
|
135
388
|
if args.location:
|
|
136
389
|
live_checks(args.location)
|
|
390
|
+
disk_checks(args.location)
|
package/deploy/verify-live.py
CHANGED
|
@@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
19
19
|
CLI = [sys.executable, str(ROOT / "raft.py")]
|
|
20
20
|
USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
|
|
21
21
|
sys.path.insert(0, str(ROOT))
|
|
22
|
-
from raft import
|
|
22
|
+
from raft import configuration, incus, remote, settings, transaction # noqa: E402
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def raft(*arguments, check=True, env=None):
|
|
@@ -424,7 +424,7 @@ def verify_configuration():
|
|
|
424
424
|
|
|
425
425
|
def verify_arguments():
|
|
426
426
|
# Invoke actual CLI processes: help and argument errors must not access a host.
|
|
427
|
-
commands = "new locations note host list
|
|
427
|
+
commands = "new locations config policy note host list capacity doctor gc prune recover info stop resume extend destroy ssh exec upload download snapshot snapshots snapshot-delete restore fork forward desktop logs status cancel backup usage self-update".split()
|
|
428
428
|
assert raft("--help").returncode == 0
|
|
429
429
|
for command in commands:
|
|
430
430
|
assert raft(command, "--help").returncode == 0
|
|
@@ -435,7 +435,15 @@ def verify_arguments():
|
|
|
435
435
|
for lifetime in ["59", "2592001", "invalid"]:
|
|
436
436
|
rejected = raft(*prefix, "--ttl", lifetime, check=False)
|
|
437
437
|
assert rejected.returncode == 2 and "Traceback" not in rejected.stderr
|
|
438
|
-
for options in [
|
|
438
|
+
for options in [
|
|
439
|
+
["--cpu", "0"],
|
|
440
|
+
["--cpu", "1.5"],
|
|
441
|
+
["--memory", "16"],
|
|
442
|
+
["--memory", "0GiB"],
|
|
443
|
+
["--memory", "1.5GiB"],
|
|
444
|
+
["--disk", "20G"],
|
|
445
|
+
["--disk", "0GiB"],
|
|
446
|
+
]:
|
|
439
447
|
assert raft("new", *options, check=False).returncode == 2
|
|
440
448
|
for arguments in [["exec", missing], ["recover", "missing"], ["forward", missing]]:
|
|
441
449
|
assert raft(*arguments, check=False).returncode == 2
|
|
@@ -444,14 +452,14 @@ def verify_arguments():
|
|
|
444
452
|
|
|
445
453
|
def verify_option_edges(location, box):
|
|
446
454
|
assert any(json.loads(line)["box"] == box for line in raft("list").stdout.splitlines())
|
|
447
|
-
reports = [json.loads(line) for line in raft("
|
|
455
|
+
reports = [json.loads(line) for line in raft("capacity", "--json").stdout.splitlines()]
|
|
448
456
|
assert {report["location"] for report in reports} == set(configuration())
|
|
449
|
-
assert "CPU RAM GiB" in raft("
|
|
457
|
+
assert "CPU RAM GiB" in raft("capacity").stdout
|
|
450
458
|
for arguments, message in [
|
|
451
459
|
(["info", "invalid"], "location-qualified handle"),
|
|
452
460
|
(["info", location + ":rf-0000000000000000"], "Raft box not found"),
|
|
453
461
|
(["list", "--location", "missing-location"], "Unknown location"),
|
|
454
|
-
(["
|
|
462
|
+
(["capacity", "--location", "missing-location"], "Unknown location"),
|
|
455
463
|
(["new", "--location", "missing-location"], "Unknown location"),
|
|
456
464
|
(["upload", box, "missing", "relative"], "must be absolute"),
|
|
457
465
|
(["download", box, "relative", "missing"], "must be absolute"),
|
|
@@ -691,29 +699,30 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False):
|
|
|
691
699
|
verify_option_edges(location, box)
|
|
692
700
|
if extended:
|
|
693
701
|
subprocess.run([sys.executable, str(ROOT / "deploy/verify-tools.py"), box], check=True)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
702
|
+
# Sizes are free-form; these cover the smallest box, an odd CPU count and
|
|
703
|
+
# more RAM per CPU than the default shape. CI hosts have 4 CPUs and 16 GiB.
|
|
704
|
+
for cpu, memory in [(1, "1GiB"), (3, "3GiB"), (2, "8GiB")]:
|
|
705
|
+
sized = raft(
|
|
706
|
+
"new",
|
|
707
|
+
"--location",
|
|
708
|
+
location,
|
|
709
|
+
"--cpu",
|
|
710
|
+
str(cpu),
|
|
711
|
+
"--memory",
|
|
712
|
+
memory,
|
|
713
|
+
).stdout.strip()
|
|
714
|
+
fixtures.append(sized)
|
|
715
|
+
assert (
|
|
716
|
+
int(json.loads(raft("info", sized).stdout)["config"]["user.raft.expires"])
|
|
717
|
+
> int(execute(sized, "date", "+%s")) + 550
|
|
718
|
+
)
|
|
719
|
+
assert execute(sized, "nproc") == str(cpu)
|
|
720
|
+
assert execute(sized, "cat", "/sys/fs/cgroup/memory.max") == str(
|
|
721
|
+
int(memory.removesuffix("GiB")) * 1024**3
|
|
722
|
+
)
|
|
723
|
+
raft("destroy", sized)
|
|
724
|
+
fixtures.remove(sized)
|
|
725
|
+
print(f"{location}: developer tools and free-form resource sizes passed", flush=True)
|
|
717
726
|
assert execute(box, "pwd") == "/workspace"
|
|
718
727
|
host_machine = subprocess.check_output(
|
|
719
728
|
["ssh", "-T", "-o", "BatchMode=yes", settings(location)["ssh"], "uname -m"],
|