@microck/raft 0.0.0-stage → 0.2.1

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 (67) hide show
  1. package/.github/assets/raft-logo.png +0 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -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 +519 -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 +86 -0
  34. package/docs/guides/background-jobs.mdx +95 -0
  35. package/docs/guides/backup-recovery.mdx +111 -0
  36. package/docs/guides/desktop-services.mdx +90 -0
  37. package/docs/guides/docker.mdx +84 -0
  38. package/docs/guides/files.mdx +91 -0
  39. package/docs/guides/long-running-services.mdx +109 -0
  40. package/docs/guides/meta.json +18 -0
  41. package/docs/guides/project-setup.mdx +112 -0
  42. package/docs/guides/scripting-ci.mdx +133 -0
  43. package/docs/guides/snapshots-forks.mdx +107 -0
  44. package/docs/guides/troubleshooting.mdx +152 -0
  45. package/docs/guides/upgrades.mdx +113 -0
  46. package/docs/incus.example.json +3 -0
  47. package/docs/index.mdx +40 -0
  48. package/docs/meta.json +21 -0
  49. package/docs/operations.mdx +239 -0
  50. package/docs/publishing.md +48 -0
  51. package/docs/reference/capacity-limits.mdx +92 -0
  52. package/docs/reference/cli.md +288 -0
  53. package/docs/reference/configuration.mdx +77 -0
  54. package/docs/reference/meta.json +10 -0
  55. package/docs/releases/0.2.0.md +22 -0
  56. package/docs/releases/0.2.1.md +14 -0
  57. package/docs/requirements.mdx +47 -0
  58. package/docs/reviews/code-review-2026-10-06.md +168 -0
  59. package/docs/site-development.md +80 -0
  60. package/docs/verification.mdx +237 -0
  61. package/images/incus/build.sh +57 -0
  62. package/images/incus/desktop.sh +21 -0
  63. package/images/incus/raft-desktop.service +7 -0
  64. package/package.json +46 -4
  65. package/raft.py +961 -0
  66. package/raft_files.py +134 -0
  67. package/skills/raft-cli/SKILL.md +117 -0
Binary file
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raft contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,168 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src=".github/assets/raft-logo.png" width="220" alt="raft logo">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">raft</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/Microck/raft/actions/workflows/check.yml"><img src="https://img.shields.io/github/actions/workflow/status/Microck/raft/check.yml?branch=main&style=flat-square&label=ci&color=000000" alt="ci badge"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-mit-000000?style=flat-square" alt="license badge"></a>
10
+ </p>
11
+
12
+ ---
13
+
14
+ `raft` is an independent, self-hosted open-source alternative to
15
+ [boat.dev](https://boat.dev). it gives you persistent Linux boxes on machines you
16
+ control, with command execution, Docker, file transfer, snapshots, forks and a
17
+ private desktop. Raft is not affiliated with Boat.
18
+
19
+ [setup](docs/operations.mdx) | [feature parity](docs/boat-capabilities.md) | [verification](docs/verification.mdx) | [agent skill](skills/raft-cli/SKILL.md) | [docs site](https://raft.micr.dev/)
20
+
21
+ ## quickstart
22
+
23
+ follow the [setup guide](docs/operations.mdx) to install the CLI, configure SSH,
24
+ set up Incus and download or build an image.
25
+
26
+ install the CLI on Linux with Python 3.11.8+:
27
+
28
+ ```sh
29
+ npm install -g @microck/raft
30
+ raft --version
31
+ ```
32
+
33
+ Python wheels and source archives are also available in
34
+ [releases](https://github.com/Microck/raft/releases). npm installation does not set
35
+ up hosts or download workspace images.
36
+
37
+ | requirement | minimum |
38
+ | --- | --- |
39
+ | host | dedicated ARM64 or AMD64 Ubuntu host, 2 CPUs, 8 GiB RAM, 80 GiB free disk |
40
+ | host access | SSH and passwordless sudo |
41
+ | controller | Linux, Python 3.11.8+, SSH and SCP; npm or uv for installation |
42
+
43
+ once your host and image are configured, run the commands below. replace `lab`
44
+ with a location from your configuration:
45
+
46
+ ```sh
47
+ box=$(raft new --location lab --ttl 600)
48
+ raft exec "$box" -- node --version
49
+ raft ssh "$box"
50
+ raft stop "$box"
51
+ ```
52
+
53
+ `raft stop` and expiration keep persistent boxes on disk. `new --disposable`
54
+ deletes a temporary box when it stops or expires. `raft resume` starts
55
+ the box with a new lifetime. `raft destroy` deletes the box and its snapshots.
56
+ disposable creation and retention cleanup require the current host worker;
57
+ redeploy from the current checkout before using them.
58
+
59
+ each host allows four saved boxes, including stopped ones. `raft limits` reports
60
+ current allocations, free disk space and recommended running counts for each
61
+ size. see [host capacity](docs/reference/capacity-limits.mdx).
62
+
63
+ ## image and workspace control
64
+
65
+ the image builder starts with Debian 13 and installs language runtimes,
66
+ build tools, Docker, Chromium, FFmpeg and a noVNC desktop. build your own image
67
+ or download an image for your host architecture from
68
+ [versioned releases](https://github.com/Microck/raft/releases). pin its fingerprint
69
+ in your configuration. see
70
+ [image builds and boot measurements](docs/boot-performance.mdx) for checks and timings.
71
+
72
+ see the task guides for [background jobs](docs/guides/background-jobs.mdx),
73
+ [file transfer](docs/guides/files.mdx),
74
+ [snapshots and forks](docs/guides/snapshots-forks.mdx),
75
+ [private services and desktops](docs/guides/desktop-services.mdx),
76
+ [backup and recovery](docs/guides/backup-recovery.mdx)
77
+ and [Docker](docs/guides/docker.mdx).
78
+
79
+ See [project setup and templates](docs/guides/project-setup.mdx),
80
+ [persistent services](docs/guides/long-running-services.mdx),
81
+ [scripting and CI](docs/guides/scripting-ci.mdx), and
82
+ [upgrades](docs/guides/upgrades.mdx) for complete operator workflows.
83
+
84
+ stop the source box before taking a snapshot, restoring one or making a fork.
85
+ stopping a box ends its jobs without saving process memory.
86
+
87
+ backups include the box's files and credentials. keep them private and import
88
+ only trusted exports. a long backup can delay expiration checks on its host.
89
+
90
+ ## startup performance
91
+
92
+ median startup times from five runs per architecture on native Ubuntu 24 CI
93
+ hosts. each box had one CPU, 2 GiB RAM and a cached image:
94
+
95
+ | measurement | ARM64 | AMD64 |
96
+ | --- | ---: | ---: |
97
+ | command ready after create | 0.87 s | 1.38 s |
98
+ | Docker ready after create | 2.28 s | 3.04 s |
99
+ | desktop, after its separate start request | 1.12 s | 1.59 s |
100
+ | compressed development image | 1.79 GiB | 1.86 GiB |
101
+
102
+ times depend on hardware, installed software and services, storage, image cache
103
+ and SSH latency. the measurements include CLI and SSH overhead. they exclude
104
+ image downloads and host reboots.
105
+
106
+ the first image unpack can take longer. the runners used different hardware,
107
+ so these results do not show that ARM64 is faster.
108
+
109
+ both native images passed the lifecycle tests and all 31 development-tool checks. see
110
+ [measurements and methodology](docs/boot-performance.mdx) for raw samples,
111
+ resume timings and the measured image-size and desktop-startup improvements.
112
+
113
+ ## feature parity
114
+
115
+ ✅ supported · ❌ unavailable · ❓ not established in Boat's docs. matching checks
116
+ do not imply identical behavior. the [full comparison](docs/boat-capabilities.md)
117
+ records differences, sources and test coverage.
118
+
119
+ | Capability | Boat | Raft | Raft limits / differences |
120
+ | --- | :---: | :---: | --- |
121
+ | Self-hosting on operator hardware | ❌ | ✅ | Incus on your hosts |
122
+ | Native ARM64 workspaces | ❌ | ✅ | Boat documents x86_64 machines only |
123
+ | Create/list/inspect/delete | ✅ | ✅ | Single operator |
124
+ | Persistent stop/resume | ✅ | ✅ | Shared host kernel |
125
+ | TTL and lifetime extension | ✅ | ✅ | Persistent boxes retain disk; disposable boxes delete |
126
+ | CPU/memory sizing and usage | ✅ | ✅ | 1 or 2 CPUs, 1, 2 or 4 GiB RAM. Disk usage covers the shared pool |
127
+ | Host capacity recommendations | ❌ | ✅ | Raft recommends running capacity and enforces four saved boxes. Boat reports plan limits |
128
+ | Root terminal and command execution | ✅ | ✅ | Host SSH transport |
129
+ | Background jobs, logs and cancellation | ✅ | ✅ | No process checkpoints |
130
+ | File upload/download | ✅ | ✅ | Files and recursive trees; new directory destinations only |
131
+ | Snapshots and same-host forks | ✅ | ✅ | Stopped source, checked under the lifecycle lock |
132
+ | Deploy/delete named snapshots | ✅ | ✅ | Same-host templates and named snapshot deletion |
133
+ | Portable backup/recovery | ❓ | ✅ | Raft native archives; Boat whole-instance archive import is not established |
134
+ | Docker and development tools | ✅ | ✅ | Native ARM64 and AMD64 images. See [image tests](docs/boot-performance.mdx) |
135
+ | Private port forwarding | ✅ | ✅ | Local SSH tunnel |
136
+ | Desktop access | ✅ | ✅ | noVNC; see verification limits |
137
+ | Reverse forwarding | ✅ | ❌ | Not implemented |
138
+ | Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented |
139
+ | Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides within supported sizes |
140
+ | CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/limits plus prune JSONL |
141
+ | Snapshot file browsing/download | ✅ | ❌ | Whole-box backup only |
142
+ | Strict per-box disk quotas | ✅ | ❌ | Shared 60 GiB Btrfs pool |
143
+ | Separate-kernel VM isolation | ✅ | ❌ | Container runtime |
144
+ | Named environments and managed secrets | ✅ | ❌ | Manual configuration |
145
+ | Organizations and webhooks | ✅ | ❌ | Raft is single-operator; no event API |
146
+ | Dashboard, remote API and SDK | ✅ | ❌ | CLI only |
147
+ | Managed agent conversations | ✅ | ❌ | Not implemented |
148
+ | Automatic deletion and retention | ✅ | ✅ | Disposable deletion; opt-in age-based prune with preview |
149
+ | CLI self-update | ✅ | ❌ | Standard npm or Python package installation |
150
+
151
+ ## verification and development
152
+
153
+ run `uv sync --locked` to install dependencies and `uv run raft --help` to check
154
+ the CLI. [verification](docs/verification.mdx) lists the real-host test suites.
155
+ they create disposable boxes and remove only their own fixtures.
156
+
157
+ the optional `--restart-incus` check restarts the host's Incus daemon. run it on
158
+ dedicated test infrastructure.
159
+
160
+ the tests cover Raft's behavior. they do not compare it against the live Boat
161
+ service. Raft has not had a security audit.
162
+
163
+ read [the contract](docs/contract.md), [architecture and references](docs/boat-parity-design.md)
164
+ and the optional [agent skill](skills/raft-cli/SKILL.md).
165
+
166
+ ## license
167
+
168
+ [MIT](LICENSE). third-party tools retain their own licenses.
@@ -0,0 +1,150 @@
1
+ """Measure real cached-image creation, resume, Docker and on-demand desktop latency."""
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ import statistics
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+ ROOT = Path(__file__).resolve().parent.parent
14
+ sys.path.insert(0, str(ROOT))
15
+ from raft import configuration, incus # noqa: E402
16
+
17
+ spec = importlib.util.spec_from_file_location("verify_live", ROOT / "deploy/verify-live.py")
18
+ live = importlib.util.module_from_spec(spec)
19
+ spec.loader.exec_module(live)
20
+
21
+ DOCKER_READY = """import subprocess,time
22
+ end=time.monotonic()+60
23
+ while time.monotonic()<end:
24
+ result=subprocess.run(['docker','info','--format','{{.ServerVersion}}'],capture_output=True,text=True)
25
+ if result.returncode==0:
26
+ print(result.stdout.strip());break
27
+ time.sleep(0.1)
28
+ else: raise SystemExit('Docker did not become ready within 60 seconds')
29
+ """
30
+ DESKTOP_READY = """import socket,time,urllib.request
31
+ end=time.monotonic()+30
32
+ while time.monotonic()<end:
33
+ try:
34
+ request=urllib.request.Request('http://127.0.0.1:6080/vnc.html',headers={'User-Agent':'OpenAI File Downloader, XaiImageApiFetch/1.0'})
35
+ with urllib.request.urlopen(request,timeout=1) as page: assert page.status==200
36
+ with socket.create_connection(('127.0.0.1',5900),1) as vnc: assert vnc.recv(12).startswith(b'RFB ')
37
+ print('desktop-ready');break
38
+ except (OSError,AssertionError): time.sleep(0.1)
39
+ else: raise SystemExit('Desktop HTTP and VNC did not become ready within 30 seconds')
40
+ """
41
+
42
+
43
+ def readiness(box, started, returned, desktop):
44
+ deadline = time.monotonic() + 60
45
+ while time.monotonic() < deadline:
46
+ probe = live.raft("exec", box, "--", "true", check=False)
47
+ if probe.returncode == 0:
48
+ break
49
+ time.sleep(0.1)
50
+ else:
51
+ raise RuntimeError("Guest command execution did not become ready")
52
+ measured = {
53
+ "cliReturnSeconds": returned - started,
54
+ "commandReadySeconds": time.monotonic() - started,
55
+ }
56
+ live.execute(box, "python3", "-c", DOCKER_READY)
57
+ measured["dockerReadySeconds"] = time.monotonic() - started
58
+ if desktop:
59
+ desktop_started = time.monotonic()
60
+ live.execute(box, "systemctl", "start", "raft-desktop")
61
+ live.execute(box, "python3", "-c", DESKTOP_READY)
62
+ measured["desktopStartSeconds"] = time.monotonic() - desktop_started
63
+ return {name: round(value, 3) for name, value in measured.items()}
64
+
65
+
66
+ def benchmark(location, samples, desktop):
67
+ host = configuration()[location]["ssh"]
68
+ metadata = subprocess.run(
69
+ ["ssh", "-T", "-o", "BatchMode=yes", host, "uname -m; nproc"],
70
+ capture_output=True,
71
+ text=True,
72
+ check=True,
73
+ ).stdout.splitlines()
74
+ started = time.monotonic()
75
+ subprocess.run(["ssh", "-T", "-o", "BatchMode=yes", host, "true"], check=True)
76
+ report = {
77
+ "hostArchitecture": metadata[0],
78
+ "hostCpus": int(metadata[1]),
79
+ "guestCpus": 1,
80
+ "guestMemory": "2GiB",
81
+ "sshRoundTripSeconds": round(time.monotonic() - started, 3),
82
+ "scope": "Cached local image; includes controller CLI and SSH; no host reboot or download",
83
+ "samples": [],
84
+ }
85
+ image = configuration()[location]["image"]
86
+ volumes = json.loads(
87
+ incus(location, "storage", "volume", "list", "raft-data", "--format", "json")
88
+ )
89
+ report["imageVolumeCachedAtStart"] = any(
90
+ volume["type"] == "image" and volume["name"] == image for volume in volumes
91
+ )
92
+ for index in range(samples):
93
+ box = None
94
+ try:
95
+ started = time.monotonic()
96
+ box = live.raft("new", "--location", location, "--ttl", "1800").stdout.strip()
97
+ returned = time.monotonic()
98
+ created = readiness(box, started, returned, desktop)
99
+ if index == 0:
100
+ report["guestArchitecture"] = live.execute(box, "dpkg", "--print-architecture")
101
+ report["bootBlame"] = live.execute(box, "systemd-analyze", "blame")
102
+ report["bootCriticalChain"] = live.execute(box, "systemd-analyze", "critical-chain")
103
+ report["imageSizeBytes"] = json.loads(
104
+ incus(
105
+ location,
106
+ "image",
107
+ "list",
108
+ configuration()[location]["image"],
109
+ "--format",
110
+ "json",
111
+ )
112
+ )[0]["size"]
113
+ live.raft("stop", box)
114
+ started = time.monotonic()
115
+ live.raft("resume", box, "--ttl", "1800")
116
+ returned = time.monotonic()
117
+ resumed = readiness(box, started, returned, desktop)
118
+ sample = {"create": created, "resume": resumed}
119
+ report["samples"].append(sample)
120
+ print(json.dumps({"sample": index + 1, **sample}), file=sys.stderr, flush=True)
121
+ finally:
122
+ if box is not None:
123
+ live.raft("destroy", box)
124
+ report["summary"] = {}
125
+ for action in ("create", "resume"):
126
+ report["summary"][action] = {}
127
+ for metric in report["samples"][0][action]:
128
+ values = sorted(sample[action][metric] for sample in report["samples"])
129
+ report["summary"][action][metric] = {
130
+ "median": round(statistics.median(values), 3),
131
+ "min": values[0],
132
+ "max": values[-1],
133
+ "p95": values[math.ceil(0.95 * len(values)) - 1],
134
+ }
135
+ return report
136
+
137
+
138
+ def main():
139
+ parser = argparse.ArgumentParser(description=__doc__)
140
+ parser.add_argument("--location", required=True)
141
+ parser.add_argument("--samples", type=int, default=5)
142
+ parser.add_argument("--desktop", action="store_true")
143
+ args = parser.parse_args()
144
+ if not 1 <= args.samples <= 20:
145
+ parser.error("samples must be between 1 and 20")
146
+ print(json.dumps(benchmark(args.location, args.samples, args.desktop), indent=2))
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
@@ -0,0 +1,95 @@
1
+ """Build a pinned Raft development template from a published Incus base image."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+ import shlex
8
+ import sys
9
+ import time
10
+ import uuid
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ from deploy import HOSTS, ROOT, copy, host_architecture, remote # noqa: E402
14
+
15
+
16
+ def main():
17
+ parser = argparse.ArgumentParser(description=__doc__)
18
+ parser.add_argument("--location", choices=HOSTS, required=True)
19
+ parser.add_argument(
20
+ "--alias", default="raft-dev", help="New image alias; existing aliases are refused"
21
+ )
22
+ args = parser.parse_args()
23
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", args.alias):
24
+ parser.error("alias must use letters, digits, underscores or hyphens")
25
+ host = HOSTS[args.location]
26
+ architecture = host_architecture(host)
27
+ started = time.monotonic()
28
+ prefix = "sudo -n incus --project raft"
29
+ aliases = json.loads(remote(host, prefix + " image list --format json"))
30
+ if any(a["name"] == args.alias for image in aliases for a in image["aliases"]):
31
+ raise RuntimeError(
32
+ args.alias + " exists; choose a fresh alias or review its removal explicitly"
33
+ )
34
+ images = json.loads(
35
+ remote(host, prefix + f" image list images:debian/13/{architecture} --format json")
36
+ )
37
+ bases = [
38
+ image
39
+ for image in images
40
+ if image["type"] == "container"
41
+ and image["properties"].get("variant") == "default"
42
+ and image["properties"].get("architecture") == architecture
43
+ ]
44
+ if len(bases) != 1:
45
+ raise RuntimeError("Expected exactly one native Debian 13 default container image")
46
+ fingerprint = bases[0]["fingerprint"]
47
+ # Incus --project selects the source project; the destination must be explicit.
48
+ remote(host, prefix + " image copy images:" + fingerprint + " local: --target-project raft")
49
+ print("Published base fingerprint: " + fingerprint, flush=True)
50
+ remote(
51
+ host,
52
+ prefix + " launch " + fingerprint + " raft-builder -c limits.cpu=2 -c limits.memory=4GiB",
53
+ )
54
+ for source, guest, mode in [
55
+ ("images/incus/build.sh", "/tmp/raft-build.sh", "755"),
56
+ ("images/incus/desktop.sh", "/usr/local/bin/raft-desktop", "755"),
57
+ ("images/incus/raft-desktop.service", "/etc/systemd/system/raft-desktop.service", "644"),
58
+ ]:
59
+ staged = "/tmp/" + Path(guest).name
60
+ copy(host, ROOT / source, staged)
61
+ remote(host, prefix + f" file push {staged} raft-builder{guest} --mode {mode}")
62
+ remote(host, prefix + " exec raft-builder -- bash /tmp/raft-build.sh")
63
+ remote(host, prefix + " stop raft-builder --timeout 30")
64
+ print(
65
+ remote(
66
+ host,
67
+ prefix
68
+ + " publish raft-builder --alias "
69
+ + shlex.quote(args.alias)
70
+ + " --compression gzip description="
71
+ + shlex.quote(f"Raft Debian 13 {architecture} development workspace"),
72
+ )
73
+ )
74
+ # Build failures intentionally retain their named builder for diagnostics.
75
+ # A successful immutable publication no longer needs its writable builder.
76
+ remote(host, prefix + " delete raft-builder")
77
+ published = json.loads(remote(host, prefix + " image list " + args.alias + " --format json"))
78
+ fingerprint = next(
79
+ image["fingerprint"]
80
+ for image in published
81
+ if any(alias["name"] == args.alias for alias in image["aliases"])
82
+ )
83
+ # Unpacking a full image can take a minute. Pay it during setup, before new
84
+ # reports a usable box; init prepares the native cache without booting a guest.
85
+ cache_probe = "raft-image-cache-" + uuid.uuid4().hex[:8]
86
+ print("Preparing optimized image cache with " + cache_probe, flush=True)
87
+ remote(host, prefix + " init " + fingerprint + " " + cache_probe)
88
+ remote(host, prefix + " delete " + cache_probe)
89
+ print(
90
+ f"Native {architecture} image built in {time.monotonic() - started:.1f} seconds", flush=True
91
+ )
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,176 @@
1
+ """Deploy isolated Incus system-container infrastructure on configured hosts.
2
+
3
+ Run once after reviewing the host setup. Incus VMs need KVM; this deployment
4
+ selects system containers explicitly and never silently substitutes a VM.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import shlex
10
+ from pathlib import Path
11
+ import subprocess
12
+ import sys
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ sys.path.insert(0, str(ROOT))
16
+ from raft import configuration # noqa: E402
17
+
18
+ HOSTS = {name: entry["ssh"] for name, entry in configuration().items()}
19
+
20
+
21
+ def remote(host, command):
22
+ response = subprocess.run(
23
+ ["ssh", "-T", "-o", "BatchMode=yes", host, command],
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+ if response.returncode:
28
+ raise RuntimeError(response.stderr.strip() or "Remote command failed: " + command)
29
+ return response.stdout
30
+
31
+
32
+ def copy(host, source, destination):
33
+ subprocess.run(["scp", "-q", str(source), f"{host}:{destination}"], check=True)
34
+
35
+
36
+ def host_architecture(host):
37
+ architecture = remote(host, "dpkg --print-architecture").strip()
38
+ if architecture not in {"arm64", "amd64"}:
39
+ raise RuntimeError("Raft hosts must use native ARM64 or AMD64 Debian/Ubuntu packages")
40
+ return architecture
41
+
42
+
43
+ def deploy(name):
44
+ host = HOSTS[name]
45
+ host_architecture(host)
46
+ copy(host, ROOT / "deploy/install-incus.sh", "/tmp/raft-install-incus.sh")
47
+ print(remote(host, "bash /tmp/raft-install-incus.sh </dev/null"), flush=True)
48
+ # Inspect rather than overwriting an unrelated existing project or pool.
49
+ projects = json.loads(remote(host, "sudo -n incus project list --format json"))
50
+ project = next((p for p in projects if p["name"] == "raft"), None)
51
+ pools = json.loads(remote(host, "sudo -n incus storage list --format json"))
52
+ networks = json.loads(remote(host, "sudo -n incus network list --format json"))
53
+ pool = next((item for item in pools if item["name"] == "raft-data"), None)
54
+ network = next((item for item in networks if item["name"] == "rfbr0"), None)
55
+ if project is not None:
56
+ if project.get("description") != "Raft managed workspaces":
57
+ raise RuntimeError("Existing raft project needs ownership verification")
58
+ else:
59
+ if pool is not None or network is not None:
60
+ raise RuntimeError(
61
+ "Raft resource names already exist without an owned project; review them explicitly"
62
+ )
63
+ available = int(
64
+ remote(
65
+ host,
66
+ shlex.join(
67
+ [
68
+ "python3",
69
+ "-c",
70
+ "import shutil; print(shutil.disk_usage('/var/lib/incus').free)",
71
+ ]
72
+ ),
73
+ )
74
+ )
75
+ if available < 80 * 1024**3:
76
+ raise RuntimeError("Fresh deployment needs at least 80 GiB free disk")
77
+ remote(
78
+ host,
79
+ "sudo -n incus project create raft -c features.images=true -c features.profiles=true </dev/null",
80
+ )
81
+ remote(
82
+ host,
83
+ "sudo -n incus project set raft --property description='Raft managed workspaces'",
84
+ )
85
+ remote(host, "sudo -n incus storage create raft-data btrfs size=60GiB")
86
+ remote(
87
+ host,
88
+ "sudo -n incus network create rfbr0 ipv4.address=10.232.0.1/24 ipv4.nat=true ipv6.address=none",
89
+ )
90
+ remote(
91
+ host,
92
+ "sudo -n incus --project raft profile device add default root disk path=/ pool=raft-data",
93
+ )
94
+ remote(
95
+ host,
96
+ "sudo -n incus --project raft profile device add default eth0 nic network=rfbr0 name=eth0",
97
+ )
98
+ remote(
99
+ host,
100
+ "sudo -n incus --project raft profile set default limits.cpu=1 limits.memory=2GiB security.nesting=true security.syscalls.intercept.mknod=true security.syscalls.intercept.setxattr=true",
101
+ )
102
+ pools = json.loads(remote(host, "sudo -n incus storage list --format json"))
103
+ networks = json.loads(remote(host, "sudo -n incus network list --format json"))
104
+ profiles = json.loads(remote(host, "sudo -n incus --project raft profile list --format json"))
105
+ pool = next((item for item in pools if item["name"] == "raft-data"), None)
106
+ network = next((item for item in networks if item["name"] == "rfbr0"), None)
107
+ profile = next((item for item in profiles if item["name"] == "default"), None)
108
+ if pool is None or pool["driver"] != "btrfs" or network is None or profile is None:
109
+ raise RuntimeError(
110
+ "Incomplete Raft infrastructure; inspect the project, pool, bridge and profile before retrying"
111
+ )
112
+ if pool["config"].get("size") != "60GiB":
113
+ raise RuntimeError(
114
+ "Raft pool must have configured size=60GiB; inspect raft-data and back up boxes "
115
+ "before explicit storage recovery. Deployment does not resize existing storage."
116
+ )
117
+
118
+ devices = profile["devices"]
119
+ if (
120
+ devices.get("root", {}).get("pool") != "raft-data"
121
+ or devices.get("eth0", {}).get("network") != "rfbr0"
122
+ or network["config"].get("ipv4.address") != "10.232.0.1/24"
123
+ or network["config"].get("ipv4.nat") != "true"
124
+ or network["config"].get("ipv6.address") != "none"
125
+ or profile["config"].get("security.nesting") != "true"
126
+ or profile["config"].get("security.syscalls.intercept.mknod") != "true"
127
+ or profile["config"].get("security.syscalls.intercept.setxattr") != "true"
128
+ or profile["config"].get("security.privileged", "false") != "false"
129
+ ):
130
+ raise RuntimeError(
131
+ "Raft profile/network differs from the contract; inspect and recover explicitly"
132
+ )
133
+ remote(host, "sudo -n mkdir -p /usr/local/lib/raft")
134
+ for source, target in [
135
+ ("deploy/raft-network.sh", "network"),
136
+ ("deploy/raft-expire.py", "expire-worker"),
137
+ ]:
138
+ copy(host, ROOT / source, "/tmp/raft-" + target)
139
+ remote(host, f"sudo -n install -m 755 /tmp/raft-{target} /usr/local/lib/raft/{target}")
140
+ wrapper = (
141
+ '#!/bin/sh\nexec flock /run/lock/raft-incus.lock /usr/local/lib/raft/expire-worker "$@"\n'
142
+ )
143
+ local_wrapper = ROOT / ".raft-expire-wrapper"
144
+ local_wrapper.write_text(wrapper)
145
+ try:
146
+ copy(host, local_wrapper, "/tmp/raft-expire-wrapper")
147
+ finally:
148
+ local_wrapper.unlink()
149
+ remote(host, "sudo -n install -m 755 /tmp/raft-expire-wrapper /usr/local/lib/raft/expire")
150
+ for unit in ["raft-expire.service", "raft-expire.timer", "raft-network.service"]:
151
+ copy(host, ROOT / "deploy" / unit, "/tmp/" + unit)
152
+ remote(host, f"sudo -n install -m 644 /tmp/{unit} /etc/systemd/system/{unit}")
153
+ remote(host, "sudo -n mkdir -p /etc/systemd/system/incus.service.d")
154
+ copy(host, ROOT / "deploy/incus-network.conf", "/tmp/raft-incus-network.conf")
155
+ remote(
156
+ host,
157
+ "sudo -n install -m 644 /tmp/raft-incus-network.conf /etc/systemd/system/incus.service.d/raft-network.conf",
158
+ )
159
+ remote(host, "sudo -n systemctl daemon-reload")
160
+ remote(host, "sudo -n systemctl enable raft-network.service raft-expire.timer")
161
+ # Reload active filtering without stopping the Incus daemon that requires it.
162
+ remote(host, "sudo -n systemctl reload-or-restart raft-network.service")
163
+ remote(host, "sudo -n systemctl start raft-expire.timer")
164
+ print(f"{name}: Incus, private bridge and host expiry timer ready", flush=True)
165
+
166
+
167
+ def main():
168
+ parser = argparse.ArgumentParser(description=__doc__)
169
+ parser.add_argument("--location", choices=HOSTS)
170
+ args = parser.parse_args()
171
+ for name in [args.location] if args.location else HOSTS:
172
+ deploy(name)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
@@ -0,0 +1,4 @@
1
+ [Unit]
2
+ # Socket activation and normal startup both pass this fail-closed dependency.
3
+ Requires=raft-network.service
4
+ After=raft-network.service
@@ -0,0 +1,22 @@
1
+ set -eu
2
+ architecture=$(dpkg --print-architecture)
3
+ case "$architecture" in
4
+ arm64|amd64) ;;
5
+ *) echo 'Raft supports native ARM64 and AMD64 hosts' >&2; exit 1 ;;
6
+ esac
7
+ if ! command -v incus >/dev/null; then
8
+ sudo mkdir -p /etc/apt/keyrings
9
+ curl -fsSL -A 'OpenAI File Downloader, XaiImageApiFetch/1.0' https://pkgs.zabbly.com/key.asc -o /tmp/raft-zabbly.asc
10
+ fingerprint=$(gpg --show-keys --with-colons /tmp/raft-zabbly.asc 2>/dev/null | awk -F: '$1=="fpr" {print $10; exit}')
11
+ test "$fingerprint" = 4EFC590696CB15B87C73A3AD82CC8797C838DCFD
12
+ sudo install -m 644 /tmp/raft-zabbly.asc /etc/apt/keyrings/raft-zabbly.asc
13
+ . /etc/os-release
14
+ printf 'Types: deb\nURIs: https://pkgs.zabbly.com/incus/lts-6.0\nSuites: %s\nComponents: main\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/raft-zabbly.asc\n' "$VERSION_CODENAME" "$architecture" | sudo tee /etc/apt/sources.list.d/raft-incus.sources >/dev/null
15
+ sudo apt-get update -qq
16
+ sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends incus btrfs-progs
17
+ fi
18
+ if ! command -v nft >/dev/null; then
19
+ sudo apt-get update -qq
20
+ sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends nftables
21
+ fi
22
+ sudo incus version