@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 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
- disposable creation and retention cleanup require the current host worker;
68
- redeploy from the current checkout before using them.
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 and
78
- size. size defaults apply only to boxes from the image: `new --from` and `fork`
79
- keep the source's size. `raft policy lab --require purpose,owner` makes the host
80
- reject boxes whose note lacks those fields, with an error that names the missing
81
- flags. it is a guardrail for agents: anyone with sudo on the host can change it.
82
- see [defaults and note policies](docs/guides/config-policy.mdx).
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; opt-in age-based prune with preview |
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
@@ -1,61 +1,139 @@
1
1
  #!/usr/bin/python3
2
- """Enforce running deadlines and preview or delete explicitly selected old boxes."""
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
- PROTOCOL = 1
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 main():
19
- cli = argparse.ArgumentParser(description=__doc__)
20
- cli.add_argument("--prune-days", type=int)
21
- cli.add_argument("--yes", action="store_true")
22
- args = cli.parse_args()
23
- if args.prune_days is not None and args.prune_days < 1:
24
- cli.error("Retention must be at least one day")
25
- if args.yes and args.prune_days is None:
26
- cli.error("--yes requires --prune-days")
27
- now = int(time.time())
28
- # The service, gc and prune all hold the same lifecycle lock. Never select
29
- # unrelated Incus instances or use creation time as a guessed stop time.
30
- for box in json.loads(run("list", "--fast", "--format", "json")):
31
- config = box["config"]
32
- if config.get("user.raft") != "true":
33
- continue
34
- name = box["name"]
35
- if args.prune_days is not None:
36
- stopped_at = config.get("user.raft.stopped-at")
37
- if box["status"] != "Stopped" or not stopped_at:
38
- continue
39
- if int(stopped_at) > now - args.prune_days * 86400:
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
- deadline = config.get("user.raft.expires")
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
- continue
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, "--clear")
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) == {