@microck/raft 0.2.1 → 0.3.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 +138 -1
- 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 +4 -1
- package/docs/guides/scripting-ci.mdx +1 -0
- package/docs/operations.mdx +33 -9
- package/docs/reference/capacity-limits.mdx +2 -2
- package/docs/reference/cli.md +63 -5
- package/docs/reference/configuration.mdx +15 -1
- package/docs/releases/0.3.0.md +15 -0
- package/docs/requirements.mdx +2 -0
- package/docs/verification.mdx +1 -1
- package/package.json +1 -1
- package/raft.py +330 -38
- package/skills/raft-cli/SKILL.md +9 -2
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,22 @@ 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.3.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"]
|
|
24
33
|
|
|
25
34
|
USAGE = """import json, shutil
|
|
26
35
|
from pathlib import Path
|
|
@@ -43,10 +52,12 @@ def configuration():
|
|
|
43
52
|
if not isinstance(inventory, dict) or not inventory:
|
|
44
53
|
raise ValueError("Configure at least one host in ~/.config/raft/incus.json")
|
|
45
54
|
for location, entry in inventory.items():
|
|
46
|
-
if not re.fullmatch(
|
|
55
|
+
if not re.fullmatch(LOCATION_NAME, location):
|
|
47
56
|
raise ValueError("Location names must use letters, digits, underscores or hyphens")
|
|
48
57
|
if not isinstance(entry, dict) or not isinstance(entry.get("ssh"), str) or not entry["ssh"]:
|
|
49
58
|
raise ValueError("Each configured location needs a nonempty ssh target")
|
|
59
|
+
if not isinstance(entry.get("note", ""), str):
|
|
60
|
+
raise ValueError("Location notes must be strings")
|
|
50
61
|
return inventory
|
|
51
62
|
|
|
52
63
|
|
|
@@ -57,6 +68,51 @@ def settings(location):
|
|
|
57
68
|
return inventory[location]
|
|
58
69
|
|
|
59
70
|
|
|
71
|
+
def edit_configuration(change):
|
|
72
|
+
"""Apply change(inventory) to the controller configuration and save it."""
|
|
73
|
+
# main() sets umask 077, so a new configuration directory is private.
|
|
74
|
+
CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
# Hold a controller lock across read, edit and replace so concurrent edits
|
|
76
|
+
# cannot drop each other. Readers need no lock because replacement is atomic.
|
|
77
|
+
with (CONFIG.parent / ".incus.json.lock").open("w") as lock:
|
|
78
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
79
|
+
# Onboarding can start on a controller that has no configuration yet.
|
|
80
|
+
inventory = configuration() if CONFIG.exists() else {}
|
|
81
|
+
change(inventory)
|
|
82
|
+
if not inventory:
|
|
83
|
+
# Removing the only location restores the unconfigured state.
|
|
84
|
+
CONFIG.unlink(missing_ok=True)
|
|
85
|
+
return
|
|
86
|
+
# Key order is preserved because the first location is the default for new.
|
|
87
|
+
# Replace atomically so an interrupted write cannot truncate the inventory;
|
|
88
|
+
# NamedTemporaryFile creates the staged file with mode 0600.
|
|
89
|
+
staged = tempfile.NamedTemporaryFile("w", dir=CONFIG.parent, prefix=".raft-", delete=False)
|
|
90
|
+
try:
|
|
91
|
+
with staged:
|
|
92
|
+
json.dump(inventory, staged, indent=2)
|
|
93
|
+
staged.write("\n")
|
|
94
|
+
os.replace(staged.name, CONFIG)
|
|
95
|
+
finally:
|
|
96
|
+
# A no-op after a successful replace; removes the file after a failure.
|
|
97
|
+
Path(staged.name).unlink(missing_ok=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_note(location, text):
|
|
101
|
+
"""Record operator guidance for a location. Raft shows notes but never enforces them."""
|
|
102
|
+
if text is not None and not text.strip():
|
|
103
|
+
raise ValueError("Use --clear to remove a note")
|
|
104
|
+
|
|
105
|
+
def change(inventory):
|
|
106
|
+
if location not in inventory:
|
|
107
|
+
raise ValueError(f"Unknown location: {location}")
|
|
108
|
+
if text is None:
|
|
109
|
+
inventory[location].pop("note", None)
|
|
110
|
+
else:
|
|
111
|
+
inventory[location]["note"] = text.strip()
|
|
112
|
+
|
|
113
|
+
edit_configuration(change)
|
|
114
|
+
|
|
115
|
+
|
|
60
116
|
def receive(command, destination, *, stdin=None):
|
|
61
117
|
"""Write streamed bytes in Python: SSH can exit zero after a local write error."""
|
|
62
118
|
with tempfile.TemporaryFile() as errors:
|
|
@@ -107,6 +163,188 @@ def incus(location, *argv, locked=False):
|
|
|
107
163
|
return response.stdout.decode()
|
|
108
164
|
|
|
109
165
|
|
|
166
|
+
# Read-only probe. It runs through the login shell of any SSH target, including
|
|
167
|
+
# macOS, so each fact degrades to an empty value instead of failing the script.
|
|
168
|
+
# Sizes are printed in KiB because mawk formats large products in exponent form.
|
|
169
|
+
HOST_FACTS = """echo "kernel=$(uname -s)"
|
|
170
|
+
if [ -r /etc/os-release ]; then . /etc/os-release; echo "os=${ID:-}"; echo "version=${VERSION_ID:-}"; fi
|
|
171
|
+
echo "arch=$(dpkg --print-architecture 2>/dev/null)"
|
|
172
|
+
echo "cpus=$(getconf _NPROCESSORS_ONLN 2>/dev/null)"
|
|
173
|
+
echo "memory=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null)"
|
|
174
|
+
directory=/var/lib/incus; [ -d "$directory" ] || directory=/var/lib
|
|
175
|
+
echo "disk=$(df -Pk "$directory" 2>/dev/null | awk 'NR == 2 {print $4}')"
|
|
176
|
+
if sudo -n true 2>/dev/null; then echo sudo=yes; else echo sudo=no; fi
|
|
177
|
+
if command -v python3 >/dev/null 2>&1; then echo python3=yes; else echo python3=no; fi
|
|
178
|
+
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
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def host_problems(facts):
|
|
183
|
+
"""Return reasons a probed SSH target cannot become a Raft host; empty means supported."""
|
|
184
|
+
kernel = facts.get("kernel") or "an unknown system"
|
|
185
|
+
if kernel != "Linux":
|
|
186
|
+
return [
|
|
187
|
+
f"The target runs {kernel}, not Linux. Raft hosts need Ubuntu 22.04 or 24.04 on "
|
|
188
|
+
"ARM64 or AMD64. On macOS, run an Ubuntu VM and onboard the VM's SSH target"
|
|
189
|
+
]
|
|
190
|
+
problems = []
|
|
191
|
+
release = f"{facts.get('os', '')} {facts.get('version', '')}".strip() or "unknown"
|
|
192
|
+
if facts.get("os") != "ubuntu" or facts.get("version") not in {"22.04", "24.04"}:
|
|
193
|
+
problems.append(f"Raft hosts need Ubuntu 22.04 or 24.04; found {release}")
|
|
194
|
+
if facts.get("arch") not in {"arm64", "amd64"}:
|
|
195
|
+
problems.append(f"Raft hosts need ARM64 or AMD64; found {facts.get('arch') or 'unknown'}")
|
|
196
|
+
if facts.get("sudo") != "yes":
|
|
197
|
+
problems.append("Configure passwordless sudo for the SSH user; `sudo -n true` must succeed")
|
|
198
|
+
if facts.get("python3") != "yes":
|
|
199
|
+
problems.append("Install python3 on the host")
|
|
200
|
+
cpus = int(facts.get("cpus") or 0)
|
|
201
|
+
if cpus < 2:
|
|
202
|
+
problems.append(f"Raft hosts need at least 2 CPUs; found {cpus}")
|
|
203
|
+
# MemTotal excludes firmware and kernel reservations: 8 GiB machines report
|
|
204
|
+
# about 7.7 GiB. The threshold accepts them and rejects 6 GiB machines.
|
|
205
|
+
memory = int(facts.get("memory") or 0) * 1024
|
|
206
|
+
if memory < 7.5 * GIB:
|
|
207
|
+
problems.append(f"Raft hosts need at least 8 GiB RAM; found {memory / GIB:.1f} GiB")
|
|
208
|
+
# Only a fresh deployment creates the 60 GiB pool; deploy.py enforces the same limit.
|
|
209
|
+
disk = int(facts.get("disk") or 0) * 1024
|
|
210
|
+
if facts.get("deployed") != "yes" and disk < 80 * GIB:
|
|
211
|
+
problems.append(f"Fresh hosts need at least 80 GiB free disk; found {disk / GIB:.1f} GiB")
|
|
212
|
+
return problems
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def deploy_module(filename):
|
|
216
|
+
"""Load a host deployment script shipped beside the CLI."""
|
|
217
|
+
path = Path(__file__).resolve().parent / "deploy" / filename
|
|
218
|
+
if not path.exists():
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"Host onboarding needs the deployment files in the npm package or a source "
|
|
221
|
+
"checkout; the Python wheel contains only the CLI"
|
|
222
|
+
)
|
|
223
|
+
spec = importlib.util.spec_from_file_location("raft_" + path.stem.replace("-", "_"), path)
|
|
224
|
+
module = importlib.util.module_from_spec(spec)
|
|
225
|
+
spec.loader.exec_module(module)
|
|
226
|
+
return module
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def smoke_test(location):
|
|
230
|
+
"""Create a disposable box, wait until it runs a command, then delete it."""
|
|
231
|
+
handle = new(
|
|
232
|
+
argparse.Namespace(
|
|
233
|
+
location=location, ttl=600, cpu=1, memory="2GiB", disposable=True, template=None
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
print("Smoke test box: " + handle, flush=True)
|
|
237
|
+
name = handle.partition(":")[2]
|
|
238
|
+
try:
|
|
239
|
+
# new returns once Incus starts the box, before every guest service is ready.
|
|
240
|
+
for _ in range(30):
|
|
241
|
+
if execute(location, name, ["true"], capture=True).returncode == 0:
|
|
242
|
+
break
|
|
243
|
+
time.sleep(2)
|
|
244
|
+
else:
|
|
245
|
+
raise RuntimeError("Smoke test box did not become command-ready: " + handle)
|
|
246
|
+
finally:
|
|
247
|
+
incus(location, "delete", name, "--force", locked=True)
|
|
248
|
+
print("Smoke test create/exec/destroy passed", flush=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def host_add(args):
|
|
252
|
+
"""Check, provision and register a new host location, then prove it works."""
|
|
253
|
+
if not re.fullmatch(LOCATION_NAME, args.location):
|
|
254
|
+
raise ValueError("Location names must use letters, digits, underscores or hyphens")
|
|
255
|
+
if CONFIG.exists() and args.location in configuration():
|
|
256
|
+
raise ValueError(f"{args.location} is already configured; check it with raft doctor")
|
|
257
|
+
archive = Path(args.image_archive).resolve() if args.image_archive else None
|
|
258
|
+
if archive is not None and not archive.is_file():
|
|
259
|
+
raise ValueError(f"Image archive not found: {archive}")
|
|
260
|
+
deployer = deploy_module("deploy.py")
|
|
261
|
+
probe = subprocess.run(
|
|
262
|
+
[
|
|
263
|
+
"ssh",
|
|
264
|
+
"-T",
|
|
265
|
+
"-o",
|
|
266
|
+
"BatchMode=yes",
|
|
267
|
+
"-o",
|
|
268
|
+
"ConnectTimeout=10",
|
|
269
|
+
args.ssh,
|
|
270
|
+
shlex.join(["sh", "-c", HOST_FACTS]),
|
|
271
|
+
],
|
|
272
|
+
capture_output=True,
|
|
273
|
+
text=True,
|
|
274
|
+
)
|
|
275
|
+
if probe.returncode:
|
|
276
|
+
raise RuntimeError(
|
|
277
|
+
f"Cannot reach {args.ssh} with key-based SSH: {probe.stderr.strip()}\n"
|
|
278
|
+
f"Verify the host key interactively, then check `ssh -o BatchMode=yes {args.ssh} true`"
|
|
279
|
+
)
|
|
280
|
+
facts = dict(line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line)
|
|
281
|
+
problems = host_problems(facts)
|
|
282
|
+
if problems:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
f"{args.ssh} cannot host Raft boxes; nothing was changed:\n- " + "\n- ".join(problems)
|
|
285
|
+
)
|
|
286
|
+
architecture = facts["arch"]
|
|
287
|
+
if args.build:
|
|
288
|
+
image = f"build a native {architecture} image from Debian 13 on the host"
|
|
289
|
+
elif archive is not None:
|
|
290
|
+
image = f"upload and import {archive}"
|
|
291
|
+
else:
|
|
292
|
+
image = f"download raft-dev-{architecture}.tar.gz from release {args.image_release} and import it"
|
|
293
|
+
print(
|
|
294
|
+
f"Plan for {args.location} ({args.ssh}: Ubuntu {facts['version']} {architecture}, "
|
|
295
|
+
f"{facts['cpus']} CPUs, {int(facts['memory']) * 1024 / GIB:.1f} GiB RAM)\n"
|
|
296
|
+
"- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs and nftables if missing\n"
|
|
297
|
+
+ (
|
|
298
|
+
"- reuse and verify the existing raft project, raft-data pool and rfbr0 bridge\n"
|
|
299
|
+
if facts["deployed"] == "yes"
|
|
300
|
+
else "- create Incus project raft, 60 GiB Btrfs pool raft-data and bridge rfbr0 "
|
|
301
|
+
"(10.232.0.1/24 with NAT)\n"
|
|
302
|
+
)
|
|
303
|
+
+ "- install raft-network.service (rfbr0 isolation rules) and raft-expire.timer\n"
|
|
304
|
+
f"- {image}; images are checked by SHA-256 and architecture\n"
|
|
305
|
+
f"- add {args.location} with the image fingerprint to {CONFIG}\n"
|
|
306
|
+
"- run doctor and a disposable create/exec/destroy smoke test\n"
|
|
307
|
+
"SSH configuration, other firewall rules, IP addresses and account passwords are not changed.",
|
|
308
|
+
flush=True,
|
|
309
|
+
)
|
|
310
|
+
if args.dry_run:
|
|
311
|
+
print("Dry run: nothing was changed")
|
|
312
|
+
return
|
|
313
|
+
if not args.yes:
|
|
314
|
+
if not sys.stdin.isatty():
|
|
315
|
+
raise ValueError("Review the plan, then rerun with --yes to apply it")
|
|
316
|
+
if input("Apply this plan? [y/N] ").strip().lower() not in {"y", "yes"}:
|
|
317
|
+
raise ValueError("Onboarding cancelled; nothing was changed")
|
|
318
|
+
deployer.deploy(args.location, args.ssh)
|
|
319
|
+
if args.build:
|
|
320
|
+
# A fresh alias avoids refusing to build beside an earlier raft-dev image.
|
|
321
|
+
alias = "raft-dev-" + uuid.uuid4().hex[:8]
|
|
322
|
+
fingerprint = deploy_module("build-image.py").build(args.ssh, alias)
|
|
323
|
+
elif archive is not None:
|
|
324
|
+
fingerprint = deployer.import_archive(args.ssh, archive, architecture)
|
|
325
|
+
else:
|
|
326
|
+
fingerprint = deployer.import_release(args.ssh, args.image_release, architecture)
|
|
327
|
+
|
|
328
|
+
def register(inventory):
|
|
329
|
+
if args.location in inventory:
|
|
330
|
+
raise ValueError(f"{args.location} was configured while onboarding ran")
|
|
331
|
+
inventory[args.location] = {"ssh": args.ssh, "image": fingerprint}
|
|
332
|
+
|
|
333
|
+
edit_configuration(register)
|
|
334
|
+
try:
|
|
335
|
+
doctor(args.location)
|
|
336
|
+
smoke_test(args.location)
|
|
337
|
+
except BaseException:
|
|
338
|
+
# Never leave a plausible-looking but unusable location behind.
|
|
339
|
+
edit_configuration(lambda inventory: inventory.pop(args.location, None))
|
|
340
|
+
print(
|
|
341
|
+
f"Removed {args.location} from {CONFIG}. Host changes remain; a rerun reuses them.",
|
|
342
|
+
file=sys.stderr,
|
|
343
|
+
)
|
|
344
|
+
raise
|
|
345
|
+
print(f"{args.location} is ready: raft new --location {args.location}")
|
|
346
|
+
|
|
347
|
+
|
|
110
348
|
def parse_handle(value):
|
|
111
349
|
location, separator, name = value.partition(":")
|
|
112
350
|
if not separator or not re.fullmatch(r"rf-[a-f0-9]{16}", name):
|
|
@@ -177,14 +415,13 @@ def capacity(host, boxes):
|
|
|
177
415
|
memory_allocated = 0
|
|
178
416
|
for box in active:
|
|
179
417
|
limits = box["expanded_config"]
|
|
180
|
-
if
|
|
181
|
-
"
|
|
182
|
-
"
|
|
183
|
-
|
|
184
|
-
}:
|
|
418
|
+
if (
|
|
419
|
+
limits["limits.cpu"] not in {str(cpu) for cpu in CPU_SIZES}
|
|
420
|
+
or limits["limits.memory"] not in MEMORY_SIZES
|
|
421
|
+
):
|
|
185
422
|
raise ValueError("Active box has unsupported sizing; inspect its Incus configuration")
|
|
186
423
|
cpu_allocated += int(limits["limits.cpu"])
|
|
187
|
-
memory_allocated += int(limits["limits.memory"]
|
|
424
|
+
memory_allocated += int(limits["limits.memory"].removesuffix("GiB")) * gib
|
|
188
425
|
memory_reserve = max(2 * gib, host["memoryTotalBytes"] // 10)
|
|
189
426
|
cpu_budget = max(0, host["cpus"] - 1)
|
|
190
427
|
memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
|
|
@@ -198,8 +435,8 @@ def capacity(host, boxes):
|
|
|
198
435
|
)
|
|
199
436
|
slots = max(0, SAVED_BOX_LIMIT - len(boxes))
|
|
200
437
|
recommendations = []
|
|
201
|
-
for cpu in
|
|
202
|
-
for memory in [
|
|
438
|
+
for cpu in CPU_SIZES:
|
|
439
|
+
for memory in [int(size.removesuffix("GiB")) for size in MEMORY_SIZES]:
|
|
203
440
|
total = min(SAVED_BOX_LIMIT, cpu_budget // cpu, memory_budget // (memory * gib))
|
|
204
441
|
additional = min(
|
|
205
442
|
max(0, SAVED_BOX_LIMIT - len(active)),
|
|
@@ -255,7 +492,11 @@ def host_limits(location):
|
|
|
255
492
|
text=True,
|
|
256
493
|
check=True,
|
|
257
494
|
)
|
|
258
|
-
return {
|
|
495
|
+
return {
|
|
496
|
+
"location": location,
|
|
497
|
+
"note": settings(location).get("note", ""),
|
|
498
|
+
**capacity(json.loads(response.stdout), inventory(location)),
|
|
499
|
+
}
|
|
259
500
|
|
|
260
501
|
|
|
261
502
|
def print_limits(report):
|
|
@@ -266,6 +507,8 @@ def print_limits(report):
|
|
|
266
507
|
f"{host['memoryTotalBytes'] / gib:.1f} GiB RAM "
|
|
267
508
|
f"({host['memoryAvailableBytes'] / gib:.1f} GiB available)"
|
|
268
509
|
)
|
|
510
|
+
if report["note"]:
|
|
511
|
+
print("Note: " + report["note"])
|
|
269
512
|
print(
|
|
270
513
|
f"Saved boxes: {report['savedBoxes']}/{report['savedBoxLimit']} enforced; "
|
|
271
514
|
f"active: {report['activeBoxes']}; free saved slots: {report['savedSlots']}"
|
|
@@ -290,6 +533,32 @@ def print_limits(report):
|
|
|
290
533
|
print("Warning: " + warning)
|
|
291
534
|
|
|
292
535
|
|
|
536
|
+
def doctor(location):
|
|
537
|
+
print(location + ": " + incus(location, "version").strip())
|
|
538
|
+
try:
|
|
539
|
+
transaction(location, CHECK_WORKER)
|
|
540
|
+
except RuntimeError as error:
|
|
541
|
+
raise RuntimeError(f"{location}: {error}") from error
|
|
542
|
+
response = subprocess.run(
|
|
543
|
+
[
|
|
544
|
+
"ssh",
|
|
545
|
+
"-T",
|
|
546
|
+
"-o",
|
|
547
|
+
"BatchMode=yes",
|
|
548
|
+
"-o",
|
|
549
|
+
"ConnectTimeout=10",
|
|
550
|
+
settings(location)["ssh"],
|
|
551
|
+
"systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
|
|
552
|
+
],
|
|
553
|
+
capture_output=True,
|
|
554
|
+
text=True,
|
|
555
|
+
check=True,
|
|
556
|
+
)
|
|
557
|
+
print(response.stdout.strip())
|
|
558
|
+
print(incus(location, "storage", "info", "raft-data").strip())
|
|
559
|
+
print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
|
|
560
|
+
|
|
561
|
+
|
|
293
562
|
def transaction(location, script, *arguments, stdout=None):
|
|
294
563
|
# All lifecycle steps in a compound operation share the host expiry lock.
|
|
295
564
|
command = [
|
|
@@ -358,6 +627,10 @@ def new(args):
|
|
|
358
627
|
raise ValueError(
|
|
359
628
|
"Build an image and configure its full immutable fingerprint before creating boxes"
|
|
360
629
|
)
|
|
630
|
+
note = settings(args.location).get("note")
|
|
631
|
+
if note:
|
|
632
|
+
# Stderr keeps stdout limited to the handle for scripts.
|
|
633
|
+
print(f"{args.location} note: {note}", file=sys.stderr)
|
|
361
634
|
name = "rf-" + uuid.uuid4().hex[:16]
|
|
362
635
|
# Initialize before starting, so a failed start still has its intended policy.
|
|
363
636
|
transaction(
|
|
@@ -388,7 +661,7 @@ def new(args):
|
|
|
388
661
|
"volatile.eth0.hwaddr=",
|
|
389
662
|
*sizing(args),
|
|
390
663
|
)
|
|
391
|
-
|
|
664
|
+
return f"{args.location}:{name}"
|
|
392
665
|
|
|
393
666
|
|
|
394
667
|
def execute(location, name, command, *, capture=False):
|
|
@@ -583,10 +856,38 @@ def parser():
|
|
|
583
856
|
create = commands.add_parser("new")
|
|
584
857
|
create.add_argument("--location", default=None)
|
|
585
858
|
create.add_argument("--ttl", type=ttl, default=600)
|
|
586
|
-
create.add_argument("--cpu", type=int, choices=
|
|
587
|
-
create.add_argument("--memory", choices=
|
|
859
|
+
create.add_argument("--cpu", type=int, choices=CPU_SIZES, default=1)
|
|
860
|
+
create.add_argument("--memory", choices=MEMORY_SIZES, default="2GiB")
|
|
588
861
|
create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
|
|
589
862
|
create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
|
|
863
|
+
commands.add_parser("locations", help="List configured locations and their notes")
|
|
864
|
+
note = commands.add_parser("note", help="Set or clear operator guidance for a location")
|
|
865
|
+
note.add_argument("location")
|
|
866
|
+
text = note.add_mutually_exclusive_group(required=True)
|
|
867
|
+
text.add_argument("text", nargs="?", help="Guidance, e.g. 'Preferred for builds'")
|
|
868
|
+
text.add_argument("--clear", action="store_true", help="Remove the location's note")
|
|
869
|
+
host = commands.add_parser("host", help="Onboard workspace hosts")
|
|
870
|
+
host_commands = host.add_subparsers(dest="host_action", required=True)
|
|
871
|
+
onboard = host_commands.add_parser(
|
|
872
|
+
"add", help="Check, provision, register and smoke-test a new host location"
|
|
873
|
+
)
|
|
874
|
+
onboard.add_argument("location")
|
|
875
|
+
onboard.add_argument(
|
|
876
|
+
"--ssh", required=True, help="Key-based SSH target: user@host or an ~/.ssh/config alias"
|
|
877
|
+
)
|
|
878
|
+
source = onboard.add_mutually_exclusive_group()
|
|
879
|
+
source.add_argument(
|
|
880
|
+
"--image-release",
|
|
881
|
+
default="v" + __version__,
|
|
882
|
+
metavar="TAG",
|
|
883
|
+
help="Release whose checksummed image to download (default: this CLI's version)",
|
|
884
|
+
)
|
|
885
|
+
source.add_argument("--image-archive", metavar="PATH", help="Import a local image archive")
|
|
886
|
+
source.add_argument("--build", action="store_true", help="Build the image on the host")
|
|
887
|
+
onboard.add_argument(
|
|
888
|
+
"--dry-run", action="store_true", help="Check the host and print the plan; change nothing"
|
|
889
|
+
)
|
|
890
|
+
onboard.add_argument("--yes", action="store_true", help="Apply the plan without prompting")
|
|
590
891
|
listing = commands.add_parser("list")
|
|
591
892
|
listing.add_argument("--location")
|
|
592
893
|
limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
|
|
@@ -630,8 +931,8 @@ def parser():
|
|
|
630
931
|
if action in ["resume", "fork", "extend"]:
|
|
631
932
|
command.add_argument("--ttl", type=ttl, required=True)
|
|
632
933
|
if action in ["resume", "fork"]:
|
|
633
|
-
command.add_argument("--cpu", type=int, choices=
|
|
634
|
-
command.add_argument("--memory", choices=
|
|
934
|
+
command.add_argument("--cpu", type=int, choices=CPU_SIZES)
|
|
935
|
+
command.add_argument("--memory", choices=MEMORY_SIZES)
|
|
635
936
|
if action == "fork":
|
|
636
937
|
command.add_argument("--disposable", action="store_true")
|
|
637
938
|
if action == "exec":
|
|
@@ -660,7 +961,18 @@ def dispatch(args):
|
|
|
660
961
|
recover(args)
|
|
661
962
|
return 0
|
|
662
963
|
if args.action == "new":
|
|
663
|
-
new(args)
|
|
964
|
+
print(new(args))
|
|
965
|
+
return 0
|
|
966
|
+
if args.action == "host":
|
|
967
|
+
host_add(args)
|
|
968
|
+
return 0
|
|
969
|
+
if args.action == "note":
|
|
970
|
+
set_note(args.location, None if args.clear else args.text)
|
|
971
|
+
return 0
|
|
972
|
+
if args.action == "locations":
|
|
973
|
+
for index, (location, entry) in enumerate(configuration().items()):
|
|
974
|
+
record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
|
|
975
|
+
print(json.dumps(record))
|
|
664
976
|
return 0
|
|
665
977
|
if args.action in ["list", "limits", "doctor", "gc", "prune"]:
|
|
666
978
|
if args.action == "prune" and args.older_than < 1:
|
|
@@ -711,27 +1023,7 @@ def dispatch(args):
|
|
|
711
1023
|
else:
|
|
712
1024
|
print(output, end="")
|
|
713
1025
|
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
|
-
)
|
|
1026
|
+
doctor(location)
|
|
735
1027
|
else:
|
|
736
1028
|
for box in inventory(location):
|
|
737
1029
|
print(
|
package/skills/raft-cli/SKILL.md
CHANGED
|
@@ -8,7 +8,14 @@ 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
|
|
|
14
21
|
```sh
|
|
@@ -32,7 +39,7 @@ Incus execution over verified host SSH. It does not expose a guest SSH port.
|
|
|
32
39
|
|
|
33
40
|
Defaults are the first configured location, one CPU, 2 GiB RAM and 600 seconds. TTL accepts 60 seconds
|
|
34
41
|
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`
|
|
42
|
+
boxes count because their disk still occupies space. Use `--memory 4GiB` or `--memory 8GiB`
|
|
36
43
|
and `--cpu 2` when needed. Btrfs bounds workspace storage to a 60 GiB pool per host.
|
|
37
44
|
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
45
|
reports CPU/RAM recommendations for all supported sizes, active allocations and
|