@microck/raft 0.4.0 → 0.5.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
@@ -30,13 +30,16 @@ npm install -g @microck/raft
30
30
  raft --version
31
31
  ```
32
32
 
33
+ `raft self-update` upgrades the CLI later. Raft prints a one-line stderr notice
34
+ when a new release exists; set `RAFT_NO_UPDATE_CHECK=1` to turn it off.
35
+
33
36
  Python wheels and source archives are also available in
34
37
  [releases](https://github.com/Microck/raft/releases). npm installation does not set
35
38
  up hosts or download workspace images.
36
39
 
37
40
  | requirement | minimum |
38
41
  | --- | --- |
39
- | host | dedicated ARM64 or AMD64 Ubuntu host, 2 CPUs, 8 GiB RAM, 80 GiB free disk |
42
+ | host | dedicated ARM64 or AMD64 Ubuntu host, 2 CPUs, 8 GiB RAM, 80 GiB free disk, plus the pool size if you enable own box disks |
40
43
  | host access | SSH and passwordless sudo |
41
44
  | controller | Linux, Python 3.11.8+, SSH and SCP; npm or uv for installation |
42
45
 
@@ -64,9 +67,19 @@ the box with a new lifetime. `raft destroy` deletes the box and its snapshots.
64
67
  disposable creation and retention cleanup require the current host worker;
65
68
  redeploy from the current checkout before using them.
66
69
 
67
- each host allows four saved boxes, including stopped ones. `raft limits` reports
68
- current allocations, free disk space and recommended running counts for each
69
- size. see [host capacity](docs/reference/capacity-limits.mdx).
70
+ size boxes for the job: `--cpu`, `--memory` and `--disk` take any whole number
71
+ up to the host. on hosts deployed with `--disk-pool SIZE`, `--disk 20GiB` gives a
72
+ box its own disk that it, and Docker inside it, cannot grow past; forks and
73
+ templates of it are copy-on-write. raft does not cap how many boxes you keep.
74
+ `raft capacity` prints an advisory estimate of what fits; it never blocks a box.
75
+ see [box sizing and capacity](docs/reference/capacity.mdx).
76
+
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).
70
83
 
71
84
  ## image and workspace control
72
85
 
@@ -131,8 +144,8 @@ records differences, sources and test coverage.
131
144
  | Create/list/inspect/delete | ✅ | ✅ | Single operator |
132
145
  | Persistent stop/resume | ✅ | ✅ | Shared host kernel |
133
146
  | TTL and lifetime extension | ✅ | ✅ | Persistent boxes retain disk; disposable boxes delete |
134
- | CPU/memory sizing and usage | ✅ | ✅ | 1 or 2 CPUs, 1, 2, 4 or 8 GiB RAM. Disk usage covers the shared pool |
135
- | Host capacity recommendations | ❌ | ✅ | Raft recommends running capacity and enforces four saved boxes. Boat reports plan limits |
147
+ | 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
+ | Host capacity estimates | ❌ | ✅ | Advisory only; Raft has no box count limit. Boat reports plan limits |
136
149
  | Root terminal and command execution | ✅ | ✅ | Host SSH transport |
137
150
  | Background jobs, logs and cancellation | ✅ | ✅ | No process checkpoints |
138
151
  | File upload/download | ✅ | ✅ | Files and recursive trees; new directory destinations only |
@@ -144,17 +157,17 @@ records differences, sources and test coverage.
144
157
  | Desktop access | ✅ | ✅ | noVNC; see verification limits |
145
158
  | Reverse forwarding | ✅ | ❌ | Not implemented |
146
159
  | Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented |
147
- | Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides within supported sizes |
148
- | CLI-wide JSON mode | ✅ | ❌ | Boat JSON/JSONL; Raft list/info/usage/limits plus prune JSONL |
160
+ | 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 |
149
162
  | Snapshot file browsing/download | ✅ | ❌ | Whole-box backup only |
150
- | Strict per-box disk quotas | ✅ | ❌ | Shared 60 GiB Btrfs pool |
163
+ | 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 |
151
164
  | Separate-kernel VM isolation | ✅ | ❌ | Container runtime |
152
165
  | Named environments and managed secrets | ✅ | ❌ | Manual configuration |
153
166
  | Organizations and webhooks | ✅ | ❌ | Raft is single-operator; no event API |
154
167
  | Dashboard, remote API and SDK | ✅ | ❌ | CLI only |
155
168
  | Managed agent conversations | ✅ | ❌ | Not implemented |
156
169
  | Automatic deletion and retention | ✅ | ✅ | Disposable deletion; opt-in age-based prune with preview |
157
- | CLI self-update | ✅ | ❌ | Standard npm or Python package installation |
170
+ | 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 |
158
171
 
159
172
  ## verification and development
160
173
 
package/deploy/deploy.py CHANGED
@@ -15,7 +15,7 @@ import sys
15
15
 
16
16
  ROOT = Path(__file__).resolve().parent.parent
17
17
  sys.path.insert(0, str(ROOT))
18
- from raft import configuration # noqa: E402
18
+ from raft import BOX_DISK_POOL, configuration, gib, gib_size # noqa: E402
19
19
 
20
20
  RELEASES = "https://github.com/Microck/raft/releases/download/"
21
21
  # Matches the user agent install-incus.sh sends for its own downloads.
@@ -134,7 +134,86 @@ def import_archive(host, archive, architecture):
134
134
  return checked_image(host, fingerprint, architecture)
135
135
 
136
136
 
137
- def deploy(name, host):
137
+ def free_disk(host):
138
+ return int(
139
+ remote(
140
+ host,
141
+ shlex.join(
142
+ [
143
+ "python3",
144
+ "-c",
145
+ "import shutil; print(shutil.disk_usage('/var/lib/incus').free)",
146
+ ]
147
+ ),
148
+ )
149
+ )
150
+
151
+
152
+ def box_disk_pool(host, pools, size):
153
+ """Create or verify the thin LVM pool that holds own box disks.
154
+
155
+ Own box disks are opt-in: without --disk-pool a host has no raft-disks pool,
156
+ which is a normal state, and --disk-pool on a later redeploy adds it. Thin
157
+ volumes make forks, templates and snapshots of own-disk boxes snapshots that
158
+ share blocks. Raft never overcommits the pool: every write path that adds a
159
+ thin volume checks that all virtual sizes still fit its data space (see
160
+ BOX_DISK_ACCOUNTING in raft.py), so the pool cannot fill.
161
+ """
162
+ pool = next((item for item in pools if item["name"] == BOX_DISK_POOL), None)
163
+ if pool is None:
164
+ if size is None:
165
+ return
166
+ # Same 20 GiB margin as the fresh-host check for the 60 GiB shared pool.
167
+ if free_disk(host) < (gib(size) + 20) * 1024**3:
168
+ raise RuntimeError(
169
+ f"Creating the {size} {BOX_DISK_POOL} pool needs {gib(size) + 20} GiB free disk; "
170
+ "pass a smaller --disk-pool or free host disk"
171
+ )
172
+ remote(
173
+ host,
174
+ f"sudo -n incus storage create {BOX_DISK_POOL} lvm size={size} lvm.use_thinpool=true",
175
+ )
176
+ pool = json.loads(remote(host, f"sudo -n incus query /1.0/storage-pools/{BOX_DISK_POOL}"))
177
+ config = pool["config"]
178
+ if pool["driver"] != "lvm" or config.get("lvm.use_thinpool") != "true":
179
+ raise RuntimeError(
180
+ f"{BOX_DISK_POOL} must be a thin LVM pool (lvm.use_thinpool=true); inspect it and "
181
+ "back up its boxes before explicit storage recovery"
182
+ )
183
+ if size is not None and config.get("size") != size:
184
+ raise RuntimeError(
185
+ f"{BOX_DISK_POOL} has size={config.get('size')}, not {size}. "
186
+ "Deployment does not resize existing storage; omit --disk-pool to keep it"
187
+ )
188
+ # The thin pool frees blocks that boxes discard, but must not pass discards on
189
+ # to its loop file: that would punch holes in the file, and a later write into
190
+ # a hole could find the host disk full and fail every own-disk box at once.
191
+ # Changing passdown to nopassdown is allowed while the pool is active.
192
+ thinpool = f"{config['lvm.vg_name']}/{config['lvm.thinpool_name']}"
193
+ discards = remote(host, f"sudo -n lvs --noheadings -o discards {thinpool}").strip()
194
+ if discards != "nopassdown":
195
+ remote(host, f"sudo -n lvchange --discards nopassdown {thinpool}")
196
+ # Incus creates the pool's loop file sparse. Allocating its full length makes
197
+ # the host disk hold the whole pool; fallocate only fills holes and never
198
+ # changes the file's length or data, so repeating it is a no-op. A pool an
199
+ # operator placed on a dedicated block device has no file to allocate.
200
+ remote(
201
+ host,
202
+ shlex.join(
203
+ [
204
+ "sudo",
205
+ "-n",
206
+ "sh",
207
+ "-c",
208
+ 'test ! -f "$1" || fallocate -l "$(stat -c %s "$1")" "$1"',
209
+ "raft",
210
+ config["source"],
211
+ ]
212
+ ),
213
+ )
214
+
215
+
216
+ def deploy(name, host, disk_pool=None):
138
217
  host_architecture(host)
139
218
  copy(host, ROOT / "deploy/install-incus.sh", "/tmp/raft-install-incus.sh")
140
219
  print(remote(host, "bash /tmp/raft-install-incus.sh </dev/null"), flush=True)
@@ -149,24 +228,29 @@ def deploy(name, host):
149
228
  if project.get("description") != "Raft managed workspaces":
150
229
  raise RuntimeError("Existing raft project needs ownership verification")
151
230
  else:
152
- if pool is not None or network is not None:
153
- raise RuntimeError(
154
- "Raft resource names already exist without an owned project; review them explicitly"
155
- )
156
- available = int(
157
- remote(
158
- host,
159
- shlex.join(
160
- [
161
- "python3",
162
- "-c",
163
- "import shutil; print(shutil.disk_usage('/var/lib/incus').free)",
164
- ]
231
+ leftovers = [
232
+ name
233
+ for name, found in [
234
+ ("storage pool raft-data", pool is not None),
235
+ ("network rfbr0", network is not None),
236
+ (
237
+ f"storage pool {BOX_DISK_POOL}",
238
+ any(item["name"] == BOX_DISK_POOL for item in pools),
165
239
  ),
240
+ ]
241
+ if found
242
+ ]
243
+ if leftovers:
244
+ raise RuntimeError(
245
+ "Raft resource names already exist without an owned project: "
246
+ + ", ".join(leftovers)
247
+ + "; review them explicitly"
166
248
  )
167
- )
168
- if available < 80 * 1024**3:
169
- raise RuntimeError("Fresh deployment needs at least 80 GiB free disk")
249
+ # 60 GiB shared pool, 20 GiB for images and the host, and the own-disk
250
+ # pool when --disk-pool enables it.
251
+ needed = 80 + (gib(disk_pool) if disk_pool else 0)
252
+ if free_disk(host) < needed * 1024**3:
253
+ raise RuntimeError(f"Fresh deployment needs at least {needed} GiB free disk")
170
254
  remote(
171
255
  host,
172
256
  "sudo -n incus project create raft -c features.images=true -c features.profiles=true </dev/null",
@@ -207,6 +291,7 @@ def deploy(name, host):
207
291
  "Raft pool must have configured size=60GiB; inspect raft-data and back up boxes "
208
292
  "before explicit storage recovery. Deployment does not resize existing storage."
209
293
  )
294
+ box_disk_pool(host, pools, disk_pool)
210
295
 
211
296
  devices = profile["devices"]
212
297
  if (
@@ -261,9 +346,17 @@ def main():
261
346
  parser = argparse.ArgumentParser(description=__doc__)
262
347
  configured = hosts()
263
348
  parser.add_argument("--location", choices=configured)
349
+ parser.add_argument(
350
+ "--disk-pool",
351
+ type=gib_size,
352
+ metavar="SIZE",
353
+ help=f"Enable own box disks: create the {BOX_DISK_POOL} pool with SIZE of host disk "
354
+ "if it is missing (default: leave own box disks as they are); an existing pool must "
355
+ "already have this size",
356
+ )
264
357
  args = parser.parse_args()
265
358
  for name in [args.location] if args.location else configured:
266
- deploy(name, configured[name])
359
+ deploy(name, configured[name], args.disk_pool)
267
360
 
268
361
 
269
362
  if __name__ == "__main__":
@@ -15,6 +15,16 @@ if ! command -v incus >/dev/null; then
15
15
  sudo apt-get update -qq
16
16
  sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends incus btrfs-progs
17
17
  fi
18
+ # raft-disks, the opt-in pool for own box disks, uses LVM thin provisioning.
19
+ if ! command -v lvcreate >/dev/null; then
20
+ sudo apt-get update -qq
21
+ sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends lvm2
22
+ fi
23
+ # raft recover reads the backup index, which Incus writes as YAML.
24
+ if ! python3 -c 'import yaml' 2>/dev/null; then
25
+ sudo apt-get update -qq
26
+ sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends python3-yaml
27
+ fi
18
28
  if ! command -v nft >/dev/null; then
19
29
  sudo apt-get update -qq
20
30
  sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y --no-install-recommends nftables
@@ -9,7 +9,7 @@ import time
9
9
  import uuid
10
10
 
11
11
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12
- from raft import ADMISSION, address, incus, inventory, parse_handle, transaction # noqa: E402
12
+ from raft import address, incus, inventory, parse_handle, transaction # noqa: E402
13
13
  import importlib.util # noqa: E402
14
14
 
15
15
  spec = importlib.util.spec_from_file_location(
@@ -32,61 +32,21 @@ def wait_address(box):
32
32
  raise RuntimeError("Resumed workspace has no managed IPv4 address after 30 seconds")
33
33
 
34
34
 
35
- def verify_admission(location, directory):
36
- fixtures = []
37
- saved = len(inventory(location))
38
- if saved >= 4:
39
- raise RuntimeError("Admission test needs at least one free slot on its target")
40
- try:
41
- for _ in range(4 - saved):
42
- box = live.raft(
43
- "new", "--location", location, "--memory", "1GiB", "--ttl", "600"
44
- ).stdout.strip()
45
- fixtures.append(box)
46
- live.wait_running(box)
47
- live.raft("stop", box)
48
- rejected = live.raft("new", "--location", location, check=False)
49
- if rejected.returncode == 0:
50
- fixtures.append(rejected.stdout.strip())
51
- assert rejected.returncode != 0 and "already holds four boxes" in rejected.stderr
52
- rejected = live.raft("fork", fixtures[0], "--ttl", "600", check=False)
53
- if rejected.returncode == 0:
54
- fixtures.append(rejected.stdout.strip())
55
- assert rejected.returncode != 0 and "already holds four boxes" in rejected.stderr
56
- with tempfile.TemporaryDirectory(dir=directory, prefix="raft-admission-") as work:
57
- # Admission must reject before native archive parsing, without creating a box.
58
- archive = Path(work) / "rejected.tar.gz"
59
- archive.write_bytes(b"not-an-incus-archive")
60
- rejected = live.raft("recover", str(archive), "--location", location, check=False)
61
- if rejected.returncode == 0:
62
- fixtures.append(rejected.stdout.strip())
63
- assert rejected.returncode != 0 and "already holds four boxes" in rejected.stderr
64
- assert len(inventory(location)) == 4
65
- report = json.loads(live.raft("limits", "--location", location, "--json").stdout)
66
- assert report["savedBoxes"] == 4 and report["savedSlots"] == 0
67
- assert all(size["newBoxes"] == 0 for size in report["recommendations"])
68
- print(
69
- f"{location}: stopped boxes count toward admission; new/fork/recover reject a fifth box",
70
- flush=True,
71
- )
72
- finally:
73
- for box in reversed(fixtures):
74
- live.raft("destroy", box)
75
-
76
-
77
35
  def verify(source, target, directory, development_image=False):
78
36
  fixtures = []
79
37
  name = "rf-" + uuid.uuid4().hex[:16]
80
38
  box = source + ":" + name
81
39
  try:
82
40
  if development_image:
83
- box = live.raft("new", "--location", source, "--ttl", "1800").stdout.strip()
41
+ box = live.raft(
42
+ "new", "--location", source, "--ttl", "1800", "--cpu", "1", "--disk", "shared"
43
+ ).stdout.strip()
84
44
  fixtures.append(box)
85
45
  live.wait_running(box)
86
46
  else:
87
47
  transaction(
88
48
  source,
89
- ADMISSION + 'incus --project raft launch images:debian/13 "$1" '
49
+ 'incus --project raft launch images:debian/13 "$1" '
90
50
  "-c user.raft=true -c limits.cpu=1 -c limits.memory=1GiB; "
91
51
  'incus --project raft config set "$1" user.raft.expires="$(( $(date +%s) + 600 ))"',
92
52
  name,
@@ -172,9 +132,36 @@ def verify(source, target, directory, development_image=False):
172
132
  print(f"{source}: archive completed ({size} bytes)", flush=True)
173
133
  assert live.raft("backup", box, str(archive), check=False).returncode != 0
174
134
  assert archive.stat().st_size == size
175
- restored = live.raft("recover", str(archive), "--location", target).stdout.strip()
176
- fixtures.append(restored)
135
+ # The archived box has no note, so a target policy rejects it after import
136
+ # and deletes the imported copy in the same locked transaction.
137
+ live.raft("policy", target, "--require", "purpose,owner")
138
+ try:
139
+ saved = len(inventory(target))
140
+ rejected = live.raft("recover", str(archive), "--location", target, check=False)
141
+ if rejected.returncode == 0:
142
+ fixtures.append(rejected.stdout.strip())
143
+ assert rejected.returncode == 1, rejected
144
+ assert "requires box note fields purpose, owner" in rejected.stderr, rejected
145
+ assert len(inventory(target)) == saved, "Rejected recovery kept a box"
146
+ restored = live.raft(
147
+ "recover",
148
+ str(archive),
149
+ "--location",
150
+ target,
151
+ "--purpose",
152
+ "Recovery verification",
153
+ "--owner",
154
+ "verify-controls",
155
+ ).stdout.strip()
156
+ fixtures.append(restored)
157
+ finally:
158
+ live.raft("policy", target, "--clear")
177
159
  print(f"{target}: recovered stopped workspace", flush=True)
160
+ note = json.loads(live.raft("info", restored).stdout)["config"]["user.raft.note"]
161
+ assert json.loads(note) == {
162
+ "purpose": "Recovery verification",
163
+ "owner": "verify-controls",
164
+ }
178
165
  metadata = json.loads(live.raft("info", restored).stdout)
179
166
  assert metadata["status"] == "Stopped"
180
167
  assert not metadata["config"].get("user.raft.expires")
@@ -237,5 +224,4 @@ if __name__ == "__main__":
237
224
  help="Export the complete configured development image rather than a small fixture",
238
225
  )
239
226
  args = parser.parse_args()
240
- verify_admission(args.target, args.directory)
241
227
  verify(args.source, args.target, args.directory, args.development_image)