@microck/raft 0.5.0 → 0.7.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/README.md +27 -15
- package/deploy/build-image.py +4 -2
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +6 -6
- package/deploy/verify-features.py +329 -19
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +22 -3
- package/deploy/verify-live.py +31 -6
- package/deploy/verify-output.py +208 -0
- package/deploy/verify-release.py +33 -4
- package/deploy/verify-run.py +571 -0
- package/deploy/verify-self-update.py +9 -1
- package/deploy/verify-sync.py +122 -0
- package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
- package/docs/boat-capabilities.md +60 -7
- package/docs/boat-parity-design.md +1 -0
- package/docs/boot-performance.mdx +21 -0
- package/docs/contract.md +88 -13
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +4 -3
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +9 -3
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +36 -14
- package/docs/guides/snapshots-forks.mdx +4 -2
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +11 -9
- package/docs/index.mdx +26 -28
- package/docs/maintainers/index.mdx +18 -0
- package/docs/maintainers/meta.json +12 -0
- package/docs/meta.json +5 -9
- package/docs/operations.mdx +17 -10
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +135 -43
- package/docs/reference/configuration.mdx +10 -6
- package/docs/reference/meta.json +3 -1
- package/docs/releases/0.2.0.md +1 -0
- package/docs/releases/0.2.1.md +1 -0
- package/docs/releases/0.3.0.md +1 -0
- package/docs/releases/0.4.0.md +1 -0
- package/docs/releases/0.5.0.md +1 -0
- package/docs/releases/0.6.0.md +17 -0
- package/docs/releases/0.7.0.md +23 -0
- package/docs/releases/index.mdx +21 -0
- package/docs/releases/meta.json +15 -0
- package/docs/requirements.mdx +1 -1
- package/docs/reviews/docs-audit-2026-10-24.md +92 -0
- package/docs/reviews/performance-audit-2026-10-29.md +90 -0
- package/docs/site-development.md +1 -0
- package/docs/verification.mdx +22 -4
- package/package.json +3 -1
- package/raft.py +526 -193
- package/raft_files.py +13 -1
- package/raft_jobs.py +390 -0
- package/raft_output.py +166 -0
- package/raft_run.py +330 -0
- package/skills/raft-cli/SKILL.md +78 -182
package/README.md
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
control, with command execution, Docker, file transfer, snapshots, forks and a
|
|
17
17
|
private desktop. Raft is not affiliated with Boat.
|
|
18
18
|
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
https://github.com/user-attachments/assets/ad6b0e67-1e33-4e36-a51b-401f29901eae
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
19
25
|
[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
26
|
|
|
21
27
|
## quickstart
|
|
@@ -55,7 +61,7 @@ once your host and image are configured, run the commands below. replace `lab`
|
|
|
55
61
|
with a location from your configuration:
|
|
56
62
|
|
|
57
63
|
```sh
|
|
58
|
-
box=$(raft new --location lab --ttl 600)
|
|
64
|
+
box=$(raft new --location lab --ttl 600 --persistent --purpose "Explore Raft" --owner "local operator" --next "Destroy after exploring")
|
|
59
65
|
raft exec "$box" -- node --version
|
|
60
66
|
raft ssh "$box"
|
|
61
67
|
raft stop "$box"
|
|
@@ -64,22 +70,26 @@ raft stop "$box"
|
|
|
64
70
|
`raft stop` and expiration keep persistent boxes on disk. `new --disposable`
|
|
65
71
|
deletes a temporary box when it stops or expires. `raft resume` starts
|
|
66
72
|
the box with a new lifetime. `raft destroy` deletes the box and its snapshots.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
`raft policy lab --delete-stopped-after 48` makes the host delete boxes left
|
|
74
|
+
stopped for 48 hours, and logs each deletion in its journal. disposable
|
|
75
|
+
creation, prune and retention require the current host worker; redeploy from
|
|
76
|
+
the current checkout before using them.
|
|
77
|
+
|
|
78
|
+
size boxes for the job: `--cpu` takes a positive whole CPU count and
|
|
79
|
+
`--memory` takes whole GiB, each up to the host total. `--disk` takes whole
|
|
80
|
+
GiB bounded by the own-disk pool reservation, or `shared`. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
|
|
72
81
|
box its own disk that it, and Docker inside it, cannot grow past; forks and
|
|
73
82
|
templates of it are copy-on-write. raft does not cap how many boxes you keep.
|
|
74
83
|
`raft capacity` prints an advisory estimate of what fits; it never blocks a box.
|
|
75
84
|
see [box sizing and capacity](docs/reference/capacity.mdx).
|
|
76
85
|
|
|
77
|
-
`raft config set` stores your defaults for `new`, such as location, TTL
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
`raft config set` stores your defaults for `new`, such as location, TTL, size
|
|
87
|
+
and `disposable true`; `new --persistent` keeps one box anyway. size defaults
|
|
88
|
+
apply only to boxes from the image: `new --from` and `fork` keep the source's
|
|
89
|
+
size. `raft policy lab --require purpose,owner` makes the host reject boxes whose
|
|
90
|
+
note lacks those fields, with an error that names the missing flags. policies
|
|
91
|
+
are guardrails for agents: anyone with sudo on the host can change them.
|
|
92
|
+
see [defaults and host policies](docs/guides/config-policy.mdx).
|
|
83
93
|
|
|
84
94
|
## image and workspace control
|
|
85
95
|
|
|
@@ -99,7 +109,8 @@ and [Docker](docs/guides/docker.mdx).
|
|
|
99
109
|
|
|
100
110
|
See [project setup and templates](docs/guides/project-setup.mdx),
|
|
101
111
|
[persistent services](docs/guides/long-running-services.mdx),
|
|
102
|
-
[scripting and CI](docs/guides/scripting-ci.mdx),
|
|
112
|
+
[scripting and CI](docs/guides/scripting-ci.mdx),
|
|
113
|
+
[disposable CI jobs](docs/guides/ci-jobs.mdx), and
|
|
103
114
|
[upgrades](docs/guides/upgrades.mdx) for complete operator workflows.
|
|
104
115
|
|
|
105
116
|
stop the source box before taking a snapshot, restoring one or making a fork.
|
|
@@ -147,6 +158,7 @@ records differences, sources and test coverage.
|
|
|
147
158
|
| CPU/memory sizing and usage | ✅ | ✅ | Any whole CPU count and GiB size up to the host. Usage covers the shared pool or the box's own disk |
|
|
148
159
|
| Host capacity estimates | ❌ | ✅ | Advisory only; Raft has no box count limit. Boat reports plan limits |
|
|
149
160
|
| Root terminal and command execution | ✅ | ✅ | Host SSH transport |
|
|
161
|
+
| Disposable foreground CI jobs | ✅ | Preview | Unreleased 0.7.0 `raft run`: fixed timeout, Git-aware upload, artifact collection and guarded cleanup; absent from published 0.6.0 |
|
|
150
162
|
| Background jobs, logs and cancellation | ✅ | ✅ | No process checkpoints |
|
|
151
163
|
| File upload/download | ✅ | ✅ | Files and recursive trees; new directory destinations only |
|
|
152
164
|
| Snapshots and same-host forks | ✅ | ✅ | Stopped source, checked under the lifecycle lock |
|
|
@@ -158,7 +170,7 @@ records differences, sources and test coverage.
|
|
|
158
170
|
| Reverse forwarding | ✅ | ❌ | Not implemented |
|
|
159
171
|
| Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented |
|
|
160
172
|
| Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides; disks keep their size |
|
|
161
|
-
| CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/capacity plus prune JSONL |
|
|
173
|
+
| CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/capacity/config plus prune JSONL |
|
|
162
174
|
| Snapshot file browsing/download | ✅ | ❌ | Whole-box backup only |
|
|
163
175
|
| Strict per-box disk quotas | ✅ | ✅ | On hosts deployed with `--disk-pool`, `new --disk SIZE` gives a box its own fixed-size, copy-on-write disk; the default shared pool has none |
|
|
164
176
|
| Separate-kernel VM isolation | ✅ | ❌ | Container runtime |
|
|
@@ -166,7 +178,7 @@ records differences, sources and test coverage.
|
|
|
166
178
|
| Organizations and webhooks | ✅ | ❌ | Raft is single-operator; no event API |
|
|
167
179
|
| Dashboard, remote API and SDK | ✅ | ❌ | CLI only |
|
|
168
180
|
| Managed agent conversations | ✅ | ❌ | Not implemented |
|
|
169
|
-
| Automatic deletion and retention | ✅ | ✅ | Disposable deletion;
|
|
181
|
+
| Automatic deletion and retention | ✅ | ✅ | Disposable deletion, optionally by default; per-host retention deletes boxes stopped for N hours; age-based prune with preview |
|
|
170
182
|
| CLI self-update | ✅ | ✅ | Explicit `raft self-update` for npm and uv installs plus a daily stderr notice. Boat also updates itself after commands; Raft only notifies |
|
|
171
183
|
|
|
172
184
|
## verification and development
|
package/deploy/build-image.py
CHANGED
|
@@ -41,15 +41,17 @@ def build(host, alias):
|
|
|
41
41
|
host,
|
|
42
42
|
prefix + " launch " + fingerprint + " raft-builder -c limits.cpu=2 -c limits.memory=4GiB",
|
|
43
43
|
)
|
|
44
|
+
# Stage outside /tmp: its boot-time tmpfs mount can hide an early file push.
|
|
44
45
|
for source, guest, mode in [
|
|
45
|
-
("images/incus/build.sh", "/
|
|
46
|
+
("images/incus/build.sh", "/root/raft-build.sh", "755"),
|
|
46
47
|
("images/incus/desktop.sh", "/usr/local/bin/raft-desktop", "755"),
|
|
47
48
|
("images/incus/raft-desktop.service", "/etc/systemd/system/raft-desktop.service", "644"),
|
|
48
49
|
]:
|
|
49
50
|
staged = "/tmp/" + Path(guest).name
|
|
50
51
|
copy(host, ROOT / source, staged)
|
|
51
52
|
remote(host, prefix + f" file push {staged} raft-builder{guest} --mode {mode}")
|
|
52
|
-
remote(host, prefix + " exec raft-builder -- bash /
|
|
53
|
+
remote(host, prefix + " exec raft-builder -- bash /root/raft-build.sh")
|
|
54
|
+
remote(host, prefix + " exec raft-builder -- rm /root/raft-build.sh")
|
|
53
55
|
remote(host, prefix + " stop raft-builder --timeout 30")
|
|
54
56
|
print(
|
|
55
57
|
remote(
|
package/deploy/raft-expire.py
CHANGED
|
@@ -1,61 +1,139 @@
|
|
|
1
1
|
#!/usr/bin/python3
|
|
2
|
-
"""Enforce running deadlines and preview or delete
|
|
2
|
+
"""Enforce running deadlines and stopped-box retention, and preview or delete
|
|
3
|
+
explicitly selected old boxes."""
|
|
3
4
|
|
|
4
5
|
import argparse
|
|
5
6
|
import json
|
|
7
|
+
import re
|
|
6
8
|
import subprocess
|
|
9
|
+
import sys
|
|
7
10
|
import time
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
# Checked by CHECK_WORKER in raft.py; bump both together. 2 added retention.
|
|
13
|
+
PROTOCOL = 2
|
|
10
14
|
|
|
11
15
|
PREFIX = ["incus", "--project", "raft"]
|
|
16
|
+
# Stopped-box retention set by `raft policy LOCATION --delete-stopped-after
|
|
17
|
+
# HOURS` on the Incus project, and when it was set. Mirrors RETENTION_KEY in
|
|
18
|
+
# raft.py, whose delete_at() reports the same deadline this worker enforces.
|
|
19
|
+
RETENTION_KEY = "user.raft.delete-stopped-after"
|
|
12
20
|
|
|
13
21
|
|
|
14
22
|
def run(*argv):
|
|
15
23
|
return subprocess.run([*PREFIX, *argv], check=True, capture_output=True, text=True).stdout
|
|
16
24
|
|
|
17
25
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
continue
|
|
41
|
-
if args.yes:
|
|
42
|
-
run("delete", name)
|
|
43
|
-
print(json.dumps({"box": name, "action": "deleted" if args.yes else "would-delete"}))
|
|
26
|
+
def retention():
|
|
27
|
+
"""Return the project's (hours, since) retention policy, or None when it is unset."""
|
|
28
|
+
projects = json.loads(run("project", "list", "--format", "json"))
|
|
29
|
+
config = next(item for item in projects if item["name"] == "raft")["config"]
|
|
30
|
+
hours = config.get(RETENTION_KEY, "")
|
|
31
|
+
since = config.get(RETENTION_KEY + ".since", "")
|
|
32
|
+
if not hours:
|
|
33
|
+
return None
|
|
34
|
+
# Raft writes whole numbers; anything else came from a native Incus edit.
|
|
35
|
+
if not re.fullmatch(r"[1-9][0-9]*", hours) or not re.fullmatch(r"[0-9]+", since):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Invalid retention {RETENTION_KEY}={hours!r} .since={since!r}; deleted no stopped "
|
|
38
|
+
"boxes. Fix it with raft policy LOCATION --delete-stopped-after HOURS or never"
|
|
39
|
+
)
|
|
40
|
+
return int(hours), int(since)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def prune(boxes, days, apply, now):
|
|
44
|
+
"""Preview or delete stopped boxes whose recorded stop is older than days."""
|
|
45
|
+
for box in boxes:
|
|
46
|
+
stopped_at = box["config"].get("user.raft.stopped-at")
|
|
47
|
+
if box["status"] != "Stopped" or not stopped_at:
|
|
44
48
|
continue
|
|
45
|
-
|
|
46
|
-
if not deadline or int(deadline) > now:
|
|
49
|
+
if int(stopped_at) > now - days * 86400:
|
|
47
50
|
continue
|
|
51
|
+
if apply:
|
|
52
|
+
run("delete", box["name"])
|
|
53
|
+
print(json.dumps({"box": box["name"], "action": "deleted" if apply else "would-delete"}))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def enforce(box, policy, now):
|
|
57
|
+
"""Apply the running deadline, then the stopped-box retention, to one box."""
|
|
58
|
+
config = box["config"]
|
|
59
|
+
name = box["name"]
|
|
60
|
+
deadline = config.get("user.raft.expires")
|
|
61
|
+
if deadline and int(deadline) <= now:
|
|
48
62
|
if box["status"] not in ["Running", "Stopped"]:
|
|
49
63
|
raise RuntimeError(f"Unexpected state for expired box {name}")
|
|
50
64
|
if config.get("user.raft.disposable") == "true":
|
|
51
65
|
run("delete", name, "--force")
|
|
52
66
|
print(f"Deleted expired disposable box {name}", flush=True)
|
|
53
|
-
|
|
67
|
+
return
|
|
54
68
|
if box["status"] == "Running":
|
|
55
69
|
run("stop", name, "--timeout", "30")
|
|
56
70
|
run("config", "set", name, f"user.raft.stopped-at={now}")
|
|
57
71
|
run("config", "unset", name, "user.raft.expires")
|
|
58
72
|
print(f"Stopped expired box {name}; disk retained", flush=True)
|
|
73
|
+
return
|
|
74
|
+
# Retention never touches a running box. A native incus start keeps the old
|
|
75
|
+
# stop time, so clear it here and the next stop starts a fresh clock. A start
|
|
76
|
+
# and stop between two worker runs still goes unnoticed.
|
|
77
|
+
if box["status"] != "Stopped":
|
|
78
|
+
if box["status"] == "Running" and config.get("user.raft.stopped-at"):
|
|
79
|
+
run("config", "unset", name, "user.raft.stopped-at")
|
|
80
|
+
return
|
|
81
|
+
stopped_at = config.get("user.raft.stopped-at")
|
|
82
|
+
if not stopped_at:
|
|
83
|
+
# Raft records the stop time on stop and expiry. A box stopped any other
|
|
84
|
+
# way (a guest poweroff, a native incus stop, a recovered backup or a
|
|
85
|
+
# failed start) starts its retention clock now. Its real stop time is
|
|
86
|
+
# unknown, and guessing an earlier one could delete it early.
|
|
87
|
+
run("config", "set", name, f"user.raft.stopped-at={now}")
|
|
88
|
+
print(f"Recorded stop time of box {name}, stopped outside Raft", flush=True)
|
|
89
|
+
return
|
|
90
|
+
if policy is None:
|
|
91
|
+
return
|
|
92
|
+
hours, since = policy
|
|
93
|
+
# The clock starts at the later of the stop and the policy change, so a new or
|
|
94
|
+
# shorter policy never deletes a box sooner than its hours after the change.
|
|
95
|
+
if max(int(stopped_at), since) + hours * 3600 > now:
|
|
96
|
+
return
|
|
97
|
+
run("delete", name)
|
|
98
|
+
stopped = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(stopped_at)))
|
|
99
|
+
message = f"Deleted box {name}, stopped since {stopped}, under the {hours}-hour retention"
|
|
100
|
+
# Retention deletes data without a command, so every deletion goes to the host
|
|
101
|
+
# journal beside the policy change that caused it (journalctl -t raft), whether
|
|
102
|
+
# the timer or raft gc ran the worker.
|
|
103
|
+
subprocess.run(["logger", "-t", "raft", message], check=True)
|
|
104
|
+
print(message, flush=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main():
|
|
108
|
+
cli = argparse.ArgumentParser(description=__doc__)
|
|
109
|
+
cli.add_argument("--prune-days", type=int)
|
|
110
|
+
cli.add_argument("--yes", action="store_true")
|
|
111
|
+
args = cli.parse_args()
|
|
112
|
+
if args.prune_days is not None and args.prune_days < 1:
|
|
113
|
+
cli.error("Retention must be at least one day")
|
|
114
|
+
if args.yes and args.prune_days is None:
|
|
115
|
+
cli.error("--yes requires --prune-days")
|
|
116
|
+
now = int(time.time())
|
|
117
|
+
# The service, gc and prune all hold the same lifecycle lock. Never select
|
|
118
|
+
# unrelated Incus instances or use creation time as a guessed stop time.
|
|
119
|
+
boxes = [
|
|
120
|
+
box
|
|
121
|
+
for box in json.loads(run("list", "--fast", "--format", "json"))
|
|
122
|
+
if box["config"].get("user.raft") == "true"
|
|
123
|
+
]
|
|
124
|
+
if args.prune_days is not None:
|
|
125
|
+
prune(boxes, args.prune_days, args.yes, now)
|
|
126
|
+
return
|
|
127
|
+
# An invalid policy must not stop deadline enforcement: apply deadlines, skip
|
|
128
|
+
# retention, and fail afterwards so the service run reports the problem.
|
|
129
|
+
try:
|
|
130
|
+
policy, problem = retention(), None
|
|
131
|
+
except ValueError as error:
|
|
132
|
+
policy, problem = None, str(error)
|
|
133
|
+
for box in boxes:
|
|
134
|
+
enforce(box, policy, now)
|
|
135
|
+
if problem:
|
|
136
|
+
sys.exit(problem)
|
|
59
137
|
|
|
60
138
|
|
|
61
139
|
if __name__ == "__main__":
|
|
@@ -88,19 +88,19 @@ def verify(source, target, directory, development_image=False):
|
|
|
88
88
|
raise RuntimeError("Detached job did not start")
|
|
89
89
|
time.sleep(0.2)
|
|
90
90
|
child = live.execute(box, "cat", "/workspace/child-pid")
|
|
91
|
-
assert
|
|
91
|
+
assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
|
|
92
92
|
live.raft("extend", box, "--ttl", "1200")
|
|
93
93
|
extended = json.loads(live.raft("info", box).stdout)
|
|
94
94
|
assert (
|
|
95
95
|
int(extended["config"]["user.raft.expires"])
|
|
96
96
|
> int(live.execute(box, "date", "+%s")) + 1100
|
|
97
97
|
)
|
|
98
|
-
assert
|
|
99
|
-
#
|
|
98
|
+
assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
|
|
99
|
+
# Guest journal rotation cannot erase host-retained job state.
|
|
100
100
|
live.execute(box, "journalctl", "--rotate", "--vacuum-size=1")
|
|
101
|
-
assert
|
|
101
|
+
assert json.loads(live.raft("status", box, job).stdout)["state"] == "running"
|
|
102
102
|
live.raft("cancel", box, job)
|
|
103
|
-
assert
|
|
103
|
+
assert live.wait_job(box, job)["state"] == "cancelled"
|
|
104
104
|
assert live.raft("logs", box, job).returncode == 0
|
|
105
105
|
assert (
|
|
106
106
|
live.raft("exec", box, "--", "test", "-e", "/proc/" + child, check=False).returncode
|
|
@@ -155,7 +155,7 @@ def verify(source, target, directory, development_image=False):
|
|
|
155
155
|
).stdout.strip()
|
|
156
156
|
fixtures.append(restored)
|
|
157
157
|
finally:
|
|
158
|
-
live.raft("policy", target, "--
|
|
158
|
+
live.raft("policy", target, "--require", "none")
|
|
159
159
|
print(f"{target}: recovered stopped workspace", flush=True)
|
|
160
160
|
note = json.loads(live.raft("info", restored).stdout)["config"]["user.raft.note"]
|
|
161
161
|
assert json.loads(note) == {
|