@microck/raft 0.0.0-stage → 0.2.1
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/.github/assets/raft-logo.png +0 -0
- package/LICENSE +21 -0
- package/README.md +167 -2
- package/deploy/benchmark-boot.py +150 -0
- package/deploy/build-image.py +95 -0
- package/deploy/deploy.py +176 -0
- package/deploy/incus-network.conf +4 -0
- package/deploy/install-incus.sh +22 -0
- package/deploy/raft-expire.py +62 -0
- package/deploy/raft-expire.service +8 -0
- package/deploy/raft-expire.timer +8 -0
- package/deploy/raft-network.service +12 -0
- package/deploy/raft-network.sh +40 -0
- package/deploy/verify-controls.py +241 -0
- package/deploy/verify-features.py +519 -0
- package/deploy/verify-fresh-host.py +301 -0
- package/deploy/verify-image-tests.py +139 -0
- package/deploy/verify-image.py +95 -0
- package/deploy/verify-limits.py +110 -0
- package/deploy/verify-live.py +903 -0
- package/deploy/verify-release.py +132 -0
- package/deploy/verify-tools.py +105 -0
- package/docs/benchmarks/amd64-native-ci.json +137 -0
- package/docs/benchmarks/arm64-baseline-a.json +108 -0
- package/docs/benchmarks/arm64-baseline-b.json +108 -0
- package/docs/benchmarks/arm64-candidate-a.json +109 -0
- package/docs/benchmarks/arm64-desktop-local.json +22 -0
- package/docs/benchmarks/arm64-native-ci.json +137 -0
- package/docs/boat-capabilities.md +64 -0
- package/docs/boat-parity-design.md +26 -0
- package/docs/boot-performance.mdx +102 -0
- package/docs/contract.md +93 -0
- package/docs/first-box.mdx +86 -0
- package/docs/guides/background-jobs.mdx +95 -0
- package/docs/guides/backup-recovery.mdx +111 -0
- package/docs/guides/desktop-services.mdx +90 -0
- package/docs/guides/docker.mdx +84 -0
- package/docs/guides/files.mdx +91 -0
- package/docs/guides/long-running-services.mdx +109 -0
- package/docs/guides/meta.json +18 -0
- package/docs/guides/project-setup.mdx +112 -0
- package/docs/guides/scripting-ci.mdx +133 -0
- package/docs/guides/snapshots-forks.mdx +107 -0
- package/docs/guides/troubleshooting.mdx +152 -0
- package/docs/guides/upgrades.mdx +113 -0
- package/docs/incus.example.json +3 -0
- package/docs/index.mdx +40 -0
- package/docs/meta.json +21 -0
- package/docs/operations.mdx +239 -0
- package/docs/publishing.md +48 -0
- package/docs/reference/capacity-limits.mdx +92 -0
- package/docs/reference/cli.md +288 -0
- package/docs/reference/configuration.mdx +77 -0
- package/docs/reference/meta.json +10 -0
- package/docs/releases/0.2.0.md +22 -0
- package/docs/releases/0.2.1.md +14 -0
- package/docs/requirements.mdx +47 -0
- package/docs/reviews/code-review-2026-10-06.md +168 -0
- package/docs/site-development.md +80 -0
- package/docs/verification.mdx +237 -0
- package/images/incus/build.sh +57 -0
- package/images/incus/desktop.sh +21 -0
- package/images/incus/raft-desktop.service +7 -0
- package/package.json +46 -4
- package/raft.py +961 -0
- package/raft_files.py +134 -0
- package/skills/raft-cli/SKILL.md +117 -0
package/raft.py
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
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.1"
|
|
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
|
+
# Lifecycle transactions take arguments, never interactive or piped input.
|
|
308
|
+
"ssh",
|
|
309
|
+
"-n",
|
|
310
|
+
"-T",
|
|
311
|
+
"-o",
|
|
312
|
+
"BatchMode=yes",
|
|
313
|
+
settings(location)["ssh"],
|
|
314
|
+
shlex.join(command),
|
|
315
|
+
]
|
|
316
|
+
if stdout is not None:
|
|
317
|
+
response = receive(connection, stdout)
|
|
318
|
+
else:
|
|
319
|
+
response = subprocess.run(connection, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
320
|
+
if response.returncode:
|
|
321
|
+
raise RuntimeError(response.stderr.decode().strip())
|
|
322
|
+
return response.stdout.decode() if response.stdout is not None else ""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def snapshot_name(value):
|
|
326
|
+
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", value):
|
|
327
|
+
raise ValueError("Invalid snapshot name")
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def sizing(args):
|
|
332
|
+
values = []
|
|
333
|
+
for field in ["cpu", "memory"]:
|
|
334
|
+
value = getattr(args, field, None)
|
|
335
|
+
if value is not None:
|
|
336
|
+
values += ["-c", f"limits.{field}={value}"]
|
|
337
|
+
return values
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def new(args):
|
|
341
|
+
source = None
|
|
342
|
+
if args.template:
|
|
343
|
+
handle, separator, snapshot = args.template.partition("/")
|
|
344
|
+
if not separator:
|
|
345
|
+
raise ValueError("Use --from LOCATION:BOX/SNAPSHOT")
|
|
346
|
+
location, original = parse_handle(handle)
|
|
347
|
+
snapshot_name(snapshot)
|
|
348
|
+
if args.location is not None and args.location != location:
|
|
349
|
+
raise ValueError("Snapshot templates must stay on their source host")
|
|
350
|
+
args.location = location
|
|
351
|
+
instance(location, original)
|
|
352
|
+
source = original + "/" + snapshot
|
|
353
|
+
if args.location is None:
|
|
354
|
+
args.location = next(iter(configuration()))
|
|
355
|
+
if source is None:
|
|
356
|
+
source = settings(args.location).get("image", "")
|
|
357
|
+
if not isinstance(source, str) or not re.fullmatch(r"[a-f0-9]{64}", source):
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"Build an image and configure its full immutable fingerprint before creating boxes"
|
|
360
|
+
)
|
|
361
|
+
name = "rf-" + uuid.uuid4().hex[:16]
|
|
362
|
+
# Initialize before starting, so a failed start still has its intended policy.
|
|
363
|
+
transaction(
|
|
364
|
+
args.location,
|
|
365
|
+
(CHECK_WORKER if args.disposable else "")
|
|
366
|
+
+ ADMISSION
|
|
367
|
+
+ 'lifetime="$1"; name="$2"; shift 2; "$@"; '
|
|
368
|
+
'incus --project raft start "$name"; '
|
|
369
|
+
'incus --project raft config set "$name" '
|
|
370
|
+
'user.raft.expires="$(( $(date +%s) + lifetime ))"',
|
|
371
|
+
str(args.ttl),
|
|
372
|
+
name,
|
|
373
|
+
"incus",
|
|
374
|
+
"--project",
|
|
375
|
+
PROJECT,
|
|
376
|
+
"copy" if args.template else "init",
|
|
377
|
+
source,
|
|
378
|
+
name,
|
|
379
|
+
"-c",
|
|
380
|
+
"user.raft=true",
|
|
381
|
+
"-c",
|
|
382
|
+
"user.raft.expires=",
|
|
383
|
+
"-c",
|
|
384
|
+
"user.raft.stopped-at=",
|
|
385
|
+
"-c",
|
|
386
|
+
f"user.raft.disposable={'true' if args.disposable else 'false'}",
|
|
387
|
+
"-c",
|
|
388
|
+
"volatile.eth0.hwaddr=",
|
|
389
|
+
*sizing(args),
|
|
390
|
+
)
|
|
391
|
+
print(f"{args.location}:{name}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def execute(location, name, command, *, capture=False):
|
|
395
|
+
return remote(location, ["exec", name, "--cwd", "/workspace", "--", *command], capture=capture)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def transfer_tree(args, location, name):
|
|
399
|
+
worker = Path(raft_files.__file__).read_text()
|
|
400
|
+
with tempfile.TemporaryFile() as archive:
|
|
401
|
+
if args.action == "upload":
|
|
402
|
+
raft_files.pack_directory(args.source, archive)
|
|
403
|
+
size = archive.tell()
|
|
404
|
+
archive.seek(0)
|
|
405
|
+
response = remote(
|
|
406
|
+
location,
|
|
407
|
+
[
|
|
408
|
+
"exec",
|
|
409
|
+
name,
|
|
410
|
+
"--",
|
|
411
|
+
"python3",
|
|
412
|
+
"-c",
|
|
413
|
+
worker,
|
|
414
|
+
"unpack",
|
|
415
|
+
args.destination,
|
|
416
|
+
"--size",
|
|
417
|
+
str(size),
|
|
418
|
+
],
|
|
419
|
+
stdin=archive,
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
response = remote(
|
|
423
|
+
location,
|
|
424
|
+
["exec", name, "--", "python3", "-c", worker, "pack", args.source],
|
|
425
|
+
stdout=archive,
|
|
426
|
+
)
|
|
427
|
+
if response.returncode == 0:
|
|
428
|
+
size = archive.tell()
|
|
429
|
+
archive.seek(0)
|
|
430
|
+
raft_files.unpack_directory(archive, args.destination, size)
|
|
431
|
+
if response.returncode:
|
|
432
|
+
raise RuntimeError(response.stderr.decode(errors="replace").strip())
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def transfer(args, location, name):
|
|
437
|
+
if args.recursive:
|
|
438
|
+
return transfer_tree(args, location, name)
|
|
439
|
+
# Stream file descriptors rather than buffering large artifacts in memory.
|
|
440
|
+
if args.action == "upload":
|
|
441
|
+
with Path(args.source).open("rb") as source:
|
|
442
|
+
response = remote(
|
|
443
|
+
location, ["file", "push", "-", name + args.destination], stdin=source
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
destination = Path(args.destination).resolve()
|
|
447
|
+
with tempfile.NamedTemporaryFile(
|
|
448
|
+
dir=destination.parent, prefix=".raft-", delete=False
|
|
449
|
+
) as target:
|
|
450
|
+
temporary = Path(target.name)
|
|
451
|
+
try:
|
|
452
|
+
response = remote(
|
|
453
|
+
location, ["file", "pull", name + args.source, "-"], stdout=target
|
|
454
|
+
)
|
|
455
|
+
if response.returncode == 0:
|
|
456
|
+
target.flush()
|
|
457
|
+
os.replace(temporary, destination)
|
|
458
|
+
finally:
|
|
459
|
+
temporary.unlink(missing_ok=True)
|
|
460
|
+
if response.returncode:
|
|
461
|
+
raise RuntimeError(response.stderr.decode().strip())
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def backup(args, location, name):
|
|
466
|
+
destination = Path(args.destination).resolve()
|
|
467
|
+
if destination.exists():
|
|
468
|
+
raise ValueError("Backup destination already exists; choose a new path")
|
|
469
|
+
with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=".raft-") as archive:
|
|
470
|
+
# Some network filesystems ignore creation modes; enforce privacy before writing.
|
|
471
|
+
os.fchmod(archive.fileno(), 0o600)
|
|
472
|
+
if os.fstat(archive.fileno()).st_mode & 0o077:
|
|
473
|
+
raise ValueError("Backup storage must support private file permissions")
|
|
474
|
+
# Check stopped state under the same lock as export, so resume cannot race it.
|
|
475
|
+
transaction(
|
|
476
|
+
location,
|
|
477
|
+
REQUIRE_STOPPED + 'exec incus --project raft export "$1" - --compression gzip',
|
|
478
|
+
name,
|
|
479
|
+
stdout=archive,
|
|
480
|
+
)
|
|
481
|
+
archive.flush()
|
|
482
|
+
os.fsync(archive.fileno())
|
|
483
|
+
# Link refuses a destination created during export; never replace another backup.
|
|
484
|
+
os.link(archive.name, destination)
|
|
485
|
+
print(str(destination))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def recover(args):
|
|
489
|
+
host = settings(args.location)["ssh"]
|
|
490
|
+
name = "rf-" + uuid.uuid4().hex[:16]
|
|
491
|
+
response = subprocess.run(
|
|
492
|
+
["ssh", "-T", "-o", "BatchMode=yes", host, "mktemp /tmp/raft-recovery.XXXXXXXX"],
|
|
493
|
+
capture_output=True,
|
|
494
|
+
check=True,
|
|
495
|
+
)
|
|
496
|
+
staged = response.stdout.decode().strip()
|
|
497
|
+
if not re.fullmatch(r"/tmp/raft-recovery\.[a-zA-Z0-9]{8}", staged):
|
|
498
|
+
raise RuntimeError("Host returned an invalid recovery staging path")
|
|
499
|
+
print("Recovery handle: " + args.location + ":" + name, file=sys.stderr, flush=True)
|
|
500
|
+
try:
|
|
501
|
+
# Send through SSH stdin, avoiding SCP remote-path interpretation.
|
|
502
|
+
with Path(args.source).open("rb") as archive:
|
|
503
|
+
subprocess.run(
|
|
504
|
+
[
|
|
505
|
+
"ssh",
|
|
506
|
+
"-T",
|
|
507
|
+
"-o",
|
|
508
|
+
"BatchMode=yes",
|
|
509
|
+
host,
|
|
510
|
+
shlex.join(["sh", "-c", 'umask 077; cat > "$1"', "raft", staged]),
|
|
511
|
+
],
|
|
512
|
+
stdin=archive,
|
|
513
|
+
check=True,
|
|
514
|
+
)
|
|
515
|
+
transaction(
|
|
516
|
+
args.location,
|
|
517
|
+
ADMISSION + 'incus --project raft import "$1" "$2" --storage raft-data '
|
|
518
|
+
"-c user.raft=true -c user.raft.expires= -c user.raft.disposable=false -c user.raft.stopped-at= -c volatile.eth0.hwaddr=",
|
|
519
|
+
staged,
|
|
520
|
+
name,
|
|
521
|
+
)
|
|
522
|
+
if instance(args.location, name)["status"] != "Stopped":
|
|
523
|
+
raise RuntimeError("Recovered box unexpectedly running: " + args.location + ":" + name)
|
|
524
|
+
print(args.location + ":" + name)
|
|
525
|
+
finally:
|
|
526
|
+
subprocess.run(
|
|
527
|
+
[
|
|
528
|
+
"ssh",
|
|
529
|
+
"-T",
|
|
530
|
+
"-o",
|
|
531
|
+
"BatchMode=yes",
|
|
532
|
+
host,
|
|
533
|
+
shlex.join(["sudo", "-n", "rm", "-f", "--", staged]),
|
|
534
|
+
],
|
|
535
|
+
check=True,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def address(location, name):
|
|
540
|
+
# Use the managed NIC, never Docker bridges or volatile process counters.
|
|
541
|
+
response = execute(location, name, ["ip", "-json", "addr", "show", "dev", "eth0"], capture=True)
|
|
542
|
+
if response.returncode:
|
|
543
|
+
raise RuntimeError(response.stderr.decode().strip())
|
|
544
|
+
interface = json.loads(response.stdout)[0]
|
|
545
|
+
for candidate in interface["addr_info"]:
|
|
546
|
+
if candidate["family"] == "inet" and candidate["scope"] == "global":
|
|
547
|
+
return candidate["local"]
|
|
548
|
+
raise ValueError("Running box has no eth0 IPv4 address yet")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def forward(location, name, port, local_port):
|
|
552
|
+
ip = address(location, name)
|
|
553
|
+
print(f"http://127.0.0.1:{local_port} (Ctrl+C closes the private tunnel)", flush=True)
|
|
554
|
+
return os.execvp(
|
|
555
|
+
"ssh",
|
|
556
|
+
[
|
|
557
|
+
"ssh",
|
|
558
|
+
"-N",
|
|
559
|
+
"-o",
|
|
560
|
+
"BatchMode=yes",
|
|
561
|
+
"-o",
|
|
562
|
+
"ExitOnForwardFailure=yes",
|
|
563
|
+
"-o",
|
|
564
|
+
"ServerAliveInterval=15",
|
|
565
|
+
"-L",
|
|
566
|
+
f"127.0.0.1:{local_port}:{ip}:{port}",
|
|
567
|
+
settings(location)["ssh"],
|
|
568
|
+
],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def ttl(value):
|
|
573
|
+
seconds = int(value)
|
|
574
|
+
if not 60 <= seconds <= 2592000:
|
|
575
|
+
raise argparse.ArgumentTypeError("TTL must be 60 seconds to 30 days")
|
|
576
|
+
return seconds
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def parser():
|
|
580
|
+
cli = argparse.ArgumentParser(description=__doc__)
|
|
581
|
+
cli.add_argument("--version", action="version", version=__version__)
|
|
582
|
+
commands = cli.add_subparsers(dest="action", required=True)
|
|
583
|
+
create = commands.add_parser("new")
|
|
584
|
+
create.add_argument("--location", default=None)
|
|
585
|
+
create.add_argument("--ttl", type=ttl, default=600)
|
|
586
|
+
create.add_argument("--cpu", type=int, choices=[1, 2], default=1)
|
|
587
|
+
create.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"], default="2GiB")
|
|
588
|
+
create.add_argument("--disposable", action="store_true", help="Delete on stop or expiry")
|
|
589
|
+
create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
|
|
590
|
+
listing = commands.add_parser("list")
|
|
591
|
+
listing.add_argument("--location")
|
|
592
|
+
limits = commands.add_parser("limits", help="Inspect host capacity and recommended box counts")
|
|
593
|
+
limits.add_argument("--location")
|
|
594
|
+
limits.add_argument("--json", action="store_true")
|
|
595
|
+
commands.add_parser("doctor")
|
|
596
|
+
gc = commands.add_parser("gc", help="Enforce deadlines, including disposable deletion")
|
|
597
|
+
gc.add_argument("--location")
|
|
598
|
+
prune = commands.add_parser("prune", help="Preview deletion of old stopped boxes")
|
|
599
|
+
prune.add_argument("--location")
|
|
600
|
+
prune.add_argument("--older-than", type=int, required=True, metavar="DAYS")
|
|
601
|
+
prune.add_argument("--yes", action="store_true", help="Delete the matching boxes")
|
|
602
|
+
recovery = commands.add_parser("recover", help="Import a portable backup as a new stopped box")
|
|
603
|
+
recovery.add_argument("source")
|
|
604
|
+
recovery.add_argument("--location", required=True)
|
|
605
|
+
for action in [
|
|
606
|
+
"info",
|
|
607
|
+
"stop",
|
|
608
|
+
"resume",
|
|
609
|
+
"extend",
|
|
610
|
+
"destroy",
|
|
611
|
+
"ssh",
|
|
612
|
+
"exec",
|
|
613
|
+
"upload",
|
|
614
|
+
"download",
|
|
615
|
+
"snapshot",
|
|
616
|
+
"snapshots",
|
|
617
|
+
"snapshot-delete",
|
|
618
|
+
"restore",
|
|
619
|
+
"fork",
|
|
620
|
+
"forward",
|
|
621
|
+
"desktop",
|
|
622
|
+
"logs",
|
|
623
|
+
"status",
|
|
624
|
+
"cancel",
|
|
625
|
+
"backup",
|
|
626
|
+
"usage",
|
|
627
|
+
]:
|
|
628
|
+
command = commands.add_parser(action)
|
|
629
|
+
command.add_argument("box")
|
|
630
|
+
if action in ["resume", "fork", "extend"]:
|
|
631
|
+
command.add_argument("--ttl", type=ttl, required=True)
|
|
632
|
+
if action in ["resume", "fork"]:
|
|
633
|
+
command.add_argument("--cpu", type=int, choices=[1, 2])
|
|
634
|
+
command.add_argument("--memory", choices=["1GiB", "2GiB", "4GiB"])
|
|
635
|
+
if action == "fork":
|
|
636
|
+
command.add_argument("--disposable", action="store_true")
|
|
637
|
+
if action == "exec":
|
|
638
|
+
command.add_argument("--detach", action="store_true")
|
|
639
|
+
command.add_argument("command", nargs="+")
|
|
640
|
+
if action in ["upload", "download"]:
|
|
641
|
+
command.add_argument("source")
|
|
642
|
+
command.add_argument("destination")
|
|
643
|
+
command.add_argument("--recursive", action="store_true")
|
|
644
|
+
if action in ["snapshot", "restore", "snapshot-delete"]:
|
|
645
|
+
command.add_argument("name")
|
|
646
|
+
if action in ["logs", "status", "cancel"]:
|
|
647
|
+
command.add_argument("job")
|
|
648
|
+
if action == "backup":
|
|
649
|
+
command.add_argument("destination")
|
|
650
|
+
if action == "forward":
|
|
651
|
+
command.add_argument("--remote", type=int, required=True)
|
|
652
|
+
command.add_argument("--local", type=int, required=True)
|
|
653
|
+
if action == "desktop":
|
|
654
|
+
command.add_argument("--local", type=int, default=6080)
|
|
655
|
+
return cli
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def dispatch(args):
|
|
659
|
+
if args.action == "recover":
|
|
660
|
+
recover(args)
|
|
661
|
+
return 0
|
|
662
|
+
if args.action == "new":
|
|
663
|
+
new(args)
|
|
664
|
+
return 0
|
|
665
|
+
if args.action in ["list", "limits", "doctor", "gc", "prune"]:
|
|
666
|
+
if args.action == "prune" and args.older_than < 1:
|
|
667
|
+
raise ValueError("Retention must be at least one day")
|
|
668
|
+
locations = configuration()
|
|
669
|
+
if getattr(args, "location", None):
|
|
670
|
+
locations = [args.location]
|
|
671
|
+
for location in locations:
|
|
672
|
+
if args.action == "limits":
|
|
673
|
+
report = host_limits(location)
|
|
674
|
+
if args.json:
|
|
675
|
+
print(json.dumps(report))
|
|
676
|
+
else:
|
|
677
|
+
print_limits(report)
|
|
678
|
+
elif args.action in ["gc", "prune"]:
|
|
679
|
+
worker = ["sudo", "-n", "/usr/local/lib/raft/expire"]
|
|
680
|
+
if args.action == "prune":
|
|
681
|
+
worker += ["--prune-days", str(args.older_than)]
|
|
682
|
+
if args.yes:
|
|
683
|
+
worker += ["--yes"]
|
|
684
|
+
if args.action == "prune":
|
|
685
|
+
output = transaction(
|
|
686
|
+
location,
|
|
687
|
+
CHECK_WORKER + 'exec /usr/local/lib/raft/expire-worker "$@"',
|
|
688
|
+
*worker[3:],
|
|
689
|
+
)
|
|
690
|
+
else:
|
|
691
|
+
response = subprocess.run(
|
|
692
|
+
[
|
|
693
|
+
"ssh",
|
|
694
|
+
"-T",
|
|
695
|
+
"-o",
|
|
696
|
+
"BatchMode=yes",
|
|
697
|
+
settings(location)["ssh"],
|
|
698
|
+
shlex.join(worker),
|
|
699
|
+
],
|
|
700
|
+
capture_output=True,
|
|
701
|
+
text=True,
|
|
702
|
+
)
|
|
703
|
+
if response.returncode:
|
|
704
|
+
raise RuntimeError(response.stderr.strip())
|
|
705
|
+
output = response.stdout
|
|
706
|
+
if args.action == "prune":
|
|
707
|
+
for line in output.splitlines():
|
|
708
|
+
record = json.loads(line)
|
|
709
|
+
record["box"] = location + ":" + record["box"]
|
|
710
|
+
print(json.dumps(record))
|
|
711
|
+
else:
|
|
712
|
+
print(output, end="")
|
|
713
|
+
elif args.action == "doctor":
|
|
714
|
+
print(location + ": " + incus(location, "version").strip())
|
|
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
|
+
)
|
|
735
|
+
else:
|
|
736
|
+
for box in inventory(location):
|
|
737
|
+
print(
|
|
738
|
+
json.dumps(
|
|
739
|
+
{
|
|
740
|
+
"box": location + ":" + box["name"],
|
|
741
|
+
"state": box["status"],
|
|
742
|
+
"stopAt": box["config"].get("user.raft.expires", ""),
|
|
743
|
+
}
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
return 0
|
|
747
|
+
location, name = parse_handle(args.box)
|
|
748
|
+
box = instance(location, name)
|
|
749
|
+
if args.action == "info":
|
|
750
|
+
print(json.dumps(box, indent=2))
|
|
751
|
+
elif args.action == "backup":
|
|
752
|
+
backup(args, location, name)
|
|
753
|
+
elif args.action == "usage":
|
|
754
|
+
# Resource counters require a running guest; info remains metadata-only.
|
|
755
|
+
response = execute(location, name, ["python3", "-c", USAGE], capture=True)
|
|
756
|
+
if response.returncode:
|
|
757
|
+
raise RuntimeError(response.stderr.decode().strip())
|
|
758
|
+
print(response.stdout.decode().strip())
|
|
759
|
+
elif args.action == "destroy":
|
|
760
|
+
incus(location, "delete", name, "--force", locked=True)
|
|
761
|
+
if any(item["name"] == name for item in inventory(location)):
|
|
762
|
+
raise RuntimeError("Deletion was not confirmed")
|
|
763
|
+
print("Destroyed " + args.box)
|
|
764
|
+
elif args.action == "stop":
|
|
765
|
+
transaction(
|
|
766
|
+
location,
|
|
767
|
+
'disposable=$(incus --project raft config get "$1" user.raft.disposable); '
|
|
768
|
+
'if [ "$disposable" = true ]; then incus --project raft delete "$1" --force; '
|
|
769
|
+
'else incus --project raft stop "$1" --timeout 30; '
|
|
770
|
+
'incus --project raft config unset "$1" user.raft.expires; '
|
|
771
|
+
'incus --project raft config set "$1" user.raft.stopped-at="$(date +%s)"; fi',
|
|
772
|
+
name,
|
|
773
|
+
)
|
|
774
|
+
elif args.action == "resume":
|
|
775
|
+
overrides = []
|
|
776
|
+
for field in ["cpu", "memory"]:
|
|
777
|
+
value = getattr(args, field)
|
|
778
|
+
if value is not None:
|
|
779
|
+
overrides.append(f"limits.{field}={value}")
|
|
780
|
+
transaction(
|
|
781
|
+
location,
|
|
782
|
+
REQUIRE_STOPPED + 'name="$1"; lifetime="$2"; shift 2; '
|
|
783
|
+
'if [ "$#" -gt 0 ]; then incus --project raft config set "$name" "$@"; fi; '
|
|
784
|
+
'incus --project raft start "$name"; '
|
|
785
|
+
'incus --project raft config set "$name" user.raft.stopped-at=; '
|
|
786
|
+
'incus --project raft config set "$name" user.raft.expires="$(( $(date +%s) + lifetime ))"',
|
|
787
|
+
name,
|
|
788
|
+
str(args.ttl),
|
|
789
|
+
*overrides,
|
|
790
|
+
)
|
|
791
|
+
elif args.action == "extend":
|
|
792
|
+
transaction(
|
|
793
|
+
location,
|
|
794
|
+
'test "$(incus --project raft list "$1" --fast --format json | '
|
|
795
|
+
'python3 -c \'import json,sys; print(next(x["status"] for x in json.load(sys.stdin) '
|
|
796
|
+
'if x["name"] == sys.argv[1]))\' "$1")" = Running '
|
|
797
|
+
'|| { echo "Extend requires a running box; use resume for a stopped box" >&2; exit 1; }; '
|
|
798
|
+
'incus --project raft config set "$1" user.raft.expires="$(( $(date +%s) + $2 ))"',
|
|
799
|
+
name,
|
|
800
|
+
str(args.ttl),
|
|
801
|
+
)
|
|
802
|
+
elif args.action == "ssh":
|
|
803
|
+
return remote(
|
|
804
|
+
location,
|
|
805
|
+
["exec", name, "--cwd", "/workspace", "--", "/bin/bash", "-l"],
|
|
806
|
+
capture=False,
|
|
807
|
+
tty=True,
|
|
808
|
+
).returncode
|
|
809
|
+
elif args.action == "exec":
|
|
810
|
+
payload = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
811
|
+
if not payload:
|
|
812
|
+
raise ValueError("Supply a command after --")
|
|
813
|
+
if args.detach:
|
|
814
|
+
job = "rfcmd-" + uuid.uuid4().hex
|
|
815
|
+
response = execute(
|
|
816
|
+
location,
|
|
817
|
+
name,
|
|
818
|
+
[
|
|
819
|
+
"systemd-run",
|
|
820
|
+
"--quiet",
|
|
821
|
+
"--unit",
|
|
822
|
+
job,
|
|
823
|
+
"--property=WorkingDirectory=/workspace",
|
|
824
|
+
"--property=RemainAfterExit=yes",
|
|
825
|
+
"--",
|
|
826
|
+
*payload,
|
|
827
|
+
],
|
|
828
|
+
capture=True,
|
|
829
|
+
)
|
|
830
|
+
if response.returncode:
|
|
831
|
+
raise RuntimeError(response.stderr.decode().strip())
|
|
832
|
+
print(job)
|
|
833
|
+
else:
|
|
834
|
+
return execute(location, name, payload).returncode
|
|
835
|
+
elif args.action in ["upload", "download"]:
|
|
836
|
+
guest_path = args.destination if args.action == "upload" else args.source
|
|
837
|
+
if not guest_path.startswith("/"):
|
|
838
|
+
raise ValueError("Guest file path must be absolute")
|
|
839
|
+
return transfer(args, location, name)
|
|
840
|
+
elif args.action == "snapshot":
|
|
841
|
+
snapshot_name(args.name)
|
|
842
|
+
print(
|
|
843
|
+
transaction(
|
|
844
|
+
location,
|
|
845
|
+
REQUIRE_STOPPED + 'incus --project raft snapshot create "$1" "$2"',
|
|
846
|
+
name,
|
|
847
|
+
args.name,
|
|
848
|
+
).strip()
|
|
849
|
+
)
|
|
850
|
+
elif args.action == "snapshot-delete":
|
|
851
|
+
snapshot_name(args.name)
|
|
852
|
+
incus(location, "snapshot", "delete", name, args.name, locked=True)
|
|
853
|
+
elif args.action == "snapshots":
|
|
854
|
+
print(incus(location, "info", name).strip())
|
|
855
|
+
elif args.action == "restore":
|
|
856
|
+
snapshot_name(args.name)
|
|
857
|
+
# Imported/forked snapshots carry source configuration. Restore files,
|
|
858
|
+
# keeping the destination's network identity and explicitly chosen limits.
|
|
859
|
+
transaction(
|
|
860
|
+
location,
|
|
861
|
+
REQUIRE_STOPPED + 'incus --project raft snapshot restore "$1" "$2" --diskonly',
|
|
862
|
+
name,
|
|
863
|
+
args.name,
|
|
864
|
+
)
|
|
865
|
+
elif args.action == "fork":
|
|
866
|
+
child = "rf-" + uuid.uuid4().hex[:16]
|
|
867
|
+
transaction(
|
|
868
|
+
location,
|
|
869
|
+
(CHECK_WORKER if args.disposable else "")
|
|
870
|
+
+ REQUIRE_STOPPED
|
|
871
|
+
+ ADMISSION
|
|
872
|
+
+ 'name="$2"; lifetime="$3"; '
|
|
873
|
+
'source="$1"; shift 3; incus --project raft copy "$source" "$name" --instance-only "$@"; '
|
|
874
|
+
'incus --project raft start "$name"; '
|
|
875
|
+
'incus --project raft config set "$name" user.raft.expires="$(( $(date +%s) + lifetime ))"',
|
|
876
|
+
name,
|
|
877
|
+
child,
|
|
878
|
+
str(args.ttl),
|
|
879
|
+
"-c",
|
|
880
|
+
"user.raft.expires=",
|
|
881
|
+
"-c",
|
|
882
|
+
"user.raft.stopped-at=",
|
|
883
|
+
"-c",
|
|
884
|
+
f"user.raft.disposable={'true' if args.disposable else 'false'}",
|
|
885
|
+
"-c",
|
|
886
|
+
"volatile.eth0.hwaddr=",
|
|
887
|
+
*sizing(args),
|
|
888
|
+
)
|
|
889
|
+
print(location + ":" + child)
|
|
890
|
+
elif args.action in ["logs", "status", "cancel"]:
|
|
891
|
+
if not re.fullmatch(r"rfcmd-[a-f0-9]{32}", args.job):
|
|
892
|
+
raise ValueError("Use the job ID returned by exec --detach")
|
|
893
|
+
loaded = execute(
|
|
894
|
+
location,
|
|
895
|
+
name,
|
|
896
|
+
["systemctl", "show", args.job, "-p", "LoadState", "--value"],
|
|
897
|
+
capture=True,
|
|
898
|
+
)
|
|
899
|
+
if loaded.returncode or loaded.stdout.strip() != b"loaded":
|
|
900
|
+
# Cancelled transient units unload, but their journal keeps history.
|
|
901
|
+
# Live units remain recognizable even after old logs are rotated away.
|
|
902
|
+
recorded = execute(
|
|
903
|
+
location,
|
|
904
|
+
name,
|
|
905
|
+
["journalctl", "--no-pager", "--quiet", "-o", "json", "-n", "1", "-u", args.job],
|
|
906
|
+
capture=True,
|
|
907
|
+
)
|
|
908
|
+
if recorded.returncode or not recorded.stdout.strip():
|
|
909
|
+
raise ValueError("Detached job not found in this running box")
|
|
910
|
+
if args.action == "cancel":
|
|
911
|
+
return execute(location, name, ["systemctl", "stop", args.job]).returncode
|
|
912
|
+
command = (
|
|
913
|
+
["journalctl", "--no-pager", "-o", "cat", "-u", args.job]
|
|
914
|
+
if args.action == "logs"
|
|
915
|
+
else [
|
|
916
|
+
"systemctl",
|
|
917
|
+
"show",
|
|
918
|
+
args.job,
|
|
919
|
+
"-p",
|
|
920
|
+
"ActiveState",
|
|
921
|
+
"-p",
|
|
922
|
+
"SubState",
|
|
923
|
+
"-p",
|
|
924
|
+
"ExecMainStatus",
|
|
925
|
+
]
|
|
926
|
+
)
|
|
927
|
+
return execute(location, name, command).returncode
|
|
928
|
+
elif args.action in ["forward", "desktop"]:
|
|
929
|
+
port = args.remote if args.action == "forward" else 6080
|
|
930
|
+
if not 1 <= port <= 65535 or not 1 <= args.local <= 65535:
|
|
931
|
+
raise ValueError("Ports must be between 1 and 65535")
|
|
932
|
+
if args.action == "desktop":
|
|
933
|
+
response = execute(location, name, ["systemctl", "start", "raft-desktop"], capture=True)
|
|
934
|
+
if response.returncode:
|
|
935
|
+
raise RuntimeError(response.stderr.decode().strip())
|
|
936
|
+
return forward(location, name, port, args.local)
|
|
937
|
+
return 0
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def main():
|
|
941
|
+
if sys.version_info < (3, 11, 8):
|
|
942
|
+
print("raft: Python 3.11.8 or newer is required", file=sys.stderr)
|
|
943
|
+
return 1
|
|
944
|
+
os.umask(0o077)
|
|
945
|
+
try:
|
|
946
|
+
return dispatch(parser().parse_args())
|
|
947
|
+
except (
|
|
948
|
+
OSError,
|
|
949
|
+
ValueError,
|
|
950
|
+
RuntimeError,
|
|
951
|
+
tarfile.TarError,
|
|
952
|
+
subprocess.CalledProcessError,
|
|
953
|
+
) as error:
|
|
954
|
+
print(f"raft: {error}", file=sys.stderr)
|
|
955
|
+
return 1
|
|
956
|
+
except KeyboardInterrupt:
|
|
957
|
+
return 130
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
if __name__ == "__main__":
|
|
961
|
+
sys.exit(main())
|