@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.
@@ -1,90 +1,108 @@
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
 
@@ -93,44 +111,280 @@ def command(*args):
93
111
  return subprocess.check_output([*CLI, *args], text=True).strip()
94
112
 
95
113
 
96
- def live_checks(location):
97
- def limits():
98
- return json.loads(command("limits", "--location", location, "--json"))
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
- baseline = limits()
101
- if baseline["savedSlots"] == 0:
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 = limits()
106
- assert active["savedBoxes"] == baseline["savedBoxes"] + 1
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 active["savedSlots"] == baseline["savedSlots"] - 1
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 = limits()
113
- assert stopped["savedBoxes"] == active["savedBoxes"]
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 = limits()
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 = limits()
124
- assert final["savedBoxes"] == baseline["savedBoxes"]
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 create/stop/resume/destroy reporting passed", flush=True)
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 using one disposable box")
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)
@@ -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 MEMORY_SIZES, configuration, incus, remote, settings, transaction # noqa: E402
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 limits 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".split()
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 [["--cpu", "0"], ["--cpu", "3"], ["--memory", "16GiB"]]:
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("limits", "--json").stdout.splitlines()]
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("limits").stdout
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
- (["limits", "--location", "missing-location"], "Unknown location"),
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
- for cpu in [1, 2]:
695
- for memory in MEMORY_SIZES:
696
- sized = raft(
697
- "new",
698
- "--location",
699
- location,
700
- "--cpu",
701
- str(cpu),
702
- "--memory",
703
- memory,
704
- ).stdout.strip()
705
- fixtures.append(sized)
706
- assert (
707
- int(json.loads(raft("info", sized).stdout)["config"]["user.raft.expires"])
708
- > int(execute(sized, "date", "+%s")) + 550
709
- )
710
- assert execute(sized, "nproc") == str(cpu)
711
- assert execute(sized, "cat", "/sys/fs/cgroup/memory.max") == str(
712
- int(memory.removesuffix("GiB")) * 1024**3
713
- )
714
- raft("destroy", sized)
715
- fixtures.remove(sized)
716
- print(f"{location}: developer tools and all resource combinations passed", flush=True)
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"],