@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.
- package/.github/assets/raft-logo.png +0 -0
- package/LICENSE +21 -0
- package/README.md +167 -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 +519 -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 +86 -0
- package/docs/guides/background-jobs.mdx +95 -0
- package/docs/guides/backup-recovery.mdx +111 -0
- package/docs/guides/desktop-services.mdx +90 -0
- package/docs/guides/docker.mdx +84 -0
- package/docs/guides/files.mdx +91 -0
- package/docs/guides/long-running-services.mdx +109 -0
- package/docs/guides/meta.json +18 -0
- package/docs/guides/project-setup.mdx +112 -0
- package/docs/guides/scripting-ci.mdx +133 -0
- package/docs/guides/snapshots-forks.mdx +107 -0
- package/docs/guides/troubleshooting.mdx +152 -0
- package/docs/guides/upgrades.mdx +113 -0
- package/docs/incus.example.json +3 -0
- package/docs/index.mdx +40 -0
- package/docs/meta.json +21 -0
- package/docs/operations.mdx +239 -0
- package/docs/publishing.md +48 -0
- package/docs/reference/capacity-limits.mdx +92 -0
- package/docs/reference/cli.md +288 -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/releases/0.2.1.md +14 -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 +237 -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 +961 -0
- package/raft_files.py +134 -0
- package/skills/raft-cli/SKILL.md +117 -0
package/raft_files.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Stream directory archives and publish new trees without replacing existing paths.
|
|
2
|
+
|
|
3
|
+
The same code runs on the Linux controller and in the guest. Unpacking always
|
|
4
|
+
uses Python's data filter, and publication uses renameat2(RENAME_NOREPLACE).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import ctypes
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
import tarfile
|
|
15
|
+
import tempfile
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def publish(source, destination):
|
|
20
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
21
|
+
rename = libc.renameat2
|
|
22
|
+
rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
|
23
|
+
rename.restype = ctypes.c_int
|
|
24
|
+
if rename(-100, os.fsencode(source), -100, os.fsencode(destination), 1):
|
|
25
|
+
code = ctypes.get_errno()
|
|
26
|
+
raise OSError(code, os.strerror(code), str(destination))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_member(member, destination):
|
|
30
|
+
if PurePosixPath(member.name).is_absolute():
|
|
31
|
+
raise ValueError("Directory transfers reject absolute paths: " + member.name)
|
|
32
|
+
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
|
33
|
+
raise ValueError("Directory transfers reject special files: " + member.name)
|
|
34
|
+
return tarfile.data_filter(member, destination)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pack_directory(source, stream):
|
|
38
|
+
source = Path(source).resolve(strict=True)
|
|
39
|
+
if not source.is_dir():
|
|
40
|
+
raise ValueError("Recursive transfer source must be a directory")
|
|
41
|
+
|
|
42
|
+
with tarfile.open(fileobj=stream, mode="w|") as archive:
|
|
43
|
+
|
|
44
|
+
def add(parent, entry, name):
|
|
45
|
+
# O_PATH pins the entry without opening a device or following a link.
|
|
46
|
+
# All metadata, reads and descent then use that held object.
|
|
47
|
+
descriptor = os.open(entry, os.O_PATH | os.O_NOFOLLOW, dir_fd=parent)
|
|
48
|
+
try:
|
|
49
|
+
metadata = os.fstat(descriptor)
|
|
50
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
51
|
+
member = tarfile.TarInfo(name)
|
|
52
|
+
member.type = tarfile.SYMTYPE
|
|
53
|
+
member.linkname = os.readlink("", dir_fd=descriptor)
|
|
54
|
+
safe_member(member, source)
|
|
55
|
+
archive.addfile(member)
|
|
56
|
+
elif stat.S_ISREG(metadata.st_mode) or stat.S_ISDIR(metadata.st_mode):
|
|
57
|
+
opened = SimpleNamespace(name=name, fileno=lambda: descriptor)
|
|
58
|
+
member = archive.gettarinfo(arcname=name, fileobj=opened)
|
|
59
|
+
safe_member(member, source)
|
|
60
|
+
if member.isfile():
|
|
61
|
+
# Reopening the pinned descriptor cannot follow a replacement
|
|
62
|
+
# pathname. Linux /proc is part of the guest/controller contract.
|
|
63
|
+
with open(f"/proc/self/fd/{descriptor}", "rb") as content:
|
|
64
|
+
archive.addfile(member, content)
|
|
65
|
+
else:
|
|
66
|
+
archive.addfile(member)
|
|
67
|
+
if member.isdir():
|
|
68
|
+
directory = os.open(".", os.O_RDONLY | os.O_DIRECTORY, dir_fd=descriptor)
|
|
69
|
+
try:
|
|
70
|
+
for child in sorted(os.listdir(directory)):
|
|
71
|
+
add(directory, child, name + "/" + child)
|
|
72
|
+
finally:
|
|
73
|
+
os.close(directory)
|
|
74
|
+
else:
|
|
75
|
+
raise ValueError("Directory transfers reject special files: " + name)
|
|
76
|
+
finally:
|
|
77
|
+
os.close(descriptor)
|
|
78
|
+
|
|
79
|
+
add(None, source, ".")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TransferReader:
|
|
83
|
+
"""Count physical bytes, including tar's read-ahead and trailing padding."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, stream):
|
|
86
|
+
self.stream = stream
|
|
87
|
+
self.bytes_read = 0
|
|
88
|
+
|
|
89
|
+
def read(self, size=-1):
|
|
90
|
+
chunk = self.stream.read(size)
|
|
91
|
+
self.bytes_read += len(chunk)
|
|
92
|
+
return chunk
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unpack_directory(stream, destination, expected_size):
|
|
96
|
+
# Resolve the parent, never the final component: existing destination symlinks
|
|
97
|
+
# must be rejected, not followed to a different publication path.
|
|
98
|
+
destination = Path(destination).absolute()
|
|
99
|
+
destination = destination.parent.resolve(strict=True) / destination.name
|
|
100
|
+
if os.path.lexists(destination):
|
|
101
|
+
raise FileExistsError("Recursive destination already exists: " + str(destination))
|
|
102
|
+
staged = Path(tempfile.mkdtemp(dir=destination.parent, prefix=".raft-tree-"))
|
|
103
|
+
try:
|
|
104
|
+
reader = TransferReader(stream)
|
|
105
|
+
with tarfile.open(fileobj=reader, mode="r|") as archive:
|
|
106
|
+
archive.extractall(staged, filter=safe_member)
|
|
107
|
+
# Tar accepts EOF between entries without a proper end marker. Drain the
|
|
108
|
+
# stream and check the sender's size before making any tree visible.
|
|
109
|
+
while reader.read(1024 * 1024):
|
|
110
|
+
pass
|
|
111
|
+
if reader.bytes_read != expected_size:
|
|
112
|
+
raise ValueError("Directory archive size mismatch; transfer did not complete")
|
|
113
|
+
publish(staged, destination)
|
|
114
|
+
finally:
|
|
115
|
+
if staged.exists():
|
|
116
|
+
shutil.rmtree(staged)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main():
|
|
120
|
+
cli = argparse.ArgumentParser(description=__doc__)
|
|
121
|
+
cli.add_argument("operation", choices=["pack", "unpack"])
|
|
122
|
+
cli.add_argument("path")
|
|
123
|
+
cli.add_argument("--size", type=int)
|
|
124
|
+
args = cli.parse_args()
|
|
125
|
+
if args.operation == "pack":
|
|
126
|
+
pack_directory(args.path, sys.stdout.buffer)
|
|
127
|
+
else:
|
|
128
|
+
if args.size is None or args.size < 0:
|
|
129
|
+
cli.error("Unpacking requires --size with the sender's archive byte count")
|
|
130
|
+
unpack_directory(sys.stdin.buffer, args.path, args.size)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: raft-cli
|
|
3
|
+
description: Create and use private persistent Linux workspaces on configured Linux hosts with Raft and Incus. Use for self-hosted builds, tests, terminals, Docker, file transfer, snapshots, and private desktop access. Hosted Boat uses its separate boat-cli skill.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Raft
|
|
7
|
+
|
|
8
|
+
Raft runs unprivileged native ARM64 or AMD64 Incus system containers. They share the host kernel.
|
|
9
|
+
The current deployment does not implement Incus VMs. Do not describe these boxes as VMs or promise cross-architecture emulation.
|
|
10
|
+
|
|
11
|
+
Run `raft doctor`, `raft limits` and `raft list` before allocating. Keep the exact
|
|
12
|
+
location-qualified handle returned by `new`. There is no implicit current box.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
box=$(raft new --location lab --ttl 600)
|
|
16
|
+
raft exec "$box" -- timeout 240 bash -lc 'git clone <url> /workspace/project && cd /workspace/project && <test-command>'
|
|
17
|
+
raft download "$box" /workspace/project/report.json ./report.json
|
|
18
|
+
raft destroy "$box"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The default development image has systemd, a guest Docker daemon, Node/npm/pnpm,
|
|
22
|
+
Bun, Deno, Python/pip/venv/uv, Go, Rust, Java, Kotlin, Scala, Ruby, PHP,
|
|
23
|
+
Erlang/Elixir, .NET, R, C/C++ tools, Git, Chromium and FFmpeg. Coding agents are not included in new images; install
|
|
24
|
+
your preferred agent when needed and authenticate separately. Never copy host
|
|
25
|
+
credentials into a box without authorization. Inspect `/opt/raft/` inside the
|
|
26
|
+
box for recorded package versions. Do not assume every language has every
|
|
27
|
+
version manager or that agents have an authenticated account.
|
|
28
|
+
|
|
29
|
+
Exec passes literal arguments; use `bash -lc` for shell syntax. A failed command
|
|
30
|
+
retains its exit status. `raft ssh "$box"` opens a root terminal through native
|
|
31
|
+
Incus execution over verified host SSH. It does not expose a guest SSH port.
|
|
32
|
+
|
|
33
|
+
Defaults are the first configured location, one CPU, 2 GiB RAM and 600 seconds. TTL accepts 60 seconds
|
|
34
|
+
to 30 days. The controller admits at most four saved boxes per host; stopped
|
|
35
|
+
boxes count because their disk still occupies space. Use `--memory 4GiB`
|
|
36
|
+
and `--cpu 2` when needed. Btrfs bounds workspace storage to a 60 GiB pool per host.
|
|
37
|
+
It does not guarantee strict per-box disk quotas with nested subvolumes. Check host disk before large builds or forks. `raft limits --location lab --json`
|
|
38
|
+
reports CPU/RAM recommendations for all supported sizes, active allocations and
|
|
39
|
+
free saved slots. Recommendations reserve host resources and remain advisory;
|
|
40
|
+
only the four-saved-box cap is enforced. Disk growth is not included in the counts.
|
|
41
|
+
|
|
42
|
+
## Persistence and cleanup
|
|
43
|
+
|
|
44
|
+
Persistent boxes stop and retain disk at expiry. `new --disposable` deletes a
|
|
45
|
+
box and its snapshots on stop or expiry. `raft stop "$box"` releases the
|
|
46
|
+
container's running processes; `raft resume "$box" --ttl 600` starts it again.
|
|
47
|
+
Stop is not pause and does not keep guest RAM allocated. Installed dependencies,
|
|
48
|
+
files and secrets remain on disk. For a temporary run, completion requires
|
|
49
|
+
successful `destroy`, even after command failure. Stop reusable runners when
|
|
50
|
+
finished. Destroy only boxes owned by the current task unless authorized otherwise.
|
|
51
|
+
|
|
52
|
+
Host systemd timers enforce deadlines every 15 seconds, even when the controller
|
|
53
|
+
is offline. `raft gc` runs those expiry workers manually and deletes expired disposable
|
|
54
|
+
boxes. `raft prune --older-than 7` previews stopped boxes older than seven days;
|
|
55
|
+
`--yes` deletes them. It skips running boxes and boxes without a recorded stop
|
|
56
|
+
time. Never apply prune outside the task-owned scope without explicit permission. A powered-off host enforces expiry when it returns.
|
|
57
|
+
|
|
58
|
+
Stop a box before a consistent disk snapshot or fork:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
raft stop "$box"
|
|
62
|
+
raft snapshot "$box" prepared
|
|
63
|
+
raft snapshots "$box"
|
|
64
|
+
copy=$(raft fork "$box" --ttl 600)
|
|
65
|
+
raft restore "$box" prepared
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Restore replaces disk contents with the snapshot and retains the destination's
|
|
69
|
+
current instance configuration, including its MAC and resource limits. Use it
|
|
70
|
+
only when the user requested that rollback. A fork copies filesystem data, including any
|
|
71
|
+
credentials installed there. Forks stay on the same host. `new --from "$box/prepared"` starts from a named
|
|
72
|
+
snapshot; `snapshot-delete "$box" prepared` removes it. Resume and fork accept
|
|
73
|
+
optional `--cpu` and `--memory` overrides. Omitted values retain the source limits.
|
|
74
|
+
Forks default to persistent; use `--disposable` explicitly for temporary clones.
|
|
75
|
+
|
|
76
|
+
`upload/download --recursive` copy directory trees into new destinations without
|
|
77
|
+
merging or overwriting. They require a running guest with Python 3.11.8+ and
|
|
78
|
+
temporary controller disk space. Escaping links and special files are rejected.
|
|
79
|
+
|
|
80
|
+
## Long commands and previews
|
|
81
|
+
|
|
82
|
+
`raft exec "$box" --detach -- <command>` returns a job ID. Use `raft status
|
|
83
|
+
"$box" <job>` and `raft logs "$box" <job>` to inspect its systemd state, exit
|
|
84
|
+
status and output. `raft cancel "$box" <job>` stops the job and its child processes. Stop/destroy
|
|
85
|
+
terminates running jobs; this is not automatic checkpointing of processes.
|
|
86
|
+
`raft extend "$box" --ttl 1800` resets a running box's deadline without
|
|
87
|
+
restarting its work. Use resume for stopped boxes. `raft usage "$box"` reports
|
|
88
|
+
cgroup memory/CPU and shared-pool disk space, not a per-box disk quota.
|
|
89
|
+
|
|
90
|
+
`raft forward "$box" --remote 8080 --local 8080` opens a foreground private
|
|
91
|
+
tunnel. `raft desktop "$box" --local 6080` starts the desktop on demand and
|
|
92
|
+
opens its private tunnel. Use the printed localhost address, with `vnc.html`
|
|
93
|
+
for the viewer. Ctrl+C closes the tunnel. The desktop service stays inside the
|
|
94
|
+
box until the box stops or its service is explicitly stopped.
|
|
95
|
+
|
|
96
|
+
Public hosting, reverse tunnels, browser-only confinement, agent conversation
|
|
97
|
+
orchestration and KVM-backed VMs are not implemented. Agent binaries can be run
|
|
98
|
+
through exec after authorized sign-in. Do not claim `boat prompt` parity.
|
|
99
|
+
|
|
100
|
+
## Portable backups
|
|
101
|
+
|
|
102
|
+
Stop before exporting a private archive, including snapshots:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
raft stop "$box"
|
|
106
|
+
raft backup "$box" ./workspace.tar.gz
|
|
107
|
+
recovered=$(raft recover ./workspace.tar.gz --location second-host)
|
|
108
|
+
raft resume "$recovered" --ttl 600
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Recovery creates a fresh stopped handle and clears the inherited deadline. It
|
|
112
|
+
never overwrites another box. Only import trusted Raft exports; archives retain
|
|
113
|
+
native configuration and guest secrets. Store them privately and check free
|
|
114
|
+
space on the controller and destination host. Large backups hold the host
|
|
115
|
+
lifecycle lock and delay expiry checks. A controller crash can leave an owned
|
|
116
|
+
staging archive or imported box; inspect the reported recovery handle before
|
|
117
|
+
cleanup. This is manual recovery, not a scheduled backup service.
|