@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.
Files changed (62) hide show
  1. package/.github/assets/raft-logo.png +0 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -2
  4. package/deploy/benchmark-boot.py +150 -0
  5. package/deploy/build-image.py +95 -0
  6. package/deploy/deploy.py +176 -0
  7. package/deploy/incus-network.conf +4 -0
  8. package/deploy/install-incus.sh +22 -0
  9. package/deploy/raft-expire.py +62 -0
  10. package/deploy/raft-expire.service +8 -0
  11. package/deploy/raft-expire.timer +8 -0
  12. package/deploy/raft-network.service +12 -0
  13. package/deploy/raft-network.sh +40 -0
  14. package/deploy/verify-controls.py +241 -0
  15. package/deploy/verify-features.py +461 -0
  16. package/deploy/verify-fresh-host.py +301 -0
  17. package/deploy/verify-image-tests.py +139 -0
  18. package/deploy/verify-image.py +95 -0
  19. package/deploy/verify-limits.py +110 -0
  20. package/deploy/verify-live.py +903 -0
  21. package/deploy/verify-release.py +132 -0
  22. package/deploy/verify-tools.py +105 -0
  23. package/docs/benchmarks/amd64-native-ci.json +137 -0
  24. package/docs/benchmarks/arm64-baseline-a.json +108 -0
  25. package/docs/benchmarks/arm64-baseline-b.json +108 -0
  26. package/docs/benchmarks/arm64-candidate-a.json +109 -0
  27. package/docs/benchmarks/arm64-desktop-local.json +22 -0
  28. package/docs/benchmarks/arm64-native-ci.json +137 -0
  29. package/docs/boat-capabilities.md +64 -0
  30. package/docs/boat-parity-design.md +26 -0
  31. package/docs/boot-performance.mdx +102 -0
  32. package/docs/contract.md +93 -0
  33. package/docs/first-box.mdx +82 -0
  34. package/docs/guides/background-jobs.mdx +92 -0
  35. package/docs/guides/backup-recovery.mdx +111 -0
  36. package/docs/guides/desktop-services.mdx +87 -0
  37. package/docs/guides/docker.mdx +84 -0
  38. package/docs/guides/files.mdx +91 -0
  39. package/docs/guides/meta.json +14 -0
  40. package/docs/guides/snapshots-forks.mdx +107 -0
  41. package/docs/guides/troubleshooting.mdx +127 -0
  42. package/docs/incus.example.json +3 -0
  43. package/docs/index.mdx +36 -0
  44. package/docs/meta.json +21 -0
  45. package/docs/operations.mdx +224 -0
  46. package/docs/publishing.md +48 -0
  47. package/docs/reference/capacity-limits.mdx +83 -0
  48. package/docs/reference/cli.md +279 -0
  49. package/docs/reference/configuration.mdx +77 -0
  50. package/docs/reference/meta.json +10 -0
  51. package/docs/releases/0.2.0.md +22 -0
  52. package/docs/requirements.mdx +47 -0
  53. package/docs/reviews/code-review-2026-10-06.md +168 -0
  54. package/docs/site-development.md +80 -0
  55. package/docs/verification.mdx +231 -0
  56. package/images/incus/build.sh +57 -0
  57. package/images/incus/desktop.sh +21 -0
  58. package/images/incus/raft-desktop.service +7 -0
  59. package/package.json +46 -4
  60. package/raft.py +955 -0
  61. package/raft_files.py +134 -0
  62. package/skills/raft-cli/SKILL.md +117 -0
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/python3
2
+ """Enforce running deadlines and preview or delete explicitly selected old boxes."""
3
+
4
+ import argparse
5
+ import json
6
+ import subprocess
7
+ import time
8
+
9
+ PROTOCOL = 1
10
+
11
+ PREFIX = ["incus", "--project", "raft"]
12
+
13
+
14
+ def run(*argv):
15
+ return subprocess.run([*PREFIX, *argv], check=True, capture_output=True, text=True).stdout
16
+
17
+
18
+ def main():
19
+ cli = argparse.ArgumentParser(description=__doc__)
20
+ cli.add_argument("--prune-days", type=int)
21
+ cli.add_argument("--yes", action="store_true")
22
+ args = cli.parse_args()
23
+ if args.prune_days is not None and args.prune_days < 1:
24
+ cli.error("Retention must be at least one day")
25
+ if args.yes and args.prune_days is None:
26
+ cli.error("--yes requires --prune-days")
27
+ now = int(time.time())
28
+ # The service, gc and prune all hold the same lifecycle lock. Never select
29
+ # unrelated Incus instances or use creation time as a guessed stop time.
30
+ for box in json.loads(run("list", "--fast", "--format", "json")):
31
+ config = box["config"]
32
+ if config.get("user.raft") != "true":
33
+ continue
34
+ name = box["name"]
35
+ if args.prune_days is not None:
36
+ stopped_at = config.get("user.raft.stopped-at")
37
+ if box["status"] != "Stopped" or not stopped_at:
38
+ continue
39
+ if int(stopped_at) > now - args.prune_days * 86400:
40
+ continue
41
+ if args.yes:
42
+ run("delete", name)
43
+ print(json.dumps({"box": name, "action": "deleted" if args.yes else "would-delete"}))
44
+ continue
45
+ deadline = config.get("user.raft.expires")
46
+ if not deadline or int(deadline) > now:
47
+ continue
48
+ if box["status"] not in ["Running", "Stopped"]:
49
+ raise RuntimeError(f"Unexpected state for expired box {name}")
50
+ if config.get("user.raft.disposable") == "true":
51
+ run("delete", name, "--force")
52
+ print(f"Deleted expired disposable box {name}", flush=True)
53
+ continue
54
+ if box["status"] == "Running":
55
+ run("stop", name, "--timeout", "30")
56
+ run("config", "set", name, f"user.raft.stopped-at={now}")
57
+ run("config", "unset", name, "user.raft.expires")
58
+ print(f"Stopped expired box {name}; disk retained", flush=True)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Enforce Raft deadlines and delete disposable workspaces
3
+ After=incus.service
4
+ Requires=incus.service
5
+ [Service]
6
+ Type=oneshot
7
+ ExecStart=/usr/bin/flock /run/lock/raft-incus.lock /usr/local/lib/raft/expire-worker
8
+ TimeoutStartSec=180
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Check Raft running deadlines
3
+ [Timer]
4
+ OnBootSec=30
5
+ OnUnitActiveSec=15
6
+ AccuracySec=1
7
+ [Install]
8
+ WantedBy=timers.target
@@ -0,0 +1,12 @@
1
+ [Unit]
2
+ Description=Private Raft bridge firewall rules
3
+ After=network-online.target docker.service nftables.service
4
+ Wants=network-online.target
5
+ Before=incus.service incus-startup.service raft-expire.service
6
+ [Service]
7
+ Type=oneshot
8
+ ExecStart=/usr/local/lib/raft/network
9
+ ExecReload=/usr/local/lib/raft/network
10
+ RemainAfterExit=yes
11
+ [Install]
12
+ WantedBy=multi-user.target
@@ -0,0 +1,40 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ # Peer frames can bypass IP hooks when bridge-netfilter is absent. Filter them
4
+ # at the bridge hook, scoped to Raft, without changing host-wide bridge sysctls.
5
+ nft -f - <<'RULES'
6
+ add table bridge raft
7
+ add chain bridge raft peers { type filter hook forward priority -200; policy accept; }
8
+ flush chain bridge raft peers
9
+ add rule bridge raft peers meta ibrname "rfbr0" meta obrname "rfbr0" counter drop
10
+ RULES
11
+ # Replace the owned IPv4 chains in one transaction. Existing jumps continue to
12
+ # enforce the old complete policy until the new complete policy commits.
13
+ iptables-restore --noflush <<'RULES'
14
+ *filter
15
+ :RAFT-INPUT - [0:0]
16
+ :RAFT-FORWARD - [0:0]
17
+ -F RAFT-INPUT
18
+ -F RAFT-FORWARD
19
+ -A RAFT-INPUT -p udp --dport 67 -j ACCEPT
20
+ -A RAFT-INPUT -d 10.232.0.1 -p udp --dport 53 -j ACCEPT
21
+ -A RAFT-INPUT -d 10.232.0.1 -p tcp --dport 53 -j ACCEPT
22
+ -A RAFT-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
23
+ -A RAFT-INPUT -j REJECT
24
+ -A RAFT-FORWARD -d 10.0.0.0/8 -j REJECT
25
+ -A RAFT-FORWARD -d 100.64.0.0/10 -j REJECT
26
+ -A RAFT-FORWARD -d 169.254.0.0/16 -j REJECT
27
+ -A RAFT-FORWARD -d 172.16.0.0/12 -j REJECT
28
+ -A RAFT-FORWARD -d 192.168.0.0/16 -j REJECT
29
+ -A RAFT-FORWARD -j ACCEPT
30
+ COMMIT
31
+ RULES
32
+ iptables -C INPUT -i rfbr0 -j RAFT-INPUT 2>/dev/null || iptables -I INPUT 1 -i rfbr0 -j RAFT-INPUT
33
+ iptables -C FORWARD -i rfbr0 -j RAFT-FORWARD 2>/dev/null || iptables -I FORWARD 1 -i rfbr0 -j RAFT-FORWARD
34
+ iptables -C FORWARD -o rfbr0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || iptables -I FORWARD 1 -o rfbr0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
35
+
36
+ iptables -C FORWARD -o rfbr0 -m conntrack --ctstate NEW -j REJECT 2>/dev/null || iptables -I FORWARD 1 -o rfbr0 -m conntrack --ctstate NEW -j REJECT
37
+ # IPv6 is not provided on this bridge; reject link-local peer traffic too.
38
+ ip6tables -C INPUT -i rfbr0 -j REJECT 2>/dev/null || ip6tables -I INPUT 1 -i rfbr0 -j REJECT
39
+ ip6tables -C FORWARD -i rfbr0 -j REJECT 2>/dev/null || ip6tables -I FORWARD 1 -i rfbr0 -j REJECT
40
+ ip6tables -C FORWARD -o rfbr0 -j REJECT 2>/dev/null || ip6tables -I FORWARD 1 -o rfbr0 -j REJECT
@@ -0,0 +1,241 @@
1
+ """Verify portable recovery between real hosts using a Debian fixture or full image."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import uuid
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12
+ from raft import ADMISSION, address, incus, inventory, parse_handle, transaction # noqa: E402
13
+ import importlib.util # noqa: E402
14
+
15
+ spec = importlib.util.spec_from_file_location(
16
+ "verify_live", Path(__file__).with_name("verify-live.py")
17
+ )
18
+ live = importlib.util.module_from_spec(spec)
19
+ spec.loader.exec_module(live)
20
+
21
+
22
+ def wait_address(box):
23
+ # Incus Running precedes DHCP readiness. Use the canonical managed-NIC
24
+ # lookup, not hostname -I, which can include guest Docker bridge addresses.
25
+ location, name = parse_handle(box)
26
+ deadline = time.monotonic() + 30
27
+ while time.monotonic() < deadline:
28
+ try:
29
+ return address(location, name)
30
+ except ValueError:
31
+ time.sleep(0.2)
32
+ raise RuntimeError("Resumed workspace has no managed IPv4 address after 30 seconds")
33
+
34
+
35
+ def verify_admission(location, directory):
36
+ fixtures = []
37
+ saved = len(inventory(location))
38
+ if saved >= 4:
39
+ raise RuntimeError("Admission test needs at least one free slot on its target")
40
+ try:
41
+ for _ in range(4 - saved):
42
+ box = live.raft(
43
+ "new", "--location", location, "--memory", "1GiB", "--ttl", "600"
44
+ ).stdout.strip()
45
+ fixtures.append(box)
46
+ live.wait_running(box)
47
+ live.raft("stop", box)
48
+ rejected = live.raft("new", "--location", location, check=False)
49
+ if rejected.returncode == 0:
50
+ fixtures.append(rejected.stdout.strip())
51
+ assert rejected.returncode != 0 and "already holds four boxes" in rejected.stderr
52
+ rejected = live.raft("fork", fixtures[0], "--ttl", "600", check=False)
53
+ if rejected.returncode == 0:
54
+ fixtures.append(rejected.stdout.strip())
55
+ assert rejected.returncode != 0 and "already holds four boxes" in rejected.stderr
56
+ with tempfile.TemporaryDirectory(dir=directory, prefix="raft-admission-") as work:
57
+ # Admission must reject before native archive parsing, without creating a box.
58
+ archive = Path(work) / "rejected.tar.gz"
59
+ archive.write_bytes(b"not-an-incus-archive")
60
+ rejected = live.raft("recover", str(archive), "--location", location, check=False)
61
+ if rejected.returncode == 0:
62
+ fixtures.append(rejected.stdout.strip())
63
+ assert rejected.returncode != 0 and "already holds four boxes" in rejected.stderr
64
+ assert len(inventory(location)) == 4
65
+ report = json.loads(live.raft("limits", "--location", location, "--json").stdout)
66
+ assert report["savedBoxes"] == 4 and report["savedSlots"] == 0
67
+ assert all(size["newBoxes"] == 0 for size in report["recommendations"])
68
+ print(
69
+ f"{location}: stopped boxes count toward admission; new/fork/recover reject a fifth box",
70
+ flush=True,
71
+ )
72
+ finally:
73
+ for box in reversed(fixtures):
74
+ live.raft("destroy", box)
75
+
76
+
77
+ def verify(source, target, directory, development_image=False):
78
+ fixtures = []
79
+ name = "rf-" + uuid.uuid4().hex[:16]
80
+ box = source + ":" + name
81
+ try:
82
+ if development_image:
83
+ box = live.raft("new", "--location", source, "--ttl", "1800").stdout.strip()
84
+ fixtures.append(box)
85
+ live.wait_running(box)
86
+ else:
87
+ transaction(
88
+ source,
89
+ ADMISSION + 'incus --project raft launch images:debian/13 "$1" '
90
+ "-c user.raft=true -c limits.cpu=1 -c limits.memory=1GiB; "
91
+ 'incus --project raft config set "$1" user.raft.expires="$(( $(date +%s) + 600 ))"',
92
+ name,
93
+ )
94
+ fixtures.append(box)
95
+ incus(source, "exec", name, "--", "mkdir", "-p", "/workspace")
96
+ live.execute(
97
+ box,
98
+ "bash",
99
+ "-ec",
100
+ "apt-get update -qq; apt-get install -y --no-install-recommends python3",
101
+ )
102
+ inventories = None
103
+ if development_image:
104
+ inventories = live.execute(
105
+ box, "sha256sum", "/opt/raft/packages.tsv", "/opt/raft/npm-packages.json"
106
+ )
107
+ print(f"{source}: fixture ready", flush=True)
108
+ live.execute(
109
+ box,
110
+ "bash",
111
+ "-ec",
112
+ "printf raft-backup-proof > /workspace/proof; chmod 640 /workspace/proof",
113
+ )
114
+ job = live.raft(
115
+ "exec",
116
+ box,
117
+ "--detach",
118
+ "--",
119
+ "bash",
120
+ "-ec",
121
+ "sleep 600 & echo $! > /workspace/child-pid; wait",
122
+ ).stdout.strip()
123
+ deadline = time.monotonic() + 10
124
+ while live.raft(
125
+ "exec", box, "--", "test", "-f", "/workspace/child-pid", check=False
126
+ ).returncode:
127
+ if time.monotonic() > deadline:
128
+ raise RuntimeError("Detached job did not start")
129
+ time.sleep(0.2)
130
+ child = live.execute(box, "cat", "/workspace/child-pid")
131
+ assert "ActiveState=active" in live.raft("status", box, job).stdout
132
+ live.raft("extend", box, "--ttl", "1200")
133
+ extended = json.loads(live.raft("info", box).stdout)
134
+ assert (
135
+ int(extended["config"]["user.raft.expires"])
136
+ > int(live.execute(box, "date", "+%s")) + 1100
137
+ )
138
+ assert "ActiveState=active" in live.raft("status", box, job).stdout
139
+ # Exercise journal retention without changing any host service or other box.
140
+ live.execute(box, "journalctl", "--rotate", "--vacuum-size=1")
141
+ assert "ActiveState=active" in live.raft("status", box, job).stdout
142
+ live.raft("cancel", box, job)
143
+ assert "ActiveState=inactive" in live.raft("status", box, job).stdout
144
+ assert live.raft("logs", box, job).returncode == 0
145
+ assert (
146
+ live.raft("exec", box, "--", "test", "-e", "/proc/" + child, check=False).returncode
147
+ != 0
148
+ )
149
+ for command in ["status", "logs", "cancel"]:
150
+ missing = live.raft(command, box, "rfcmd-" + "0" * 32, check=False)
151
+ assert missing.returncode == 1 and "Detached job not found" in missing.stderr
152
+ usage = json.loads(live.raft("usage", box).stdout)
153
+ assert 0 < usage["memoryBytes"] < int(usage["memoryLimit"])
154
+ assert usage["filesystemScope"] == "shared Raft pool, not per-box usage"
155
+ with tempfile.TemporaryDirectory(dir=directory, prefix="raft-controls-") as work:
156
+ archive = Path(work) / "workspace with spaces.tar.gz"
157
+ assert live.raft("backup", box, str(archive), check=False).returncode != 0
158
+ assert not archive.exists()
159
+ live.raft("stop", box)
160
+ uploaded = Path(work) / "stopped upload.bin"
161
+ downloaded = Path(work) / "stopped download.bin"
162
+ uploaded.write_bytes(bytes(range(256)))
163
+ live.raft("upload", box, str(uploaded), "/workspace/stopped file.bin")
164
+ live.raft("download", box, "/workspace/stopped file.bin", str(downloaded))
165
+ assert downloaded.read_bytes() == uploaded.read_bytes()
166
+ assert live.raft("extend", box, "--ttl", "600", check=False).returncode != 0
167
+ live.raft("snapshot", box, "checkpoint")
168
+ print(f"{source}: exporting stopped workspace and snapshot", flush=True)
169
+ live.raft("backup", box, str(archive))
170
+ size = archive.stat().st_size
171
+ assert size > 0 and archive.stat().st_mode & 0o077 == 0
172
+ print(f"{source}: archive completed ({size} bytes)", flush=True)
173
+ assert live.raft("backup", box, str(archive), check=False).returncode != 0
174
+ assert archive.stat().st_size == size
175
+ restored = live.raft("recover", str(archive), "--location", target).stdout.strip()
176
+ fixtures.append(restored)
177
+ print(f"{target}: recovered stopped workspace", flush=True)
178
+ metadata = json.loads(live.raft("info", restored).stdout)
179
+ assert metadata["status"] == "Stopped"
180
+ assert not metadata["config"].get("user.raft.expires")
181
+ assert "checkpoint" in live.raft("snapshots", restored).stdout
182
+ live.raft("resume", restored, "--ttl", "600")
183
+ if development_image:
184
+ live.wait_running(restored)
185
+ assert (
186
+ live.execute(
187
+ restored,
188
+ "sha256sum",
189
+ "/opt/raft/packages.tsv",
190
+ "/opt/raft/npm-packages.json",
191
+ )
192
+ == inventories
193
+ )
194
+ import subprocess
195
+
196
+ subprocess.run(
197
+ [sys.executable, str(Path(__file__).with_name("verify-tools.py")), restored],
198
+ check=True,
199
+ )
200
+ recovered_config = json.loads(live.raft("info", restored).stdout)["config"]
201
+ source_config = json.loads(live.raft("info", box).stdout)["config"]
202
+ assert recovered_config["volatile.eth0.hwaddr"] != source_config["volatile.eth0.hwaddr"]
203
+ assert live.execute(restored, "cat", "/workspace/proof") == "raft-backup-proof"
204
+ assert live.execute(restored, "stat", "-c", "%a", "/workspace/proof") == "640"
205
+ live.execute(restored, "bash", "-ec", "printf changed > /workspace/proof")
206
+ live.raft("stop", restored)
207
+ live.raft("restore", restored, "checkpoint")
208
+ live.raft("resume", restored, "--ttl", "600")
209
+ assert live.execute(restored, "cat", "/workspace/proof") == "raft-backup-proof"
210
+ restored_config = json.loads(live.raft("info", restored).stdout)["config"]
211
+ assert restored_config["volatile.eth0.hwaddr"] != source_config["volatile.eth0.hwaddr"]
212
+ if source == target:
213
+ live.raft("resume", box, "--ttl", "600")
214
+ original_ip = wait_address(box)
215
+ recovered_ip = wait_address(restored)
216
+ assert original_ip != recovered_ip
217
+ assert live.execute(box, "cat", "/workspace/proof") == "raft-backup-proof"
218
+ print(
219
+ f"{source} -> {target}: cancellation, usage, portable recovery and snapshots passed ({size} bytes)",
220
+ flush=True,
221
+ )
222
+ finally:
223
+ for fixture in reversed(fixtures):
224
+ live.raft("destroy", fixture)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ parser = argparse.ArgumentParser(description=__doc__)
229
+ parser.add_argument("--source", required=True)
230
+ parser.add_argument("--target", required=True)
231
+ parser.add_argument(
232
+ "--directory", default=None, help="Controller directory with room for the archive"
233
+ )
234
+ parser.add_argument(
235
+ "--development-image",
236
+ action="store_true",
237
+ help="Export the complete configured development image rather than a small fixture",
238
+ )
239
+ args = parser.parse_args()
240
+ verify_admission(args.target, args.directory)
241
+ verify(args.source, args.target, args.directory, args.development_image)