@microck/raft 0.4.0 → 0.6.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 +30 -14
- package/deploy/deploy.py +112 -19
- package/deploy/install-incus.sh +10 -0
- package/deploy/raft-expire.py +109 -31
- package/deploy/verify-controls.py +34 -48
- package/deploy/verify-features.py +592 -3
- package/deploy/verify-fresh-host.py +64 -1
- package/deploy/verify-limits.py +327 -72
- package/deploy/verify-live.py +56 -31
- package/deploy/verify-self-update.py +372 -0
- package/docs/boat-capabilities.md +8 -7
- package/docs/boot-performance.mdx +1 -1
- package/docs/contract.md +33 -23
- package/docs/first-box.mdx +1 -1
- package/docs/guides/backup-recovery.mdx +2 -2
- package/docs/guides/config-policy.mdx +253 -0
- package/docs/guides/docker.mdx +9 -1
- package/docs/guides/long-running-services.mdx +10 -4
- package/docs/guides/meta.json +1 -0
- package/docs/guides/project-setup.mdx +4 -3
- package/docs/guides/scripting-ci.mdx +27 -10
- package/docs/guides/snapshots-forks.mdx +9 -3
- package/docs/guides/troubleshooting.mdx +37 -8
- package/docs/guides/upgrades.mdx +17 -5
- package/docs/index.mdx +1 -0
- package/docs/operations.mdx +16 -14
- package/docs/reference/capacity.mdx +198 -0
- package/docs/reference/cli.md +178 -35
- package/docs/reference/configuration.mdx +40 -2
- package/docs/reference/meta.json +1 -1
- package/docs/releases/0.5.0.md +19 -0
- package/docs/releases/0.6.0.md +16 -0
- package/docs/requirements.mdx +2 -2
- package/docs/verification.mdx +14 -8
- package/package.json +1 -1
- package/raft.py +1181 -128
- package/skills/raft-cli/SKILL.md +79 -13
- package/docs/reference/capacity-limits.mdx +0 -92
package/raft.py
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
5
|
import fcntl
|
|
6
|
+
import getpass
|
|
7
|
+
import hashlib
|
|
6
8
|
import importlib.util
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
@@ -10,26 +12,38 @@ from pathlib import Path
|
|
|
10
12
|
import re
|
|
11
13
|
import shlex
|
|
12
14
|
import shutil
|
|
15
|
+
import socket
|
|
13
16
|
import subprocess
|
|
14
17
|
import sys
|
|
15
18
|
import tempfile
|
|
16
19
|
import tarfile
|
|
20
|
+
import threading
|
|
17
21
|
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
18
24
|
import uuid
|
|
19
25
|
|
|
20
26
|
import raft_files
|
|
21
27
|
|
|
22
|
-
__version__ = "0.
|
|
28
|
+
__version__ = "0.6.0"
|
|
23
29
|
|
|
24
30
|
CONFIG = Path.home() / ".config/raft/incus.json"
|
|
31
|
+
# Controller defaults for new live apart from the location inventory, so location
|
|
32
|
+
# names can never collide with setting names.
|
|
33
|
+
DEFAULTS_CONFIG = Path.home() / ".config/raft/config.json"
|
|
25
34
|
PROJECT = "raft"
|
|
26
|
-
SAVED_BOX_LIMIT = 4
|
|
27
35
|
LOCATION_NAME = r"[a-zA-Z0-9][a-zA-Z0-9_-]*"
|
|
28
36
|
GIB = 1024**3
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
# Boxes use the shared Btrfs pool unless `new --disk` gives them their own disk:
|
|
38
|
+
# a fixed-size thin logical volume in this LVM pool. Deployment always creates the
|
|
39
|
+
# shared pool; the own-disk pool exists only on hosts deployed with --disk-pool.
|
|
40
|
+
SHARED_POOL = "raft-data"
|
|
41
|
+
BOX_DISK_POOL = "raft-disks"
|
|
42
|
+
# Size of a box created from the image without --cpu, --memory or a config.json
|
|
43
|
+
# default. Boxes created from a snapshot keep the snapshot's CPU, RAM and disk
|
|
44
|
+
# unless a flag overrides it; config.json defaults never do.
|
|
45
|
+
DEFAULT_CPU = 1
|
|
46
|
+
DEFAULT_MEMORY = "2GiB"
|
|
33
47
|
# Box notes follow one template so any agent can tell why a box exists, who uses
|
|
34
48
|
# it and what happens to it next. Each value is free text; purpose is required.
|
|
35
49
|
BOX_NOTE_FIELDS = ["purpose", "owner", "next"]
|
|
@@ -45,7 +59,6 @@ print(json.dumps({
|
|
|
45
59
|
'effectiveCpus': (group / 'cpuset.cpus.effective').read_text().strip(),
|
|
46
60
|
'filesystemUsedBytes': disk.used,
|
|
47
61
|
'filesystemAvailableBytes': disk.free,
|
|
48
|
-
'filesystemScope': 'shared Raft pool, not per-box usage',
|
|
49
62
|
}))
|
|
50
63
|
"""
|
|
51
64
|
|
|
@@ -71,35 +84,43 @@ def settings(location):
|
|
|
71
84
|
return inventory[location]
|
|
72
85
|
|
|
73
86
|
|
|
74
|
-
def
|
|
75
|
-
"""Apply change(
|
|
87
|
+
def edit_json(path, load, change):
|
|
88
|
+
"""Apply change(document) to a controller JSON file and save it.
|
|
89
|
+
|
|
90
|
+
load() reads and validates the current file. An empty document removes the file.
|
|
91
|
+
"""
|
|
76
92
|
# main() sets umask 077, so a new configuration directory is private.
|
|
77
|
-
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
94
|
# Hold a controller lock across read, edit and replace so concurrent edits
|
|
79
95
|
# cannot drop each other. Readers need no lock because replacement is atomic.
|
|
80
|
-
with (
|
|
96
|
+
with (path.parent / f".{path.name}.lock").open("w") as lock:
|
|
81
97
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
82
98
|
# Onboarding can start on a controller that has no configuration yet.
|
|
83
|
-
|
|
84
|
-
change(
|
|
85
|
-
if not
|
|
86
|
-
# Removing the
|
|
87
|
-
|
|
99
|
+
document = load() if path.exists() else {}
|
|
100
|
+
change(document)
|
|
101
|
+
if not document:
|
|
102
|
+
# Removing the last entry restores the unconfigured state.
|
|
103
|
+
path.unlink(missing_ok=True)
|
|
88
104
|
return
|
|
89
105
|
# Key order is preserved because the first location is the default for new.
|
|
90
|
-
# Replace atomically so an interrupted write cannot truncate the
|
|
106
|
+
# Replace atomically so an interrupted write cannot truncate the file;
|
|
91
107
|
# NamedTemporaryFile creates the staged file with mode 0600.
|
|
92
|
-
staged = tempfile.NamedTemporaryFile("w", dir=
|
|
108
|
+
staged = tempfile.NamedTemporaryFile("w", dir=path.parent, prefix=".raft-", delete=False)
|
|
93
109
|
try:
|
|
94
110
|
with staged:
|
|
95
|
-
json.dump(
|
|
111
|
+
json.dump(document, staged, indent=2)
|
|
96
112
|
staged.write("\n")
|
|
97
|
-
os.replace(staged.name,
|
|
113
|
+
os.replace(staged.name, path)
|
|
98
114
|
finally:
|
|
99
115
|
# A no-op after a successful replace; removes the file after a failure.
|
|
100
116
|
Path(staged.name).unlink(missing_ok=True)
|
|
101
117
|
|
|
102
118
|
|
|
119
|
+
def edit_configuration(change):
|
|
120
|
+
"""Apply change(inventory) to the location inventory and save it."""
|
|
121
|
+
edit_json(CONFIG, configuration, change)
|
|
122
|
+
|
|
123
|
+
|
|
103
124
|
def set_note(location, text):
|
|
104
125
|
"""Record operator guidance for a location. Raft shows notes but never enforces them."""
|
|
105
126
|
if text is not None and not text.strip():
|
|
@@ -136,6 +157,222 @@ def read_box_note(box):
|
|
|
136
157
|
return json.loads(stored) if stored else None
|
|
137
158
|
|
|
138
159
|
|
|
160
|
+
def cpu_count(value):
|
|
161
|
+
"""Parse --cpu: any positive whole number of CPUs."""
|
|
162
|
+
if not re.fullmatch(r"[1-9][0-9]*", value):
|
|
163
|
+
raise argparse.ArgumentTypeError("CPU count must be a positive whole number")
|
|
164
|
+
return int(value)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def gib_size(value):
|
|
168
|
+
"""Parse --memory, --disk and --disk-pool. Whole GiB like 16GiB is the one accepted form."""
|
|
169
|
+
if not re.fullmatch(r"[1-9][0-9]*GiB", value):
|
|
170
|
+
raise argparse.ArgumentTypeError("Use a whole number of GiB, e.g. 16GiB")
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def disk_size(value):
|
|
175
|
+
"""Parse new --disk: an own-disk size like 20GiB, or shared for the shared pool."""
|
|
176
|
+
return value if value == "shared" else gib_size(value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def gib(size):
|
|
180
|
+
return int(size.removesuffix("GiB"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def box_disk(box):
|
|
184
|
+
"""Return a box's own disk size, e.g. 20GiB, or "shared" for the shared pool."""
|
|
185
|
+
root = box["expanded_devices"].get("root", {})
|
|
186
|
+
return root.get("size", "") if root.get("pool") == BOX_DISK_POOL else "shared"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Box note policy. A host can require note fields for every box it holds; the
|
|
190
|
+
# policy lives on the host, in the Incus project key user.raft.require (for
|
|
191
|
+
# example "purpose,owner"), so every controller reads the same rule. Each
|
|
192
|
+
# command that creates a box or changes its note checks the policy twice: once
|
|
193
|
+
# from the controller for an actionable message before any work, and again in
|
|
194
|
+
# the host transaction that writes the note, under the host lock. This is a
|
|
195
|
+
# guardrail for agents: SSH and sudo on the host bypass it, and controllers
|
|
196
|
+
# older than this check never run it.
|
|
197
|
+
POLICY_KEY = "user.raft.require"
|
|
198
|
+
# Stopped-box retention, the second host policy: the host expiry worker deletes
|
|
199
|
+
# raft boxes that have been stopped for this many hours. Its .since key holds
|
|
200
|
+
# when the policy was last set, in epoch seconds: the retention clock starts at
|
|
201
|
+
# the later of a box's stop and that time, so setting or shortening a policy
|
|
202
|
+
# never deletes a box sooner than the given hours after the change. Mirrors
|
|
203
|
+
# RETENTION_KEY in deploy/raft-expire.py, which enforces it.
|
|
204
|
+
RETENTION_KEY = "user.raft.delete-stopped-after"
|
|
205
|
+
RETENTION_SINCE_KEY = RETENTION_KEY + ".since"
|
|
206
|
+
|
|
207
|
+
# Host half of the check, run with the required fields and the note the box
|
|
208
|
+
# will carry. Kept short; the controller builds the detailed message.
|
|
209
|
+
CHECK_NOTE_CODE = """import json, sys
|
|
210
|
+
required = [field for field in sys.argv[1].split(",") if field]
|
|
211
|
+
note = json.loads(sys.argv[2]) if sys.argv[2] else {}
|
|
212
|
+
missing = [field for field in required if not note.get(field)]
|
|
213
|
+
if missing:
|
|
214
|
+
raise SystemExit(
|
|
215
|
+
"This host requires box note fields " + ", ".join(required)
|
|
216
|
+
+ " (Incus project key user.raft.require); missing " + ", ".join(missing)
|
|
217
|
+
+ ". Nothing was changed. Give the note with "
|
|
218
|
+
+ " ".join("--" + field for field in required) + " and retry"
|
|
219
|
+
)
|
|
220
|
+
"""
|
|
221
|
+
# Read the policy with errexit active: inside a command substitution used as an
|
|
222
|
+
# argument, a failed read would pass an empty policy and let every note through.
|
|
223
|
+
READ_NOTE_POLICY = f"require=$(incus project get raft {POLICY_KEY})\n"
|
|
224
|
+
# Callers set $note to the exact value they write to user.raft.note.
|
|
225
|
+
CHECK_NOTE = "python3 -c " + shlex.quote(CHECK_NOTE_CODE) + ' "$require" "$note"'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def host_policy(location):
|
|
229
|
+
"""Return a location's host policies and their last recorded changes, as raft config shows them."""
|
|
230
|
+
# `incus query` rejects the --project flag that remote() always passes.
|
|
231
|
+
projects = json.loads(incus(location, "project", "list", "--format", "json"))
|
|
232
|
+
config = next(item for item in projects if item["name"] == PROJECT)["config"]
|
|
233
|
+
stored = config.get(POLICY_KEY, "")
|
|
234
|
+
required = [field for field in stored.split(",") if field]
|
|
235
|
+
unknown = [field for field in required if field not in BOX_NOTE_FIELDS]
|
|
236
|
+
if unknown:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"{location}: host policy {POLICY_KEY}={stored!r} names unknown note fields; "
|
|
239
|
+
f"fix it with raft policy {location} --require FIELDS or --require none"
|
|
240
|
+
)
|
|
241
|
+
hours = config.get(RETENTION_KEY, "")
|
|
242
|
+
since = config.get(RETENTION_SINCE_KEY, "")
|
|
243
|
+
# Raft writes both as whole numbers; anything else came from a native Incus edit.
|
|
244
|
+
if hours and not (re.fullmatch(r"[1-9][0-9]*", hours) and re.fullmatch(r"[0-9]+", since)):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"{location}: host policy {RETENTION_KEY}={hours!r} ({RETENTION_SINCE_KEY}={since!r}) "
|
|
247
|
+
f"is not a whole number of hours; fix it with raft policy {location} "
|
|
248
|
+
"--delete-stopped-after HOURS or --delete-stopped-after never"
|
|
249
|
+
)
|
|
250
|
+
return {
|
|
251
|
+
"require": required,
|
|
252
|
+
"requireChanged": config.get(POLICY_KEY + ".changed", ""),
|
|
253
|
+
"deleteStoppedAfterHours": int(hours) if hours else None,
|
|
254
|
+
"deleteStoppedAfterSince": int(since) if hours else None,
|
|
255
|
+
"deleteStoppedAfterChanged": config.get(RETENTION_KEY + ".changed", ""),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def delete_at(box, policy):
|
|
260
|
+
"""When the host worker deletes a stopped box under the retention policy, or None.
|
|
261
|
+
|
|
262
|
+
Must match the deletion rule in deploy/raft-expire.py. A stopped box without
|
|
263
|
+
a recorded stop time has no deadline until the worker records one.
|
|
264
|
+
"""
|
|
265
|
+
hours = policy["deleteStoppedAfterHours"]
|
|
266
|
+
stopped = box["config"].get("user.raft.stopped-at")
|
|
267
|
+
if box["status"] != "Stopped" or hours is None or not stopped:
|
|
268
|
+
return None
|
|
269
|
+
return max(int(stopped), policy["deleteStoppedAfterSince"]) + hours * 3600
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def note_problem(location, required, note, command, hint=""):
|
|
273
|
+
"""Describe how note falls short of the required fields, or return None."""
|
|
274
|
+
missing = [field for field in required if not (note or {}).get(field)]
|
|
275
|
+
if not missing:
|
|
276
|
+
return None
|
|
277
|
+
flags = " and ".join("--" + field for field in missing)
|
|
278
|
+
usage = " ".join(
|
|
279
|
+
f'--{field} "..."' if field in required else f'[--{field} "..."]'
|
|
280
|
+
for field in BOX_NOTE_FIELDS
|
|
281
|
+
)
|
|
282
|
+
return (
|
|
283
|
+
f"location {location} requires {flags} (host policy).{hint} "
|
|
284
|
+
f"Add {'it' if len(missing) == 1 else 'them'}: raft {command} {usage}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def require_note(location, note, command, hint=""):
|
|
289
|
+
"""Fail before any host change when note lacks a field the location requires."""
|
|
290
|
+
problem = note_problem(location, host_policy(location)["require"], note, command, hint)
|
|
291
|
+
if problem:
|
|
292
|
+
raise ValueError(problem)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def note_fields(text):
|
|
296
|
+
"""Parse --require FIELDS into BOX_NOTE_FIELDS order; none means no required fields."""
|
|
297
|
+
if text == "none":
|
|
298
|
+
return []
|
|
299
|
+
fields = [field.strip() for field in text.split(",") if field.strip()]
|
|
300
|
+
unknown = [field for field in fields if field not in BOX_NOTE_FIELDS]
|
|
301
|
+
if unknown or not fields:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
"Give note fields from " + ", ".join(BOX_NOTE_FIELDS) + ", e.g. --require purpose,owner"
|
|
304
|
+
)
|
|
305
|
+
if "purpose" not in fields:
|
|
306
|
+
# Every box note has a purpose, so a policy without it would be misleading.
|
|
307
|
+
raise ValueError("Box notes always need purpose; include it, e.g. --require purpose,owner")
|
|
308
|
+
return [field for field in BOX_NOTE_FIELDS if field in fields]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def retention_hours(text):
|
|
312
|
+
"""Parse --delete-stopped-after HOURS: a positive whole number, or never for no retention."""
|
|
313
|
+
if text == "never":
|
|
314
|
+
return None
|
|
315
|
+
if not re.fullmatch(r"[1-9][0-9]*", text):
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"Give a positive whole number of hours, e.g. --delete-stopped-after 48, or never"
|
|
318
|
+
)
|
|
319
|
+
return int(text)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def set_policy(location, key, value, journal, check=""):
|
|
323
|
+
"""Set one host policy key to value, empty to clear it, and record the change.
|
|
324
|
+
|
|
325
|
+
Returns the previous value. journal names the policy in the host journal entry;
|
|
326
|
+
check is shell that must pass first, under the same lock.
|
|
327
|
+
"""
|
|
328
|
+
# Who and when is recorded on the host beside the policy and in its journal.
|
|
329
|
+
# The controller identity is self-reported; SUDO_USER is the SSH account.
|
|
330
|
+
# Retention also records when it was set, for the worker's clock (see RETENTION_KEY).
|
|
331
|
+
since = f' {RETENTION_SINCE_KEY}="$([ -z "$1" ] || date +%s)"' if key == RETENTION_KEY else ""
|
|
332
|
+
return transaction(
|
|
333
|
+
location,
|
|
334
|
+
check + f"before=$(incus project get raft {key})\n"
|
|
335
|
+
'changed="$(date -u +%Y-%m-%dT%H:%M:%SZ) by ${SUDO_USER:-root} from $2"\n'
|
|
336
|
+
f'incus project set raft {key}="$1" {key}.changed="$changed"{since}\n'
|
|
337
|
+
f"logger -t raft \"{journal}: '$before' -> '$1' ($changed)\"\n"
|
|
338
|
+
"printf '%s\\n' \"$before\"",
|
|
339
|
+
value,
|
|
340
|
+
f"{getpass.getuser()}@{socket.gethostname()}",
|
|
341
|
+
).strip()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def change_policy(args):
|
|
345
|
+
"""Apply raft policy: each given option sets or clears its own policy independently."""
|
|
346
|
+
if args.require is None and args.delete_stopped_after is None:
|
|
347
|
+
raise ValueError("Give --require FIELDS|none, --delete-stopped-after HOURS|never, or both")
|
|
348
|
+
settings(args.location)
|
|
349
|
+
# Parse every option before the first host change. Retention goes first: its
|
|
350
|
+
# worker check is the one step that can still refuse.
|
|
351
|
+
fields = None if args.require is None else note_fields(args.require)
|
|
352
|
+
hours = (
|
|
353
|
+
None if args.delete_stopped_after is None else retention_hours(args.delete_stopped_after)
|
|
354
|
+
)
|
|
355
|
+
if args.delete_stopped_after is not None:
|
|
356
|
+
value = "" if hours is None else str(hours)
|
|
357
|
+
before = set_policy(
|
|
358
|
+
args.location,
|
|
359
|
+
RETENTION_KEY,
|
|
360
|
+
value,
|
|
361
|
+
"Stopped-box retention: hours",
|
|
362
|
+
# Only a current worker enforces retention; clearing is safe on any worker.
|
|
363
|
+
check=CHECK_WORKER if hours else "",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def shown(stored):
|
|
367
|
+
return "never" if not stored else stored + (" hour" if stored == "1" else " hours")
|
|
368
|
+
|
|
369
|
+
print(f"{args.location}: delete stopped boxes after {shown(before)} -> {shown(value)}")
|
|
370
|
+
if fields is not None:
|
|
371
|
+
value = ",".join(fields)
|
|
372
|
+
before = set_policy(args.location, POLICY_KEY, value, "Box note policy: required fields")
|
|
373
|
+
print(f"{args.location}: required note fields {before or 'none'} -> {value or 'none'}")
|
|
374
|
+
|
|
375
|
+
|
|
139
376
|
def receive(command, destination, *, stdin=None):
|
|
140
377
|
"""Write streamed bytes in Python: SSH can exit zero after a local write error."""
|
|
141
378
|
with tempfile.TemporaryFile() as errors:
|
|
@@ -202,7 +439,7 @@ if sudo -n incus project list --format csv -c n 2>/dev/null | grep -qx raft; the
|
|
|
202
439
|
"""
|
|
203
440
|
|
|
204
441
|
|
|
205
|
-
def host_problems(facts):
|
|
442
|
+
def host_problems(facts, disk_pool=None):
|
|
206
443
|
"""Return reasons a probed SSH target cannot become a Raft host; empty means supported."""
|
|
207
444
|
kernel = facts.get("kernel") or "an unknown system"
|
|
208
445
|
if kernel != "Linux":
|
|
@@ -228,10 +465,17 @@ def host_problems(facts):
|
|
|
228
465
|
memory = int(facts.get("memory") or 0) * 1024
|
|
229
466
|
if memory < 7.5 * GIB:
|
|
230
467
|
problems.append(f"Raft hosts need at least 8 GiB RAM; found {memory / GIB:.1f} GiB")
|
|
231
|
-
# Only a fresh deployment creates the 60 GiB pool
|
|
468
|
+
# Only a fresh deployment creates the 60 GiB shared pool, and the own-disk
|
|
469
|
+
# pool when --disk-pool asks for it; deploy.py enforces the same minimum.
|
|
232
470
|
disk = int(facts.get("disk") or 0) * 1024
|
|
233
|
-
|
|
234
|
-
|
|
471
|
+
needed = 80 + (gib(disk_pool) if disk_pool else 0)
|
|
472
|
+
if facts.get("deployed") != "yes" and disk < needed * GIB:
|
|
473
|
+
problems.append(
|
|
474
|
+
f"Fresh hosts need at least {needed} GiB free disk"
|
|
475
|
+
+ (f" (80 GiB plus the {disk_pool} own-disk pool)" if disk_pool else "")
|
|
476
|
+
+ f"; found {disk / GIB:.1f} GiB"
|
|
477
|
+
+ (". A smaller --disk-pool needs less" if disk_pool else "")
|
|
478
|
+
)
|
|
235
479
|
return problems
|
|
236
480
|
|
|
237
481
|
|
|
@@ -253,7 +497,18 @@ def smoke_test(location):
|
|
|
253
497
|
"""Create a disposable box, wait until it runs a command, then delete it."""
|
|
254
498
|
handle = new(
|
|
255
499
|
argparse.Namespace(
|
|
256
|
-
location=location,
|
|
500
|
+
location=location,
|
|
501
|
+
ttl=600,
|
|
502
|
+
cpu=1,
|
|
503
|
+
memory="2GiB",
|
|
504
|
+
# Explicit, so a configured disk default never moves the smoke box off the shared pool.
|
|
505
|
+
disk="shared",
|
|
506
|
+
disposable=True,
|
|
507
|
+
template=None,
|
|
508
|
+
# A full note satisfies any note policy left on a reused deployment.
|
|
509
|
+
purpose="raft host add smoke test",
|
|
510
|
+
owner="raft host add",
|
|
511
|
+
next="Deleted by the smoke test",
|
|
257
512
|
)
|
|
258
513
|
)
|
|
259
514
|
print("Smoke test box: " + handle, flush=True)
|
|
@@ -301,7 +556,7 @@ def host_add(args):
|
|
|
301
556
|
f"Verify the host key interactively, then check `ssh -o BatchMode=yes {args.ssh} true`"
|
|
302
557
|
)
|
|
303
558
|
facts = dict(line.split("=", 1) for line in probe.stdout.splitlines() if "=" in line)
|
|
304
|
-
problems = host_problems(facts)
|
|
559
|
+
problems = host_problems(facts, args.disk_pool)
|
|
305
560
|
if problems:
|
|
306
561
|
raise ValueError(
|
|
307
562
|
f"{args.ssh} cannot host Raft boxes; nothing was changed:\n- " + "\n- ".join(problems)
|
|
@@ -316,13 +571,20 @@ def host_add(args):
|
|
|
316
571
|
print(
|
|
317
572
|
f"Plan for {args.location} ({args.ssh}: Ubuntu {facts['version']} {architecture}, "
|
|
318
573
|
f"{facts['cpus']} CPUs, {int(facts['memory']) * 1024 / GIB:.1f} GiB RAM)\n"
|
|
319
|
-
"- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs and nftables
|
|
574
|
+
"- install Incus 6.0 LTS from the Zabbly apt repository, btrfs-progs, lvm2 and nftables "
|
|
575
|
+
"if missing\n"
|
|
320
576
|
+ (
|
|
321
577
|
"- reuse and verify the existing raft project, raft-data pool and rfbr0 bridge\n"
|
|
322
578
|
if facts["deployed"] == "yes"
|
|
323
579
|
else "- create Incus project raft, 60 GiB Btrfs pool raft-data and bridge rfbr0 "
|
|
324
580
|
"(10.232.0.1/24 with NAT)\n"
|
|
325
581
|
)
|
|
582
|
+
+ (
|
|
583
|
+
f"- create the {args.disk_pool} thin LVM pool raft-disks for own box disks if it is "
|
|
584
|
+
"missing, and allocate its file on the host disk in full\n"
|
|
585
|
+
if args.disk_pool
|
|
586
|
+
else ""
|
|
587
|
+
)
|
|
326
588
|
+ "- install raft-network.service (rfbr0 isolation rules) and raft-expire.timer\n"
|
|
327
589
|
f"- {image}; images are checked by SHA-256 and architecture\n"
|
|
328
590
|
f"- add {args.location} with the image fingerprint to {CONFIG}\n"
|
|
@@ -338,7 +600,7 @@ def host_add(args):
|
|
|
338
600
|
raise ValueError("Review the plan, then rerun with --yes to apply it")
|
|
339
601
|
if input("Apply this plan? [y/N] ").strip().lower() not in {"y", "yes"}:
|
|
340
602
|
raise ValueError("Onboarding cancelled; nothing was changed")
|
|
341
|
-
deployer.deploy(args.location, args.ssh)
|
|
603
|
+
deployer.deploy(args.location, args.ssh, args.disk_pool)
|
|
342
604
|
if args.build:
|
|
343
605
|
# A fresh alias avoids refusing to build beside an earlier raft-dev image.
|
|
344
606
|
alias = "raft-dev-" + uuid.uuid4().hex[:8]
|
|
@@ -392,114 +654,270 @@ def inventory(location):
|
|
|
392
654
|
]
|
|
393
655
|
|
|
394
656
|
|
|
395
|
-
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)))')
|
|
396
|
-
test "$count" -lt {SAVED_BOX_LIMIT} || {{ echo 'Location already holds four boxes' >&2; exit 1; }}
|
|
397
|
-
"""
|
|
398
|
-
|
|
399
|
-
|
|
400
657
|
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")
|
|
401
658
|
test "$state" = Stopped || { echo 'Stop the source box before resume, snapshot, restore, fork or backup' >&2; exit 1; }
|
|
402
659
|
"""
|
|
403
660
|
|
|
404
661
|
|
|
662
|
+
# Refuses a host whose expiry worker speaks another PROTOCOL than this CLI. Bump
|
|
663
|
+
# PROTOCOL here and in deploy/raft-expire.py together whenever the CLI starts to
|
|
664
|
+
# rely on new worker behavior; protocol 2 added stopped-box retention.
|
|
405
665
|
CHECK_WORKER = (
|
|
406
666
|
"python3 -c "
|
|
407
667
|
+ shlex.quote(
|
|
408
668
|
"import runpy; "
|
|
409
669
|
"worker = runpy.run_path('/usr/local/lib/raft/expire-worker'); "
|
|
410
|
-
"\nif worker.get('PROTOCOL') !=
|
|
670
|
+
"\nif worker.get('PROTOCOL') != 2:\n"
|
|
411
671
|
" raise SystemExit('Host expiry worker differs from this CLI; deploy the current Raft checkout first')"
|
|
412
672
|
)
|
|
413
673
|
+ "\n"
|
|
414
674
|
)
|
|
415
675
|
|
|
416
676
|
|
|
417
|
-
|
|
677
|
+
# Host-side accounting for the own-disk pool, shared by capacity reports and the
|
|
678
|
+
# reservation check. It runs as root on the host, where python3 and lvs exist.
|
|
679
|
+
#
|
|
680
|
+
# raft-disks is a thin LVM pool, so forks, templates and snapshots of own-disk
|
|
681
|
+
# boxes are thin snapshots that share blocks instead of copies. Thin volumes can
|
|
682
|
+
# promise more space than the pool holds, and a full thin pool fails every volume
|
|
683
|
+
# in it at once. Raft never lets that happen: a thin volume can never hold more
|
|
684
|
+
# data than its virtual size, so while the virtual sizes of all thin volumes in
|
|
685
|
+
# the pool (boxes, their snapshots, the cached image and anything else) add up to
|
|
686
|
+
# no more than the pool's data space, the pool cannot fill. Every Raft operation
|
|
687
|
+
# that adds a thin volume to raft-disks checks that sum first, under the host lock.
|
|
688
|
+
BOX_DISK_ACCOUNTING = (
|
|
689
|
+
"POOL = "
|
|
690
|
+
+ repr(BOX_DISK_POOL)
|
|
691
|
+
+ """
|
|
692
|
+
import json, subprocess, sys
|
|
693
|
+
GIB = 1024**3
|
|
694
|
+
|
|
695
|
+
def query(path):
|
|
696
|
+
return json.loads(subprocess.run(['incus', 'query', path], check=True, capture_output=True).stdout)
|
|
697
|
+
|
|
698
|
+
def size_bytes(value):
|
|
699
|
+
# Raft sizes own disks in whole GiB; anything else came from a native Incus edit.
|
|
700
|
+
if not value.endswith('GiB') or not value[:-3].isdigit():
|
|
701
|
+
sys.exit(f'Unexpected own-disk size {value!r}; inspect it with incus')
|
|
702
|
+
return int(value[:-3]) * GIB
|
|
703
|
+
|
|
704
|
+
def box_disk_pool():
|
|
705
|
+
# Own box disks are opt-in per host, so a missing pool is a normal state.
|
|
706
|
+
if '/1.0/storage-pools/' + POOL not in query('/1.0/storage-pools'):
|
|
707
|
+
return None
|
|
708
|
+
config = query('/1.0/storage-pools/' + POOL)['config']
|
|
709
|
+
thinpool = config['lvm.thinpool_name']
|
|
710
|
+
columns = 'lv_name,lv_size,pool_lv,data_percent,metadata_percent'
|
|
711
|
+
report = subprocess.run(['lvs', '--reportformat', 'json', '--units', 'b', '--nosuffix', '-o', columns, config['lvm.vg_name']], check=True, capture_output=True)
|
|
712
|
+
volumes = json.loads(report.stdout)['report'][0]['lv']
|
|
713
|
+
pool = next(volume for volume in volumes if volume['lv_name'] == thinpool)
|
|
714
|
+
# LVM reports usage only while the thin pool is active; reservations never need it.
|
|
715
|
+
active = pool['data_percent'] != ''
|
|
716
|
+
total = int(pool['lv_size'])
|
|
717
|
+
return {
|
|
718
|
+
'totalBytes': total,
|
|
719
|
+
'reservedBytes': sum(int(volume['lv_size']) for volume in volumes if volume['pool_lv'] == thinpool),
|
|
720
|
+
'usedBytes': int(total * float(pool['data_percent']) / 100) if active else None,
|
|
721
|
+
'metadataUsedPercent': float(pool['metadata_percent']) if active else None,
|
|
722
|
+
# Incus caches the image as a thin volume of the pool's volume.size, or
|
|
723
|
+
# its 10 GiB default, and starts each own-disk box as a snapshot of it.
|
|
724
|
+
'imageVolumeBytes': size_bytes(config.get('volume.size', '10GiB')),
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
def reserve(location, pool, added, origin=''):
|
|
728
|
+
# Exit with the reason unless thin volumes of the added sizes fit in full.
|
|
729
|
+
# origin, when given, is a sentence saying where the requested size came from.
|
|
730
|
+
suffix = '. ' + origin if origin else ''
|
|
731
|
+
if added and pool is None:
|
|
732
|
+
sys.exit(
|
|
733
|
+
f'{location} has no {POOL} pool, so own box disks are not enabled there; nothing was '
|
|
734
|
+
f'created. Enable them with python3 deploy/deploy.py --location {location} '
|
|
735
|
+
'--disk-pool SIZE' + suffix
|
|
736
|
+
)
|
|
737
|
+
if added and pool['reservedBytes'] + sum(added) > pool['totalBytes']:
|
|
738
|
+
sys.exit(
|
|
739
|
+
f'{location}: own box disks would need {sum(added) / GIB:.1f} GiB more of {POOL}, '
|
|
740
|
+
f'which has {(pool["totalBytes"] - pool["reservedBytes"]) / GIB:.1f} of '
|
|
741
|
+
f'{pool["totalBytes"] / GIB:.1f} GiB unreserved; nothing was created. Destroy '
|
|
742
|
+
'own-disk boxes or snapshots you no longer need, or use a smaller disk or the '
|
|
743
|
+
'shared pool' + suffix
|
|
744
|
+
)
|
|
745
|
+
"""
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
HOST_RESOURCES = (
|
|
749
|
+
BOX_DISK_ACCOUNTING
|
|
750
|
+
+ """
|
|
751
|
+
import os, shutil
|
|
418
752
|
from pathlib import Path
|
|
419
753
|
memory = dict(line.split()[:2] for line in Path('/proc/meminfo').read_text().splitlines())
|
|
420
754
|
disk = shutil.disk_usage('/var/lib/incus')
|
|
421
|
-
|
|
755
|
+
space = query('/1.0/storage-pools/raft-data/resources')['space']
|
|
756
|
+
own = box_disk_pool()
|
|
757
|
+
if own is not None:
|
|
758
|
+
del own['imageVolumeBytes']
|
|
422
759
|
print(json.dumps({
|
|
423
760
|
'cpus': len(os.sched_getaffinity(0)),
|
|
424
761
|
'memoryTotalBytes': int(memory['MemTotal:']) * 1024,
|
|
425
762
|
'memoryAvailableBytes': int(memory['MemAvailable:']) * 1024,
|
|
426
763
|
'hostDiskFreeBytes': disk.free,
|
|
427
|
-
'
|
|
428
|
-
'
|
|
764
|
+
'sharedPool': {'totalBytes': space['total'], 'freeBytes': space['total'] - space['used']},
|
|
765
|
+
'boxDiskPool': own,
|
|
429
766
|
}))
|
|
430
767
|
"""
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Reservation check run inside lifecycle transactions before Incus adds a thin
|
|
771
|
+
# volume to raft-disks. Arguments: location (for messages), the requested own-disk
|
|
772
|
+
# size in GiB (0 for none), the source: image:FINGERPRINT, BOX or BOX/SNAPSHOT,
|
|
773
|
+
# and the origin sentence for refusals (empty when the size is a flag).
|
|
774
|
+
# A new volume starts as a thin snapshot of its source and is then resized to the
|
|
775
|
+
# requested size, so it needs the larger of the two. Copies of shared-pool boxes
|
|
776
|
+
# without --disk never touch raft-disks and need nothing.
|
|
777
|
+
RESERVE_BOX_DISK = (
|
|
778
|
+
BOX_DISK_ACCOUNTING
|
|
779
|
+
+ """
|
|
780
|
+
location, requested, source, origin = sys.argv[1], int(sys.argv[2]) * GIB, sys.argv[3], sys.argv[4]
|
|
781
|
+
pool = box_disk_pool()
|
|
782
|
+
added = []
|
|
783
|
+
if source.startswith('image:'):
|
|
784
|
+
if requested:
|
|
785
|
+
image = pool['imageVolumeBytes'] if pool else 0
|
|
786
|
+
added.append(max(requested, image))
|
|
787
|
+
# Incus caches the image in the pool the first time an own-disk box needs it.
|
|
788
|
+
volume = f'/1.0/storage-pools/{POOL}/volumes/image/{source[6:]}'
|
|
789
|
+
if pool and subprocess.run(['incus', 'query', volume], capture_output=True).returncode:
|
|
790
|
+
added.append(image)
|
|
791
|
+
else:
|
|
792
|
+
path = '/1.0/instances/' + source.replace('/', '/snapshots/') + '?project=raft'
|
|
793
|
+
root = query(path)['expanded_devices']['root']
|
|
794
|
+
if root.get('pool') == POOL:
|
|
795
|
+
added.append(max(requested, size_bytes(root['size'])))
|
|
796
|
+
elif requested:
|
|
797
|
+
added.append(requested)
|
|
798
|
+
reserve(location, pool, added, origin)
|
|
799
|
+
"""
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# Prints the pool `raft recover` imports into, from backup/index.yaml, the first
|
|
803
|
+
# entry of every Incus export. Own-disk backups return to raft-disks with their
|
|
804
|
+
# recorded size after the same reservation check: Incus restores the box and each
|
|
805
|
+
# of its snapshots as a thin volume of that size. Other backups use the shared pool.
|
|
806
|
+
RECOVERY_POOL = (
|
|
807
|
+
BOX_DISK_ACCOUNTING
|
|
808
|
+
+ "SHARED_POOL = "
|
|
809
|
+
+ repr(SHARED_POOL)
|
|
810
|
+
+ """
|
|
811
|
+
import tarfile, yaml
|
|
812
|
+
archive, location = sys.argv[1], sys.argv[2]
|
|
813
|
+
with tarfile.open(archive, 'r|gz') as backup:
|
|
814
|
+
entry = backup.next()
|
|
815
|
+
if entry is None or entry.name != 'backup/index.yaml':
|
|
816
|
+
sys.exit('Not an Incus instance backup: backup/index.yaml is not its first entry')
|
|
817
|
+
index = yaml.safe_load(backup.extractfile(entry))
|
|
818
|
+
if index.get('pool') != POOL:
|
|
819
|
+
print(SHARED_POOL)
|
|
820
|
+
sys.exit()
|
|
821
|
+
size = size_bytes(index['config']['container']['expanded_devices']['root']['size'])
|
|
822
|
+
reserve(location, box_disk_pool(), [size] * (1 + len(index.get('snapshots') or [])))
|
|
823
|
+
print(POOL)
|
|
824
|
+
"""
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def reserve_box_disk(location, disk, source, origin=""):
|
|
829
|
+
"""Shell line that refuses an own-disk volume raft-disks could not hold in full."""
|
|
830
|
+
size = str(gib(disk) if disk else 0)
|
|
831
|
+
return shlex.join(["python3", "-c", RESERVE_BOX_DISK, location, size, source, origin]) + "\n"
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
# Agents read capacity output to plan work. Earlier wording that looked like a cap
|
|
835
|
+
# made them skip boxes they needed, so every report leads with this sentence.
|
|
836
|
+
ADVISORY = (
|
|
837
|
+
"Advisory estimate only. Raft enforces no box count or size budget; "
|
|
838
|
+
"create the boxes your task needs."
|
|
839
|
+
)
|
|
431
840
|
|
|
432
841
|
|
|
433
842
|
def capacity(host, boxes):
|
|
434
|
-
"""
|
|
435
|
-
gib = 1024**3
|
|
843
|
+
"""Advisory planning numbers, not a reservation, admission rule or limit."""
|
|
436
844
|
active = [box for box in boxes if box["status"] != "Stopped"]
|
|
437
845
|
cpu_allocated = 0
|
|
438
846
|
memory_allocated = 0
|
|
439
847
|
for box in active:
|
|
440
848
|
limits = box["expanded_config"]
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
849
|
+
# Raft writes whole CPU counts and whole GiB; other forms come from native Incus edits.
|
|
850
|
+
if not re.fullmatch(r"[1-9][0-9]*", limits["limits.cpu"]) or not re.fullmatch(
|
|
851
|
+
r"[1-9][0-9]*GiB", limits["limits.memory"]
|
|
444
852
|
):
|
|
445
|
-
raise ValueError(
|
|
853
|
+
raise ValueError(
|
|
854
|
+
f"Active box {box['name']} has sizing Raft did not set; inspect its Incus configuration"
|
|
855
|
+
)
|
|
446
856
|
cpu_allocated += int(limits["limits.cpu"])
|
|
447
|
-
memory_allocated +=
|
|
448
|
-
memory_reserve = max(2 *
|
|
857
|
+
memory_allocated += gib(limits["limits.memory"]) * GIB
|
|
858
|
+
memory_reserve = max(2 * GIB, host["memoryTotalBytes"] // 10)
|
|
449
859
|
cpu_budget = max(0, host["cpus"] - 1)
|
|
450
860
|
memory_budget = max(0, host["memoryTotalBytes"] - memory_reserve)
|
|
451
|
-
|
|
452
|
-
|
|
861
|
+
cpu_headroom = max(0, cpu_budget - cpu_allocated)
|
|
862
|
+
memory_headroom = max(
|
|
453
863
|
0,
|
|
454
864
|
min(
|
|
455
865
|
memory_budget - memory_allocated,
|
|
456
866
|
host["memoryAvailableBytes"] - memory_reserve,
|
|
457
867
|
),
|
|
458
868
|
)
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
"newBoxes": min(slots, additional),
|
|
476
|
-
}
|
|
477
|
-
)
|
|
869
|
+
# Example shapes double from the 1 CPU / 2 GiB default at 2 GiB per CPU and
|
|
870
|
+
# stop at the largest that fits an empty host. Boxes may use any other size;
|
|
871
|
+
# headroom lets callers work out counts for those.
|
|
872
|
+
estimates = []
|
|
873
|
+
cpu = 1
|
|
874
|
+
while cpu == 1 or (cpu <= cpu_budget and 2 * cpu * GIB <= memory_budget):
|
|
875
|
+
memory = 2 * cpu * GIB
|
|
876
|
+
estimates.append(
|
|
877
|
+
{
|
|
878
|
+
"cpu": cpu,
|
|
879
|
+
"memoryGiB": 2 * cpu,
|
|
880
|
+
"fitEmptyHost": min(cpu_budget // cpu, memory_budget // memory),
|
|
881
|
+
"fitNow": min(cpu_headroom // cpu, memory_headroom // memory),
|
|
882
|
+
}
|
|
883
|
+
)
|
|
884
|
+
cpu *= 2
|
|
478
885
|
warnings = []
|
|
479
886
|
if cpu_allocated > cpu_budget or memory_allocated > memory_budget:
|
|
480
|
-
warnings.append(
|
|
887
|
+
warnings.append(
|
|
888
|
+
"Active boxes may use more CPU or RAM than the host budget; under load they share it"
|
|
889
|
+
)
|
|
481
890
|
if host["memoryAvailableBytes"] < memory_reserve:
|
|
482
891
|
warnings.append(
|
|
483
|
-
"Available host RAM is below the reserve; stop boxes or reduce other workloads"
|
|
892
|
+
"Available host RAM is below the reserve; stop idle boxes or reduce other workloads"
|
|
484
893
|
)
|
|
485
|
-
if host["hostDiskFreeBytes"] < 5 *
|
|
894
|
+
if host["hostDiskFreeBytes"] < 5 * GIB or host["sharedPool"]["freeBytes"] < 5 * GIB:
|
|
486
895
|
warnings.append(
|
|
487
896
|
"Host or shared-pool free disk is below 5 GiB; check image and workspace growth"
|
|
488
897
|
)
|
|
898
|
+
# LVM sizes thin pool metadata for every data block mapped, and reservations
|
|
899
|
+
# keep mappings within that, so this should never fire. It stays because full
|
|
900
|
+
# metadata stops every own-disk box and needs offline repair.
|
|
901
|
+
own = host["boxDiskPool"]
|
|
902
|
+
if own is not None and (own["metadataUsedPercent"] or 0) >= 80:
|
|
903
|
+
warnings.append(
|
|
904
|
+
f"{BOX_DISK_POOL} thin pool metadata is {own['metadataUsedPercent']:.0f}% full; "
|
|
905
|
+
"inspect it with lvs before creating more own-disk boxes"
|
|
906
|
+
)
|
|
489
907
|
return {
|
|
908
|
+
"notice": ADVISORY,
|
|
490
909
|
"host": host,
|
|
491
|
-
"
|
|
492
|
-
"savedBoxes": len(boxes),
|
|
910
|
+
"boxes": len(boxes),
|
|
493
911
|
"activeBoxes": len(active),
|
|
494
|
-
"savedSlots": slots,
|
|
495
912
|
"allocated": {"cpus": cpu_allocated, "memoryBytes": memory_allocated},
|
|
496
913
|
"reserve": {"cpus": 1, "memoryBytes": memory_reserve},
|
|
497
|
-
"
|
|
914
|
+
"headroom": {"cpus": cpu_headroom, "memoryBytes": memory_headroom},
|
|
915
|
+
"estimates": estimates,
|
|
498
916
|
"warnings": warnings,
|
|
499
917
|
}
|
|
500
918
|
|
|
501
919
|
|
|
502
|
-
def
|
|
920
|
+
def host_capacity(location):
|
|
503
921
|
response = subprocess.run(
|
|
504
922
|
[
|
|
505
923
|
"ssh",
|
|
@@ -522,36 +940,47 @@ def host_limits(location):
|
|
|
522
940
|
}
|
|
523
941
|
|
|
524
942
|
|
|
525
|
-
def
|
|
526
|
-
gib = 1024**3
|
|
943
|
+
def print_capacity(report):
|
|
527
944
|
host = report["host"]
|
|
945
|
+
print(report["notice"])
|
|
528
946
|
print(
|
|
529
947
|
f"{report['location']}: {host['cpus']} CPUs, "
|
|
530
|
-
f"{host['memoryTotalBytes'] /
|
|
531
|
-
f"({host['memoryAvailableBytes'] /
|
|
948
|
+
f"{host['memoryTotalBytes'] / GIB:.1f} GiB RAM "
|
|
949
|
+
f"({host['memoryAvailableBytes'] / GIB:.1f} GiB available)"
|
|
532
950
|
)
|
|
533
951
|
if report["note"]:
|
|
534
952
|
print("Note: " + report["note"])
|
|
953
|
+
print(f"Boxes: {report['boxes']} saved, {report['activeBoxes']} active")
|
|
535
954
|
print(
|
|
536
|
-
f"
|
|
537
|
-
f"
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
f"
|
|
541
|
-
f"{report['allocated']['memoryBytes'] / gib:.1f} GiB; "
|
|
542
|
-
f"host reserve: 1 CPU, {report['reserve']['memoryBytes'] / gib:.1f} GiB"
|
|
955
|
+
f"Allocated to active boxes: {report['allocated']['cpus']} CPUs, "
|
|
956
|
+
f"{report['allocated']['memoryBytes'] / GIB:.1f} GiB; "
|
|
957
|
+
f"host reserve: 1 CPU, {report['reserve']['memoryBytes'] / GIB:.1f} GiB; "
|
|
958
|
+
f"headroom: {report['headroom']['cpus']} CPUs, "
|
|
959
|
+
f"{report['headroom']['memoryBytes'] / GIB:.1f} GiB"
|
|
543
960
|
)
|
|
544
961
|
print(
|
|
545
|
-
f"Disk free: {host['
|
|
546
|
-
f"{host['
|
|
962
|
+
f"Disk free: {host['sharedPool']['freeBytes'] / GIB:.1f} of "
|
|
963
|
+
f"{host['sharedPool']['totalBytes'] / GIB:.1f} GiB shared pool, "
|
|
964
|
+
f"{host['hostDiskFreeBytes'] / GIB:.1f} GiB host"
|
|
547
965
|
)
|
|
548
|
-
|
|
549
|
-
|
|
966
|
+
own = host["boxDiskPool"]
|
|
967
|
+
if own is None:
|
|
968
|
+
print("Own box disks: not enabled (redeploy with --disk-pool SIZE)")
|
|
969
|
+
else:
|
|
970
|
+
print(
|
|
971
|
+
f"Own box disks: {(own['totalBytes'] - own['reservedBytes']) / GIB:.1f} of "
|
|
972
|
+
f"{own['totalBytes'] / GIB:.1f} GiB unreserved"
|
|
973
|
+
)
|
|
974
|
+
print("CPU RAM GiB Fit on empty host Fit now")
|
|
975
|
+
for size in report["estimates"]:
|
|
550
976
|
print(
|
|
551
|
-
f"{size['cpu']:>3} {size['memoryGiB']:>7} {size['
|
|
552
|
-
f"{size['
|
|
977
|
+
f"{size['cpu']:>3} {size['memoryGiB']:>7} {size['fitEmptyHost']:>17} "
|
|
978
|
+
f"{size['fitNow']:>7}"
|
|
553
979
|
)
|
|
554
|
-
print(
|
|
980
|
+
print(
|
|
981
|
+
"Counts assume every box uses its full CPU and RAM at once. Boxes share host CPUs, "
|
|
982
|
+
"so more can run when they are not all busy. Any whole CPU count and GiB size works."
|
|
983
|
+
)
|
|
555
984
|
for warning in report["warnings"]:
|
|
556
985
|
print("Warning: " + warning)
|
|
557
986
|
|
|
@@ -578,7 +1007,12 @@ def doctor(location):
|
|
|
578
1007
|
check=True,
|
|
579
1008
|
)
|
|
580
1009
|
print(response.stdout.strip())
|
|
581
|
-
print(incus(location, "storage", "info",
|
|
1010
|
+
print(incus(location, "storage", "info", SHARED_POOL).strip())
|
|
1011
|
+
# Own box disks are opt-in, so a host without their pool is healthy.
|
|
1012
|
+
if BOX_DISK_POOL in incus(location, "storage", "list", "--format", "csv", "-c", "n").split():
|
|
1013
|
+
print(incus(location, "storage", "info", BOX_DISK_POOL).strip())
|
|
1014
|
+
else:
|
|
1015
|
+
print("own box disks: not enabled (redeploy with --disk-pool SIZE)")
|
|
582
1016
|
print(f"{len(inventory(location))} saved boxes; system containers (shared host kernel)")
|
|
583
1017
|
|
|
584
1018
|
|
|
@@ -629,8 +1063,52 @@ def sizing(args):
|
|
|
629
1063
|
return values
|
|
630
1064
|
|
|
631
1065
|
|
|
1066
|
+
def fits_host(args):
|
|
1067
|
+
"""Shell checks that refuse a CPU or RAM limit larger than the whole host.
|
|
1068
|
+
|
|
1069
|
+
Incus accepts such limits and quietly gives the box less, so an agent would
|
|
1070
|
+
plan around resources it never gets. MemTotal is rounded up to whole GiB:
|
|
1071
|
+
an 8 GiB machine reports about 7.7 GiB and must still accept 8GiB. The
|
|
1072
|
+
parsers already restrict both values to whole numbers, so they embed safely.
|
|
1073
|
+
"""
|
|
1074
|
+
checks = ""
|
|
1075
|
+
if args.cpu is not None:
|
|
1076
|
+
checks += (
|
|
1077
|
+
f'test {args.cpu} -le "$(nproc)" || '
|
|
1078
|
+
f'{{ echo "Requested {args.cpu} CPUs, but this host has $(nproc)" >&2; exit 1; }}\n'
|
|
1079
|
+
)
|
|
1080
|
+
if args.memory is not None:
|
|
1081
|
+
checks += (
|
|
1082
|
+
"total=$(awk '/^MemTotal:/ {print int(($2 + 1048575) / 1048576)}' /proc/meminfo)\n"
|
|
1083
|
+
f'test {gib(args.memory)} -le "$total" || '
|
|
1084
|
+
f'{{ echo "Requested {args.memory} RAM, but this host has $total GiB" >&2; exit 1; }}\n'
|
|
1085
|
+
)
|
|
1086
|
+
return checks
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def own_disk(size):
|
|
1090
|
+
"""Incus device overrides that give a box its own fixed-size disk in raft-disks.
|
|
1091
|
+
|
|
1092
|
+
The full root device is spelled out because `incus copy` of a snapshot whose
|
|
1093
|
+
root comes from the profile adds an override as a whole new device.
|
|
1094
|
+
"""
|
|
1095
|
+
return [
|
|
1096
|
+
"-d",
|
|
1097
|
+
"root,type=disk",
|
|
1098
|
+
"-d",
|
|
1099
|
+
"root,path=/",
|
|
1100
|
+
"-d",
|
|
1101
|
+
f"root,pool={BOX_DISK_POOL}",
|
|
1102
|
+
"-d",
|
|
1103
|
+
f"root,size={size}",
|
|
1104
|
+
]
|
|
1105
|
+
|
|
1106
|
+
|
|
632
1107
|
def new(args):
|
|
633
1108
|
box_fields = box_note(args)
|
|
1109
|
+
# Sizes are filled here only for boxes from the image; a box from a snapshot
|
|
1110
|
+
# keeps the snapshot's CPU, RAM and disk unless a flag overrides them.
|
|
1111
|
+
from_config = apply_new_defaults(args)
|
|
634
1112
|
source = None
|
|
635
1113
|
if args.template:
|
|
636
1114
|
handle, separator, snapshot = args.template.partition("/")
|
|
@@ -643,8 +1121,6 @@ def new(args):
|
|
|
643
1121
|
args.location = location
|
|
644
1122
|
instance(location, original)
|
|
645
1123
|
source = original + "/" + snapshot
|
|
646
|
-
if args.location is None:
|
|
647
|
-
args.location = next(iter(configuration()))
|
|
648
1124
|
if source is None:
|
|
649
1125
|
source = settings(args.location).get("image", "")
|
|
650
1126
|
if not isinstance(source, str) or not re.fullmatch(r"[a-f0-9]{64}", source):
|
|
@@ -655,18 +1131,37 @@ def new(args):
|
|
|
655
1131
|
if note:
|
|
656
1132
|
# Stderr keeps stdout limited to the handle for scripts.
|
|
657
1133
|
print(f"{args.location} note: {note}", file=sys.stderr)
|
|
1134
|
+
require_note(args.location, box_fields, "new")
|
|
658
1135
|
name = "rf-" + uuid.uuid4().hex[:16]
|
|
659
1136
|
# Initialize before starting, so a failed start still has its intended policy.
|
|
660
1137
|
transaction(
|
|
661
1138
|
args.location,
|
|
662
1139
|
(CHECK_WORKER if args.disposable else "")
|
|
663
|
-
+
|
|
664
|
-
|
|
1140
|
+
+ fits_host(args)
|
|
1141
|
+
# Plain boxes from the image stay off raft-disks and skip the check.
|
|
1142
|
+
+ (
|
|
1143
|
+
reserve_box_disk(
|
|
1144
|
+
args.location,
|
|
1145
|
+
args.disk,
|
|
1146
|
+
source if args.template else "image:" + source,
|
|
1147
|
+
# A refusal of a configured disk says where the size came from.
|
|
1148
|
+
disk_origin(args.disk) if "disk" in from_config else "",
|
|
1149
|
+
)
|
|
1150
|
+
if args.disk or args.template
|
|
1151
|
+
else ""
|
|
1152
|
+
)
|
|
1153
|
+
+ 'lifetime="$1"; name="$2"; note="$3"; shift 3\n'
|
|
1154
|
+
+ READ_NOTE_POLICY
|
|
1155
|
+
+ CHECK_NOTE
|
|
1156
|
+
# Always set the note, so a box created from a snapshot never inherits the
|
|
1157
|
+
# source note. The host checks the same value it writes.
|
|
1158
|
+
+ '\n"$@" -c user.raft.note="$note"; '
|
|
665
1159
|
'incus --project raft start "$name"; '
|
|
666
1160
|
'incus --project raft config set "$name" '
|
|
667
1161
|
'user.raft.expires="$(( $(date +%s) + lifetime ))"',
|
|
668
1162
|
str(args.ttl),
|
|
669
1163
|
name,
|
|
1164
|
+
json.dumps(box_fields) if box_fields else "",
|
|
670
1165
|
"incus",
|
|
671
1166
|
"--project",
|
|
672
1167
|
PROJECT,
|
|
@@ -682,11 +1177,12 @@ def new(args):
|
|
|
682
1177
|
"-c",
|
|
683
1178
|
f"user.raft.disposable={'true' if args.disposable else 'false'}",
|
|
684
1179
|
"-c",
|
|
685
|
-
# Always set, so a box created from a snapshot never inherits the source note.
|
|
686
|
-
"user.raft.note=" + (json.dumps(box_fields) if box_fields else ""),
|
|
687
|
-
"-c",
|
|
688
1180
|
"volatile.eth0.hwaddr=",
|
|
689
1181
|
*sizing(args),
|
|
1182
|
+
# Without --disk, init uses the profile's shared pool and copy keeps the
|
|
1183
|
+
# snapshot's disk: the shared pool or an own disk of the same size. Copies
|
|
1184
|
+
# within either pool are snapshots that share the source's blocks.
|
|
1185
|
+
*(own_disk(args.disk) if args.disk else []),
|
|
690
1186
|
)
|
|
691
1187
|
return f"{args.location}:{name}"
|
|
692
1188
|
|
|
@@ -786,7 +1282,11 @@ def backup(args, location, name):
|
|
|
786
1282
|
|
|
787
1283
|
|
|
788
1284
|
def recover(args):
|
|
1285
|
+
box_fields = box_note(args)
|
|
789
1286
|
host = settings(args.location)["ssh"]
|
|
1287
|
+
if box_fields:
|
|
1288
|
+
# Without a new note the archived one applies; the host checks it after import.
|
|
1289
|
+
require_note(args.location, box_fields, f"recover {args.source} --location {args.location}")
|
|
790
1290
|
name = "rf-" + uuid.uuid4().hex[:16]
|
|
791
1291
|
response = subprocess.run(
|
|
792
1292
|
["ssh", "-T", "-o", "BatchMode=yes", host, "mktemp /tmp/raft-recovery.XXXXXXXX"],
|
|
@@ -812,12 +1312,24 @@ def recover(args):
|
|
|
812
1312
|
stdin=archive,
|
|
813
1313
|
check=True,
|
|
814
1314
|
)
|
|
1315
|
+
# The archive carries the source note, known only after import. A rejected
|
|
1316
|
+
# note deletes the imported box inside the same locked transaction.
|
|
815
1317
|
transaction(
|
|
816
1318
|
args.location,
|
|
817
|
-
|
|
818
|
-
|
|
1319
|
+
# The reservation check runs first and creates nothing when it refuses.
|
|
1320
|
+
'pool=$(python3 -c "$3" "$1" "$4")\n'
|
|
1321
|
+
+ READ_NOTE_POLICY
|
|
1322
|
+
+ 'incus --project raft import "$1" "$2" --storage "$pool" '
|
|
1323
|
+
"-c user.raft=true -c user.raft.expires= -c user.raft.disposable=false -c user.raft.stopped-at= -c volatile.eth0.hwaddr=\n"
|
|
1324
|
+
'if [ -n "$5" ]; then incus --project raft config set "$2" user.raft.note="$5"; fi\n'
|
|
1325
|
+
'note=$(incus --project raft config get "$2" user.raft.note)\n'
|
|
1326
|
+
+ CHECK_NOTE
|
|
1327
|
+
+ ' || { incus --project raft delete "$2" --force; exit 1; }',
|
|
819
1328
|
staged,
|
|
820
1329
|
name,
|
|
1330
|
+
RECOVERY_POOL,
|
|
1331
|
+
args.location,
|
|
1332
|
+
json.dumps(box_fields) if box_fields else "",
|
|
821
1333
|
)
|
|
822
1334
|
if instance(args.location, name)["status"] != "Stopped":
|
|
823
1335
|
raise RuntimeError("Recovered box unexpectedly running: " + args.location + ":" + name)
|
|
@@ -876,8 +1388,400 @@ def ttl(value):
|
|
|
876
1388
|
return seconds
|
|
877
1389
|
|
|
878
1390
|
|
|
1391
|
+
def location_name(value):
|
|
1392
|
+
if not re.fullmatch(LOCATION_NAME, value):
|
|
1393
|
+
raise ValueError("Location names must use letters, digits, underscores or hyphens")
|
|
1394
|
+
return value
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def boolean(value):
|
|
1398
|
+
"""Parse a true or false setting, e.g. raft config set disposable true."""
|
|
1399
|
+
if value not in {"true", "false"}:
|
|
1400
|
+
raise argparse.ArgumentTypeError("Use true or false")
|
|
1401
|
+
return value == "true"
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def shown_default(value):
|
|
1405
|
+
"""Print a default as it is typed: true and false, not Python's True and False."""
|
|
1406
|
+
return json.dumps(value) if isinstance(value, bool) else value
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
# Options of new that ~/.config/raft/config.json can default, with their built-in
|
|
1410
|
+
# defaults and value checks. Values go through str() first, so a configured value
|
|
1411
|
+
# is accepted exactly when the same text passed as the flag would be. Adding a
|
|
1412
|
+
# defaultable option means adding one entry here and keeping its flag default None.
|
|
1413
|
+
NEW_DEFAULTS = {
|
|
1414
|
+
# None means the first configured location.
|
|
1415
|
+
"location": (None, location_name),
|
|
1416
|
+
"ttl": (600, ttl),
|
|
1417
|
+
"cpu": (DEFAULT_CPU, cpu_count),
|
|
1418
|
+
"memory": (DEFAULT_MEMORY, gib_size),
|
|
1419
|
+
"disk": ("shared", disk_size),
|
|
1420
|
+
# new --disposable and --persistent override it. Unlike the sizes it applies
|
|
1421
|
+
# to new --from too: a snapshot carries no lifecycle, new always sets one.
|
|
1422
|
+
"disposable": (False, boolean),
|
|
1423
|
+
}
|
|
1424
|
+
# A box from a snapshot keeps the snapshot's size, so these defaults, configured
|
|
1425
|
+
# or built-in, apply only to boxes created from the image.
|
|
1426
|
+
SIZE_DEFAULTS = {"cpu", "memory", "disk"}
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def default_value(key, value):
|
|
1430
|
+
"""Validate one configured default; errors name the file and the key."""
|
|
1431
|
+
if key not in NEW_DEFAULTS:
|
|
1432
|
+
raise ValueError(
|
|
1433
|
+
f"{DEFAULTS_CONFIG}: unknown key defaults.{key}; "
|
|
1434
|
+
"supported keys: " + ", ".join(NEW_DEFAULTS)
|
|
1435
|
+
)
|
|
1436
|
+
try:
|
|
1437
|
+
# JSON true and false read back as the true and false text the CLI accepts.
|
|
1438
|
+
return NEW_DEFAULTS[key][1](str(shown_default(value)))
|
|
1439
|
+
except (ValueError, argparse.ArgumentTypeError) as error:
|
|
1440
|
+
raise ValueError(
|
|
1441
|
+
f"{DEFAULTS_CONFIG}: defaults.{key}={value!r} is invalid: {error}"
|
|
1442
|
+
) from None
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def configured_defaults(unchecked=None):
|
|
1446
|
+
"""Return the validated defaults from config.json; a missing file means none.
|
|
1447
|
+
|
|
1448
|
+
The unchecked key is returned as stored, so `raft config set/unset KEY` can
|
|
1449
|
+
repair a file whose only problem is that key.
|
|
1450
|
+
"""
|
|
1451
|
+
if not DEFAULTS_CONFIG.exists():
|
|
1452
|
+
return {}
|
|
1453
|
+
try:
|
|
1454
|
+
document = json.loads(DEFAULTS_CONFIG.read_text())
|
|
1455
|
+
except json.JSONDecodeError as error:
|
|
1456
|
+
raise ValueError(f"{DEFAULTS_CONFIG}: invalid JSON: {error}") from None
|
|
1457
|
+
if (
|
|
1458
|
+
not isinstance(document, dict)
|
|
1459
|
+
or set(document) - {"defaults"}
|
|
1460
|
+
or not isinstance(document.get("defaults", {}), dict)
|
|
1461
|
+
):
|
|
1462
|
+
raise ValueError(f'{DEFAULTS_CONFIG}: expected {{"defaults": {{"KEY": VALUE, ...}}}}')
|
|
1463
|
+
return {
|
|
1464
|
+
key: value if key == unchecked else default_value(key, value)
|
|
1465
|
+
for key, value in document.get("defaults", {}).items()
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def apply_new_defaults(args):
|
|
1470
|
+
"""Fill omitted new options: explicit flag, then config.json, then the built-in default.
|
|
1471
|
+
|
|
1472
|
+
Returns the keys whose value came from config.json. With --from only explicit
|
|
1473
|
+
flags change the size: the snapshot's CPU, RAM and disk beat any default.
|
|
1474
|
+
"""
|
|
1475
|
+
configured = configured_defaults()
|
|
1476
|
+
used = set()
|
|
1477
|
+
for key, (builtin, _) in NEW_DEFAULTS.items():
|
|
1478
|
+
# getattr's default lets internal callers such as smoke_test omit options.
|
|
1479
|
+
if key == "location" or getattr(args, key, None) is not None:
|
|
1480
|
+
continue
|
|
1481
|
+
if args.template and key in SIZE_DEFAULTS:
|
|
1482
|
+
continue
|
|
1483
|
+
if key in configured:
|
|
1484
|
+
used.add(key)
|
|
1485
|
+
setattr(args, key, configured.get(key, builtin))
|
|
1486
|
+
if args.disk == "shared":
|
|
1487
|
+
if args.template:
|
|
1488
|
+
raise ValueError(
|
|
1489
|
+
"--from keeps the snapshot's disk; leave out --disk, or give --disk SIZE "
|
|
1490
|
+
"for an own disk of that size"
|
|
1491
|
+
)
|
|
1492
|
+
# The rest of new() spells the shared pool as no own disk.
|
|
1493
|
+
args.disk = None
|
|
1494
|
+
# A --from template fixes the location, so only plain creation takes a default.
|
|
1495
|
+
if args.location is None and not args.template:
|
|
1496
|
+
if "location" not in configured:
|
|
1497
|
+
args.location = next(iter(configuration()))
|
|
1498
|
+
elif configured["location"] in configuration():
|
|
1499
|
+
args.location = configured["location"]
|
|
1500
|
+
used.add("location")
|
|
1501
|
+
else:
|
|
1502
|
+
raise ValueError(
|
|
1503
|
+
f"{DEFAULTS_CONFIG}: defaults.location names unknown location "
|
|
1504
|
+
f"{configured['location']}; run raft config set location NAME or "
|
|
1505
|
+
"raft config unset location"
|
|
1506
|
+
)
|
|
1507
|
+
return used
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def effective_defaults():
|
|
1511
|
+
"""Return each defaultable option's effective value and where it comes from."""
|
|
1512
|
+
configured = configured_defaults()
|
|
1513
|
+
report = {}
|
|
1514
|
+
for key, (builtin, _) in NEW_DEFAULTS.items():
|
|
1515
|
+
if key in configured:
|
|
1516
|
+
report[key] = {"value": configured[key], "source": "config"}
|
|
1517
|
+
else:
|
|
1518
|
+
value = next(iter(configuration())) if key == "location" else builtin
|
|
1519
|
+
report[key] = {"value": value, "source": "built-in"}
|
|
1520
|
+
return report
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def disk_origin(size):
|
|
1524
|
+
"""Explain a reservation refusal for an own disk that config.json chose, not a flag."""
|
|
1525
|
+
return (
|
|
1526
|
+
f"The {size} own disk comes from defaults.disk in {DEFAULTS_CONFIG}; "
|
|
1527
|
+
"give --disk shared or --disk SIZE, or run raft config unset disk"
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def set_default(key, value):
|
|
1532
|
+
"""Set (value) or remove (None) one default in config.json and print the change."""
|
|
1533
|
+
if value is not None:
|
|
1534
|
+
value = default_value(key, value)
|
|
1535
|
+
if key == "location":
|
|
1536
|
+
settings(value)
|
|
1537
|
+
previous = {}
|
|
1538
|
+
|
|
1539
|
+
def change(document):
|
|
1540
|
+
defaults = document.setdefault("defaults", {})
|
|
1541
|
+
previous.update(defaults)
|
|
1542
|
+
if value is None:
|
|
1543
|
+
defaults.pop(key, None)
|
|
1544
|
+
else:
|
|
1545
|
+
defaults[key] = value
|
|
1546
|
+
if not defaults:
|
|
1547
|
+
document.pop("defaults")
|
|
1548
|
+
|
|
1549
|
+
edit_json(DEFAULTS_CONFIG, lambda: {"defaults": configured_defaults(key)}, change)
|
|
1550
|
+
before = shown_default(previous.get(key, "built-in default"))
|
|
1551
|
+
print(
|
|
1552
|
+
f"defaults.{key}: {before} -> {'built-in default' if value is None else shown_default(value)}"
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
# GitHub releases are the one source of version truth. The release workflow
|
|
1557
|
+
# publishes npm before it creates the GitHub release, so every version this API
|
|
1558
|
+
# reports is already installable from both supported channels.
|
|
1559
|
+
RELEASES_API = "https://api.github.com/repos/Microck/raft/releases"
|
|
1560
|
+
# Matches the user agent deploy/deploy.py sends for release downloads.
|
|
1561
|
+
USER_AGENT = "OpenAI File Downloader, XaiImageApiFetch/1.0"
|
|
1562
|
+
# self-update --check exit status when a newer release exists, as dnf check-update uses.
|
|
1563
|
+
UPDATE_AVAILABLE = 100
|
|
1564
|
+
UPDATE_CHECK_INTERVAL = 24 * 3600
|
|
1565
|
+
UPDATE_CHECK_TIMEOUT = 2
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def parse_version(text):
|
|
1569
|
+
"""Return an X.Y.Z release version, optionally v-prefixed, as a comparable tuple."""
|
|
1570
|
+
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", text)
|
|
1571
|
+
if match is None:
|
|
1572
|
+
raise ValueError(f"Not a release version: {text}")
|
|
1573
|
+
return tuple(int(part) for part in match.groups())
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def release_version(value):
|
|
1577
|
+
"""Parser type for self-update --version; returns the version without its v."""
|
|
1578
|
+
parse_version(value)
|
|
1579
|
+
return value.removeprefix("v")
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def fetch(url, timeout):
|
|
1583
|
+
request = urllib.request.Request(
|
|
1584
|
+
url, headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"}
|
|
1585
|
+
)
|
|
1586
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def release(api, version=None, timeout=30):
|
|
1590
|
+
"""Return GitHub metadata for the latest release, or for release vVERSION."""
|
|
1591
|
+
try:
|
|
1592
|
+
with fetch(f"{api}/tags/v{version}" if version else api + "/latest", timeout) as response:
|
|
1593
|
+
metadata = json.load(response)
|
|
1594
|
+
except urllib.error.HTTPError as error:
|
|
1595
|
+
if version and error.code == 404:
|
|
1596
|
+
raise ValueError(f"No published Raft release v{version}") from error
|
|
1597
|
+
raise
|
|
1598
|
+
metadata["version"] = metadata["tag_name"].removeprefix("v")
|
|
1599
|
+
parse_version(metadata["version"])
|
|
1600
|
+
return metadata
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def update_message(latest):
|
|
1604
|
+
return f"raft {latest} is available (you have {__version__}). Run: raft self-update"
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def update_cache():
|
|
1608
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "raft/update.json"
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def save_update_check():
|
|
1612
|
+
"""Record a release check. A torn write only makes the next command check again."""
|
|
1613
|
+
path = update_cache()
|
|
1614
|
+
try:
|
|
1615
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1616
|
+
path.write_text(json.dumps({"checkedAt": time.time()}))
|
|
1617
|
+
except OSError:
|
|
1618
|
+
pass
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def update_check(action, api=RELEASES_API):
|
|
1622
|
+
"""Start the passive release check for a command; the returned function prints its notice.
|
|
1623
|
+
|
|
1624
|
+
At most once per UPDATE_CHECK_INTERVAL, a background thread asks GitHub for
|
|
1625
|
+
the latest release while the command runs, and only that command can print
|
|
1626
|
+
the notice, so a controller sees it at most once per interval. The notice is
|
|
1627
|
+
one stderr line, so stdout stays parseable. It never installs anything, and
|
|
1628
|
+
network, thread or cache failures stay silent. RAFT_NO_UPDATE_CHECK=1
|
|
1629
|
+
disables it.
|
|
1630
|
+
"""
|
|
1631
|
+
if action == "self-update" or os.environ.get("RAFT_NO_UPDATE_CHECK"):
|
|
1632
|
+
return lambda: None
|
|
1633
|
+
try:
|
|
1634
|
+
checked = float(json.loads(update_cache().read_text())["checkedAt"])
|
|
1635
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
1636
|
+
checked = 0
|
|
1637
|
+
# A timestamp in the future, e.g. after a clock change, counts as stale.
|
|
1638
|
+
if 0 <= time.time() - checked < UPDATE_CHECK_INTERVAL:
|
|
1639
|
+
return lambda: None
|
|
1640
|
+
# Record the attempt first: an offline controller then waits for at most one
|
|
1641
|
+
# timeout per interval instead of one per command.
|
|
1642
|
+
save_update_check()
|
|
1643
|
+
found = {}
|
|
1644
|
+
|
|
1645
|
+
def check():
|
|
1646
|
+
try:
|
|
1647
|
+
found["latest"] = release(api, timeout=UPDATE_CHECK_TIMEOUT)["version"]
|
|
1648
|
+
except Exception:
|
|
1649
|
+
# A best-effort notice must not print tracebacks or fail commands.
|
|
1650
|
+
pass
|
|
1651
|
+
|
|
1652
|
+
thread = threading.Thread(target=check, daemon=True)
|
|
1653
|
+
try:
|
|
1654
|
+
thread.start()
|
|
1655
|
+
except RuntimeError:
|
|
1656
|
+
# The runtime could not create the thread; skip this interval's notice.
|
|
1657
|
+
return lambda: None
|
|
1658
|
+
|
|
1659
|
+
def notice():
|
|
1660
|
+
# Commands usually outlast the request; otherwise wait a bounded time.
|
|
1661
|
+
thread.join(UPDATE_CHECK_TIMEOUT)
|
|
1662
|
+
latest = found.get("latest")
|
|
1663
|
+
if latest is not None and parse_version(latest) > parse_version(__version__):
|
|
1664
|
+
print(update_message(latest), file=sys.stderr)
|
|
1665
|
+
|
|
1666
|
+
return notice
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def install_channel(source, prefix):
|
|
1670
|
+
"""Return (channel, root) for the installation holding source, the resolved raft.py.
|
|
1671
|
+
|
|
1672
|
+
self-update manages npm global installs and uv tools. Any other layout raises
|
|
1673
|
+
ValueError with instructions, so an update never moves to another channel.
|
|
1674
|
+
"""
|
|
1675
|
+
package = source.parent
|
|
1676
|
+
if (package / ".git").exists():
|
|
1677
|
+
raise ValueError(
|
|
1678
|
+
f"raft runs from the source checkout {package}; update it with "
|
|
1679
|
+
"`git fetch --tags && git checkout vX.Y.Z` and `uv sync --locked`"
|
|
1680
|
+
)
|
|
1681
|
+
# A global install puts the package at PREFIX/lib/node_modules/@microck/raft and
|
|
1682
|
+
# links PREFIX/bin/raft to raft.py. Project-local node_modules have no such link.
|
|
1683
|
+
if package.parts[-4:] == ("lib", "node_modules", "@microck", "raft"):
|
|
1684
|
+
npm_prefix = package.parents[3]
|
|
1685
|
+
if (npm_prefix / "bin/raft").resolve() == source:
|
|
1686
|
+
return "npm", npm_prefix
|
|
1687
|
+
# uv names each tool environment after its package and leaves a receipt in it.
|
|
1688
|
+
# Editable installs keep raft.py outside the environment and are excluded.
|
|
1689
|
+
if prefix.name == "raft" and (prefix / "uv-receipt.toml").exists():
|
|
1690
|
+
if source.is_relative_to(prefix):
|
|
1691
|
+
return "uv", prefix
|
|
1692
|
+
raise ValueError(
|
|
1693
|
+
f"self-update manages npm global installs and uv tools; {source} is neither. "
|
|
1694
|
+
"Reinstall with the tool you used, from a release verified against its SHA256SUMS: "
|
|
1695
|
+
"https://github.com/Microck/raft/releases"
|
|
1696
|
+
)
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def verified_wheel(metadata, directory):
|
|
1700
|
+
"""Download the release wheel and check it against the release SHA256SUMS."""
|
|
1701
|
+
name = f"raft-{metadata['version']}-py3-none-any.whl"
|
|
1702
|
+
assets = {asset["name"]: asset["browser_download_url"] for asset in metadata["assets"]}
|
|
1703
|
+
for asset in [name, "SHA256SUMS"]:
|
|
1704
|
+
if asset not in assets:
|
|
1705
|
+
raise ValueError(
|
|
1706
|
+
f"Release {metadata['tag_name']} has no {asset}; nothing was installed"
|
|
1707
|
+
)
|
|
1708
|
+
with fetch(assets[asset], 60) as response, (directory / asset).open("wb") as file:
|
|
1709
|
+
shutil.copyfileobj(response, file)
|
|
1710
|
+
expected = [
|
|
1711
|
+
line.split()[0]
|
|
1712
|
+
for line in (directory / "SHA256SUMS").read_text().splitlines()
|
|
1713
|
+
if line.split()[1:] == [name]
|
|
1714
|
+
]
|
|
1715
|
+
with (directory / name).open("rb") as file:
|
|
1716
|
+
digest = hashlib.file_digest(file, "sha256").hexdigest()
|
|
1717
|
+
if expected != [digest]:
|
|
1718
|
+
raise ValueError(f"{name} does not match the release SHA256SUMS; nothing was installed")
|
|
1719
|
+
return directory / name
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def self_update(args, api=RELEASES_API):
|
|
1723
|
+
"""Install the latest or the requested release through the channel that installed raft."""
|
|
1724
|
+
if args.check:
|
|
1725
|
+
latest = release(api)["version"]
|
|
1726
|
+
save_update_check()
|
|
1727
|
+
if parse_version(latest) > parse_version(__version__):
|
|
1728
|
+
print(update_message(latest))
|
|
1729
|
+
return UPDATE_AVAILABLE
|
|
1730
|
+
print(f"raft {__version__} is up to date (latest release: {latest})")
|
|
1731
|
+
return 0
|
|
1732
|
+
if args.version is not None and parse_version(args.version) == parse_version(__version__):
|
|
1733
|
+
print(f"raft {__version__} is already installed")
|
|
1734
|
+
return 0
|
|
1735
|
+
# Refuse unmanaged layouts before any download.
|
|
1736
|
+
channel, root = install_channel(Path(__file__).resolve(), Path(sys.prefix).resolve())
|
|
1737
|
+
tool = shutil.which(channel)
|
|
1738
|
+
if tool is None:
|
|
1739
|
+
raise ValueError(f"{channel} installed raft but is not on PATH")
|
|
1740
|
+
metadata = release(api, args.version)
|
|
1741
|
+
target = metadata["version"]
|
|
1742
|
+
# Without --version, never downgrade a build that is newer than the latest release.
|
|
1743
|
+
if args.version is None and parse_version(target) <= parse_version(__version__):
|
|
1744
|
+
print(f"raft {__version__} is up to date (latest release: {target})")
|
|
1745
|
+
return 0
|
|
1746
|
+
# Installer output goes to stderr; stdout carries only the result below.
|
|
1747
|
+
if channel == "npm":
|
|
1748
|
+
# An explicit prefix keeps the update in this install even when another
|
|
1749
|
+
# Node.js installation provides npm. The registry checks package integrity.
|
|
1750
|
+
subprocess.run(
|
|
1751
|
+
[tool, "install", "--global", "--prefix", str(root), f"@microck/raft@{target}"],
|
|
1752
|
+
stdout=sys.stderr,
|
|
1753
|
+
check=True,
|
|
1754
|
+
)
|
|
1755
|
+
else:
|
|
1756
|
+
tools = subprocess.run([tool, "tool", "dir"], capture_output=True, text=True, check=True)
|
|
1757
|
+
if Path(tools.stdout.strip()).resolve() != root.parent:
|
|
1758
|
+
raise ValueError(
|
|
1759
|
+
f"uv now installs tools in {tools.stdout.strip()}, not {root.parent}; "
|
|
1760
|
+
"set UV_TOOL_DIR to the original directory, then retry"
|
|
1761
|
+
)
|
|
1762
|
+
with tempfile.TemporaryDirectory(prefix="raft-update-") as work:
|
|
1763
|
+
wheel = verified_wheel(metadata, Path(work))
|
|
1764
|
+
print(f"Verified {wheel.name} against the release SHA256SUMS", file=sys.stderr)
|
|
1765
|
+
subprocess.run(
|
|
1766
|
+
[tool, "tool", "install", "--force", str(wheel)], stdout=sys.stderr, check=True
|
|
1767
|
+
)
|
|
1768
|
+
installed = subprocess.run(
|
|
1769
|
+
[str(root / "bin/raft"), "--version"], capture_output=True, text=True, check=True
|
|
1770
|
+
)
|
|
1771
|
+
if installed.stdout.strip() != target:
|
|
1772
|
+
raise RuntimeError(f"{channel} installed {installed.stdout.strip()}, not {target}")
|
|
1773
|
+
print(f"raft {__version__} -> {target}")
|
|
1774
|
+
print("Release notes: " + metadata["html_url"])
|
|
1775
|
+
print(
|
|
1776
|
+
"Hosts were not changed. Run `raft doctor`: if a host's worker differs from this CLI, "
|
|
1777
|
+
f"redeploy that host from release {metadata['tag_name']}. "
|
|
1778
|
+
f"`raft host add` now imports {metadata['tag_name']} images by default."
|
|
1779
|
+
)
|
|
1780
|
+
return 0
|
|
1781
|
+
|
|
1782
|
+
|
|
879
1783
|
def note_options(command):
|
|
880
|
-
"""Add the box note template flags shared by new and
|
|
1784
|
+
"""Add the box note template flags shared by new, note, fork and recover."""
|
|
881
1785
|
command.add_argument(
|
|
882
1786
|
"--purpose", help="Box note: what the box is for, e.g. 'Integration tests for PR 42'"
|
|
883
1787
|
)
|
|
@@ -895,13 +1799,70 @@ def parser():
|
|
|
895
1799
|
commands = cli.add_subparsers(dest="action", required=True)
|
|
896
1800
|
create = commands.add_parser("new")
|
|
897
1801
|
create.add_argument("--location", default=None)
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
create.add_argument("--
|
|
901
|
-
create.add_argument(
|
|
1802
|
+
# Omitted options take ~/.config/raft/config.json defaults, then NEW_DEFAULTS.
|
|
1803
|
+
# With --from, every omitted size comes from the snapshot instead of a default.
|
|
1804
|
+
create.add_argument("--ttl", type=ttl)
|
|
1805
|
+
create.add_argument(
|
|
1806
|
+
"--cpu",
|
|
1807
|
+
type=cpu_count,
|
|
1808
|
+
help=f"CPU limit (default: config, else {DEFAULT_CPU}; with --from, the snapshot's)",
|
|
1809
|
+
)
|
|
1810
|
+
create.add_argument(
|
|
1811
|
+
"--memory",
|
|
1812
|
+
type=gib_size,
|
|
1813
|
+
help="RAM limit, e.g. 16GiB "
|
|
1814
|
+
f"(default: config, else {DEFAULT_MEMORY}; with --from, the snapshot's)",
|
|
1815
|
+
)
|
|
1816
|
+
create.add_argument(
|
|
1817
|
+
"--disk",
|
|
1818
|
+
type=disk_size,
|
|
1819
|
+
metavar="SIZE",
|
|
1820
|
+
help="Give the box its own disk of SIZE, e.g. 20GiB, that it cannot grow past, "
|
|
1821
|
+
"or shared for the shared pool "
|
|
1822
|
+
"(default: config, else shared; with --from, the snapshot's disk)",
|
|
1823
|
+
)
|
|
1824
|
+
lifecycle = create.add_mutually_exclusive_group()
|
|
1825
|
+
lifecycle.add_argument(
|
|
1826
|
+
"--disposable",
|
|
1827
|
+
dest="disposable",
|
|
1828
|
+
action="store_const",
|
|
1829
|
+
const=True,
|
|
1830
|
+
help="Delete on stop or expiry (default: config, else persistent)",
|
|
1831
|
+
)
|
|
1832
|
+
lifecycle.add_argument(
|
|
1833
|
+
"--persistent",
|
|
1834
|
+
dest="disposable",
|
|
1835
|
+
action="store_const",
|
|
1836
|
+
const=False,
|
|
1837
|
+
help="Keep the disk on stop or expiry, despite a disposable default",
|
|
1838
|
+
)
|
|
902
1839
|
create.add_argument("--from", dest="template", metavar="LOCATION:BOX/SNAPSHOT")
|
|
903
1840
|
note_options(create)
|
|
904
1841
|
commands.add_parser("locations", help="List configured locations and their notes")
|
|
1842
|
+
defaults = commands.add_parser(
|
|
1843
|
+
"config", help="Show defaults for new and each location's note policy, or change defaults"
|
|
1844
|
+
)
|
|
1845
|
+
default_commands = defaults.add_subparsers(dest="config_action")
|
|
1846
|
+
default_set = default_commands.add_parser("set", help="Set a default for new")
|
|
1847
|
+
default_set.add_argument("key", choices=list(NEW_DEFAULTS))
|
|
1848
|
+
default_set.add_argument("value")
|
|
1849
|
+
default_unset = default_commands.add_parser("unset", help="Restore a built-in default")
|
|
1850
|
+
default_unset.add_argument("key", choices=list(NEW_DEFAULTS))
|
|
1851
|
+
policy = commands.add_parser(
|
|
1852
|
+
"policy",
|
|
1853
|
+
help="Set a location's required box note fields and stopped-box retention",
|
|
1854
|
+
)
|
|
1855
|
+
policy.add_argument("location")
|
|
1856
|
+
policy.add_argument(
|
|
1857
|
+
"--require",
|
|
1858
|
+
metavar="FIELDS|none",
|
|
1859
|
+
help="Note fields every box must carry, e.g. purpose,owner; none stops requiring them",
|
|
1860
|
+
)
|
|
1861
|
+
policy.add_argument(
|
|
1862
|
+
"--delete-stopped-after",
|
|
1863
|
+
metavar="HOURS|never",
|
|
1864
|
+
help="Delete boxes stopped for HOURS, e.g. 48; never keeps them",
|
|
1865
|
+
)
|
|
905
1866
|
note = commands.add_parser("note", help="Set or clear the note of a location or a box")
|
|
906
1867
|
note.add_argument("target", metavar="LOCATION|BOX", help="A location name or a box handle")
|
|
907
1868
|
note.add_argument("text", nargs="?", help="Location guidance, e.g. 'Preferred for builds'")
|
|
@@ -929,13 +1890,22 @@ def parser():
|
|
|
929
1890
|
"--dry-run", action="store_true", help="Check the host and print the plan; change nothing"
|
|
930
1891
|
)
|
|
931
1892
|
onboard.add_argument("--yes", action="store_true", help="Apply the plan without prompting")
|
|
1893
|
+
onboard.add_argument(
|
|
1894
|
+
"--disk-pool",
|
|
1895
|
+
type=gib_size,
|
|
1896
|
+
metavar="SIZE",
|
|
1897
|
+
help="Enable own box disks: set aside SIZE of host disk for them, e.g. 40GiB "
|
|
1898
|
+
"(default: not enabled)",
|
|
1899
|
+
)
|
|
932
1900
|
listing = commands.add_parser("list")
|
|
933
1901
|
listing.add_argument("--location")
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1902
|
+
report = commands.add_parser("capacity", help=ADVISORY, description=ADVISORY)
|
|
1903
|
+
report.add_argument("--location")
|
|
1904
|
+
report.add_argument("--json", action="store_true")
|
|
937
1905
|
commands.add_parser("doctor")
|
|
938
|
-
gc = commands.add_parser(
|
|
1906
|
+
gc = commands.add_parser(
|
|
1907
|
+
"gc", help="Enforce deadlines and retention, including disposable deletion"
|
|
1908
|
+
)
|
|
939
1909
|
gc.add_argument("--location")
|
|
940
1910
|
prune = commands.add_parser("prune", help="Preview deletion of old stopped boxes")
|
|
941
1911
|
prune.add_argument("--location")
|
|
@@ -944,6 +1914,22 @@ def parser():
|
|
|
944
1914
|
recovery = commands.add_parser("recover", help="Import a portable backup as a new stopped box")
|
|
945
1915
|
recovery.add_argument("source")
|
|
946
1916
|
recovery.add_argument("--location", required=True)
|
|
1917
|
+
note_options(recovery)
|
|
1918
|
+
update = commands.add_parser(
|
|
1919
|
+
"self-update", help="Upgrade this CLI to the latest or a chosen release"
|
|
1920
|
+
)
|
|
1921
|
+
target = update.add_mutually_exclusive_group()
|
|
1922
|
+
target.add_argument(
|
|
1923
|
+
"--check",
|
|
1924
|
+
action="store_true",
|
|
1925
|
+
help=f"Only report whether a newer release exists; exit {UPDATE_AVAILABLE} if one does",
|
|
1926
|
+
)
|
|
1927
|
+
target.add_argument(
|
|
1928
|
+
"--version",
|
|
1929
|
+
type=release_version,
|
|
1930
|
+
metavar="X.Y.Z",
|
|
1931
|
+
help="Install this release instead of the latest, e.g. to roll back",
|
|
1932
|
+
)
|
|
947
1933
|
for action in [
|
|
948
1934
|
"info",
|
|
949
1935
|
"stop",
|
|
@@ -972,10 +1958,11 @@ def parser():
|
|
|
972
1958
|
if action in ["resume", "fork", "extend"]:
|
|
973
1959
|
command.add_argument("--ttl", type=ttl, required=True)
|
|
974
1960
|
if action in ["resume", "fork"]:
|
|
975
|
-
command.add_argument("--cpu", type=
|
|
976
|
-
command.add_argument("--memory",
|
|
1961
|
+
command.add_argument("--cpu", type=cpu_count)
|
|
1962
|
+
command.add_argument("--memory", type=gib_size)
|
|
977
1963
|
if action == "fork":
|
|
978
1964
|
command.add_argument("--disposable", action="store_true")
|
|
1965
|
+
note_options(command)
|
|
979
1966
|
if action == "exec":
|
|
980
1967
|
command.add_argument("--detach", action="store_true")
|
|
981
1968
|
command.add_argument("command", nargs="+")
|
|
@@ -998,6 +1985,8 @@ def parser():
|
|
|
998
1985
|
|
|
999
1986
|
|
|
1000
1987
|
def dispatch(args):
|
|
1988
|
+
if args.action == "self-update":
|
|
1989
|
+
return self_update(args)
|
|
1001
1990
|
if args.action == "recover":
|
|
1002
1991
|
recover(args)
|
|
1003
1992
|
return 0
|
|
@@ -1007,6 +1996,21 @@ def dispatch(args):
|
|
|
1007
1996
|
if args.action == "host":
|
|
1008
1997
|
host_add(args)
|
|
1009
1998
|
return 0
|
|
1999
|
+
if args.action == "config":
|
|
2000
|
+
if args.config_action is None:
|
|
2001
|
+
report = {"file": str(DEFAULTS_CONFIG), "defaults": effective_defaults(), "policy": {}}
|
|
2002
|
+
for location in configuration():
|
|
2003
|
+
try:
|
|
2004
|
+
report["policy"][location] = host_policy(location)
|
|
2005
|
+
except RuntimeError as error:
|
|
2006
|
+
raise RuntimeError(f"{location}: {error}") from error
|
|
2007
|
+
print(json.dumps(report, indent=2))
|
|
2008
|
+
else:
|
|
2009
|
+
set_default(args.key, args.value if args.config_action == "set" else None)
|
|
2010
|
+
return 0
|
|
2011
|
+
if args.action == "policy":
|
|
2012
|
+
change_policy(args)
|
|
2013
|
+
return 0
|
|
1010
2014
|
if args.action == "note":
|
|
1011
2015
|
fields = box_note(args)
|
|
1012
2016
|
# Location names cannot contain ":", so a colon always means a box handle.
|
|
@@ -1023,28 +2027,41 @@ def dispatch(args):
|
|
|
1023
2027
|
raise ValueError("Give --purpose (with optional --owner and --next) or --clear")
|
|
1024
2028
|
location, name = parse_handle(args.target)
|
|
1025
2029
|
instance(location, name)
|
|
2030
|
+
require_note(
|
|
2031
|
+
location,
|
|
2032
|
+
fields,
|
|
2033
|
+
f"note {args.target}",
|
|
2034
|
+
" Clearing would leave the box without them." if args.clear else "",
|
|
2035
|
+
)
|
|
1026
2036
|
# Each call replaces the whole note; an empty value removes the key.
|
|
1027
|
-
|
|
1028
|
-
|
|
2037
|
+
transaction(
|
|
2038
|
+
location,
|
|
2039
|
+
'note="$2"\n'
|
|
2040
|
+
+ READ_NOTE_POLICY
|
|
2041
|
+
+ CHECK_NOTE
|
|
2042
|
+
+ '\nincus --project raft config set "$1" user.raft.note="$note"',
|
|
2043
|
+
name,
|
|
2044
|
+
"" if args.clear else json.dumps(fields),
|
|
2045
|
+
)
|
|
1029
2046
|
return 0
|
|
1030
2047
|
if args.action == "locations":
|
|
1031
2048
|
for index, (location, entry) in enumerate(configuration().items()):
|
|
1032
2049
|
record = {"location": location, "default": index == 0, "note": entry.get("note", "")}
|
|
1033
2050
|
print(json.dumps(record))
|
|
1034
2051
|
return 0
|
|
1035
|
-
if args.action in ["list", "
|
|
2052
|
+
if args.action in ["list", "capacity", "doctor", "gc", "prune"]:
|
|
1036
2053
|
if args.action == "prune" and args.older_than < 1:
|
|
1037
2054
|
raise ValueError("Retention must be at least one day")
|
|
1038
2055
|
locations = configuration()
|
|
1039
2056
|
if getattr(args, "location", None):
|
|
1040
2057
|
locations = [args.location]
|
|
1041
2058
|
for location in locations:
|
|
1042
|
-
if args.action == "
|
|
1043
|
-
report =
|
|
2059
|
+
if args.action == "capacity":
|
|
2060
|
+
report = host_capacity(location)
|
|
1044
2061
|
if args.json:
|
|
1045
2062
|
print(json.dumps(report))
|
|
1046
2063
|
else:
|
|
1047
|
-
|
|
2064
|
+
print_capacity(report)
|
|
1048
2065
|
elif args.action in ["gc", "prune"]:
|
|
1049
2066
|
worker = ["sudo", "-n", "/usr/local/lib/raft/expire"]
|
|
1050
2067
|
if args.action == "prune":
|
|
@@ -1083,18 +2100,25 @@ def dispatch(args):
|
|
|
1083
2100
|
elif args.action == "doctor":
|
|
1084
2101
|
doctor(location)
|
|
1085
2102
|
else:
|
|
2103
|
+
policy = host_policy(location)
|
|
1086
2104
|
for box in inventory(location):
|
|
2105
|
+
deletion = delete_at(box, policy)
|
|
1087
2106
|
print(
|
|
1088
2107
|
json.dumps(
|
|
1089
2108
|
{
|
|
1090
2109
|
"box": location + ":" + box["name"],
|
|
1091
2110
|
"state": box["status"],
|
|
1092
2111
|
"stopAt": box["config"].get("user.raft.expires", ""),
|
|
2112
|
+
# Epoch text like stopAt; empty when no retention deletion is due.
|
|
2113
|
+
"deleteAt": "" if deletion is None else str(deletion),
|
|
2114
|
+
"disk": box_disk(box),
|
|
1093
2115
|
"note": read_box_note(box),
|
|
1094
2116
|
}
|
|
1095
2117
|
)
|
|
1096
2118
|
)
|
|
1097
2119
|
return 0
|
|
2120
|
+
# Only fork accepts note flags here; check them before contacting the host.
|
|
2121
|
+
fields = box_note(args)
|
|
1098
2122
|
location, name = parse_handle(args.box)
|
|
1099
2123
|
box = instance(location, name)
|
|
1100
2124
|
if args.action == "info":
|
|
@@ -1106,7 +2130,12 @@ def dispatch(args):
|
|
|
1106
2130
|
response = execute(location, name, ["python3", "-c", USAGE], capture=True)
|
|
1107
2131
|
if response.returncode:
|
|
1108
2132
|
raise RuntimeError(response.stderr.decode().strip())
|
|
1109
|
-
|
|
2133
|
+
usage = json.loads(response.stdout)
|
|
2134
|
+
disk = box_disk(box)
|
|
2135
|
+
usage["filesystemScope"] = (
|
|
2136
|
+
"shared Raft pool, not per-box usage" if disk == "shared" else f"own {disk} box disk"
|
|
2137
|
+
)
|
|
2138
|
+
print(json.dumps(usage))
|
|
1110
2139
|
elif args.action == "destroy":
|
|
1111
2140
|
incus(location, "delete", name, "--force", locked=True)
|
|
1112
2141
|
if any(item["name"] == name for item in inventory(location)):
|
|
@@ -1130,7 +2159,7 @@ def dispatch(args):
|
|
|
1130
2159
|
overrides.append(f"limits.{field}={value}")
|
|
1131
2160
|
transaction(
|
|
1132
2161
|
location,
|
|
1133
|
-
REQUIRE_STOPPED + 'name="$1"; lifetime="$2"; shift 2; '
|
|
2162
|
+
REQUIRE_STOPPED + fits_host(args) + 'name="$1"; lifetime="$2"; shift 2; '
|
|
1134
2163
|
'if [ "$#" -gt 0 ]; then incus --project raft config set "$name" "$@"; fi; '
|
|
1135
2164
|
'incus --project raft start "$name"; '
|
|
1136
2165
|
'incus --project raft config set "$name" user.raft.stopped-at=; '
|
|
@@ -1193,7 +2222,9 @@ def dispatch(args):
|
|
|
1193
2222
|
print(
|
|
1194
2223
|
transaction(
|
|
1195
2224
|
location,
|
|
1196
|
-
REQUIRE_STOPPED
|
|
2225
|
+
REQUIRE_STOPPED
|
|
2226
|
+
+ reserve_box_disk(location, None, name)
|
|
2227
|
+
+ 'incus --project raft snapshot create "$1" "$2"',
|
|
1197
2228
|
name,
|
|
1198
2229
|
args.name,
|
|
1199
2230
|
).strip()
|
|
@@ -1207,6 +2238,9 @@ def dispatch(args):
|
|
|
1207
2238
|
snapshot_name(args.name)
|
|
1208
2239
|
# Imported/forked snapshots carry source configuration. Restore files,
|
|
1209
2240
|
# keeping the destination's network identity and explicitly chosen limits.
|
|
2241
|
+
# On raft-disks Incus swaps in a thin snapshot of the snapshot for the box's
|
|
2242
|
+
# volume, so a restore needs no reservation: it writes no data, and the
|
|
2243
|
+
# reserved total is unchanged once the old volume is removed.
|
|
1210
2244
|
transaction(
|
|
1211
2245
|
location,
|
|
1212
2246
|
REQUIRE_STOPPED + 'incus --project raft snapshot restore "$1" "$2" --diskonly',
|
|
@@ -1214,19 +2248,33 @@ def dispatch(args):
|
|
|
1214
2248
|
args.name,
|
|
1215
2249
|
)
|
|
1216
2250
|
elif args.action == "fork":
|
|
2251
|
+
# Note flags replace the whole note, as with raft note; without them the
|
|
2252
|
+
# fork keeps the source note, which must then satisfy the host policy.
|
|
2253
|
+
require_note(
|
|
2254
|
+
location,
|
|
2255
|
+
fields or read_box_note(box),
|
|
2256
|
+
f"fork {args.box} --ttl {args.ttl}",
|
|
2257
|
+
"" if fields else " Forks keep the source note unless you give a new one.",
|
|
2258
|
+
)
|
|
1217
2259
|
child = "rf-" + uuid.uuid4().hex[:16]
|
|
1218
2260
|
transaction(
|
|
1219
2261
|
location,
|
|
1220
2262
|
(CHECK_WORKER if args.disposable else "")
|
|
1221
2263
|
+ REQUIRE_STOPPED
|
|
1222
|
-
+
|
|
1223
|
-
+
|
|
1224
|
-
'
|
|
2264
|
+
+ fits_host(args)
|
|
2265
|
+
+ reserve_box_disk(location, None, name)
|
|
2266
|
+
+ 'name="$2"; lifetime="$3"; source="$1"; note="$4"; shift 4\n'
|
|
2267
|
+
# Read the source note under the lock, so the check sees what the copy keeps.
|
|
2268
|
+
'if [ -z "$note" ]; then note=$(incus --project raft config get "$source" user.raft.note); fi\n'
|
|
2269
|
+
+ READ_NOTE_POLICY
|
|
2270
|
+
+ CHECK_NOTE
|
|
2271
|
+
+ '\nincus --project raft copy "$source" "$name" --instance-only "$@" -c user.raft.note="$note"; '
|
|
1225
2272
|
'incus --project raft start "$name"; '
|
|
1226
2273
|
'incus --project raft config set "$name" user.raft.expires="$(( $(date +%s) + lifetime ))"',
|
|
1227
2274
|
name,
|
|
1228
2275
|
child,
|
|
1229
2276
|
str(args.ttl),
|
|
2277
|
+
json.dumps(fields) if fields else "",
|
|
1230
2278
|
"-c",
|
|
1231
2279
|
"user.raft.expires=",
|
|
1232
2280
|
"-c",
|
|
@@ -1293,8 +2341,11 @@ def main():
|
|
|
1293
2341
|
print("raft: Python 3.11.8 or newer is required", file=sys.stderr)
|
|
1294
2342
|
return 1
|
|
1295
2343
|
os.umask(0o077)
|
|
2344
|
+
# Parsing exits for --version, --help and usage errors, before any release check.
|
|
2345
|
+
args = parser().parse_args()
|
|
2346
|
+
notice = update_check(args.action, RELEASES_API)
|
|
1296
2347
|
try:
|
|
1297
|
-
|
|
2348
|
+
status = dispatch(args)
|
|
1298
2349
|
except (
|
|
1299
2350
|
OSError,
|
|
1300
2351
|
ValueError,
|
|
@@ -1303,9 +2354,11 @@ def main():
|
|
|
1303
2354
|
subprocess.CalledProcessError,
|
|
1304
2355
|
) as error:
|
|
1305
2356
|
print(f"raft: {error}", file=sys.stderr)
|
|
1306
|
-
|
|
2357
|
+
status = 1
|
|
1307
2358
|
except KeyboardInterrupt:
|
|
1308
2359
|
return 130
|
|
2360
|
+
notice()
|
|
2361
|
+
return status
|
|
1309
2362
|
|
|
1310
2363
|
|
|
1311
2364
|
if __name__ == "__main__":
|