@microck/raft 0.5.0 → 0.6.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 +13 -10
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +1 -1
- package/deploy/verify-features.py +294 -17
- package/deploy/verify-limits.py +2 -1
- package/deploy/verify-live.py +19 -3
- package/docs/contract.md +12 -11
- package/docs/first-box.mdx +1 -1
- package/docs/guides/backup-recovery.mdx +1 -1
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/long-running-services.mdx +8 -2
- package/docs/guides/scripting-ci.mdx +8 -5
- package/docs/guides/snapshots-forks.mdx +1 -1
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +4 -2
- package/docs/index.mdx +1 -1
- package/docs/operations.mdx +10 -10
- package/docs/reference/cli.md +51 -25
- package/docs/reference/configuration.mdx +10 -6
- package/docs/releases/0.6.0.md +16 -0
- package/docs/verification.mdx +1 -1
- package/package.json +1 -1
- package/raft.py +163 -31
- package/skills/raft-cli/SKILL.md +21 -6
package/README.md
CHANGED
|
@@ -64,8 +64,10 @@ raft stop "$box"
|
|
|
64
64
|
`raft stop` and expiration keep persistent boxes on disk. `new --disposable`
|
|
65
65
|
deletes a temporary box when it stops or expires. `raft resume` starts
|
|
66
66
|
the box with a new lifetime. `raft destroy` deletes the box and its snapshots.
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
`raft policy lab --delete-stopped-after 48` makes the host delete boxes left
|
|
68
|
+
stopped for 48 hours, and logs each deletion in its journal. disposable
|
|
69
|
+
creation, prune and retention require the current host worker; redeploy from
|
|
70
|
+
the current checkout before using them.
|
|
69
71
|
|
|
70
72
|
size boxes for the job: `--cpu`, `--memory` and `--disk` take any whole number
|
|
71
73
|
up to the host. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
|
|
@@ -74,12 +76,13 @@ templates of it are copy-on-write. raft does not cap how many boxes you keep.
|
|
|
74
76
|
`raft capacity` prints an advisory estimate of what fits; it never blocks a box.
|
|
75
77
|
see [box sizing and capacity](docs/reference/capacity.mdx).
|
|
76
78
|
|
|
77
|
-
`raft config set` stores your defaults for `new`, such as location, TTL
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
`raft config set` stores your defaults for `new`, such as location, TTL, size
|
|
80
|
+
and `disposable true`; `new --persistent` keeps one box anyway. size defaults
|
|
81
|
+
apply only to boxes from the image: `new --from` and `fork` keep the source's
|
|
82
|
+
size. `raft policy lab --require purpose,owner` makes the host reject boxes whose
|
|
83
|
+
note lacks those fields, with an error that names the missing flags. policies
|
|
84
|
+
are guardrails for agents: anyone with sudo on the host can change them.
|
|
85
|
+
see [defaults and host policies](docs/guides/config-policy.mdx).
|
|
83
86
|
|
|
84
87
|
## image and workspace control
|
|
85
88
|
|
|
@@ -158,7 +161,7 @@ records differences, sources and test coverage.
|
|
|
158
161
|
| Reverse forwarding | ✅ | ❌ | Not implemented |
|
|
159
162
|
| Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented |
|
|
160
163
|
| 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 |
|
|
164
|
+
| CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/capacity/config plus prune JSONL |
|
|
162
165
|
| Snapshot file browsing/download | ✅ | ❌ | Whole-box backup only |
|
|
163
166
|
| 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
167
|
| Separate-kernel VM isolation | ✅ | ❌ | Container runtime |
|
|
@@ -166,7 +169,7 @@ records differences, sources and test coverage.
|
|
|
166
169
|
| Organizations and webhooks | ✅ | ❌ | Raft is single-operator; no event API |
|
|
167
170
|
| Dashboard, remote API and SDK | ✅ | ❌ | CLI only |
|
|
168
171
|
| Managed agent conversations | ✅ | ❌ | Not implemented |
|
|
169
|
-
| Automatic deletion and retention | ✅ | ✅ | Disposable deletion;
|
|
172
|
+
| Automatic deletion and retention | ✅ | ✅ | Disposable deletion, optionally by default; per-host retention deletes boxes stopped for N hours; age-based prune with preview |
|
|
170
173
|
| 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
174
|
|
|
172
175
|
## verification and development
|
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__":
|
|
@@ -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) == {
|