@microck/raft 0.0.0-stage → 0.2.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/.github/assets/raft-logo.png +0 -0
- package/LICENSE +21 -0
- package/README.md +162 -2
- package/deploy/benchmark-boot.py +150 -0
- package/deploy/build-image.py +95 -0
- package/deploy/deploy.py +176 -0
- package/deploy/incus-network.conf +4 -0
- package/deploy/install-incus.sh +22 -0
- package/deploy/raft-expire.py +62 -0
- package/deploy/raft-expire.service +8 -0
- package/deploy/raft-expire.timer +8 -0
- package/deploy/raft-network.service +12 -0
- package/deploy/raft-network.sh +40 -0
- package/deploy/verify-controls.py +241 -0
- package/deploy/verify-features.py +461 -0
- package/deploy/verify-fresh-host.py +301 -0
- package/deploy/verify-image-tests.py +139 -0
- package/deploy/verify-image.py +95 -0
- package/deploy/verify-limits.py +110 -0
- package/deploy/verify-live.py +903 -0
- package/deploy/verify-release.py +132 -0
- package/deploy/verify-tools.py +105 -0
- package/docs/benchmarks/amd64-native-ci.json +137 -0
- package/docs/benchmarks/arm64-baseline-a.json +108 -0
- package/docs/benchmarks/arm64-baseline-b.json +108 -0
- package/docs/benchmarks/arm64-candidate-a.json +109 -0
- package/docs/benchmarks/arm64-desktop-local.json +22 -0
- package/docs/benchmarks/arm64-native-ci.json +137 -0
- package/docs/boat-capabilities.md +64 -0
- package/docs/boat-parity-design.md +26 -0
- package/docs/boot-performance.mdx +102 -0
- package/docs/contract.md +93 -0
- package/docs/first-box.mdx +82 -0
- package/docs/guides/background-jobs.mdx +92 -0
- package/docs/guides/backup-recovery.mdx +111 -0
- package/docs/guides/desktop-services.mdx +87 -0
- package/docs/guides/docker.mdx +84 -0
- package/docs/guides/files.mdx +91 -0
- package/docs/guides/meta.json +14 -0
- package/docs/guides/snapshots-forks.mdx +107 -0
- package/docs/guides/troubleshooting.mdx +127 -0
- package/docs/incus.example.json +3 -0
- package/docs/index.mdx +36 -0
- package/docs/meta.json +21 -0
- package/docs/operations.mdx +224 -0
- package/docs/publishing.md +48 -0
- package/docs/reference/capacity-limits.mdx +83 -0
- package/docs/reference/cli.md +279 -0
- package/docs/reference/configuration.mdx +77 -0
- package/docs/reference/meta.json +10 -0
- package/docs/releases/0.2.0.md +22 -0
- package/docs/requirements.mdx +47 -0
- package/docs/reviews/code-review-2026-10-06.md +168 -0
- package/docs/site-development.md +80 -0
- package/docs/verification.mdx +231 -0
- package/images/incus/build.sh +57 -0
- package/images/incus/desktop.sh +21 -0
- package/images/incus/raft-desktop.service +7 -0
- package/package.json +46 -4
- package/raft.py +955 -0
- package/raft_files.py +134 -0
- package/skills/raft-cli/SKILL.md +117 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Provision and reboot a disposable KVM Ubuntu host using the canonical deployer.
|
|
2
|
+
|
|
3
|
+
CI-only harness. Writes temporary verified SSH/configuration in its own HOME.
|
|
4
|
+
Uses a sparse 100 GiB virtual disk, not an 80 GiB physical capacity guarantee.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
+
USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
|
|
20
|
+
BASE = "https://cloud-images.ubuntu.com/noble/current/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(*args, **kwargs):
|
|
24
|
+
return subprocess.run(args, check=True, **kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify_firewall(env):
|
|
28
|
+
"""Disrupt only this harness's disposable VM to prove the startup dependency."""
|
|
29
|
+
ssh = ["ssh", "raft-fresh"]
|
|
30
|
+
requires = subprocess.check_output(
|
|
31
|
+
[*ssh, "systemctl show incus.service -p Requires -p After"], env=env, text=True
|
|
32
|
+
)
|
|
33
|
+
assert all(
|
|
34
|
+
"raft-network.service" in line.split("=", 1)[1].split() for line in requires.splitlines()
|
|
35
|
+
)
|
|
36
|
+
started = subprocess.check_output(
|
|
37
|
+
[*ssh, "systemctl show incus.service -p ExecMainStartTimestampMonotonic --value"],
|
|
38
|
+
env=env,
|
|
39
|
+
text=True,
|
|
40
|
+
)
|
|
41
|
+
# Deliberately inject an obsolete allowance with no boxes present, then redeploy.
|
|
42
|
+
run(
|
|
43
|
+
*ssh,
|
|
44
|
+
"sudo iptables -N RAFT-VERIFY-UNRELATED && "
|
|
45
|
+
"sudo iptables -A RAFT-VERIFY-UNRELATED -j RETURN && "
|
|
46
|
+
"sudo iptables -I RAFT-FORWARD 1 -d 169.254.0.0/16 -j ACCEPT",
|
|
47
|
+
env=env,
|
|
48
|
+
)
|
|
49
|
+
run(sys.executable, str(ROOT / "deploy/deploy.py"), "--location", "lab", env=env)
|
|
50
|
+
run(
|
|
51
|
+
*ssh,
|
|
52
|
+
"sudo iptables -C RAFT-FORWARD -d 169.254.0.0/16 -j REJECT && "
|
|
53
|
+
"! sudo iptables -C RAFT-FORWARD -d 169.254.0.0/16 -j ACCEPT && "
|
|
54
|
+
"sudo iptables -C RAFT-VERIFY-UNRELATED -j RETURN && "
|
|
55
|
+
"sudo iptables -F RAFT-VERIFY-UNRELATED && sudo iptables -X RAFT-VERIFY-UNRELATED",
|
|
56
|
+
env=env,
|
|
57
|
+
)
|
|
58
|
+
unchanged = subprocess.check_output(
|
|
59
|
+
[*ssh, "systemctl show incus.service -p ExecMainStartTimestampMonotonic --value"],
|
|
60
|
+
env=env,
|
|
61
|
+
text=True,
|
|
62
|
+
)
|
|
63
|
+
assert started == unchanged, "Firewall reload restarted the Incus daemon"
|
|
64
|
+
run(*ssh, "sudo cp /usr/local/lib/raft/network /tmp/raft-network-proof", env=env)
|
|
65
|
+
try:
|
|
66
|
+
run(
|
|
67
|
+
*ssh,
|
|
68
|
+
"sudo systemctl stop raft-expire.timer incus-startup.service incus.socket incus.service raft-network.service",
|
|
69
|
+
env=env,
|
|
70
|
+
)
|
|
71
|
+
run(
|
|
72
|
+
*ssh,
|
|
73
|
+
"printf '#!/bin/sh\\nexit 1\\n' | sudo tee /usr/local/lib/raft/network >/dev/null",
|
|
74
|
+
env=env,
|
|
75
|
+
)
|
|
76
|
+
failed = subprocess.run(
|
|
77
|
+
[*ssh, "sudo systemctl start incus.service"], env=env, capture_output=True, text=True
|
|
78
|
+
)
|
|
79
|
+
assert failed.returncode != 0, "Incus started despite a failed firewall dependency"
|
|
80
|
+
active = subprocess.run(
|
|
81
|
+
[*ssh, "systemctl is-active incus.service"], env=env, capture_output=True, text=True
|
|
82
|
+
)
|
|
83
|
+
assert active.stdout.strip() != "active"
|
|
84
|
+
run(*ssh, "sudo systemctl start incus.socket", env=env)
|
|
85
|
+
failed = subprocess.run(
|
|
86
|
+
[*ssh, "sudo timeout 20 incus query /1.0"], env=env, capture_output=True, text=True
|
|
87
|
+
)
|
|
88
|
+
assert failed.returncode != 0, "Socket activation bypassed the firewall dependency"
|
|
89
|
+
active = subprocess.run(
|
|
90
|
+
[*ssh, "systemctl is-active incus.service"], env=env, capture_output=True, text=True
|
|
91
|
+
)
|
|
92
|
+
assert active.stdout.strip() != "active"
|
|
93
|
+
finally:
|
|
94
|
+
run(
|
|
95
|
+
*ssh,
|
|
96
|
+
"sudo cp /tmp/raft-network-proof /usr/local/lib/raft/network && sudo rm /tmp/raft-network-proof && "
|
|
97
|
+
"sudo systemctl reset-failed incus.service raft-network.service && "
|
|
98
|
+
"sudo systemctl reload-or-restart raft-network.service && "
|
|
99
|
+
"sudo systemctl start incus.socket incus.service incus-startup.service raft-expire.timer",
|
|
100
|
+
env=env,
|
|
101
|
+
)
|
|
102
|
+
print(
|
|
103
|
+
"Repeat deploy removed stale rules without restarting Incus; firewall failure blocked normal and socket startup",
|
|
104
|
+
flush=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verify(archive):
|
|
109
|
+
# Fail rather than silently switching to software emulation.
|
|
110
|
+
if not os.access("/dev/kvm", os.R_OK | os.W_OK):
|
|
111
|
+
raise RuntimeError("Fresh-host verification requires accessible KVM")
|
|
112
|
+
with tempfile.TemporaryDirectory(prefix="raft-fresh-") as directory:
|
|
113
|
+
work = Path(directory)
|
|
114
|
+
home = work / "controller"
|
|
115
|
+
ssh = home / ".ssh"
|
|
116
|
+
config = home / ".config/raft"
|
|
117
|
+
ssh.mkdir(parents=True, mode=0o700)
|
|
118
|
+
config.mkdir(parents=True, mode=0o700)
|
|
119
|
+
for key in ["client", "host"]:
|
|
120
|
+
run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(work / key))
|
|
121
|
+
(ssh / "config").write_text(
|
|
122
|
+
f"Host raft-fresh\n HostName localhost\n Port 2222\n User raftci\n"
|
|
123
|
+
f" IdentityFile {work / 'client'}\n IdentitiesOnly yes\n"
|
|
124
|
+
f" UserKnownHostsFile {ssh / 'known_hosts'}\n StrictHostKeyChecking yes\n"
|
|
125
|
+
)
|
|
126
|
+
(ssh / "known_hosts").write_text("[localhost]:2222 " + (work / "host.pub").read_text())
|
|
127
|
+
configuration = config / "incus.json"
|
|
128
|
+
configuration.write_text(json.dumps({"lab": {"ssh": "raft-fresh", "image": ""}}))
|
|
129
|
+
configuration.chmod(0o600)
|
|
130
|
+
# A per-harness HOME keeps the operator's controller config untouched.
|
|
131
|
+
env = {**os.environ, "HOME": str(home)}
|
|
132
|
+
cloud = {
|
|
133
|
+
"hostname": "raft-fresh",
|
|
134
|
+
"users": [
|
|
135
|
+
{
|
|
136
|
+
"name": "raftci",
|
|
137
|
+
"sudo": "ALL=(ALL) NOPASSWD:ALL",
|
|
138
|
+
"shell": "/bin/bash",
|
|
139
|
+
"ssh_authorized_keys": [(work / "client.pub").read_text().strip()],
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"ssh_pwauth": False,
|
|
143
|
+
"ssh_keys": {
|
|
144
|
+
"ed25519_private": (work / "host").read_text(),
|
|
145
|
+
"ed25519_public": (work / "host.pub").read_text(),
|
|
146
|
+
},
|
|
147
|
+
"packages": ["curl", "gnupg", "btrfs-progs"],
|
|
148
|
+
}
|
|
149
|
+
(work / "user-data").write_text("#cloud-config\n" + json.dumps(cloud))
|
|
150
|
+
(work / "meta-data").write_text("instance-id: raft-fresh\nlocal-hostname: raft-fresh\n")
|
|
151
|
+
image = work / "noble-server-cloudimg-amd64.img"
|
|
152
|
+
request = urllib.request.Request(BASE + "SHA256SUMS", headers={"User-Agent": USER_AGENT})
|
|
153
|
+
sums = urllib.request.urlopen(request, timeout=60).read().decode()
|
|
154
|
+
checksum = next(
|
|
155
|
+
line.split()[0]
|
|
156
|
+
for line in sums.splitlines()
|
|
157
|
+
if line.split()[-1].lstrip("*") == image.name
|
|
158
|
+
)
|
|
159
|
+
run("curl", "-fsSL", "-A", USER_AGENT, BASE + image.name, "-o", str(image))
|
|
160
|
+
with image.open("rb") as downloaded:
|
|
161
|
+
assert hashlib.file_digest(downloaded, "sha256").hexdigest() == checksum
|
|
162
|
+
run("qemu-img", "resize", str(image), "100G")
|
|
163
|
+
run(
|
|
164
|
+
"cloud-localds",
|
|
165
|
+
str(work / "seed.img"),
|
|
166
|
+
str(work / "user-data"),
|
|
167
|
+
str(work / "meta-data"),
|
|
168
|
+
)
|
|
169
|
+
with (work / "serial.log").open("wb") as serial:
|
|
170
|
+
vm = subprocess.Popen(
|
|
171
|
+
[
|
|
172
|
+
"qemu-system-x86_64",
|
|
173
|
+
"-enable-kvm",
|
|
174
|
+
"-cpu",
|
|
175
|
+
"host",
|
|
176
|
+
"-smp",
|
|
177
|
+
"2",
|
|
178
|
+
"-m",
|
|
179
|
+
"8192",
|
|
180
|
+
"-display",
|
|
181
|
+
"none",
|
|
182
|
+
"-serial",
|
|
183
|
+
"stdio",
|
|
184
|
+
"-monitor",
|
|
185
|
+
"none",
|
|
186
|
+
"-drive",
|
|
187
|
+
f"file={image},format=qcow2,if=virtio",
|
|
188
|
+
"-drive",
|
|
189
|
+
f"file={work / 'seed.img'},format=raw,if=virtio",
|
|
190
|
+
"-netdev",
|
|
191
|
+
"user,id=net,hostfwd=tcp:127.0.0.1:2222-:22",
|
|
192
|
+
"-device",
|
|
193
|
+
"virtio-net-pci,netdev=net",
|
|
194
|
+
],
|
|
195
|
+
stdout=serial,
|
|
196
|
+
stderr=subprocess.STDOUT,
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
# SSH expands ~ using passwd, not HOME. Pass the fixture config explicitly
|
|
200
|
+
# through a PATH wrapper shared by CLI SSH and SCP calls.
|
|
201
|
+
binaries = work / "bin"
|
|
202
|
+
binaries.mkdir()
|
|
203
|
+
for tool in ["ssh", "scp"]:
|
|
204
|
+
(binaries / tool).write_text(
|
|
205
|
+
f'#!/bin/sh\nexec /usr/bin/{tool} -F "{ssh / "config"}" "$@"\n'
|
|
206
|
+
)
|
|
207
|
+
(binaries / tool).chmod(0o755)
|
|
208
|
+
env["PATH"] = str(binaries) + os.pathsep + env["PATH"]
|
|
209
|
+
deadline = time.monotonic() + 300
|
|
210
|
+
while time.monotonic() < deadline:
|
|
211
|
+
if vm.poll() is not None:
|
|
212
|
+
raise RuntimeError("Fresh VM exited during boot")
|
|
213
|
+
probe = subprocess.run(
|
|
214
|
+
[
|
|
215
|
+
"ssh",
|
|
216
|
+
"-T",
|
|
217
|
+
"-o",
|
|
218
|
+
"BatchMode=yes",
|
|
219
|
+
"-o",
|
|
220
|
+
"ConnectTimeout=2",
|
|
221
|
+
"raft-fresh",
|
|
222
|
+
"true",
|
|
223
|
+
],
|
|
224
|
+
env=env,
|
|
225
|
+
capture_output=True,
|
|
226
|
+
)
|
|
227
|
+
if probe.returncode == 0:
|
|
228
|
+
break
|
|
229
|
+
time.sleep(2)
|
|
230
|
+
else:
|
|
231
|
+
raise RuntimeError("Fresh VM SSH did not become ready")
|
|
232
|
+
run("ssh", "raft-fresh", "sudo cloud-init status --wait --long", env=env)
|
|
233
|
+
run(sys.executable, str(ROOT / "deploy/deploy.py"), "--location", "lab", env=env)
|
|
234
|
+
verify_firewall(env)
|
|
235
|
+
run("scp", str(archive), "raft-fresh:/tmp/raft-image.tar.gz", env=env)
|
|
236
|
+
run(
|
|
237
|
+
"ssh",
|
|
238
|
+
"raft-fresh",
|
|
239
|
+
"sudo incus --project raft image import /tmp/raft-image.tar.gz --alias raft-dev",
|
|
240
|
+
env=env,
|
|
241
|
+
)
|
|
242
|
+
images = subprocess.check_output(
|
|
243
|
+
[
|
|
244
|
+
"ssh",
|
|
245
|
+
"raft-fresh",
|
|
246
|
+
"sudo incus --project raft image list raft-dev --format json",
|
|
247
|
+
],
|
|
248
|
+
env=env,
|
|
249
|
+
text=True,
|
|
250
|
+
)
|
|
251
|
+
fingerprint = next(
|
|
252
|
+
item["fingerprint"]
|
|
253
|
+
for item in json.loads(images)
|
|
254
|
+
if any(a["name"] == "raft-dev" for a in item["aliases"])
|
|
255
|
+
)
|
|
256
|
+
configuration.write_text(
|
|
257
|
+
json.dumps({"lab": {"ssh": "raft-fresh", "image": fingerprint}})
|
|
258
|
+
)
|
|
259
|
+
run("ssh", "raft-fresh", "rm /tmp/raft-image.tar.gz", env=env)
|
|
260
|
+
run(
|
|
261
|
+
sys.executable,
|
|
262
|
+
str(ROOT / "deploy/verify-live.py"),
|
|
263
|
+
"--location",
|
|
264
|
+
"lab",
|
|
265
|
+
"--reboot-host",
|
|
266
|
+
env=env,
|
|
267
|
+
)
|
|
268
|
+
# Oversize only this disposable pool after all boxes have been removed.
|
|
269
|
+
run("ssh", "raft-fresh", "sudo incus storage set raft-data size=61GiB", env=env)
|
|
270
|
+
drift = subprocess.run(
|
|
271
|
+
[sys.executable, str(ROOT / "deploy/deploy.py"), "--location", "lab"],
|
|
272
|
+
env=env,
|
|
273
|
+
capture_output=True,
|
|
274
|
+
text=True,
|
|
275
|
+
)
|
|
276
|
+
assert drift.returncode != 0 and "size=60GiB" in drift.stderr
|
|
277
|
+
size = subprocess.check_output(
|
|
278
|
+
["ssh", "raft-fresh", "sudo incus storage get raft-data size"],
|
|
279
|
+
env=env,
|
|
280
|
+
text=True,
|
|
281
|
+
)
|
|
282
|
+
assert size.strip() == "61GiB", "Deployment silently resized a drifting pool"
|
|
283
|
+
print(
|
|
284
|
+
"Fresh provisioning, firewall failure gating, redeployment, oversize rejection and real host reboot passed",
|
|
285
|
+
flush=True,
|
|
286
|
+
)
|
|
287
|
+
finally:
|
|
288
|
+
vm.terminate()
|
|
289
|
+
vm.wait(timeout=30)
|
|
290
|
+
# Report diagnostics only on failure; never publish fixture keys.
|
|
291
|
+
if sys.exc_info()[0]:
|
|
292
|
+
print(
|
|
293
|
+
(work / "serial.log").read_text(errors="replace")[-10000:], file=sys.stderr
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
299
|
+
parser.add_argument("archive", type=Path)
|
|
300
|
+
args = parser.parse_args()
|
|
301
|
+
verify(args.archive.resolve())
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Exercise the real image inspector with clean and credential-bearing tar archives."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tarfile
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify():
|
|
17
|
+
with tempfile.TemporaryDirectory(prefix="raft-image-check-") as directory:
|
|
18
|
+
work = Path(directory)
|
|
19
|
+
key = work / "key"
|
|
20
|
+
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True)
|
|
21
|
+
home = PurePosixPath("rootfs", "home", "developer")
|
|
22
|
+
root = PurePosixPath("rootfs", "root")
|
|
23
|
+
cases = [
|
|
24
|
+
("clean", None, tarfile.REGTYPE, "", True),
|
|
25
|
+
("user-key", home / ".ssh/id_ed25519", tarfile.REGTYPE, "", False),
|
|
26
|
+
("root-key", root / ".ssh/id_ed25519", tarfile.REGTYPE, "", False),
|
|
27
|
+
("user-key-link", home / ".ssh/id_ed25519", tarfile.SYMTYPE, "/etc/passwd", False),
|
|
28
|
+
("ssh-directory-link", home / ".ssh", tarfile.SYMTYPE, "/etc", False),
|
|
29
|
+
(
|
|
30
|
+
"user-key-hardlink",
|
|
31
|
+
home / ".ssh/id_ed25519",
|
|
32
|
+
tarfile.LNKTYPE,
|
|
33
|
+
"metadata.yaml",
|
|
34
|
+
False,
|
|
35
|
+
),
|
|
36
|
+
("user-key-fifo", home / ".ssh/id_ed25519", tarfile.FIFOTYPE, "", False),
|
|
37
|
+
("key-target-link", home / "alias", tarfile.SYMTYPE, ".ssh/id_ed25519", False),
|
|
38
|
+
(
|
|
39
|
+
"key-target-hardlink",
|
|
40
|
+
home / "alias",
|
|
41
|
+
tarfile.LNKTYPE,
|
|
42
|
+
str(home / ".ssh/id_ed25519"),
|
|
43
|
+
False,
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"host-key-link",
|
|
47
|
+
PurePosixPath("rootfs/etc/ssh/ssh_host_ed25519_key"),
|
|
48
|
+
tarfile.SYMTYPE,
|
|
49
|
+
"/etc/passwd",
|
|
50
|
+
False,
|
|
51
|
+
),
|
|
52
|
+
("npm-token", home / ".npmrc", tarfile.REGTYPE, "", False),
|
|
53
|
+
("empty-ssh-directory", home / ".ssh", tarfile.DIRTYPE, "", True),
|
|
54
|
+
(
|
|
55
|
+
"package-config",
|
|
56
|
+
PurePosixPath("rootfs/usr/lib/node_modules/npm/.npmrc"),
|
|
57
|
+
tarfile.REGTYPE,
|
|
58
|
+
"",
|
|
59
|
+
True,
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
"package-token",
|
|
63
|
+
PurePosixPath("rootfs/usr/lib/node_modules/npm/npmrc"),
|
|
64
|
+
tarfile.REGTYPE,
|
|
65
|
+
"",
|
|
66
|
+
False,
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"normal-link",
|
|
70
|
+
PurePosixPath("rootfs/usr/bin/example"),
|
|
71
|
+
tarfile.SYMTYPE,
|
|
72
|
+
"../lib/example",
|
|
73
|
+
True,
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
for label, path, kind, target, accepted in cases:
|
|
77
|
+
case = work / label
|
|
78
|
+
case.mkdir()
|
|
79
|
+
archive = case / "image.tar.gz"
|
|
80
|
+
resources = {
|
|
81
|
+
"metadata.yaml": b"architecture: aarch64\n",
|
|
82
|
+
"rootfs/etc/machine-id": b"",
|
|
83
|
+
"rootfs/opt/raft/packages.tsv": b"example\t1\n",
|
|
84
|
+
"rootfs/opt/raft/npm-packages.json": b"{}\n",
|
|
85
|
+
}
|
|
86
|
+
with tarfile.open(archive, "w:gz") as output:
|
|
87
|
+
for name, content in resources.items():
|
|
88
|
+
member = tarfile.TarInfo(name)
|
|
89
|
+
member.size = len(content)
|
|
90
|
+
output.addfile(member, io.BytesIO(content))
|
|
91
|
+
if path:
|
|
92
|
+
member = tarfile.TarInfo(str(path))
|
|
93
|
+
member.type = kind
|
|
94
|
+
member.linkname = target
|
|
95
|
+
content = (
|
|
96
|
+
key.read_bytes()
|
|
97
|
+
if "key" in label
|
|
98
|
+
else b"registry=https://registry.npmjs.org/\n"
|
|
99
|
+
)
|
|
100
|
+
if label in {"npm-token", "package-token"}:
|
|
101
|
+
content = b"//registry.npmjs.org/:_authToken=fixture-token\n"
|
|
102
|
+
if kind == tarfile.REGTYPE:
|
|
103
|
+
member.size = len(content)
|
|
104
|
+
output.addfile(member, io.BytesIO(content) if kind == tarfile.REGTYPE else None)
|
|
105
|
+
fingerprint = hashlib.sha256(archive.read_bytes()).hexdigest()
|
|
106
|
+
response = subprocess.run(
|
|
107
|
+
[
|
|
108
|
+
sys.executable,
|
|
109
|
+
str(ROOT / "deploy/verify-image.py"),
|
|
110
|
+
str(archive),
|
|
111
|
+
"--architecture",
|
|
112
|
+
"arm64",
|
|
113
|
+
"--fingerprint",
|
|
114
|
+
fingerprint,
|
|
115
|
+
],
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
env={
|
|
119
|
+
**os.environ,
|
|
120
|
+
"GITHUB_SHA": "0" * 40,
|
|
121
|
+
"GITHUB_REPOSITORY": "Microck/raft",
|
|
122
|
+
"GITHUB_RUN_ID": "0",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
assert (response.returncode == 0) == accepted, (label, response.stderr)
|
|
126
|
+
if accepted:
|
|
127
|
+
manifest = json.loads((case / "manifest-arm64.json").read_text())
|
|
128
|
+
assert manifest["sha256"] == fingerprint
|
|
129
|
+
assert (case / "packages-arm64.tsv").read_bytes() == resources[
|
|
130
|
+
"rootfs/opt/raft/packages.tsv"
|
|
131
|
+
]
|
|
132
|
+
else:
|
|
133
|
+
assert "credential" in response.stderr.lower(), (label, response.stderr)
|
|
134
|
+
assert not (case / "manifest-arm64.json").exists()
|
|
135
|
+
print(f"Image inspector: {len(cases)} real clean/credential archive cases passed")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
verify()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Inspect a clean unified Incus image and write its public release manifest."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
import re
|
|
9
|
+
import tarfile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def credential_path(path):
|
|
13
|
+
"""Reject credential locations even when a tar entry is a link or special file."""
|
|
14
|
+
return (
|
|
15
|
+
".ssh" in path.parts
|
|
16
|
+
or (path.parent.name == "ssh" and path.name.startswith("ssh_host_"))
|
|
17
|
+
or path.name in {".netrc", "incus.json"}
|
|
18
|
+
or (path.name == ".npmrc" and path.parts[:2] in {("rootfs", "root"), ("rootfs", "home")})
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify(archive, architecture, fingerprint):
|
|
23
|
+
if archive.stat().st_size >= 2 * 1024**3:
|
|
24
|
+
raise ValueError("Image exceeds GitHub's per-release-asset limit")
|
|
25
|
+
with archive.open("rb") as exported:
|
|
26
|
+
digest = hashlib.file_digest(exported, "sha256").hexdigest()
|
|
27
|
+
if digest != fingerprint:
|
|
28
|
+
raise ValueError("Export SHA256 does not match its immutable Incus fingerprint")
|
|
29
|
+
wanted = {
|
|
30
|
+
"rootfs/opt/raft/packages.tsv": f"packages-{architecture}.tsv",
|
|
31
|
+
"rootfs/opt/raft/npm-packages.json": f"npm-packages-{architecture}.json",
|
|
32
|
+
"metadata.yaml": None,
|
|
33
|
+
"rootfs/etc/machine-id": None,
|
|
34
|
+
}
|
|
35
|
+
found = set()
|
|
36
|
+
with tarfile.open(archive, "r|gz") as image:
|
|
37
|
+
for member in image:
|
|
38
|
+
path = PurePosixPath(member.name)
|
|
39
|
+
if path.is_absolute() or ".." in path.parts:
|
|
40
|
+
raise ValueError("Unsafe image member: " + member.name)
|
|
41
|
+
name = str(path)
|
|
42
|
+
if not member.isdir() and (
|
|
43
|
+
credential_path(path)
|
|
44
|
+
or path.parts[:2] == ("rootfs", "workspace")
|
|
45
|
+
or (
|
|
46
|
+
(member.issym() or member.islnk())
|
|
47
|
+
and credential_path(PurePosixPath(member.linkname))
|
|
48
|
+
)
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("Operator content or credential entry in template: " + name)
|
|
51
|
+
if not member.isfile():
|
|
52
|
+
continue
|
|
53
|
+
# npm itself ships a package-local .npmrc. Check all npm configs
|
|
54
|
+
# for credentials rather than treating every package config as a secret.
|
|
55
|
+
if path.name in {".npmrc", "npmrc"}:
|
|
56
|
+
content = image.extractfile(member).read().decode(errors="replace")
|
|
57
|
+
if re.search(
|
|
58
|
+
r"(?mi)^[^#;\n]*(?:_authToken|_auth|_password|password)\s*=\s*\S+", content
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("Credential setting in npm config: " + name)
|
|
61
|
+
if name in wanted:
|
|
62
|
+
content = image.extractfile(member).read()
|
|
63
|
+
found.add(name)
|
|
64
|
+
if name.endswith("machine-id") and content.strip():
|
|
65
|
+
raise ValueError("Template has a populated machine ID")
|
|
66
|
+
if wanted[name]:
|
|
67
|
+
(archive.parent / wanted[name]).write_bytes(content)
|
|
68
|
+
if found != set(wanted):
|
|
69
|
+
raise ValueError("Missing template resources: " + str(set(wanted) - found))
|
|
70
|
+
manifest = {
|
|
71
|
+
"architecture": architecture,
|
|
72
|
+
"filename": archive.name,
|
|
73
|
+
"sizeBytes": archive.stat().st_size,
|
|
74
|
+
"sha256": digest,
|
|
75
|
+
"incusFingerprint": fingerprint,
|
|
76
|
+
"sourceCommit": os.environ["GITHUB_SHA"],
|
|
77
|
+
"buildRun": f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}",
|
|
78
|
+
"base": "Debian 13 default system container",
|
|
79
|
+
"isolation": "Unprivileged system container; shares the host kernel",
|
|
80
|
+
"checks": ["extended lifecycle", "31 developer tools", "same-host full-image recovery"],
|
|
81
|
+
"licensing": "Raft is MIT; bundled third-party packages retain their licenses in /usr/share/doc and upstream distributions",
|
|
82
|
+
}
|
|
83
|
+
(archive.parent / f"manifest-{architecture}.json").write_text(
|
|
84
|
+
json.dumps(manifest, indent=2) + "\n"
|
|
85
|
+
)
|
|
86
|
+
print("Clean image verified: " + archive.name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
91
|
+
parser.add_argument("archive", type=Path)
|
|
92
|
+
parser.add_argument("--architecture", choices=["arm64", "amd64"], required=True)
|
|
93
|
+
parser.add_argument("--fingerprint", required=True)
|
|
94
|
+
args = parser.parse_args()
|
|
95
|
+
verify(args.archive, args.architecture, args.fingerprint)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Check capacity boundaries and optionally real disposable box transitions."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
10
|
+
sys.path.insert(0, str(ROOT))
|
|
11
|
+
from raft import capacity # noqa: E402
|
|
12
|
+
|
|
13
|
+
GIB = 1024**3
|
|
14
|
+
CLI = [sys.executable, str(ROOT / "raft.py")]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def policy_checks():
|
|
18
|
+
# These are allocation fixtures, not mocked host services. Expected counts
|
|
19
|
+
# describe the documented policy independently of its arithmetic.
|
|
20
|
+
host = {
|
|
21
|
+
"cpus": 8,
|
|
22
|
+
"memoryTotalBytes": 16 * GIB,
|
|
23
|
+
"memoryAvailableBytes": 14 * GIB,
|
|
24
|
+
"hostDiskFreeBytes": 40 * GIB,
|
|
25
|
+
"poolTotalBytes": 60 * GIB,
|
|
26
|
+
"poolFreeBytes": 30 * GIB,
|
|
27
|
+
}
|
|
28
|
+
running = {"status": "Running", "expanded_config": {"limits.cpu": "2", "limits.memory": "4GiB"}}
|
|
29
|
+
stopped = {"status": "Stopped"}
|
|
30
|
+
report = capacity(host, [running, stopped])
|
|
31
|
+
assert report["savedBoxes"] == 2 and report["activeBoxes"] == 1
|
|
32
|
+
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
|
+
full = capacity(host, [stopped] * 4)
|
|
36
|
+
assert full["savedSlots"] == 0
|
|
37
|
+
assert all(r["newBoxes"] == 0 for r in full["recommendations"])
|
|
38
|
+
assert any(r["additionalRunning"] > 0 for r in full["recommendations"])
|
|
39
|
+
frozen = capacity(host, [{**running, "status": "Frozen"}])
|
|
40
|
+
assert frozen["allocated"] == report["allocated"]
|
|
41
|
+
low = capacity({**host, "memoryAvailableBytes": GIB, "poolFreeBytes": GIB}, [])
|
|
42
|
+
assert all(r["additionalRunning"] == 0 for r in low["recommendations"])
|
|
43
|
+
assert len(low["warnings"]) == 2
|
|
44
|
+
assert all(r["totalRunning"] == 0 for r in capacity({**host, "cpus": 1}, [])["recommendations"])
|
|
45
|
+
assert all(
|
|
46
|
+
r["totalRunning"] == 0
|
|
47
|
+
for r in capacity({**host, "memoryTotalBytes": GIB}, [])["recommendations"]
|
|
48
|
+
)
|
|
49
|
+
large = capacity({**host, "cpus": 64, "memoryTotalBytes": 128 * GIB}, [])
|
|
50
|
+
assert large["reserve"]["memoryBytes"] == 128 * GIB // 10
|
|
51
|
+
assert all(r["totalRunning"] == 4 for r in large["recommendations"])
|
|
52
|
+
over = capacity({**host, "cpus": 2}, [running])
|
|
53
|
+
assert over["warnings"] and all(r["additionalRunning"] == 0 for r in over["recommendations"])
|
|
54
|
+
for cpu, memory, expected in [(2, 8, 1), (4, 8, 3), (4, 16, 3), (8, 32, 4)]:
|
|
55
|
+
example = capacity({**host, "cpus": cpu, "memoryTotalBytes": memory * GIB}, [])
|
|
56
|
+
default = next(
|
|
57
|
+
r for r in example["recommendations"] if r["cpu"] == 1 and r["memoryGiB"] == 2
|
|
58
|
+
)
|
|
59
|
+
assert default["totalRunning"] == expected
|
|
60
|
+
print(
|
|
61
|
+
"Capacity policy: mixed sizes, stopped/frozen boxes, low RAM/disk, reserves and saved cap passed",
|
|
62
|
+
flush=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def command(*args):
|
|
67
|
+
return subprocess.check_output([*CLI, *args], text=True).strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def live_checks(location):
|
|
71
|
+
def limits():
|
|
72
|
+
return json.loads(command("limits", "--location", location, "--json"))
|
|
73
|
+
|
|
74
|
+
baseline = limits()
|
|
75
|
+
if baseline["savedSlots"] == 0:
|
|
76
|
+
raise RuntimeError("Capacity E2E needs one free saved-box slot")
|
|
77
|
+
box = command("new", "--location", location, "--cpu", "1", "--memory", "1GiB", "--ttl", "600")
|
|
78
|
+
try:
|
|
79
|
+
active = limits()
|
|
80
|
+
assert active["savedBoxes"] == baseline["savedBoxes"] + 1
|
|
81
|
+
assert active["activeBoxes"] == baseline["activeBoxes"] + 1
|
|
82
|
+
assert active["allocated"]["cpus"] == baseline["allocated"]["cpus"] + 1
|
|
83
|
+
assert active["allocated"]["memoryBytes"] == baseline["allocated"]["memoryBytes"] + GIB
|
|
84
|
+
assert active["savedSlots"] == baseline["savedSlots"] - 1
|
|
85
|
+
command("stop", box)
|
|
86
|
+
stopped = limits()
|
|
87
|
+
assert stopped["savedBoxes"] == active["savedBoxes"]
|
|
88
|
+
assert stopped["activeBoxes"] == baseline["activeBoxes"]
|
|
89
|
+
assert stopped["allocated"] == baseline["allocated"]
|
|
90
|
+
command("resume", box, "--ttl", "600")
|
|
91
|
+
resumed = limits()
|
|
92
|
+
assert resumed["activeBoxes"] == active["activeBoxes"]
|
|
93
|
+
assert resumed["allocated"] == active["allocated"]
|
|
94
|
+
assert "CPU RAM GiB" in command("limits", "--location", location)
|
|
95
|
+
finally:
|
|
96
|
+
command("destroy", box)
|
|
97
|
+
final = limits()
|
|
98
|
+
assert final["savedBoxes"] == baseline["savedBoxes"]
|
|
99
|
+
assert final["activeBoxes"] == baseline["activeBoxes"]
|
|
100
|
+
assert final["allocated"] == baseline["allocated"]
|
|
101
|
+
print(f"{location}: real capacity create/stop/resume/destroy reporting passed", flush=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
106
|
+
parser.add_argument("--location", help="Also test a real host using one disposable box")
|
|
107
|
+
args = parser.parse_args()
|
|
108
|
+
policy_checks()
|
|
109
|
+
if args.location:
|
|
110
|
+
live_checks(args.location)
|