@microck/raft 0.5.0 → 0.7.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 +27 -15
- package/deploy/build-image.py +4 -2
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +6 -6
- package/deploy/verify-features.py +329 -19
- package/deploy/verify-jobs.py +264 -0
- package/deploy/verify-limits.py +22 -3
- package/deploy/verify-live.py +31 -6
- package/deploy/verify-output.py +208 -0
- package/deploy/verify-release.py +33 -4
- package/deploy/verify-run.py +571 -0
- package/deploy/verify-self-update.py +9 -1
- package/deploy/verify-sync.py +122 -0
- package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
- package/docs/boat-capabilities.md +60 -7
- package/docs/boat-parity-design.md +1 -0
- package/docs/boot-performance.mdx +21 -0
- package/docs/contract.md +88 -13
- package/docs/first-box.mdx +22 -19
- package/docs/guides/background-jobs.mdx +38 -31
- package/docs/guides/backup-recovery.mdx +4 -3
- package/docs/guides/ci-jobs.mdx +209 -0
- package/docs/guides/config-policy.mdx +109 -35
- package/docs/guides/files.mdx +2 -2
- package/docs/guides/index.mdx +31 -0
- package/docs/guides/long-running-services.mdx +9 -3
- package/docs/guides/meta.json +7 -2
- package/docs/guides/project-setup.mdx +1 -1
- package/docs/guides/scripting-ci.mdx +36 -14
- package/docs/guides/snapshots-forks.mdx +4 -2
- package/docs/guides/troubleshooting.mdx +19 -2
- package/docs/guides/upgrades.mdx +11 -9
- package/docs/index.mdx +26 -28
- package/docs/maintainers/index.mdx +18 -0
- package/docs/maintainers/meta.json +12 -0
- package/docs/meta.json +5 -9
- package/docs/operations.mdx +17 -10
- package/docs/publishing.md +14 -4
- package/docs/reference/capacity.mdx +2 -1
- package/docs/reference/cli.md +135 -43
- package/docs/reference/configuration.mdx +10 -6
- package/docs/reference/meta.json +3 -1
- package/docs/releases/0.2.0.md +1 -0
- package/docs/releases/0.2.1.md +1 -0
- package/docs/releases/0.3.0.md +1 -0
- package/docs/releases/0.4.0.md +1 -0
- package/docs/releases/0.5.0.md +1 -0
- package/docs/releases/0.6.0.md +17 -0
- package/docs/releases/0.7.0.md +23 -0
- package/docs/releases/index.mdx +21 -0
- package/docs/releases/meta.json +15 -0
- package/docs/requirements.mdx +1 -1
- package/docs/reviews/docs-audit-2026-10-24.md +92 -0
- package/docs/reviews/performance-audit-2026-10-29.md +90 -0
- package/docs/site-development.md +1 -0
- package/docs/verification.mdx +22 -4
- package/package.json +3 -1
- package/raft.py +526 -193
- package/raft_files.py +13 -1
- package/raft_jobs.py +390 -0
- package/raft_output.py +166 -0
- package/raft_run.py +330 -0
- package/skills/raft-cli/SKILL.md +78 -182
package/raft_run.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""One-shot CI orchestration; guests are disposable containers, not registered runners."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
import signal
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
import raft_output
|
|
16
|
+
|
|
17
|
+
GRACE = 30
|
|
18
|
+
LEASE_MARGIN = 120
|
|
19
|
+
|
|
20
|
+
# Host-only state. /run is cleared on reboot, when queued SSH processes also die.
|
|
21
|
+
# Never put root-created markers in world-writable /run/lock or follow symlinks.
|
|
22
|
+
CANCELLATIONS = """import os, re, stat, sys
|
|
23
|
+
CANCEL_ROOT = "/run/raft-ci-cancelled"
|
|
24
|
+
def cancel_directory(create=False):
|
|
25
|
+
if create:
|
|
26
|
+
try:
|
|
27
|
+
os.mkdir(CANCEL_ROOT, 0o700)
|
|
28
|
+
except FileExistsError:
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
directory = os.open(CANCEL_ROOT, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
if create:
|
|
34
|
+
raise
|
|
35
|
+
return None
|
|
36
|
+
metadata = os.fstat(directory)
|
|
37
|
+
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
|
|
38
|
+
os.close(directory)
|
|
39
|
+
raise RuntimeError('Unsafe CI cancellation directory; refusing to use it')
|
|
40
|
+
return directory
|
|
41
|
+
|
|
42
|
+
def cancellation(token, record=False):
|
|
43
|
+
if not re.fullmatch('[a-f0-9]{32}', token):
|
|
44
|
+
raise ValueError('Invalid CI run identity')
|
|
45
|
+
directory = cancel_directory(record)
|
|
46
|
+
if directory is None:
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
if record:
|
|
50
|
+
try:
|
|
51
|
+
marker = os.open(token, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
52
|
+
0o600, dir_fd=directory)
|
|
53
|
+
except FileExistsError:
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
os.close(marker)
|
|
57
|
+
try:
|
|
58
|
+
os.stat(token, dir_fd=directory, follow_symlinks=False)
|
|
59
|
+
return True
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return False
|
|
62
|
+
finally:
|
|
63
|
+
os.close(directory)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
CHECK_CANCELLED = (
|
|
67
|
+
CANCELLATIONS
|
|
68
|
+
+ """
|
|
69
|
+
if cancellation(sys.argv[1]):
|
|
70
|
+
sys.exit('CI run was cancelled before creation')
|
|
71
|
+
"""
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def creation_guard(token):
|
|
76
|
+
if not re.fullmatch("[a-f0-9]{32}", token):
|
|
77
|
+
raise ValueError("Invalid CI run identity")
|
|
78
|
+
return shlex.join(["python3", "-c", CHECK_CANCELLED, token]) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# The caller holds the existing host lifecycle lock around this entire program.
|
|
82
|
+
CLEANUP = (
|
|
83
|
+
CANCELLATIONS
|
|
84
|
+
+ """import json, subprocess, sys
|
|
85
|
+
name, token, creation = sys.argv[1:]
|
|
86
|
+
if creation not in ("pending", "acknowledged"):
|
|
87
|
+
sys.exit("Invalid CI creation state")
|
|
88
|
+
if creation == "pending":
|
|
89
|
+
cancellation(token, record=True)
|
|
90
|
+
def boxes():
|
|
91
|
+
result = subprocess.run(['incus', '--project', 'raft', 'list', name, '--format', 'json'],
|
|
92
|
+
check=True, stdout=subprocess.PIPE)
|
|
93
|
+
return [box for box in json.loads(result.stdout) if box['name'] == name]
|
|
94
|
+
found = boxes()
|
|
95
|
+
if not found:
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
config = found[0].get('config', {})
|
|
98
|
+
if len(found) != 1 or config.get('user.raft') != 'true' or config.get('user.raft.run') != token:
|
|
99
|
+
sys.exit('Refusing cleanup: box ownership does not match this run')
|
|
100
|
+
subprocess.run(['incus', '--project', 'raft', 'delete', name, '--force'], check=True)
|
|
101
|
+
if boxes():
|
|
102
|
+
sys.exit('Run cleanup deletion was not confirmed')
|
|
103
|
+
"""
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Stopped(Exception):
|
|
108
|
+
def __init__(self, status, message):
|
|
109
|
+
super().__init__(message)
|
|
110
|
+
self.status = status
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def timeout_seconds(value):
|
|
114
|
+
seconds = int(value)
|
|
115
|
+
if not 1 <= seconds <= 2592000 - LEASE_MARGIN:
|
|
116
|
+
raise argparse.ArgumentTypeError("Timeout must be 1 second to 30 days minus 120 seconds")
|
|
117
|
+
return seconds
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def add_options(command):
|
|
121
|
+
command.add_argument(
|
|
122
|
+
"--timeout",
|
|
123
|
+
type=timeout_seconds,
|
|
124
|
+
default=600,
|
|
125
|
+
help="Total setup/upload/command deadline in seconds (default: 600)",
|
|
126
|
+
)
|
|
127
|
+
upload = command.add_mutually_exclusive_group()
|
|
128
|
+
upload.add_argument(
|
|
129
|
+
"--push",
|
|
130
|
+
metavar="DIRECTORY",
|
|
131
|
+
help="Upload Git-tracked and unignored files; exclude .git; reject submodules",
|
|
132
|
+
)
|
|
133
|
+
upload.add_argument(
|
|
134
|
+
"--push-all",
|
|
135
|
+
metavar="DIRECTORY",
|
|
136
|
+
help="Explicitly upload the entire tree, including hidden and ignored files",
|
|
137
|
+
)
|
|
138
|
+
command.add_argument(
|
|
139
|
+
"--cwd", help="Guest working directory (upload: /workspace/project; otherwise /workspace)"
|
|
140
|
+
)
|
|
141
|
+
command.add_argument(
|
|
142
|
+
"--pull",
|
|
143
|
+
nargs=2,
|
|
144
|
+
action="append",
|
|
145
|
+
default=[],
|
|
146
|
+
metavar=("GUEST_DIRECTORY", "NEW_LOCAL_DIRECTORY"),
|
|
147
|
+
help="Collect a directory before cleanup, including after a failed command",
|
|
148
|
+
)
|
|
149
|
+
command.add_argument(
|
|
150
|
+
"command", nargs=argparse.REMAINDER, help="Command argv after --; not a shell string"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def guest_path(value):
|
|
155
|
+
path = PurePosixPath(value)
|
|
156
|
+
if not path.is_absolute() or ".." in path.parts or "\0" in value:
|
|
157
|
+
raise ValueError("Guest paths must be absolute and contain no '..' or NUL")
|
|
158
|
+
return str(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def git_files(source, *, all_option="--push-all"):
|
|
162
|
+
"""Fail closed on Git errors; do not accidentally upload an ignored credential."""
|
|
163
|
+
|
|
164
|
+
def git(*arguments):
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["git", "-C", str(source), *arguments], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
167
|
+
)
|
|
168
|
+
if result.returncode:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Git-filtered source requires a readable Git working tree; use {all_option} only "
|
|
171
|
+
"after checking the entire tree for secrets: "
|
|
172
|
+
+ result.stderr.decode(errors="replace").strip()
|
|
173
|
+
)
|
|
174
|
+
return result.stdout
|
|
175
|
+
|
|
176
|
+
if git("rev-parse", "--is-inside-work-tree").strip() != b"true":
|
|
177
|
+
raise ValueError("Git-filtered source requires a Git working tree")
|
|
178
|
+
for record in git("ls-files", "--stage", "-z", "--", ".").split(b"\0"):
|
|
179
|
+
if record.startswith(b"160000 "):
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"Git-filtered source does not copy submodules; stage their contents explicitly"
|
|
182
|
+
)
|
|
183
|
+
paths = git("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ".")
|
|
184
|
+
return {
|
|
185
|
+
os.fsdecode(path)
|
|
186
|
+
for path in paths.split(b"\0")
|
|
187
|
+
if path and ".git" not in PurePosixPath(os.fsdecode(path)).parts
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run(args, api):
|
|
192
|
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
193
|
+
if not command or any("\0" in value for value in command):
|
|
194
|
+
raise ValueError("run requires a command after --, without NUL characters")
|
|
195
|
+
source = (
|
|
196
|
+
Path(args.push or args.push_all).resolve(strict=True)
|
|
197
|
+
if args.push or args.push_all
|
|
198
|
+
else None
|
|
199
|
+
)
|
|
200
|
+
if source is not None and not source.is_dir():
|
|
201
|
+
raise ValueError("Upload source must be a directory")
|
|
202
|
+
cwd = guest_path(args.cwd or ("/workspace/project" if source else "/workspace"))
|
|
203
|
+
pulls = []
|
|
204
|
+
destinations = set()
|
|
205
|
+
for remote, local in args.pull:
|
|
206
|
+
destination = Path(local).absolute()
|
|
207
|
+
destination = destination.parent.resolve(strict=True) / destination.name
|
|
208
|
+
if os.path.lexists(destination) or destination in destinations:
|
|
209
|
+
raise ValueError("Each pull destination must be a new path: " + str(destination))
|
|
210
|
+
destinations.add(destination)
|
|
211
|
+
pulls.append((guest_path(remote), str(destination)))
|
|
212
|
+
args.disposable = True
|
|
213
|
+
args.ttl = args.timeout + LEASE_MARGIN
|
|
214
|
+
token = uuid.uuid4().hex
|
|
215
|
+
allocated = None
|
|
216
|
+
started = False
|
|
217
|
+
creation_acknowledged = False
|
|
218
|
+
status = 1
|
|
219
|
+
phase = "job"
|
|
220
|
+
began = time.monotonic()
|
|
221
|
+
signals = (signal.SIGINT, signal.SIGTERM, signal.SIGALRM)
|
|
222
|
+
previous = {number: signal.getsignal(number) for number in signals}
|
|
223
|
+
old_timer = signal.getitimer(signal.ITIMER_REAL)
|
|
224
|
+
if old_timer[0]:
|
|
225
|
+
raise RuntimeError("run cannot replace an existing process alarm")
|
|
226
|
+
|
|
227
|
+
def stop(number, _frame):
|
|
228
|
+
if number == signal.SIGALRM:
|
|
229
|
+
raise Stopped(124, f"{phase} deadline exceeded")
|
|
230
|
+
raise Stopped(128 + number, f"interrupted by signal {number}")
|
|
231
|
+
|
|
232
|
+
def record(location, name):
|
|
233
|
+
nonlocal allocated
|
|
234
|
+
allocated = (location, name)
|
|
235
|
+
print(f"raft run: allocated {location}:{name}", file=sys.stderr, flush=True)
|
|
236
|
+
|
|
237
|
+
def issue(error):
|
|
238
|
+
nonlocal status
|
|
239
|
+
raft_output.error("operation", error, prefix="raft run")
|
|
240
|
+
if status == 0:
|
|
241
|
+
status = 1
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
for number in signals:
|
|
245
|
+
signal.signal(number, stop)
|
|
246
|
+
signal.setitimer(signal.ITIMER_REAL, args.timeout)
|
|
247
|
+
try:
|
|
248
|
+
include = git_files(source) if args.push else None
|
|
249
|
+
api.new(args, run_id=token, on_allocate=record)
|
|
250
|
+
creation_acknowledged = True
|
|
251
|
+
location, name = allocated
|
|
252
|
+
while api.execute(location, name, ["true"], capture=True).returncode:
|
|
253
|
+
time.sleep(0.2)
|
|
254
|
+
if source:
|
|
255
|
+
api.transfer_tree(
|
|
256
|
+
SimpleNamespace(
|
|
257
|
+
action="upload", source=source, destination=cwd, include=include
|
|
258
|
+
),
|
|
259
|
+
location,
|
|
260
|
+
name,
|
|
261
|
+
)
|
|
262
|
+
started = True
|
|
263
|
+
result = api.execute(location, name, command, cwd=cwd)
|
|
264
|
+
status = result.returncode if result.returncode >= 0 else 128 - result.returncode
|
|
265
|
+
except Stopped as error:
|
|
266
|
+
status = error.status
|
|
267
|
+
issue(error)
|
|
268
|
+
except KeyboardInterrupt:
|
|
269
|
+
status = 130
|
|
270
|
+
issue("interrupted")
|
|
271
|
+
except Exception as error:
|
|
272
|
+
issue(error)
|
|
273
|
+
finally:
|
|
274
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
275
|
+
# A repeated cancellation cannot interrupt the bounded cleanup grace.
|
|
276
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
277
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
278
|
+
if allocated is not None:
|
|
279
|
+
location, name = allocated
|
|
280
|
+
try:
|
|
281
|
+
if started and pulls:
|
|
282
|
+
phase = "artifact collection"
|
|
283
|
+
signal.setitimer(signal.ITIMER_REAL, GRACE)
|
|
284
|
+
try:
|
|
285
|
+
for remote, local in pulls:
|
|
286
|
+
try:
|
|
287
|
+
api.transfer_tree(
|
|
288
|
+
SimpleNamespace(
|
|
289
|
+
action="download", source=remote, destination=local
|
|
290
|
+
),
|
|
291
|
+
location,
|
|
292
|
+
name,
|
|
293
|
+
)
|
|
294
|
+
except Stopped:
|
|
295
|
+
raise
|
|
296
|
+
except Exception as error:
|
|
297
|
+
issue(f"could not collect {remote}: {error}")
|
|
298
|
+
except Stopped as error:
|
|
299
|
+
issue(error)
|
|
300
|
+
finally:
|
|
301
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
302
|
+
finally:
|
|
303
|
+
phase = "cleanup"
|
|
304
|
+
signal.setitimer(signal.ITIMER_REAL, GRACE)
|
|
305
|
+
try:
|
|
306
|
+
api.transaction(
|
|
307
|
+
location,
|
|
308
|
+
'exec python3 -c "$4" "$1" "$2" "$3"',
|
|
309
|
+
name,
|
|
310
|
+
token,
|
|
311
|
+
"acknowledged" if creation_acknowledged else "pending",
|
|
312
|
+
CLEANUP,
|
|
313
|
+
)
|
|
314
|
+
print(f"raft run: cleanup confirmed for {location}:{name}", file=sys.stderr)
|
|
315
|
+
except Exception as error:
|
|
316
|
+
issue(
|
|
317
|
+
f"cleanup not confirmed for {location}:{name}: {error}; inspect this handle. "
|
|
318
|
+
"The host expiry worker is the backstop, not proof of deletion."
|
|
319
|
+
)
|
|
320
|
+
finally:
|
|
321
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
322
|
+
finally:
|
|
323
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
324
|
+
for number, handler in previous.items():
|
|
325
|
+
signal.signal(number, handler)
|
|
326
|
+
signal.setitimer(signal.ITIMER_REAL, *old_timer)
|
|
327
|
+
print(
|
|
328
|
+
f"raft run: finished in {time.monotonic() - began:.1f}s (status {status})", file=sys.stderr
|
|
329
|
+
)
|
|
330
|
+
return status
|
package/skills/raft-cli/SKILL.md
CHANGED
|
@@ -1,188 +1,84 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: raft-cli
|
|
3
|
-
description: Create and
|
|
3
|
+
description: Create and operate private Linux workspaces with Raft. Discover the installed CLI, host policies and guest tools before choosing commands for builds, tests, files, jobs or desktop access. Hosted Boat uses its separate skill.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Raft
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
box=$(raft new --cpu 4 --memory 16GiB --disk 20GiB --ttl 3600 --purpose "Build and test the monorepo" --owner "codex task 123" --next "Destroy when CI passes")
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Built-in defaults are the first configured location, one CPU, 2 GiB RAM, the
|
|
89
|
-
shared pool and 600 seconds; the operator can change them per controller, so
|
|
90
|
-
pass `--location`, `--ttl`, `--cpu`, `--memory` and `--disk` (`--disk shared`
|
|
91
|
-
for the shared pool) when the values matter. `new --from` and `fork` keep the
|
|
92
|
-
source's CPU, RAM and disk instead of any default; each flag overrides only its
|
|
93
|
-
own value. TTL accepts 60 seconds to 30
|
|
94
|
-
days. A request larger than the whole host fails with the host's real size;
|
|
95
|
-
retry with what it reports. If the own-disk pool has too little unreserved
|
|
96
|
-
space for `--disk`, a snapshot or a copy, destroy own-disk boxes or snapshots
|
|
97
|
-
the task no longer needs, or pick a smaller disk or the shared pool.
|
|
98
|
-
`raft list` shows each box's `disk` (`"shared"` or its size).
|
|
99
|
-
|
|
100
|
-
`raft capacity --location lab --json` is an advisory estimate of host CPU/RAM
|
|
101
|
-
headroom and free disk. It is advice, never a reason to skip creating a box or
|
|
102
|
-
to shrink one below what the task needs. Boxes share host CPUs; an estimate of
|
|
103
|
-
zero means new boxes may run slower, not that they are refused.
|
|
104
|
-
|
|
105
|
-
## Persistence and cleanup
|
|
106
|
-
|
|
107
|
-
Persistent boxes stop and retain disk at expiry. `new --disposable` deletes a
|
|
108
|
-
box and its snapshots on stop or expiry. `raft stop "$box"` releases the
|
|
109
|
-
container's running processes; `raft resume "$box" --ttl 600` starts it again.
|
|
110
|
-
Stop is not pause and does not keep guest RAM allocated. Installed dependencies,
|
|
111
|
-
files and secrets remain on disk. For a temporary run, completion requires
|
|
112
|
-
successful `destroy`, even after command failure. Stop reusable runners when
|
|
113
|
-
finished. Destroy only boxes owned by the current task unless authorized otherwise.
|
|
114
|
-
|
|
115
|
-
Host systemd timers enforce deadlines every 15 seconds, even when the controller
|
|
116
|
-
is offline. `raft gc` runs those expiry workers manually and deletes expired disposable
|
|
117
|
-
boxes. `raft prune --older-than 7` previews stopped boxes older than seven days;
|
|
118
|
-
`--yes` deletes them. It skips running boxes and boxes without a recorded stop
|
|
119
|
-
time. Never apply prune outside the task-owned scope without explicit permission. A powered-off host enforces expiry when it returns.
|
|
120
|
-
|
|
121
|
-
Stop a box before a consistent disk snapshot or fork:
|
|
122
|
-
|
|
123
|
-
```sh
|
|
124
|
-
raft stop "$box"
|
|
125
|
-
raft snapshot "$box" prepared
|
|
126
|
-
raft snapshots "$box"
|
|
127
|
-
copy=$(raft fork "$box" --ttl 600)
|
|
128
|
-
raft restore "$box" prepared
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Restore replaces disk contents with the snapshot and retains the destination's
|
|
132
|
-
current instance configuration, including its MAC and resource limits. Use it
|
|
133
|
-
only when the user requested that rollback. A fork copies filesystem data, including any
|
|
134
|
-
credentials installed there. Forks stay on the same host. `new --from "$box/prepared"` starts from a named
|
|
135
|
-
snapshot; `snapshot-delete "$box" prepared` removes it. Resume and fork accept
|
|
136
|
-
optional `--cpu` and `--memory` overrides. Omitted values retain the source limits.
|
|
137
|
-
Forks and `new --from` keep the source's CPU, RAM and disk unless a flag
|
|
138
|
-
overrides them; `new --from "$box/prepared" --disk 60GiB` copies the snapshot onto
|
|
139
|
-
a bigger own disk when a box runs out of space. To spawn many agents from one
|
|
140
|
-
prepared own-disk snapshot, run `new --from` once per agent: each copy is a thin
|
|
141
|
-
snapshot created in seconds.
|
|
142
|
-
Forks default to persistent; use `--disposable` explicitly for temporary clones.
|
|
143
|
-
|
|
144
|
-
`upload/download --recursive` copy directory trees into new destinations without
|
|
145
|
-
merging or overwriting. They require a running guest with Python 3.11.8+ and
|
|
146
|
-
temporary controller disk space. Escaping links and special files are rejected.
|
|
147
|
-
|
|
148
|
-
## Long commands and previews
|
|
149
|
-
|
|
150
|
-
`raft exec "$box" --detach -- <command>` returns a job ID. Use `raft status
|
|
151
|
-
"$box" <job>` and `raft logs "$box" <job>` to inspect its systemd state, exit
|
|
152
|
-
status and output. `raft cancel "$box" <job>` stops the job and its child processes. Stop/destroy
|
|
153
|
-
terminates running jobs; this is not automatic checkpointing of processes.
|
|
154
|
-
`raft extend "$box" --ttl 1800` resets a running box's deadline without
|
|
155
|
-
restarting its work. Use resume for stopped boxes. `raft usage "$box"` reports
|
|
156
|
-
cgroup memory/CPU and disk space; `filesystemScope` says whether that is the
|
|
157
|
-
shared pool or the box's own disk.
|
|
158
|
-
|
|
159
|
-
`raft forward "$box" --remote 8080 --local 8080` opens a foreground private
|
|
160
|
-
tunnel. `raft desktop "$box" --local 6080` starts the desktop on demand and
|
|
161
|
-
opens its private tunnel. Use the printed localhost address, with `vnc.html`
|
|
162
|
-
for the viewer. Ctrl+C closes the tunnel. The desktop service stays inside the
|
|
163
|
-
box until the box stops or its service is explicitly stopped.
|
|
164
|
-
|
|
165
|
-
Public hosting, reverse tunnels, browser-only confinement, agent conversation
|
|
166
|
-
orchestration and KVM-backed VMs are not implemented. Agent binaries can be run
|
|
167
|
-
through exec after authorized sign-in. Do not claim `boat prompt` parity.
|
|
168
|
-
|
|
169
|
-
## Portable backups
|
|
170
|
-
|
|
171
|
-
Stop before exporting a private archive, including snapshots:
|
|
172
|
-
|
|
173
|
-
```sh
|
|
174
|
-
raft stop "$box"
|
|
175
|
-
raft backup "$box" ./workspace.tar.gz
|
|
176
|
-
recovered=$(raft recover ./workspace.tar.gz --location second-host)
|
|
177
|
-
raft resume "$recovered" --ttl 600
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
Recovery creates a fresh stopped handle and clears the inherited deadline. It
|
|
181
|
-
never overwrites another box. A box with its own disk comes back on an own disk
|
|
182
|
-
of the same size, so the destination needs own box disks enabled and room for the
|
|
183
|
-
box and each of its snapshots. Only import trusted Raft exports; archives retain
|
|
184
|
-
native configuration and guest secrets. Store them privately and check free
|
|
185
|
-
space on the controller and destination host. Large backups hold the host
|
|
186
|
-
lifecycle lock and delay expiry checks. A controller crash can leave an owned
|
|
187
|
-
staging archive or imported box; inspect the reported recovery handle before
|
|
188
|
-
cleanup. This is manual recovery, not a scheduled backup service.
|
|
8
|
+
## Discover before choosing a workflow
|
|
9
|
+
|
|
10
|
+
1. Run `raft --version` and `raft --help`. The installed command tree is the
|
|
11
|
+
capability source. Do not infer support from Boat, a newer release, or this
|
|
12
|
+
skill. Never update the CLI merely to make an example work.
|
|
13
|
+
2. Match the task to a command in that help. Read `raft COMMAND --help` and
|
|
14
|
+
nested help until you reach the operation. Read its arguments, defaults,
|
|
15
|
+
output and constraints before invoking it. Revisit help when the version
|
|
16
|
+
changes or the task needs a different operation.
|
|
17
|
+
3. If help cannot answer a material question, inspect the matching installed
|
|
18
|
+
source or that version's Raft command reference and runtime contract. In a
|
|
19
|
+
checkout these are `docs/reference/cli.md` and `docs/contract.md`; published
|
|
20
|
+
sources are at https://github.com/Microck/raft. Current website documentation
|
|
21
|
+
can describe unreleased code. Report unsupported capabilities instead of
|
|
22
|
+
inventing flags or silently using native Incus to bypass Raft.
|
|
23
|
+
|
|
24
|
+
This skill defines the operating procedure. Command help and runtime inspection
|
|
25
|
+
supply the changing feature set, sizes, limits and software inventory.
|
|
26
|
+
|
|
27
|
+
## Select a host and establish ownership
|
|
28
|
+
|
|
29
|
+
1. Read `raft locations` for configured hosts and operator notes. Follow those
|
|
30
|
+
notes when choosing a location; do not change them to fit the task.
|
|
31
|
+
2. Read help for `config`, `capacity`, `doctor` and `list`. Inspect the chosen
|
|
32
|
+
host's defaults, required note fields, retention, health, capacity and boxes.
|
|
33
|
+
Use location filtering when supported so an unrelated offline host does not
|
|
34
|
+
prevent inspection. On older versions without it, report the limitation;
|
|
35
|
+
do not rewrite the operator's inventory.
|
|
36
|
+
3. Read existing box notes before using a box. A handle or familiar name does
|
|
37
|
+
not establish ownership. Operate only on task-owned boxes unless authorized
|
|
38
|
+
otherwise, and keep the exact location-qualified handle returned by creation.
|
|
39
|
+
4. Give each new box a purpose, owner and next action using the flags discovered
|
|
40
|
+
in help. Update its note when the plan changes. Obey host policy errors;
|
|
41
|
+
never weaken policy or defaults to get past them.
|
|
42
|
+
5. Size the box for the workload using the host's actual capacity and storage
|
|
43
|
+
capabilities. Capacity is advice, not a box-count quota. Check free disk
|
|
44
|
+
before large uploads, builds, snapshots or copies.
|
|
45
|
+
|
|
46
|
+
## Choose lifecycle and verify work
|
|
47
|
+
|
|
48
|
+
- For bounded one-shot work, check whether the installed CLI provides a command
|
|
49
|
+
that owns creation, execution, collection and cleanup. Read its timeout and
|
|
50
|
+
failure semantics before choosing it. Otherwise manage that lifecycle explicitly.
|
|
51
|
+
- For reusable work, select persistence explicitly and inspect retention.
|
|
52
|
+
Stopping ends processes; it does not preserve memory. A disposable box can
|
|
53
|
+
disappear on stop, and a persistent box can expire under stopped-box retention.
|
|
54
|
+
Snapshots inside a box do not protect it against deletion of that box.
|
|
55
|
+
- Probe the needed guest service after creation; a returned handle does not
|
|
56
|
+
prove readiness. Inspect tool versions inside the guest and its `/opt/raft/`
|
|
57
|
+
inventory instead of assuming an image has every compiler, agent or login.
|
|
58
|
+
- Exec takes literal arguments. Use an explicit shell for shell syntax. Preserve
|
|
59
|
+
the command's exit status and collect artifacts before cleanup, including on
|
|
60
|
+
failure. After an uncertain transport failure, inspect the exact handle or job
|
|
61
|
+
before rerunning a payload that might already have executed.
|
|
62
|
+
- For temporary work, confirm destruction even after a failed command. For
|
|
63
|
+
reusable work, stop when finished and report the handle and retention deadline.
|
|
64
|
+
An SSH error or an eventual TTL is not proof of immediate cleanup.
|
|
65
|
+
|
|
66
|
+
## Preserve the boundaries
|
|
67
|
+
|
|
68
|
+
Raft workspaces are native-architecture containers sharing the host kernel.
|
|
69
|
+
Do not promise VM isolation or cross-architecture execution. Inspect the actual
|
|
70
|
+
runtime before claiming any isolation or resource limit.
|
|
71
|
+
|
|
72
|
+
Host onboarding, deployment, upgrades and global cleanup affect more than a
|
|
73
|
+
single workspace. Perform them only within explicit authorization; inspect a
|
|
74
|
+
supported dry-run or preview first. Do not change daily-driver hosts or install
|
|
75
|
+
updates as a side effect of an ordinary workspace task.
|
|
76
|
+
|
|
77
|
+
Snapshots, forks, transfers and backups can carry credentials. Copy only the
|
|
78
|
+
required files and never inject host credentials without authorization. Git
|
|
79
|
+
filtering is not a secret scanner. Import only trusted backups and store them
|
|
80
|
+
privately. Restore only an explicitly requested rollback.
|
|
81
|
+
|
|
82
|
+
For a tunnel or desktop, follow current help for listener addresses and service
|
|
83
|
+
binding. Report the address actually printed. A private tunnel is not public
|
|
84
|
+
hosting, and closing it does not necessarily stop the guest service.
|