@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
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.