@microck/raft 0.2.1 → 0.4.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 +9 -1
- package/deploy/build-image.py +22 -18
- package/deploy/deploy.py +100 -6
- package/deploy/verify-features.py +173 -2
- package/deploy/verify-fresh-host.py +31 -26
- package/deploy/verify-limits.py +27 -1
- package/deploy/verify-live.py +6 -5
- package/docs/boat-capabilities.md +1 -1
- package/docs/contract.md +6 -1
- package/docs/guides/scripting-ci.mdx +4 -2
- package/docs/operations.mdx +33 -9
- package/docs/reference/capacity-limits.mdx +2 -2
- package/docs/reference/cli.md +84 -6
- package/docs/reference/configuration.mdx +15 -1
- package/docs/releases/0.3.0.md +15 -0
- package/docs/releases/0.4.0.md +15 -0
- package/docs/requirements.mdx +2 -0
- package/docs/verification.mdx +1 -1
- package/package.json +1 -1
- package/raft.py +389 -38
- package/skills/raft-cli/SKILL.md +23 -3
package/raft.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
"""Self-hosted persistent Linux workspaces powered by Incus."""
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
import fcntl
|
|
6
|
+
import importlib.util
|
|
5
7
|
import json
|
|
6
8
|
import os
|
|
7
9
|
from pathlib import Path
|
|
@@ -12,15 +14,25 @@ import subprocess
|
|
|
12
14
|
import sys
|
|
13
15
|
import tempfile
|
|
14
16
|
import tarfile
|
|
17
|
+
import time
|
|
15
18
|
import uuid
|
|
16
19
|
|
|
17
20
|
import raft_files
|
|
18
21
|
|
|
19
|
-
__version__ = "0.
|
|
22
|
+
__version__ = "0.4.0"
|
|
20
23
|
|
|
21
24
|
CONFIG = Path.home() / ".config/raft/incus.json"
|
|
22
25
|
PROJECT = "raft"
|
|
23
26
|
SAVED_BOX_LIMIT = 4
|
|
27
|
+
LOCATION_NAME = r"[a-zA-Z0-9][a-zA-Z0-9_-]*"
|
|
28
|
+
GIB = 1024**3
|
|
29
|
+
# Supported box sizes. Parser choices, capacity validation and recommendations
|
|
30
|
+
# all derive from these lists, so adding a size here covers every command.
|
|
31
|
+
CPU_SIZES = [1, 2]
|
|
32
|
+
MEMORY_SIZES = ["1GiB", "2GiB", "4GiB", "8GiB"]
|
|
33
|
+
# Box notes follow one template so any agent can tell why a box exists, who uses
|
|
34
|
+
# it and what happens to it next. Each value is free text; purpose is required.
|
|
35
|
+
BOX_NOTE_FIELDS = ["purpose", "owner", "next"]
|
|
24
36
|
|
|
25
37
|
USAGE = """import json, shutil
|
|
26
38
|
from pathlib import Path
|
|
@@ -43,10 +55,12 @@ def configuration():
|
|
|
43
55
|
if not isinstance(inventory, dict) or not inventory:
|
|
44
56
|
raise ValueError("Configure at least one host in ~/.config/raft/incus.json")
|
|
45
57
|
for location, entry in inventory.items():
|
|
46
|
-
if not re.fullmatch(
|
|
58
|
+
if not re.fullmatch(LOCATION_NAME, location):
|
|
47
59
|
raise ValueError("Location names must use letters, digits, underscores or hyphens")
|
|
48
60
|
if not isinstance(entry, dict) or not isinstance(entry.get("ssh"), str) or not entry["ssh"]:
|
|
49
61
|
raise ValueError("Each configured location needs a nonempty ssh target")
|
|
62
|
+
if not isinstance(entry.get("note", ""), str):
|
|
63
|
+
raise ValueError("Location notes must be strings")
|
|
50
64
|
return inventory
|
|
51
65
|
|
|
52
66
|
|
|
@@ -57,6 +71,71 @@ def settings(location):
|
|
|
57
71
|
return inventory[location]
|
|
58
72
|
|
|
59
73
|
|
|
74
|
+
def edit_configuration(change):
|
|
75
|
+
"""Apply change(inventory) to the controller configuration and save it."""
|
|
76
|
+
# main() sets umask 077, so a new configuration directory is private.
|
|
77
|
+
CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
# Hold a controller lock across read, edit and replace so concurrent edits
|
|
79
|
+
# cannot drop each other. Readers need no lock because replacement is atomic.
|
|
80
|
+
with (CONFIG.parent / ".incus.json.lock").open("w") as lock:
|
|
81
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
82
|
+
# Onboarding can start on a controller that has no configuration yet.
|
|
83
|
+
inventory = configuration() if CONFIG.exists() else {}
|
|
84
|
+
change(inventory)
|
|
85
|
+
if not inventory:
|
|
86
|
+
# Removing the only location restores the unconfigured state.
|
|
87
|
+
CONFIG.unlink(missing_ok=True)
|
|
88
|
+
return
|
|
89
|
+
# Key order is preserved because the first location is the default for new.
|
|
90
|
+
# Replace atomically so an interrupted write cannot truncate the inventory;
|
|
91
|
+
# NamedTemporaryFile creates the staged file with mode 0600.
|
|
92
|
+
staged = tempfile.NamedTemporaryFile("w", dir=CONFIG.parent, prefix=".raft-", delete=False)
|
|
93
|
+
try:
|
|
94
|
+
with staged:
|
|
95
|
+
json.dump(inventory, staged, indent=2)
|
|
96
|
+
staged.write("\n")
|
|
97
|
+
os.replace(staged.name, CONFIG)
|
|
98
|
+
finally:
|
|
99
|
+
# A no-op after a successful replace; removes the file after a failure.
|
|
100
|
+
Path(staged.name).unlink(missing_ok=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_note(location, text):
|
|
104
|
+
"""Record operator guidance for a location. Raft shows notes but never enforces them."""
|
|
105
|
+
if text is not None and not text.strip():
|
|
106
|
+
raise ValueError("Use --clear to remove a note")
|
|
107
|
+
|
|
108
|
+
def change(inventory):
|
|
109
|
+
if location not in inventory:
|
|
110
|
+
raise ValueError(f"Unknown location: {location}")
|
|
111
|
+
if text is None:
|
|
112
|
+
inventory[location].pop("note", None)
|
|
113
|
+
else:
|
|
114
|
+
inventory[location]["note"] = text.strip()
|
|
115
|
+
|
|
116
|
+
edit_configuration(change)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def box_note(args):
|
|
120
|
+
"""Build a box note from --purpose, --owner and --next; None when none is given."""
|
|
121
|
+
note = {}
|
|
122
|
+
for field in BOX_NOTE_FIELDS:
|
|
123
|
+
value = getattr(args, field, None)
|
|
124
|
+
if value is not None:
|
|
125
|
+
if not value.strip():
|
|
126
|
+
raise ValueError(f"--{field} cannot be empty")
|
|
127
|
+
note[field] = value.strip()
|
|
128
|
+
if note and "purpose" not in note:
|
|
129
|
+
raise ValueError("Box notes need --purpose")
|
|
130
|
+
return note or None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_box_note(box):
|
|
134
|
+
"""Return a box's note as a dict, or None. Raft writes it as JSON in user.raft.note."""
|
|
135
|
+
stored = box["config"].get("user.raft.note")
|
|
136
|
+
return json.loads(stored) if stored else None
|
|
137
|
+
|
|
138
|
+
|
|
60
139
|
def receive(command, destination, *, stdin=None):
|
|
61
140
|
"""Write streamed bytes in Python: SSH can exit zero after a local write error."""
|
|
62
141
|
with tempfile.TemporaryFile() as errors:
|
|
@@ -107,6 +186,188 @@ def incus(location, *argv, locked=False):
|
|
|
107
186
|
return response.stdout.decode()
|
|
108
187
|
|
|
109
188
|
|
|
189
|
+
# Read-only probe. It runs through the login shell of any SSH target, including
|
|
190
|
+
# macOS, so each fact degrades to an empty value instead of failing the script.
|
|
191
|
+
# Sizes are printed in KiB because mawk formats large products in exponent form.
|
|
192
|
+
HOST_FACTS = """echo "kernel=$(uname -s)"
|
|
193
|
+
if [ -r /etc/os-release ]; then . /etc/os-release; echo "os=${ID:-}"; echo "version=${VERSION_ID:-}"; fi
|
|
194
|
+
echo "arch=$(dpkg --print-architecture 2>/dev/null)"
|
|
195
|
+
echo "cpus=$(getconf _NPROCESSORS_ONLN 2>/dev/null)"
|
|
196
|
+
echo "memory=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null)"
|
|
197
|
+
directory=/var/lib/incus; [ -d "$directory" ] || directory=/var/lib
|
|
198
|
+
echo "disk=$(df -Pk "$directory" 2>/dev/null | awk 'NR == 2 {print $4}')"
|
|
199
|
+
if sudo -n true 2>/dev/null; then echo sudo=yes; else echo sudo=no; fi
|
|
200
|
+
if command -v python3 >/dev/null 2>&1; then echo python3=yes; else echo python3=no; fi
|
|
201
|
+
if sudo -n incus project list --format csv -c n 2>/dev/null | grep -qx raft; then echo deployed=yes; else echo deployed=no; fi
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def host_problems(facts):
|
|
206
|
+
"""Return reasons a probed SSH target cannot become a Raft host; empty means supported."""
|
|
207
|
+
kernel = facts.get("kernel") or "an unknown system"
|
|
208
|
+
if kernel != "Linux":
|
|
209
|
+
return [
|
|
210
|
+
f"The target runs {kernel}, not Linux. Raft hosts need Ubuntu 22.04 or 24.04 on "
|
|
211
|
+
"ARM64 or AMD64. On macOS, run an Ubuntu VM and onboard the VM's SSH target"
|
|
212
|
+
]
|
|
213
|
+
problems = []
|
|
214
|
+
release = f"{facts.get('os', '')} {facts.get('version', '')}".strip() or "unknown"
|
|
215
|
+
if facts.get("os") != "ubuntu" or facts.get("version") not in {"22.04", "24.04"}:
|
|
216
|
+
problems.append(f"Raft hosts need Ubuntu 22.04 or 24.04; found {release}")
|
|
217
|
+
if facts.get("arch") not in {"arm64", "amd64"}:
|
|
218
|
+
problems.append(f"Raft hosts need ARM64 or AMD64; found {facts.get('arch') or 'unknown'}")
|
|
219
|
+
if facts.get("sudo") != "yes":
|
|
220
|
+
problems.append("Configure passwordless sudo for the SSH user; `sudo -n true` must succeed")
|
|
221
|
+
if facts.get("python3") != "yes":
|
|
222
|
+
problems.append("Install python3 on the host")
|
|
223
|
+
cpus = int(facts.get("cpus") or 0)
|
|
224
|
+
if cpus < 2:
|
|
225
|
+
problems.append(f"Raft hosts need at least 2 CPUs; found {cpus}")
|
|
226
|
+
# MemTotal excludes firmware and kernel reservations: 8 GiB machines report
|
|
227
|
+
# about 7.7 GiB. The threshold accepts them and rejects 6 GiB machines.
|
|
228
|
+
memory = int(facts.get("memory") or 0) * 1024
|
|
229
|
+
if memory < 7.5 * GIB:
|
|
230
|
+
problems.append(f"Raft hosts need at least 8 GiB RAM; found {memory / GIB:.1f} GiB")
|
|
231
|
+
# Only a fresh deployment creates the 60 GiB pool; deploy.py enforces the same limit.
|
|
232
|
+
disk = int(facts.get("disk") or 0) * 1024
|
|
233
|
+
if facts.get("deployed") != "yes" and disk < 80 * GIB:
|
|
234
|
+
problems.append(f"Fresh hosts need at least 80 GiB free disk; found {disk / GIB:.1f} GiB")
|
|
235
|
+
return problems
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def deploy_module(filename):
|
|
239
|
+
"""Load a host deployment script shipped beside the CLI."""
|
|
240
|
+
path = Path(__file__).resolve().parent / "deploy" / filename
|
|
241
|
+
if not path.exists():
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"Host onboarding needs the deployment files in the npm package or a source "
|
|
244
|
+
"checkout; the Python wheel contains only the CLI"
|
|
245
|
+
)
|
|
246
|
+
spec = importlib.util.spec_from_file_location("raft_" + path.stem.replace("-", "_"), path)
|
|
247
|
+
module = importlib.util.module_from_spec(spec)
|
|
248
|
+
spec.loader.exec_module(module)
|
|
249
|
+
return module
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def smoke_test(location):
|
|
253
|
+
"""Create a disposable box, wait until it runs a command, then delete it."""
|
|
254
|
+
handle = new(
|
|
255
|
+
argparse.Namespace(
|
|
256
|
+
location=location, ttl=600, cpu=1, memory="2GiB", disposable=True, template=None
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
print("Smoke test box: " + handle, flush=True)
|
|
260
|
+
name = handle.partition(":")[2]
|
|
261
|
+
try:
|
|
262
|
+
# new returns once Incus starts the box, before every guest service is ready.
|
|
263
|
+
for _ in range(30):
|
|
264
|
+
if execute(location, name, ["true"], capture=True).returncode == 0:
|
|
265
|
+
break
|
|
266
|
+
time.sleep(2)
|
|
267
|
+
else:
|
|
268
|
+
raise RuntimeError("Smoke test box did not become command-ready: " + handle)
|
|
269
|
+
finally:
|
|
270
|
+
incus(location, "delete", name, "--force", locked=True)
|
|
271
|
+
print("Smoke test create/exec/destroy passed", flush=True)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def host_add(args):
|
|
275
|
+
"""Check, provision and register a new host location, then prove it works."""
|
|
276
|
+
if not re.fullmatch(LOCATION_NAME, args.location):
|
|
277
|
+
raise ValueError("Location names must use letters, digits, underscores or hyphens")
|
|
278
|
+
if CONFIG.exists() and args.location in configuration():
|
|
279
|
+
raise ValueError(f"{args.location} is already configured; check it with raft doctor")
|
|
280
|
+
archive = Path(args.image_archive).resolve() if args.image_archive else None
|
|
281
|
+
if archive is not None and not archive.is_file():
|
|
282
|
+
raise ValueError(f"Image archive not found: {archive}")
|
|
283
|
+
deployer = deploy_module("deploy.py")
|
|
284
|
+
probe = subprocess.run(
|
|
285
|
+
[
|
|
286
|
+
"ssh",
|
|
287
|
+
"-T",
|
|
288
|
+
"-o",
|
|
289
|
+
"BatchMode=yes",
|
|
290
|
+
"-o",
|
|
291
|
+
"ConnectTimeout=10",
|
|
292
|
+
args.ssh,
|
|
293
|
+
shlex.join(["sh", "-c", HOST_FACTS]),
|
|
294
|
+
],
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
)
|
|
298
|
+
if probe.returncode:
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
f"Cannot reach {args.ssh} with key-based SSH: {probe.stderr.strip()}\n"
|
|
301
|
+
f"Verify the host key interactively, then check `ssh -o BatchMode=yes {args.ssh} true`"
|
|
302
|
+
)
|
|
303
|
+
facts = dict(line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line)
|
|
304
|
+
problems = host_problems(facts)
|
|
305
|
+
if problems:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"{args.ssh} cannot host Raft boxes; nothing was changed:\n- " + "\n- ".join(problems)
|
|
308
|
+
)
|
|
309
|
+
architecture = facts["arch"]
|
|
310
|
+
if args.build:
|
|
311
|
+
image = f"build a native {architecture} image from Debian 13 on the host"
|
|
312
|
+
elif archive is not None:
|
|
313
|
+
image = f"upload and import {archive}"
|
|
314
|
+
else:
|
|
315
|
+
image = f"download raft-dev-{architecture}.tar.gz from release {args.image_release} and import it"
|
|
316
|
+
print(
|
|
317
|
+
f"Plan for {args.location} ({args.ssh}: Ubuntu {facts['version']} {architecture}, "
|
|
318
|
+
f"{facts['cpus']} CPUs, {int(facts['memory']) * 1024 / GIB:.1f} GiB RAM)\n"
|
|
319
|
+
"- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs and nftables if missing\n"
|
|
320
|
+
+ (
|
|
321
|
+
"- reuse and verify the existing raft project, raft-data pool and rfbr0 bridge\n"
|
|
322
|
+
if facts["deployed"] == "yes"
|
|
323
|
+
else "- create Incus project raft, 60 GiB Btrfs pool raft-data and bridge rfbr0 "
|
|
324
|
+
"(10.232.0.1/24 with NAT)\n"
|
|
325
|
+
)
|
|
326
|
+
+ "- install raft-network.service (rfbr0 isolation rules) and raft-expire.timer\n"
|
|
327
|
+
f"- {image}; images are checked by SHA-256 and architecture\n"
|
|
328
|
+
f"- add {args.location} with the image fingerprint to {CONFIG}\n"
|
|
329
|
+
"- run doctor and a disposable create/exec/destroy smoke test\n"
|
|
330
|
+
"SSH configuration, other firewall rules, IP addresses and account passwords are not changed.",
|
|
331
|
+
flush=True,
|
|
332
|
+
)
|
|
333
|
+
if args.dry_run:
|
|
334
|
+
print("Dry run: nothing was changed")
|
|
335
|
+
return
|
|
336
|
+
if not args.yes:
|
|
337
|
+
if not sys.stdin.isatty():
|
|
338
|
+
raise ValueError("Review the plan, then rerun with --yes to apply it")
|
|
339
|
+
if input("Apply this plan? [y/N] ").strip().lower() not in {"y", "yes"}:
|
|
340
|
+
raise ValueError("Onboarding cancelled; nothing was changed")
|
|
341
|
+
deployer.deploy(args.location, args.ssh)
|
|
342
|
+
if args.build:
|
|
343
|
+
# A fresh alias avoids refusing to build beside an earlier raft-dev image.
|
|
344
|
+
alias = "raft-dev-" + uuid.uuid4().hex[:8]
|
|
345
|
+
fingerprint = deploy_module("build-image.py").build(args.ssh, alias)
|
|
346
|
+
elif archive is not None:
|
|
347
|
+
fingerprint = deployer.import_archive(args.ssh, archive, architecture)
|
|
348
|
+
else:
|
|
349
|
+
fingerprint = deployer.import_release(args.ssh, args.image_release, architecture)
|
|
350
|
+
|
|
351
|
+
def register(inventory):
|
|
352
|
+
if args.location in inventory:
|
|
353
|
+
raise ValueError(f"{args.location} was configured while onboarding ran")
|
|
354
|
+
inventory[args.location] = {"ssh": args.ssh, "image": fingerprint}
|
|
355
|
+
|
|
356
|
+
edit_configuration(register)
|
|
357
|
+
try:
|
|
358
|
+
doctor(args.location)
|
|
359
|
+
smoke_test(args.location)
|
|
360
|
+
except BaseException:
|
|
361
|
+
# Never leave a plausible-looking but unusable location behind.
|
|
362
|
+
edit_configuration(lambda inventory: inventory.pop(args.location, None))
|
|
363
|
+
print(
|
|
364
|
+
f"Removed {args.location} from {CONFIG}. Host changes remain; a rerun reuses them.",
|
|
365
|
+
file=sys.stderr,
|
|
366
|
+
)
|
|
367
|
+
raise
|
|
368
|
+
print(f"{args.location} is ready: raft new --location {args.location}")
|
|
369
|
+
|
|
370
|
+
|
|
110
371
|
def parse_handle(value):
|
|
111
372
|
location, separator, name = value.partition(":")
|
|
112
373
|
if not separator or not re.fullmatch(r"rf-[a-f0-9]{16}", name):
|
|
@@ -177,14 +438,13 @@ def capacity(host, boxes):
|
|
|
177
438
|
memory_allocated = 0
|
|
178
439
|
for box in active:
|
|
179
440
|
limits = box["expanded_config"]
|
|
180
|
-
if
|
|
181
|
-
"
|
|
182
|
-
"
|
|
183
|
-
|
|
184
|
-
}:
|
|
441
|
+
if (
|
|
442
|
+
limits["limits.cpu"] not in {str(cpu) for cpu in CPU_SIZES}
|
|
443
|
+
or limits["limits.memory"] not in MEMORY_SIZES
|
|
444
|
+
):
|
|
185
445
|
raise ValueError("Active box has unsupported sizing; inspect its Incus configuration")
|
|
186
446
|
cpu_allocated += int(limits["limits.cpu"])
|
|
187
|
-
memory_allocated += int(limits["limits.memory"]
|
|
447
|
+
memory_allocated += int(limits["limits.memory"].removesuffix("GiB")) * gib
|
|
188
448
|
memory_reserve = max(2 * gib, host["memoryTotalBytes"] // 10)
|
|
189
449
|
cpu_budget = max(0, host["cpus"] - 1)
|
|
190
450
|
memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
|
|
@@ -198,8 +458,8 @@ def capacity(host, boxes):
|
|
|
198
458
|
)
|
|
199
459
|
slots = max(0, SAVED_BOX_LIMIT - len(boxes))
|
|
200
460
|
recommendations = []
|
|
201
|
-
for cpu in
|
|
202
|
-
for memory in [
|
|
461
|
+
for cpu in CPU_SIZES:
|
|
462
|
+
for memory in [int(size.removesuffix("GiB")) for size in MEMORY_SIZES]:
|
|
203
463
|
total = min(SAVED_BOX_LIMIT, cpu_budget // cpu, memory_budget // (memory * gib))
|
|
204
464
|
additional = min(
|
|
205
465
|
max(0, SAVED_BOX_LIMIT - len(active)),
|
|
@@ -255,7 +515,11 @@ def host_limits(location):
|
|
|
255
515
|
text=True,
|
|
256
516
|
check=True,
|
|
257
517
|
)
|
|
258
|
-
return {
|
|
518
|
+
return {
|
|
519
|
+
"location": location,
|
|
520
|
+
"note": settings(location).get("note", ""),
|
|
521
|
+
**capacity(json.loads(response.stdout), inventory(location)),
|
|
522
|
+
}
|
|
259
523
|
|
|
260
524
|
|
|
261
525
|
def print_limits(report):
|
|
@@ -266,6 +530,8 @@ def print_limits(report):
|
|
|
266
530
|
f"{host['memoryTotalBytes'] / gib:.1f} GiB RAM "
|
|
267
531
|
f"({host['memoryAvailableBytes'] / gib:.1f} GiB available)"
|
|
268
532
|
)
|
|
533
|
+
if report["note"]:
|
|
534
|
+
print("Note: " + report["note"])
|
|
269
535
|
print(
|
|
270
536
|
f"Saved boxes: {report['savedBoxes']}/{report['savedBoxLimit']} enforced; "
|
|
271
537
|
f"active: {report['activeBoxes']}; free saved slots: {report['savedSlots']}"
|
|
@@ -290,6 +556,32 @@ def print_limits(report):
|
|
|
290
556
|
print("Warning: " + warning)
|
|
291
557
|
|
|
292
558
|
|
|
559
|
+
def doctor(location):
|
|
560
|
+
print(location + ": " + incus(location, "version").strip())
|
|
561
|
+
try:
|
|
562
|
+
transaction(location, CHECK_WORKER)
|
|
563
|
+
except RuntimeError as error:
|
|
564
|
+
raise RuntimeError(f"{location}: {error}") from error
|
|
565
|
+
response = subprocess.run(
|
|
566
|
+
[
|
|
567
|
+
"ssh",
|
|
568
|
+
"-T",
|
|
569
|
+
"-o",
|
|
570
|
+
"BatchMode=yes",
|
|
571
|
+
"-o",
|
|
572
|
+
"ConnectTimeout=10",
|
|
573
|
+
settings(location)["ssh"],
|
|
574
|
+
"systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
|
|
575
|
+
],
|
|
576
|
+
capture_output=True,
|
|
577
|
+
text=True,
|
|
578
|
+
check=True,
|
|
579
|
+
)
|
|
580
|
+
print(response.stdout.strip())
|
|
581
|
+
print(incus(location, "storage", "info", "raft-data").strip())
|
|
582
|
+
print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
|
|
583
|
+
|
|
584
|
+
|
|
293
585
|
def transaction(location, script, *arguments, stdout=None):
|
|
294
586
|
# All lifecycle steps in a compound operation share the host expiry lock.
|
|
295
587
|
command = [
|
|
@@ -338,6 +630,7 @@ def sizing(args):
|
|
|
338
630
|
|
|
339
631
|
|
|
340
632
|
def new(args):
|
|
633
|
+
box_fields = box_note(args)
|
|
341
634
|
source = None
|
|
342
635
|
if args.template:
|
|
343
636
|
handle, separator, snapshot = args.template.partition("/")
|
|
@@ -358,6 +651,10 @@ def new(args):
|
|
|
358
651
|
raise ValueError(
|
|
359
652
|
"Build an image and configure its full immutable fingerprint before creating boxes"
|
|
360
653
|
)
|
|
654
|
+
note = settings(args.location).get("note")
|
|
655
|
+
if note:
|
|
656
|
+
# Stderr keeps stdout limited to the handle for scripts.
|
|
657
|
+
print(f"{args.location} note: {note}", file=sys.stderr)
|
|
361
658
|
name = "rf-" + uuid.uuid4().hex[:16]
|
|
362
659
|
# Initialize before starting, so a failed start still has its intended policy.
|
|
363
660
|
transaction(
|
|
@@ -385,10 +682,13 @@ def new(args):
|
|
|
385
682
|
"-c",
|
|
386
683
|
f"user.raft.disposable={'true' if args.disposable else 'false'}",
|
|
387
684
|
"-c",
|
|
685
|
+
# Always set, so a box created from a snapshot never inherits the source note.
|
|
686
|
+
"user.raft.note=" + (json.dumps(box_fields) if box_fields else ""),
|
|
687
|
+
"-c",
|
|
388
688
|
"volatile.eth0.hwaddr=",
|
|
389
689
|
*sizing(args),
|
|
390
690
|
)
|
|
391
|
-
|
|
691
|
+
return f"{args.location}:{name}"
|
|
392
692
|
|
|
393
693
|
|
|
394
694
|
def execute(location, name, command, *, capture=False):
|
|
@@ -576,6 +876,19 @@ def ttl(value):
|
|
|
576
876
|
return seconds
|
|
577
877
|
|
|
578
878
|
|
|
879
|
+
def note_options(command):
|
|
880
|
+
"""Add the box note template flags shared by new and note."""
|
|
881
|
+
command.add_argument(
|
|
882
|
+
"--purpose", help="Box note: what the box is for, e.g. 'Integration tests for PR 42'"
|
|
883
|
+
)
|
|
884
|
+
command.add_argument(
|
|
885
|
+
"--owner", help="Box note: who uses the box, e.g. 'codex session in ~/work/app'"
|
|
886
|
+
)
|
|
887
|
+
command.add_argument(
|
|
888
|
+
"--next", help="Box note: what happens next, e.g. 'Destroy after the tests pass'"
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
579
892
|
def parser():
|
|
580
893
|
cli = argparse.ArgumentParser(description=__doc__)
|
|
581
894
|
cli.add_argument("--version", action="version", version=__version__)
|
|
@@ -583,10 +896,39 @@ def parser():
|
|
|
583
896
|
create = commands.add_parser("new")
|
|
584
897
|
create.add_argument("--location", default=None)
|
|
585
898
|
create.add_argument("--ttl", type=ttl, default=600)
|
|
586
|
-
create.add_argument("--cpu", type=int, choices=
|
|
587
|
-
create.add_argument("--memory", choices=
|
|
899
|
+
create.add_argument("--cpu", type=int, choices=CPU_SIZES, default=1)
|
|
900
|
+
create.add_argument("--memory", choices=MEMORY_SIZES, default="2GiB")
|
|
588
901
|
create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
|
|
589
902
|
create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
|
|
903
|
+
note_options(create)
|
|
904
|
+
commands.add_parser("locations", help="List configured locations and their notes")
|
|
905
|
+
note = commands.add_parser("note", help="Set or clear the note of a location or a box")
|
|
906
|
+
note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
|
|
907
|
+
note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
|
|
908
|
+
note.add_argument("--clear", action="store_true", help="Remove the note")
|
|
909
|
+
note_options(note)
|
|
910
|
+
host = commands.add_parser("host", help="Onboard workspace hosts")
|
|
911
|
+
host_commands = host.add_subparsers(dest="host_action", required=True)
|
|
912
|
+
onboard = host_commands.add_parser(
|
|
913
|
+
"add", help="Check, provision, register and smoke-test a new host location"
|
|
914
|
+
)
|
|
915
|
+
onboard.add_argument("location")
|
|
916
|
+
onboard.add_argument(
|
|
917
|
+
"--ssh", required=True, help="Key-based SSH target: user@host or an ~/.ssh/config alias"
|
|
918
|
+
)
|
|
919
|
+
source = onboard.add_mutually_exclusive_group()
|
|
920
|
+
source.add_argument(
|
|
921
|
+
"--image-release",
|
|
922
|
+
default="v" + __version__,
|
|
923
|
+
metavar="TAG",
|
|
924
|
+
help="Release whose checksummed image to download (default: this CLI's version)",
|
|
925
|
+
)
|
|
926
|
+
source.add_argument("--image-archive", metavar="PATH", help="Import a local image archive")
|
|
927
|
+
source.add_argument("--build", action="store_true", help="Build the image on the host")
|
|
928
|
+
onboard.add_argument(
|
|
929
|
+
"--dry-run", action="store_true", help="Check the host and print the plan; change nothing"
|
|
930
|
+
)
|
|
931
|
+
onboard.add_argument("--yes", action="store_true", help="Apply the plan without prompting")
|
|
590
932
|
listing = commands.add_parser("list")
|
|
591
933
|
listing.add_argument("--location")
|
|
592
934
|
limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
|
|
@@ -630,8 +972,8 @@ def parser():
|
|
|
630
972
|
if action in ["resume", "fork", "extend"]:
|
|
631
973
|
command.add_argument("--ttl", type=ttl, required=True)
|
|
632
974
|
if action in ["resume", "fork"]:
|
|
633
|
-
command.add_argument("--cpu", type=int, choices=
|
|
634
|
-
command.add_argument("--memory", choices=
|
|
975
|
+
command.add_argument("--cpu", type=int, choices=CPU_SIZES)
|
|
976
|
+
command.add_argument("--memory", choices=MEMORY_SIZES)
|
|
635
977
|
if action == "fork":
|
|
636
978
|
command.add_argument("--disposable", action="store_true")
|
|
637
979
|
if action == "exec":
|
|
@@ -660,7 +1002,35 @@ def dispatch(args):
|
|
|
660
1002
|
recover(args)
|
|
661
1003
|
return 0
|
|
662
1004
|
if args.action == "new":
|
|
663
|
-
new(args)
|
|
1005
|
+
print(new(args))
|
|
1006
|
+
return 0
|
|
1007
|
+
if args.action == "host":
|
|
1008
|
+
host_add(args)
|
|
1009
|
+
return 0
|
|
1010
|
+
if args.action == "note":
|
|
1011
|
+
fields = box_note(args)
|
|
1012
|
+
# Location names cannot contain ":", so a colon always means a box handle.
|
|
1013
|
+
if ":" not in args.target:
|
|
1014
|
+
if fields is not None:
|
|
1015
|
+
raise ValueError("--purpose, --owner and --next describe boxes; pass a box handle")
|
|
1016
|
+
if args.clear == (args.text is not None):
|
|
1017
|
+
raise ValueError("Give the location note TEXT or --clear")
|
|
1018
|
+
set_note(args.target, None if args.clear else args.text)
|
|
1019
|
+
return 0
|
|
1020
|
+
if args.text is not None:
|
|
1021
|
+
raise ValueError("Describe boxes with --purpose, --owner and --next")
|
|
1022
|
+
if args.clear == (fields is not None):
|
|
1023
|
+
raise ValueError("Give --purpose (with optional --owner and --next) or --clear")
|
|
1024
|
+
location, name = parse_handle(args.target)
|
|
1025
|
+
instance(location, name)
|
|
1026
|
+
# Each call replaces the whole note; an empty value removes the key.
|
|
1027
|
+
value = "" if args.clear else json.dumps(fields)
|
|
1028
|
+
incus(location, "config", "set", name, "user.raft.note=" + value, locked=True)
|
|
1029
|
+
return 0
|
|
1030
|
+
if args.action == "locations":
|
|
1031
|
+
for index, (location, entry) in enumerate(configuration().items()):
|
|
1032
|
+
record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
|
|
1033
|
+
print(json.dumps(record))
|
|
664
1034
|
return 0
|
|
665
1035
|
if args.action in ["list", "limits", "doctor", "gc", "prune"]:
|
|
666
1036
|
if args.action == "prune" and args.older_than < 1:
|
|
@@ -711,27 +1081,7 @@ def dispatch(args):
|
|
|
711
1081
|
else:
|
|
712
1082
|
print(output, end="")
|
|
713
1083
|
elif args.action == "doctor":
|
|
714
|
-
|
|
715
|
-
try:
|
|
716
|
-
transaction(location, CHECK_WORKER)
|
|
717
|
-
except RuntimeError as error:
|
|
718
|
-
raise RuntimeError(f"{location}: {error}") from error
|
|
719
|
-
response = subprocess.run(
|
|
720
|
-
[
|
|
721
|
-
"ssh",
|
|
722
|
-
"-T",
|
|
723
|
-
settings(location)["ssh"],
|
|
724
|
-
"systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
|
|
725
|
-
],
|
|
726
|
-
capture_output=True,
|
|
727
|
-
text=True,
|
|
728
|
-
check=True,
|
|
729
|
-
)
|
|
730
|
-
print(response.stdout.strip())
|
|
731
|
-
print(incus(location, "storage", "info", "raft-data").strip())
|
|
732
|
-
print(
|
|
733
|
-
f"{len(inventory(location))} saved boxes; system containers (shared host kernel)"
|
|
734
|
-
)
|
|
1084
|
+
doctor(location)
|
|
735
1085
|
else:
|
|
736
1086
|
for box in inventory(location):
|
|
737
1087
|
print(
|
|
@@ -740,6 +1090,7 @@ def dispatch(args):
|
|
|
740
1090
|
"box": location + ":" + box["name"],
|
|
741
1091
|
"state": box["status"],
|
|
742
1092
|
"stopAt": box["config"].get("user.raft.expires", ""),
|
|
1093
|
+
"note": read_box_note(box),
|
|
743
1094
|
}
|
|
744
1095
|
)
|
|
745
1096
|
)
|
package/skills/raft-cli/SKILL.md
CHANGED
|
@@ -8,11 +8,31 @@ description: Create and use private persistent Linux workspaces on configured Li
|
|
|
8
8
|
Raft runs unprivileged native ARM64 or AMD64 Incus system containers. They share the host kernel.
|
|
9
9
|
The current deployment does not implement Incus VMs. Do not describe these boxes as VMs or promise cross-architecture emulation.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
`raft host add` installs packages and services on a machine. Run it only when the
|
|
12
|
+
user explicitly asks to onboard that host, and show its `--dry-run` plan first.
|
|
13
|
+
|
|
14
|
+
Run `raft locations`, `raft doctor`, `raft limits` and `raft list` before allocating.
|
|
15
|
+
Follow each location's `note`: the operator uses it to mark preferred hosts and
|
|
16
|
+
hosts to avoid for some work. Pass `--location` explicitly when a note rules out
|
|
17
|
+
the default. Do not change notes unless the user asks; `raft note LOCATION TEXT`
|
|
18
|
+
sets one and `raft note LOCATION --clear` removes it. Keep the exact
|
|
12
19
|
location-qualified handle returned by `new`. There is no implicit current box.
|
|
13
20
|
|
|
21
|
+
Note every box you create. Box notes follow one template so the next agent can
|
|
22
|
+
tell whether a box is in use:
|
|
23
|
+
|
|
24
|
+
- `--purpose`: what the box is for (required), e.g. `Integration tests for PR 42`
|
|
25
|
+
- `--owner`: who uses it, e.g. `codex session in ~/work/app`
|
|
26
|
+
- `--next`: what happens next, e.g. `Destroy after the tests pass`
|
|
27
|
+
|
|
28
|
+
Pass them to `new`, and run `raft note "$box" --purpose ...` when the plan
|
|
29
|
+
changes; each call replaces the whole note. `raft list` shows every box's
|
|
30
|
+
`note`. Read it before you resume, stop, fork or destroy a box. Treat a box whose
|
|
31
|
+
note names another owner or a job unrelated to your task as not yours. Forks
|
|
32
|
+
keep the source note, so update a fork's note when its job differs.
|
|
33
|
+
|
|
14
34
|
```sh
|
|
15
|
-
box=$(raft new --location lab --ttl 600)
|
|
35
|
+
box=$(raft new --location lab --ttl 600 --purpose "Run the project tests" --owner "codex task 123" --next "Destroy when the tests finish")
|
|
16
36
|
raft exec "$box" -- timeout 240 bash -lc 'git clone <url> /workspace/project && cd /workspace/project && <test-command>'
|
|
17
37
|
raft download "$box" /workspace/project/report.json ./report.json
|
|
18
38
|
raft destroy "$box"
|
|
@@ -32,7 +52,7 @@ Incus execution over verified host SSH. It does not expose a guest SSH port.
|
|
|
32
52
|
|
|
33
53
|
Defaults are the first configured location, one CPU, 2 GiB RAM and 600 seconds. TTL accepts 60 seconds
|
|
34
54
|
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`
|
|
55
|
+
boxes count because their disk still occupies space. Use `--memory 4GiB` or `--memory 8GiB`
|
|
36
56
|
and `--cpu 2` when needed. Btrfs bounds workspace storage to a 60 GiB pool per host.
|
|
37
57
|
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
58
|
reports CPU/RAM recommendations for all supported sizes, active allocations and
|