@microck/raft 0.0.0-stage → 0.2.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.
Files changed (62) hide show
  1. package/.github/assets/raft-logo.png +0 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -2
  4. package/deploy/benchmark-boot.py +150 -0
  5. package/deploy/build-image.py +95 -0
  6. package/deploy/deploy.py +176 -0
  7. package/deploy/incus-network.conf +4 -0
  8. package/deploy/install-incus.sh +22 -0
  9. package/deploy/raft-expire.py +62 -0
  10. package/deploy/raft-expire.service +8 -0
  11. package/deploy/raft-expire.timer +8 -0
  12. package/deploy/raft-network.service +12 -0
  13. package/deploy/raft-network.sh +40 -0
  14. package/deploy/verify-controls.py +241 -0
  15. package/deploy/verify-features.py +461 -0
  16. package/deploy/verify-fresh-host.py +301 -0
  17. package/deploy/verify-image-tests.py +139 -0
  18. package/deploy/verify-image.py +95 -0
  19. package/deploy/verify-limits.py +110 -0
  20. package/deploy/verify-live.py +903 -0
  21. package/deploy/verify-release.py +132 -0
  22. package/deploy/verify-tools.py +105 -0
  23. package/docs/benchmarks/amd64-native-ci.json +137 -0
  24. package/docs/benchmarks/arm64-baseline-a.json +108 -0
  25. package/docs/benchmarks/arm64-baseline-b.json +108 -0
  26. package/docs/benchmarks/arm64-candidate-a.json +109 -0
  27. package/docs/benchmarks/arm64-desktop-local.json +22 -0
  28. package/docs/benchmarks/arm64-native-ci.json +137 -0
  29. package/docs/boat-capabilities.md +64 -0
  30. package/docs/boat-parity-design.md +26 -0
  31. package/docs/boot-performance.mdx +102 -0
  32. package/docs/contract.md +93 -0
  33. package/docs/first-box.mdx +82 -0
  34. package/docs/guides/background-jobs.mdx +92 -0
  35. package/docs/guides/backup-recovery.mdx +111 -0
  36. package/docs/guides/desktop-services.mdx +87 -0
  37. package/docs/guides/docker.mdx +84 -0
  38. package/docs/guides/files.mdx +91 -0
  39. package/docs/guides/meta.json +14 -0
  40. package/docs/guides/snapshots-forks.mdx +107 -0
  41. package/docs/guides/troubleshooting.mdx +127 -0
  42. package/docs/incus.example.json +3 -0
  43. package/docs/index.mdx +36 -0
  44. package/docs/meta.json +21 -0
  45. package/docs/operations.mdx +224 -0
  46. package/docs/publishing.md +48 -0
  47. package/docs/reference/capacity-limits.mdx +83 -0
  48. package/docs/reference/cli.md +279 -0
  49. package/docs/reference/configuration.mdx +77 -0
  50. package/docs/reference/meta.json +10 -0
  51. package/docs/releases/0.2.0.md +22 -0
  52. package/docs/requirements.mdx +47 -0
  53. package/docs/reviews/code-review-2026-10-06.md +168 -0
  54. package/docs/site-development.md +80 -0
  55. package/docs/verification.mdx +231 -0
  56. package/images/incus/build.sh +57 -0
  57. package/images/incus/desktop.sh +21 -0
  58. package/images/incus/raft-desktop.service +7 -0
  59. package/package.json +46 -4
  60. package/raft.py +955 -0
  61. package/raft_files.py +134 -0
  62. package/skills/raft-cli/SKILL.md +117 -0
package/raft.py ADDED
@@ -0,0 +1,955 @@
1
+ #!/usr/bin/env python3
2
+ """Self-hosted persistent Linux workspaces powered by Incus."""
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import tarfile
15
+ import uuid
16
+
17
+ import raft_files
18
+
19
+ __version__ = "0.2.0"
20
+
21
+ CONFIG = Path.home() / ".config/raft/incus.json"
22
+ PROJECT = "raft"
23
+ SAVED_BOX_LIMIT = 4
24
+
25
+ USAGE = """import json, shutil
26
+ from pathlib import Path
27
+ group = Path('/sys/fs/cgroup')
28
+ disk = shutil.disk_usage('/workspace')
29
+ print(json.dumps({
30
+ 'memoryBytes': int((group / 'memory.current').read_text()),
31
+ 'memoryLimit': (group / 'memory.max').read_text().strip(),
32
+ 'cpuTime': dict(line.split() for line in (group / 'cpu.stat').read_text().splitlines()),
33
+ 'effectiveCpus': (group / 'cpuset.cpus.effective').read_text().strip(),
34
+ 'filesystemUsedBytes': disk.used,
35
+ 'filesystemAvailableBytes': disk.free,
36
+ 'filesystemScope': 'shared Raft pool, not per-box usage',
37
+ }))
38
+ """
39
+
40
+
41
+ def configuration():
42
+ inventory = json.loads(CONFIG.read_text())
43
+ if not isinstance(inventory, dict) or not inventory:
44
+ raise ValueError("Configure at least one host in ~/.config/raft/incus.json")
45
+ for location, entry in inventory.items():
46
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]*", location):
47
+ raise ValueError("Location names must use letters, digits, underscores or hyphens")
48
+ if not isinstance(entry, dict) or not isinstance(entry.get("ssh"), str) or not entry["ssh"]:
49
+ raise ValueError("Each configured location needs a nonempty ssh target")
50
+ return inventory
51
+
52
+
53
+ def settings(location):
54
+ inventory = configuration()
55
+ if location not in inventory:
56
+ raise ValueError(f"Unknown location: {location}")
57
+ return inventory[location]
58
+
59
+
60
+ def receive(command, destination, *, stdin=None):
61
+ """Write streamed bytes in Python: SSH can exit zero after a local write error."""
62
+ with tempfile.TemporaryFile() as errors:
63
+ with subprocess.Popen(
64
+ command, stdin=stdin, stdout=subprocess.PIPE, stderr=errors
65
+ ) as process:
66
+ try:
67
+ shutil.copyfileobj(process.stdout, destination)
68
+ except BaseException:
69
+ process.kill()
70
+ process.wait()
71
+ raise
72
+ returncode = process.wait()
73
+ errors.seek(0)
74
+ return subprocess.CompletedProcess(command, returncode, None, errors.read())
75
+
76
+
77
+ def remote(location, argv, *, capture=True, stdin=None, stdout=None, tty=False, locked=False):
78
+ command = ["sudo", "-n"]
79
+ if locked:
80
+ command += ["flock", "/run/lock/raft-incus.lock"]
81
+ command += ["incus", "--project", PROJECT, *argv]
82
+ connection = [
83
+ "ssh",
84
+ *(["-tt"] if tty else ["-T"]),
85
+ "-o",
86
+ "BatchMode=yes",
87
+ "-o",
88
+ "ConnectTimeout=10",
89
+ settings(location)["ssh"],
90
+ shlex.join(command),
91
+ ]
92
+ if stdout is not None:
93
+ return receive(connection, stdout, stdin=stdin)
94
+ return subprocess.run(
95
+ connection,
96
+ stdin=stdin,
97
+ stdout=subprocess.PIPE if capture else None,
98
+ stderr=subprocess.PIPE if capture else None,
99
+ check=False,
100
+ )
101
+
102
+
103
+ def incus(location, *argv, locked=False):
104
+ response = remote(location, argv, locked=locked)
105
+ if response.returncode:
106
+ raise RuntimeError(response.stderr.decode(errors="replace").strip())
107
+ return response.stdout.decode()
108
+
109
+
110
+ def parse_handle(value):
111
+ location, separator, name = value.partition(":")
112
+ if not separator or not re.fullmatch(r"rf-[a-f0-9]{16}", name):
113
+ raise ValueError("Use the location-qualified handle returned by new")
114
+ settings(location)
115
+ return location, name
116
+
117
+
118
+ def instance(location, name):
119
+ box = json.loads(incus(location, "list", name, "--fast", "--format", "json"))
120
+ box = next((item for item in box if item["name"] == name), None)
121
+ if box is None or box["config"].get("user.raft") != "true":
122
+ raise ValueError("Raft box not found")
123
+ return box
124
+
125
+
126
+ def inventory(location):
127
+ return [
128
+ box
129
+ for box in json.loads(incus(location, "list", "--fast", "--format", "json"))
130
+ if box["config"].get("user.raft") == "true"
131
+ ]
132
+
133
+
134
+ ADMISSION = f"""count=$(incus --project raft list --fast --format json | python3 -c 'import json,sys; print(sum(x["config"].get("user.raft")=="true" for x in json.load(sys.stdin)))')
135
+ test "$count" -lt {SAVED_BOX_LIMIT} || {{ echo 'Location already holds four boxes' >&2; exit 1; }}
136
+ """
137
+
138
+
139
+ REQUIRE_STOPPED = """state=$(incus --project raft list "$1" --fast --format json | python3 -c 'import json,sys; print(next(x["status"] for x in json.load(sys.stdin) if x["name"] == sys.argv[1] and x["config"].get("user.raft") == "true"))' "$1")
140
+ test "$state" = Stopped || { echo 'Stop the source box before resume, snapshot, restore, fork or backup' >&2; exit 1; }
141
+ """
142
+
143
+
144
+ CHECK_WORKER = (
145
+ "python3 -c "
146
+ + shlex.quote(
147
+ "import runpy; "
148
+ "worker = runpy.run_path('/usr/local/lib/raft/expire-worker'); "
149
+ "\nif worker.get('PROTOCOL') != 1:\n"
150
+ " raise SystemExit('Host expiry worker differs from this CLI; deploy the current Raft checkout first')"
151
+ )
152
+ + "\n"
153
+ )
154
+
155
+
156
+ HOST_RESOURCES = """import json, os, shutil, subprocess
157
+ from pathlib import Path
158
+ memory = dict(line.split()[:2] for line in Path('/proc/meminfo').read_text().splitlines())
159
+ disk = shutil.disk_usage('/var/lib/incus')
160
+ pool = json.loads(subprocess.check_output(['incus', 'query', '/1.0/storage-pools/raft-data/resources']))
161
+ print(json.dumps({
162
+ 'cpus': len(os.sched_getaffinity(0)),
163
+ 'memoryTotalBytes': int(memory['MemTotal:']) * 1024,
164
+ 'memoryAvailableBytes': int(memory['MemAvailable:']) * 1024,
165
+ 'hostDiskFreeBytes': disk.free,
166
+ 'poolTotalBytes': pool['space']['total'],
167
+ 'poolFreeBytes': pool['space']['total'] - pool['space']['used'],
168
+ }))
169
+ """
170
+
171
+
172
+ def capacity(host, boxes):
173
+ """Conservative allocation advice, not an exclusive CPU or memory reservation."""
174
+ gib = 1024**3
175
+ active = [box for box in boxes if box["status"] != "Stopped"]
176
+ cpu_allocated = 0
177
+ memory_allocated = 0
178
+ for box in active:
179
+ limits = box["expanded_config"]
180
+ if limits["limits.cpu"] not in {"1", "2"} or limits["limits.memory"] not in {
181
+ "1GiB",
182
+ "2GiB",
183
+ "4GiB",
184
+ }:
185
+ raise ValueError("Active box has unsupported sizing; inspect its Incus configuration")
186
+ cpu_allocated += int(limits["limits.cpu"])
187
+ memory_allocated += int(limits["limits.memory"][0]) * gib
188
+ memory_reserve = max(2 * gib, host["memoryTotalBytes"] // 10)
189
+ cpu_budget = max(0, host["cpus"] - 1)
190
+ memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
191
+ cpu_remaining = max(0, cpu_budget - cpu_allocated)
192
+ memory_remaining = max(
193
+ 0,
194
+ min(
195
+ memory_budget - memory_allocated,
196
+ host["memoryAvailableBytes"] - memory_reserve,
197
+ ),
198
+ )
199
+ slots = max(0, SAVED_BOX_LIMIT - len(boxes))
200
+ recommendations = []
201
+ for cpu in [1, 2]:
202
+ for memory in [1, 2, 4]:
203
+ total = min(SAVED_BOX_LIMIT, cpu_budget // cpu, memory_budget // (memory * gib))
204
+ additional = min(
205
+ max(0, SAVED_BOX_LIMIT - len(active)),
206
+ cpu_remaining // cpu,
207
+ memory_remaining // (memory * gib),
208
+ )
209
+ recommendations.append(
210
+ {
211
+ "cpu": cpu,
212
+ "memoryGiB": memory,
213
+ "totalRunning": total,
214
+ "additionalRunning": additional,
215
+ "newBoxes": min(slots, additional),
216
+ }
217
+ )
218
+ warnings = []
219
+ if cpu_allocated > cpu_budget or memory_allocated > memory_budget:
220
+ warnings.append("Active configured limits exceed the recommended host budget")
221
+ if host["memoryAvailableBytes"] < memory_reserve:
222
+ warnings.append(
223
+ "Available host RAM is below the reserve; stop boxes or reduce other workloads"
224
+ )
225
+ if host["hostDiskFreeBytes"] < 5 * gib or host["poolFreeBytes"] < 5 * gib:
226
+ warnings.append(
227
+ "Host or shared-pool free disk is below 5 GiB; check image and workspace growth"
228
+ )
229
+ return {
230
+ "host": host,
231
+ "savedBoxLimit": SAVED_BOX_LIMIT,
232
+ "savedBoxes": len(boxes),
233
+ "activeBoxes": len(active),
234
+ "savedSlots": slots,
235
+ "allocated": {"cpus": cpu_allocated, "memoryBytes": memory_allocated},
236
+ "reserve": {"cpus": 1, "memoryBytes": memory_reserve},
237
+ "recommendations": recommendations,
238
+ "warnings": warnings,
239
+ }
240
+
241
+
242
+ def host_limits(location):
243
+ response = subprocess.run(
244
+ [
245
+ "ssh",
246
+ "-T",
247
+ "-o",
248
+ "BatchMode=yes",
249
+ "-o",
250
+ "ConnectTimeout=10",
251
+ settings(location)["ssh"],
252
+ shlex.join(["sudo", "-n", "python3", "-c", HOST_RESOURCES]),
253
+ ],
254
+ capture_output=True,
255
+ text=True,
256
+ check=True,
257
+ )
258
+ return {"location": location, **capacity(json.loads(response.stdout), inventory(location))}
259
+
260
+
261
+ def print_limits(report):
262
+ gib = 1024**3
263
+ host = report["host"]
264
+ print(
265
+ f"{report['location']}: {host['cpus']} CPUs, "
266
+ f"{host['memoryTotalBytes'] / gib:.1f} GiB RAM "
267
+ f"({host['memoryAvailableBytes'] / gib:.1f} GiB available)"
268
+ )
269
+ print(
270
+ f"Saved boxes: {report['savedBoxes']}/{report['savedBoxLimit']} enforced; "
271
+ f"active: {report['activeBoxes']}; free saved slots: {report['savedSlots']}"
272
+ )
273
+ print(
274
+ f"Active limits: {report['allocated']['cpus']} CPUs, "
275
+ f"{report['allocated']['memoryBytes'] / gib:.1f} GiB; "
276
+ f"host reserve: 1 CPU, {report['reserve']['memoryBytes'] / gib:.1f} GiB"
277
+ )
278
+ print(
279
+ f"Disk free: {host['poolFreeBytes'] / gib:.1f} GiB shared pool, "
280
+ f"{host['hostDiskFreeBytes'] / gib:.1f} GiB host"
281
+ )
282
+ print("CPU RAM GiB Total running More running New boxes")
283
+ for size in report["recommendations"]:
284
+ print(
285
+ f"{size['cpu']:>3} {size['memoryGiB']:>7} {size['totalRunning']:>13} "
286
+ f"{size['additionalRunning']:>12} {size['newBoxes']:>9}"
287
+ )
288
+ print("Advisory CPU/RAM counts; total assumes an empty host. Disk growth is not included.")
289
+ for warning in report["warnings"]:
290
+ print("Warning: " + warning)
291
+
292
+
293
+ def transaction(location, script, *arguments, stdout=None):
294
+ # All lifecycle steps in a compound operation share the host expiry lock.
295
+ command = [
296
+ "sudo",
297
+ "-n",
298
+ "flock",
299
+ "/run/lock/raft-incus.lock",
300
+ "sh",
301
+ "-c",
302
+ "set -eu\n" + script,
303
+ "raft",
304
+ *arguments,
305
+ ]
306
+ connection = [
307
+ "ssh",
308
+ "-T",
309
+ "-o",
310
+ "BatchMode=yes",
311
+ settings(location)["ssh"],
312
+ shlex.join(command),
313
+ ]
314
+ if stdout is not None:
315
+ response = receive(connection, stdout)
316
+ else:
317
+ response = subprocess.run(connection, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
318
+ if response.returncode:
319
+ raise RuntimeError(response.stderr.decode().strip())
320
+ return response.stdout.decode() if response.stdout is not None else ""
321
+
322
+
323
+ def snapshot_name(value):
324
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", value):
325
+ raise ValueError("Invalid snapshot name")
326
+ return value
327
+
328
+
329
+ def sizing(args):
330
+ values = []
331
+ for field in ["cpu", "memory"]:
332
+ value = getattr(args, field, None)
333
+ if value is not None:
334
+ values += ["-c", f"limits.{field}={value}"]
335
+ return values
336
+
337
+
338
+ def new(args):
339
+ source = None
340
+ if args.template:
341
+ handle, separator, snapshot = args.template.partition("/")
342
+ if not separator:
343
+ raise ValueError("Use --from LOCATION:BOX/SNAPSHOT")
344
+ location, original = parse_handle(handle)
345
+ snapshot_name(snapshot)
346
+ if args.location is not None and args.location != location:
347
+ raise ValueError("Snapshot templates must stay on their source host")
348
+ args.location = location
349
+ instance(location, original)
350
+ source = original + "/" + snapshot
351
+ if args.location is None:
352
+ args.location = next(iter(configuration()))
353
+ if source is None:
354
+ source = settings(args.location).get("image", "")
355
+ if not isinstance(source, str) or not re.fullmatch(r"[a-f0-9]{64}", source):
356
+ raise ValueError(
357
+ "Build an image and configure its full immutable fingerprint before creating boxes"
358
+ )
359
+ name = "rf-" + uuid.uuid4().hex[:16]
360
+ # Initialize before starting, so a failed start still has its intended policy.
361
+ transaction(
362
+ args.location,
363
+ (CHECK_WORKER if args.disposable else "")
364
+ + ADMISSION
365
+ + 'lifetime="$1"; name="$2"; shift 2; "$@"; '
366
+ 'incus --project raft start "$name"; '
367
+ 'incus --project raft config set "$name" '
368
+ 'user.raft.expires="$(( $(date +%s) + lifetime ))"',
369
+ str(args.ttl),
370
+ name,
371
+ "incus",
372
+ "--project",
373
+ PROJECT,
374
+ "copy" if args.template else "init",
375
+ source,
376
+ name,
377
+ "-c",
378
+ "user.raft=true",
379
+ "-c",
380
+ "user.raft.expires=",
381
+ "-c",
382
+ "user.raft.stopped-at=",
383
+ "-c",
384
+ f"user.raft.disposable={'true' if args.disposable else 'false'}",
385
+ "-c",
386
+ "volatile.eth0.hwaddr=",
387
+ *sizing(args),
388
+ )
389
+ print(f"{args.location}:{name}")
390
+
391
+
392
+ def execute(location, name, command, *, capture=False):
393
+ return remote(location, ["exec", name, "--cwd", "/workspace", "--", *command], capture=capture)
394
+
395
+
396
+ def transfer_tree(args, location, name):
397
+ worker = Path(raft_files.__file__).read_text()
398
+ with tempfile.TemporaryFile() as archive:
399
+ if args.action == "upload":
400
+ raft_files.pack_directory(args.source, archive)
401
+ size = archive.tell()
402
+ archive.seek(0)
403
+ response = remote(
404
+ location,
405
+ [
406
+ "exec",
407
+ name,
408
+ "--",
409
+ "python3",
410
+ "-c",
411
+ worker,
412
+ "unpack",
413
+ args.destination,
414
+ "--size",
415
+ str(size),
416
+ ],
417
+ stdin=archive,
418
+ )
419
+ else:
420
+ response = remote(
421
+ location,
422
+ ["exec", name, "--", "python3", "-c", worker, "pack", args.source],
423
+ stdout=archive,
424
+ )
425
+ if response.returncode == 0:
426
+ size = archive.tell()
427
+ archive.seek(0)
428
+ raft_files.unpack_directory(archive, args.destination, size)
429
+ if response.returncode:
430
+ raise RuntimeError(response.stderr.decode(errors="replace").strip())
431
+ return 0
432
+
433
+
434
+ def transfer(args, location, name):
435
+ if args.recursive:
436
+ return transfer_tree(args, location, name)
437
+ # Stream file descriptors rather than buffering large artifacts in memory.
438
+ if args.action == "upload":
439
+ with Path(args.source).open("rb") as source:
440
+ response = remote(
441
+ location, ["file", "push", "-", name + args.destination], stdin=source
442
+ )
443
+ else:
444
+ destination = Path(args.destination).resolve()
445
+ with tempfile.NamedTemporaryFile(
446
+ dir=destination.parent, prefix=".raft-", delete=False
447
+ ) as target:
448
+ temporary = Path(target.name)
449
+ try:
450
+ response = remote(
451
+ location, ["file", "pull", name + args.source, "-"], stdout=target
452
+ )
453
+ if response.returncode == 0:
454
+ target.flush()
455
+ os.replace(temporary, destination)
456
+ finally:
457
+ temporary.unlink(missing_ok=True)
458
+ if response.returncode:
459
+ raise RuntimeError(response.stderr.decode().strip())
460
+ return 0
461
+
462
+
463
+ def backup(args, location, name):
464
+ destination = Path(args.destination).resolve()
465
+ if destination.exists():
466
+ raise ValueError("Backup destination already exists; choose a new path")
467
+ with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=".raft-") as archive:
468
+ # Some network filesystems ignore creation modes; enforce privacy before writing.
469
+ os.fchmod(archive.fileno(), 0o600)
470
+ if os.fstat(archive.fileno()).st_mode & 0o077:
471
+ raise ValueError("Backup storage must support private file permissions")
472
+ # Check stopped state under the same lock as export, so resume cannot race it.
473
+ transaction(
474
+ location,
475
+ REQUIRE_STOPPED + 'exec incus --project raft export "$1" - --compression gzip',
476
+ name,
477
+ stdout=archive,
478
+ )
479
+ archive.flush()
480
+ os.fsync(archive.fileno())
481
+ # Link refuses a destination created during export; never replace another backup.
482
+ os.link(archive.name, destination)
483
+ print(str(destination))
484
+
485
+
486
+ def recover(args):
487
+ host = settings(args.location)["ssh"]
488
+ name = "rf-" + uuid.uuid4().hex[:16]
489
+ response = subprocess.run(
490
+ ["ssh", "-T", "-o", "BatchMode=yes", host, "mktemp /tmp/raft-recovery.XXXXXXXX"],
491
+ capture_output=True,
492
+ check=True,
493
+ )
494
+ staged = response.stdout.decode().strip()
495
+ if not re.fullmatch(r"/tmp/raft-recovery\.[a-zA-Z0-9]{8}", staged):
496
+ raise RuntimeError("Host returned an invalid recovery staging path")
497
+ print("Recovery handle: " + args.location + ":" + name, file=sys.stderr, flush=True)
498
+ try:
499
+ # Send through SSH stdin, avoiding SCP remote-path interpretation.
500
+ with Path(args.source).open("rb") as archive:
501
+ subprocess.run(
502
+ [
503
+ "ssh",
504
+ "-T",
505
+ "-o",
506
+ "BatchMode=yes",
507
+ host,
508
+ shlex.join(["sh", "-c", 'umask 077; cat > "$1"', "raft", staged]),
509
+ ],
510
+ stdin=archive,
511
+ check=True,
512
+ )
513
+ transaction(
514
+ args.location,
515
+ ADMISSION + 'incus --project raft import "$1" "$2" --storage raft-data '
516
+ "-c user.raft=true -c user.raft.expires= -c user.raft.disposable=false -c user.raft.stopped-at= -c volatile.eth0.hwaddr=",
517
+ staged,
518
+ name,
519
+ )
520
+ if instance(args.location, name)["status"] != "Stopped":
521
+ raise RuntimeError("Recovered box unexpectedly running: " + args.location + ":" + name)
522
+ print(args.location + ":" + name)
523
+ finally:
524
+ subprocess.run(
525
+ [
526
+ "ssh",
527
+ "-T",
528
+ "-o",
529
+ "BatchMode=yes",
530
+ host,
531
+ shlex.join(["sudo", "-n", "rm", "-f", "--", staged]),
532
+ ],
533
+ check=True,
534
+ )
535
+
536
+
537
+ def address(location, name):
538
+ # Use the managed NIC, never Docker bridges or volatile process counters.
539
+ response = execute(location, name, ["ip", "-json", "addr", "show", "dev", "eth0"], capture=True)
540
+ if response.returncode:
541
+ raise RuntimeError(response.stderr.decode().strip())
542
+ interface = json.loads(response.stdout)[0]
543
+ for candidate in interface["addr_info"]:
544
+ if candidate["family"] == "inet" and candidate["scope"] == "global":
545
+ return candidate["local"]
546
+ raise ValueError("Running box has no eth0 IPv4 address yet")
547
+
548
+
549
+ def forward(location, name, port, local_port):
550
+ ip = address(location, name)
551
+ print(f"http://127.0.0.1:{local_port} (Ctrl+C closes the private tunnel)", flush=True)
552
+ return os.execvp(
553
+ "ssh",
554
+ [
555
+ "ssh",
556
+ "-N",
557
+ "-o",
558
+ "BatchMode=yes",
559
+ "-o",
560
+ "ExitOnForwardFailure=yes",
561
+ "-o",
562
+ "ServerAliveInterval=15",
563
+ "-L",
564
+ f"127.0.0.1:{local_port}:{ip}:{port}",
565
+ settings(location)["ssh"],
566
+ ],
567
+ )
568
+
569
+
570
+ def ttl(value):
571
+ seconds = int(value)
572
+ if not 60 <= seconds <= 2592000:
573
+ raise argparse.ArgumentTypeError("TTL must be 60 seconds to 30 days")
574
+ return seconds
575
+
576
+
577
+ def parser():
578
+ cli = argparse.ArgumentParser(description=__doc__)
579
+ cli.add_argument("--version", action="version", version=__version__)
580
+ commands = cli.add_subparsers(dest="action", required=True)
581
+ create = commands.add_parser("new")
582
+ create.add_argument("--location", default=None)
583
+ create.add_argument("--ttl", type=ttl, default=600)
584
+ create.add_argument("--cpu", type=int, choices=[1, 2], default=1)
585
+ create.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"], default="2GiB")
586
+ create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
587
+ create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
588
+ listing = commands.add_parser("list")
589
+ listing.add_argument("--location")
590
+ limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
591
+ limits.add_argument("--location")
592
+ limits.add_argument("--json", action="store_true")
593
+ commands.add_parser("doctor")
594
+ gc = commands.add_parser("gc", help="Enforce deadlines, including disposable deletion")
595
+ gc.add_argument("--location")
596
+ prune = commands.add_parser("prune", help="Preview deletion of old stopped boxes")
597
+ prune.add_argument("--location")
598
+ prune.add_argument("--older-than", type=int, required=True, metavar="DAYS")
599
+ prune.add_argument("--yes", action="store_true", help="Delete the matching boxes")
600
+ recovery = commands.add_parser("recover", help="Import a portable backup as a new stopped box")
601
+ recovery.add_argument("source")
602
+ recovery.add_argument("--location", required=True)
603
+ for action in [
604
+ "info",
605
+ "stop",
606
+ "resume",
607
+ "extend",
608
+ "destroy",
609
+ "ssh",
610
+ "exec",
611
+ "upload",
612
+ "download",
613
+ "snapshot",
614
+ "snapshots",
615
+ "snapshot-delete",
616
+ "restore",
617
+ "fork",
618
+ "forward",
619
+ "desktop",
620
+ "logs",
621
+ "status",
622
+ "cancel",
623
+ "backup",
624
+ "usage",
625
+ ]:
626
+ command = commands.add_parser(action)
627
+ command.add_argument("box")
628
+ if action in ["resume", "fork", "extend"]:
629
+ command.add_argument("--ttl", type=ttl, required=True)
630
+ if action in ["resume", "fork"]:
631
+ command.add_argument("--cpu", type=int, choices=[1, 2])
632
+ command.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"])
633
+ if action == "fork":
634
+ command.add_argument("--disposable", action="store_true")
635
+ if action == "exec":
636
+ command.add_argument("--detach", action="store_true")
637
+ command.add_argument("command", nargs="+")
638
+ if action in ["upload", "download"]:
639
+ command.add_argument("source")
640
+ command.add_argument("destination")
641
+ command.add_argument("--recursive", action="store_true")
642
+ if action in ["snapshot", "restore", "snapshot-delete"]:
643
+ command.add_argument("name")
644
+ if action in ["logs", "status", "cancel"]:
645
+ command.add_argument("job")
646
+ if action == "backup":
647
+ command.add_argument("destination")
648
+ if action == "forward":
649
+ command.add_argument("--remote", type=int, required=True)
650
+ command.add_argument("--local", type=int, required=True)
651
+ if action == "desktop":
652
+ command.add_argument("--local", type=int, default=6080)
653
+ return cli
654
+
655
+
656
+ def dispatch(args):
657
+ if args.action == "recover":
658
+ recover(args)
659
+ return 0
660
+ if args.action == "new":
661
+ new(args)
662
+ return 0
663
+ if args.action in ["list", "limits", "doctor", "gc", "prune"]:
664
+ if args.action == "prune" and args.older_than < 1:
665
+ raise ValueError("Retention must be at least one day")
666
+ locations = configuration()
667
+ if getattr(args, "location", None):
668
+ locations = [args.location]
669
+ for location in locations:
670
+ if args.action == "limits":
671
+ report = host_limits(location)
672
+ if args.json:
673
+ print(json.dumps(report))
674
+ else:
675
+ print_limits(report)
676
+ elif args.action in ["gc", "prune"]:
677
+ worker = ["sudo", "-n", "/usr/local/lib/raft/expire"]
678
+ if args.action == "prune":
679
+ worker += ["--prune-days", str(args.older_than)]
680
+ if args.yes:
681
+ worker += ["--yes"]
682
+ if args.action == "prune":
683
+ output = transaction(
684
+ location,
685
+ CHECK_WORKER + 'exec /usr/local/lib/raft/expire-worker "$@"',
686
+ *worker[3:],
687
+ )
688
+ else:
689
+ response = subprocess.run(
690
+ [
691
+ "ssh",
692
+ "-T",
693
+ "-o",
694
+ "BatchMode=yes",
695
+ settings(location)["ssh"],
696
+ shlex.join(worker),
697
+ ],
698
+ capture_output=True,
699
+ text=True,
700
+ )
701
+ if response.returncode:
702
+ raise RuntimeError(response.stderr.strip())
703
+ output = response.stdout
704
+ if args.action == "prune":
705
+ for line in output.splitlines():
706
+ record = json.loads(line)
707
+ record["box"] = location + ":" + record["box"]
708
+ print(json.dumps(record))
709
+ else:
710
+ print(output, end="")
711
+ elif args.action == "doctor":
712
+ print(location + ": " + incus(location, "version").strip())
713
+ response = subprocess.run(
714
+ [
715
+ "ssh",
716
+ "-T",
717
+ settings(location)["ssh"],
718
+ "systemctl is-active incus raft-expire.timer raft-network.service && df -h /",
719
+ ],
720
+ capture_output=True,
721
+ text=True,
722
+ check=True,
723
+ )
724
+ print(response.stdout.strip())
725
+ print(incus(location, "storage", "info", "raft-data").strip())
726
+ print(
727
+ f"{len(inventory(location))} saved boxes; system containers (shared host kernel)"
728
+ )
729
+ else:
730
+ for box in inventory(location):
731
+ print(
732
+ json.dumps(
733
+ {
734
+ "box": location + ":" + box["name"],
735
+ "state": box["status"],
736
+ "stopAt": box["config"].get("user.raft.expires", ""),
737
+ }
738
+ )
739
+ )
740
+ return 0
741
+ location, name = parse_handle(args.box)
742
+ box = instance(location, name)
743
+ if args.action == "info":
744
+ print(json.dumps(box, indent=2))
745
+ elif args.action == "backup":
746
+ backup(args, location, name)
747
+ elif args.action == "usage":
748
+ # Resource counters require a running guest; info remains metadata-only.
749
+ response = execute(location, name, ["python3", "-c", USAGE], capture=True)
750
+ if response.returncode:
751
+ raise RuntimeError(response.stderr.decode().strip())
752
+ print(response.stdout.decode().strip())
753
+ elif args.action == "destroy":
754
+ incus(location, "delete", name, "--force", locked=True)
755
+ if any(item["name"] == name for item in inventory(location)):
756
+ raise RuntimeError("Deletion was not confirmed")
757
+ print("Destroyed " + args.box)
758
+ elif args.action == "stop":
759
+ transaction(
760
+ location,
761
+ 'disposable=$(incus --project raft config get "$1" user.raft.disposable); '
762
+ 'if [ "$disposable" = true ]; then incus --project raft delete "$1" --force; '
763
+ 'else incus --project raft stop "$1" --timeout 30; '
764
+ 'incus --project raft config unset "$1" user.raft.expires; '
765
+ 'incus --project raft config set "$1" user.raft.stopped-at="$(date +%s)"; fi',
766
+ name,
767
+ )
768
+ elif args.action == "resume":
769
+ overrides = []
770
+ for field in ["cpu", "memory"]:
771
+ value = getattr(args, field)
772
+ if value is not None:
773
+ overrides.append(f"limits.{field}={value}")
774
+ transaction(
775
+ location,
776
+ REQUIRE_STOPPED + 'name="$1"; lifetime="$2"; shift 2; '
777
+ 'if [ "$#" -gt 0 ]; then incus --project raft config set "$name" "$@"; fi; '
778
+ 'incus --project raft start "$name"; '
779
+ 'incus --project raft config set "$name" user.raft.stopped-at=; '
780
+ 'incus --project raft config set "$name" user.raft.expires="$(( $(date +%s) + lifetime ))"',
781
+ name,
782
+ str(args.ttl),
783
+ *overrides,
784
+ )
785
+ elif args.action == "extend":
786
+ transaction(
787
+ location,
788
+ 'test "$(incus --project raft list "$1" --fast --format json | '
789
+ 'python3 -c \'import json,sys; print(next(x["status"] for x in json.load(sys.stdin) '
790
+ 'if x["name"] == sys.argv[1]))\' "$1")" = Running '
791
+ '|| { echo "Extend requires a running box; use resume for a stopped box" >&2; exit 1; }; '
792
+ 'incus --project raft config set "$1" user.raft.expires="$(( $(date +%s) + $2 ))"',
793
+ name,
794
+ str(args.ttl),
795
+ )
796
+ elif args.action == "ssh":
797
+ return remote(
798
+ location,
799
+ ["exec", name, "--cwd", "/workspace", "--", "/bin/bash", "-l"],
800
+ capture=False,
801
+ tty=True,
802
+ ).returncode
803
+ elif args.action == "exec":
804
+ payload = args.command[1:] if args.command[:1] == ["--"] else args.command
805
+ if not payload:
806
+ raise ValueError("Supply a command after --")
807
+ if args.detach:
808
+ job = "rfcmd-" + uuid.uuid4().hex
809
+ response = execute(
810
+ location,
811
+ name,
812
+ [
813
+ "systemd-run",
814
+ "--quiet",
815
+ "--unit",
816
+ job,
817
+ "--property=WorkingDirectory=/workspace",
818
+ "--property=RemainAfterExit=yes",
819
+ "--",
820
+ *payload,
821
+ ],
822
+ capture=True,
823
+ )
824
+ if response.returncode:
825
+ raise RuntimeError(response.stderr.decode().strip())
826
+ print(job)
827
+ else:
828
+ return execute(location, name, payload).returncode
829
+ elif args.action in ["upload", "download"]:
830
+ guest_path = args.destination if args.action == "upload" else args.source
831
+ if not guest_path.startswith("/"):
832
+ raise ValueError("Guest file path must be absolute")
833
+ return transfer(args, location, name)
834
+ elif args.action == "snapshot":
835
+ snapshot_name(args.name)
836
+ print(
837
+ transaction(
838
+ location,
839
+ REQUIRE_STOPPED + 'incus --project raft snapshot create "$1" "$2"',
840
+ name,
841
+ args.name,
842
+ ).strip()
843
+ )
844
+ elif args.action == "snapshot-delete":
845
+ snapshot_name(args.name)
846
+ incus(location, "snapshot", "delete", name, args.name, locked=True)
847
+ elif args.action == "snapshots":
848
+ print(incus(location, "info", name).strip())
849
+ elif args.action == "restore":
850
+ snapshot_name(args.name)
851
+ # Imported/forked snapshots carry source configuration. Restore files,
852
+ # keeping the destination's network identity and explicitly chosen limits.
853
+ transaction(
854
+ location,
855
+ REQUIRE_STOPPED + 'incus --project raft snapshot restore "$1" "$2" --diskonly',
856
+ name,
857
+ args.name,
858
+ )
859
+ elif args.action == "fork":
860
+ child = "rf-" + uuid.uuid4().hex[:16]
861
+ transaction(
862
+ location,
863
+ (CHECK_WORKER if args.disposable else "")
864
+ + REQUIRE_STOPPED
865
+ + ADMISSION
866
+ + 'name="$2"; lifetime="$3"; '
867
+ 'source="$1"; shift 3; incus --project raft copy "$source" "$name" --instance-only "$@"; '
868
+ 'incus --project raft start "$name"; '
869
+ 'incus --project raft config set "$name" user.raft.expires="$(( $(date +%s) + lifetime ))"',
870
+ name,
871
+ child,
872
+ str(args.ttl),
873
+ "-c",
874
+ "user.raft.expires=",
875
+ "-c",
876
+ "user.raft.stopped-at=",
877
+ "-c",
878
+ f"user.raft.disposable={'true' if args.disposable else 'false'}",
879
+ "-c",
880
+ "volatile.eth0.hwaddr=",
881
+ *sizing(args),
882
+ )
883
+ print(location + ":" + child)
884
+ elif args.action in ["logs", "status", "cancel"]:
885
+ if not re.fullmatch(r"rfcmd-[a-f0-9]{32}", args.job):
886
+ raise ValueError("Use the job ID returned by exec --detach")
887
+ loaded = execute(
888
+ location,
889
+ name,
890
+ ["systemctl", "show", args.job, "-p", "LoadState", "--value"],
891
+ capture=True,
892
+ )
893
+ if loaded.returncode or loaded.stdout.strip() != b"loaded":
894
+ # Cancelled transient units unload, but their journal keeps history.
895
+ # Live units remain recognizable even after old logs are rotated away.
896
+ recorded = execute(
897
+ location,
898
+ name,
899
+ ["journalctl", "--no-pager", "--quiet", "-o", "json", "-n", "1", "-u", args.job],
900
+ capture=True,
901
+ )
902
+ if recorded.returncode or not recorded.stdout.strip():
903
+ raise ValueError("Detached job not found in this running box")
904
+ if args.action == "cancel":
905
+ return execute(location, name, ["systemctl", "stop", args.job]).returncode
906
+ command = (
907
+ ["journalctl", "--no-pager", "-o", "cat", "-u", args.job]
908
+ if args.action == "logs"
909
+ else [
910
+ "systemctl",
911
+ "show",
912
+ args.job,
913
+ "-p",
914
+ "ActiveState",
915
+ "-p",
916
+ "SubState",
917
+ "-p",
918
+ "ExecMainStatus",
919
+ ]
920
+ )
921
+ return execute(location, name, command).returncode
922
+ elif args.action in ["forward", "desktop"]:
923
+ port = args.remote if args.action == "forward" else 6080
924
+ if not 1 <= port <= 65535 or not 1 <= args.local <= 65535:
925
+ raise ValueError("Ports must be between 1 and 65535")
926
+ if args.action == "desktop":
927
+ response = execute(location, name, ["systemctl", "start", "raft-desktop"], capture=True)
928
+ if response.returncode:
929
+ raise RuntimeError(response.stderr.decode().strip())
930
+ return forward(location, name, port, args.local)
931
+ return 0
932
+
933
+
934
+ def main():
935
+ if sys.version_info < (3, 11, 8):
936
+ print("raft: Python 3.11.8 or newer is required", file=sys.stderr)
937
+ return 1
938
+ os.umask(0o077)
939
+ try:
940
+ return dispatch(parser().parse_args())
941
+ except (
942
+ OSError,
943
+ ValueError,
944
+ RuntimeError,
945
+ tarfile.TarError,
946
+ subprocess.CalledProcessError,
947
+ ) as error:
948
+ print(f"raft: {error}", file=sys.stderr)
949
+ return 1
950
+ except KeyboardInterrupt:
951
+ return 130
952
+
953
+
954
+ if __name__ == "__main__":
955
+ sys.exit(main())