@astrofoundry/pi-astro 0.33.2 → 0.34.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 +1 -1
- package/agents/arcane.md +4 -3
- package/extensions/astro-discord/daily-report.test.ts +29 -1
- package/extensions/astro-discord/daily-report.ts +42 -0
- package/extensions/astro-discord/index.ts +78 -5
- package/extensions/astro-discord/reports.ts +2 -0
- package/extensions/astro-discord/tools-report.ts +1 -1
- package/extensions/specialist-gate/approvals.test.ts +5 -0
- package/extensions/specialist-gate/index.ts +3 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +19 -21
- package/skills/edge/SKILL.md +4 -0
- package/specialists/AGENTS.md +2 -0
- package/specialists/README.md +7 -5
- package/specialists/arcane/run.ts +279 -3
- package/specialists/edge/run.ts +40 -0
- package/specialists/entry/remote/spc-arcane-host-entry.sh +15 -0
- package/specialists/entry/remote/spc-arcane-install.sh +35 -0
- package/specialists/entry/remote/spc-arcane-op.py +685 -0
- package/specialists/entry/remote/spc-arcane-sudoers +4 -0
- package/specialists/lib/arcane-host.test.ts +147 -0
- package/specialists/lib/arcane-host.ts +148 -0
- package/specialists/lib/arcane-manager.ts +81 -0
- package/specialists/lib/deployment.ts +152 -0
- package/specialists/lib/repo.ts +2 -1
- package/specialists/wrappers.test.ts +19 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""Root-side operations for the arcane specialist's forced-command key on VM 100.
|
|
3
|
+
|
|
4
|
+
Installed root-owned as /usr/local/sbin/spc-arcane-op. The forced command
|
|
5
|
+
spc-arcane-host-entry runs it through one exact sudoers line for user
|
|
6
|
+
spc-arcane, so this file is the only privileged surface of that key. Every
|
|
7
|
+
operation has a fixed name and strictly checked arguments; the read-only
|
|
8
|
+
checks run the reviewed arcane-ops probes from /usr/local/lib/spc-arcane and
|
|
9
|
+
print only the fields those probes allow. Commands, image digests, and
|
|
10
|
+
database names come from this file, never from the caller.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import gzip
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tarfile
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
PROBES = Path("/usr/local/lib/spc-arcane")
|
|
30
|
+
DOCKER = "/usr/bin/docker"
|
|
31
|
+
COMPOSE_DIR = Path("/opt/docker/arcane")
|
|
32
|
+
COMPOSE_FILE = COMPOSE_DIR / "compose.yaml"
|
|
33
|
+
MANAGER = "arcane"
|
|
34
|
+
MANAGER_IMAGE = "ghcr.io/getarcaneapp/manager"
|
|
35
|
+
MANAGER_DATA = Path("/var/lib/docker/volumes/arcane_arcane-data/_data")
|
|
36
|
+
UPGRADES = Path("/var/backups/arcane-upgrades")
|
|
37
|
+
DUMPS = Path("/var/backups/spc-arcane")
|
|
38
|
+
KEEP_DUMPS = 3
|
|
39
|
+
LOCAL_API = "http://127.0.0.1:3552"
|
|
40
|
+
|
|
41
|
+
# PostgreSQL stacks with plain pg_dump/psql dumps: database container, role and
|
|
42
|
+
# database (both named like the stack's POSTGRES_USER and POSTGRES_DB), the one
|
|
43
|
+
# app container stopped during a restore, and a table whose row count shows the
|
|
44
|
+
# data arrived (None when no table is known).
|
|
45
|
+
DATABASES = {
|
|
46
|
+
"mealie": {"db": "mealie-db", "user": "mealie", "database": "mealie", "app": "mealie", "table": "recipes"},
|
|
47
|
+
"paperless": {"db": "paperless-db", "user": "paperless", "database": "paperless", "app": "paperless", "table": "documents_document"},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Tracked host files the audit compares with the repository's host/ folder.
|
|
51
|
+
HOST_FILES = (
|
|
52
|
+
("arcane-backup.sh", "/usr/local/bin/arcane-backup.sh"),
|
|
53
|
+
("arcane-backup.service", "/etc/systemd/system/arcane-backup.service"),
|
|
54
|
+
("arcane-backup.timer", "/etc/systemd/system/arcane-backup.timer"),
|
|
55
|
+
("restic-check.sh", "/usr/local/bin/restic-check.sh"),
|
|
56
|
+
("restic-check.service", "/etc/systemd/system/restic-check.service"),
|
|
57
|
+
("restic-check.timer", "/etc/systemd/system/restic-check.timer"),
|
|
58
|
+
("media-watchdog.sh", "/usr/local/bin/media-watchdog.sh"),
|
|
59
|
+
("media-watchdog.service", "/etc/systemd/system/media-watchdog.service"),
|
|
60
|
+
("media-watchdog.timer", "/etc/systemd/system/media-watchdog.timer"),
|
|
61
|
+
("nas-media-check.py", "/usr/local/sbin/nas-media-check"),
|
|
62
|
+
("nas-media-check.service", "/etc/systemd/system/nas-media-check.service"),
|
|
63
|
+
("nas-media-check.timer", "/etc/systemd/system/nas-media-check.timer"),
|
|
64
|
+
("nas-media-check.logrotate", "/etc/logrotate.d/nas-media-check"),
|
|
65
|
+
("grimoire-api-firewall.sh", "/usr/local/sbin/grimoire-api-firewall.sh"),
|
|
66
|
+
("grimoire-api-firewall.service", "/etc/systemd/system/grimoire-api-firewall.service"),
|
|
67
|
+
("spc-backup-arcane-entry", "/usr/local/sbin/spc-backup-arcane-entry"),
|
|
68
|
+
("spc-arcane-host-entry", "/usr/local/sbin/spc-arcane-host-entry"),
|
|
69
|
+
("spc-arcane-op", "/usr/local/sbin/spc-arcane-op"),
|
|
70
|
+
("spc-arcane-sudoers", "/etc/sudoers.d/spc-arcane"),
|
|
71
|
+
)
|
|
72
|
+
UNITS = tuple(name for name, _ in HOST_FILES if name.endswith((".service", ".timer")))
|
|
73
|
+
LOOPBACK_CHECKS = {
|
|
74
|
+
"grimoire": "http://127.0.0.1:8001/healthz",
|
|
75
|
+
"mealie": "http://127.0.0.1:8083/api/recipes?page=1&perPage=1",
|
|
76
|
+
}
|
|
77
|
+
MEALIE_API = "http://127.0.0.1:8083/api/recipes?page=1&perPage=1"
|
|
78
|
+
OBJECT_ROOTS = {"grimoire": "/var/lib/grimoire/object-store"}
|
|
79
|
+
CAPACITY_PATHS = {"containerd": "/var/lib/containerd", "grimoire": "/var/lib/grimoire/object-store"}
|
|
80
|
+
STORAGE_AREAS = (
|
|
81
|
+
"root", "var", "var-lib", "logs", "docker", "volumes", "container-logs", "containerd", "grimoire",
|
|
82
|
+
"supabase", "nzb-scratch", "nzb-complete", "opt", "cache", "backups", "backup-staging", "backup-grimoire",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
86
|
+
UTC = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")
|
|
87
|
+
STAMP = re.compile(r"^[0-9]{8}T[0-9]{6}Z$")
|
|
88
|
+
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
89
|
+
VERSION = re.compile(r"^v?[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$")
|
|
90
|
+
IMDB = re.compile(r"^tt[0-9]{1,12}$")
|
|
91
|
+
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
92
|
+
INTEGER = re.compile(r"^[0-9]{1,9}$")
|
|
93
|
+
OBJECT_PATH = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_./-]{0,255}$")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Refused(Exception):
|
|
97
|
+
"""A request this file does not accept; the caller sees usage text and exit 2."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Failed(Exception):
|
|
101
|
+
"""An operation that started and did not complete; exit 1."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def emit(value):
|
|
105
|
+
print(json.dumps(value, sort_keys=True))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def need(pattern, value, what):
|
|
109
|
+
if not isinstance(value, str) or pattern.fullmatch(value) is None:
|
|
110
|
+
raise Refused(what)
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def arity(args, low, high, usage):
|
|
115
|
+
if not low <= len(args) <= high:
|
|
116
|
+
raise Refused(usage)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def utc_stamp():
|
|
120
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def run(argv, timeout, cwd=None, stdin=None, stdout=None):
|
|
124
|
+
"""Runs a fixed argument list; output that could hold data is never returned to the caller."""
|
|
125
|
+
try:
|
|
126
|
+
return subprocess.run(argv, cwd=cwd, stdin=stdin, stdout=stdout if stdout is not None else subprocess.PIPE,
|
|
127
|
+
stderr=subprocess.PIPE, timeout=timeout, check=False)
|
|
128
|
+
except subprocess.TimeoutExpired:
|
|
129
|
+
raise Failed(f"{Path(argv[0]).name} {argv[1] if len(argv) > 1 else ''} timed out after {timeout} s") from None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def must(argv, timeout, what, cwd=None):
|
|
133
|
+
result = run(argv, timeout, cwd=cwd)
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
raise Failed(f"{what} failed (exit {result.returncode}); its output was withheld")
|
|
136
|
+
return result.stdout.decode("utf-8", "replace")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def sha256_file(path):
|
|
140
|
+
digest = hashlib.sha256()
|
|
141
|
+
with open(path, "rb") as source:
|
|
142
|
+
for block in iter(lambda: source.read(1 << 20), b""):
|
|
143
|
+
digest.update(block)
|
|
144
|
+
return digest.hexdigest()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def root_dir(path):
|
|
148
|
+
"""Creates a directory only root can read; refuses one that already exists as a link or non-directory."""
|
|
149
|
+
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
150
|
+
raise Failed(f"{path} is not a plain directory")
|
|
151
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
152
|
+
os.chown(path, 0, 0)
|
|
153
|
+
os.chmod(path, 0o700)
|
|
154
|
+
return path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------- read-only probes
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def probe(module, request):
|
|
161
|
+
"""Runs one reviewed arcane-ops probe; it prints its own allowlisted JSON."""
|
|
162
|
+
path = PROBES / f"{module}.py"
|
|
163
|
+
spec = importlib.util.spec_from_file_location(f"spc_arcane_{module}", path)
|
|
164
|
+
if spec is None or spec.loader is None:
|
|
165
|
+
raise Failed(f"probe {module} is not installed")
|
|
166
|
+
loaded = importlib.util.module_from_spec(spec)
|
|
167
|
+
spec.loader.exec_module(loaded)
|
|
168
|
+
loaded.run(request)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def window_args(args, usage):
|
|
172
|
+
since, until = args
|
|
173
|
+
need(UTC, since, usage)
|
|
174
|
+
need(UTC, until, usage)
|
|
175
|
+
return since, until
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def op_container_memory(args):
|
|
179
|
+
arity(args, 1, 1, "container-memory <name>")
|
|
180
|
+
probe("resource_probe", {"kind": "container_memory", "name": need(NAME, args[0], "container-memory <name>")})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def op_container_stats(args):
|
|
184
|
+
arity(args, 0, 8, "container-stats [name...] (at most 8)")
|
|
185
|
+
probe("resource_probe", {"kind": "container_stats", "names": [need(NAME, a, "container-stats [name...]") for a in args]})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def op_container_logs(args):
|
|
189
|
+
usage = "container-logs <name> <since> <until> [docker|bazarr-file] (UTC like 2026-09-06T12:15:00Z)"
|
|
190
|
+
arity(args, 3, 4, usage)
|
|
191
|
+
name = need(NAME, args[0], usage)
|
|
192
|
+
since, until = window_args(args[1:3], usage)
|
|
193
|
+
source = args[3] if len(args) == 4 else "docker"
|
|
194
|
+
if source not in ("docker", "bazarr-file") or (source == "bazarr-file" and name != "bazarr"):
|
|
195
|
+
raise Refused("the bazarr-file source is only available for bazarr")
|
|
196
|
+
probe("incident_probe", {"kind": "container_logs", "name": name, "since": since, "until": until, "source": source})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def op_container_oom(args):
|
|
200
|
+
usage = "container-oom <name> <since> <until>"
|
|
201
|
+
arity(args, 3, 3, usage)
|
|
202
|
+
name = need(NAME, args[0], usage)
|
|
203
|
+
since, until = window_args(args[1:3], usage)
|
|
204
|
+
probe("incident_probe", {"kind": "container_oom", "name": name, "since": since, "until": until})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def op_kernel_oom(args):
|
|
208
|
+
usage = "kernel-oom <since> <until>"
|
|
209
|
+
arity(args, 2, 2, usage)
|
|
210
|
+
since, until = window_args(args, usage)
|
|
211
|
+
probe("kernel_probe", {"kind": "kernel_oom", "since": since, "until": until})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def op_host_audit(args):
|
|
215
|
+
arity(args, 0, 0, "host-audit")
|
|
216
|
+
probe("host_probe", {"kind": "audit", "files": [{"name": n, "path": p} for n, p in HOST_FILES], "units": list(UNITS)})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def op_host_capacity(args):
|
|
220
|
+
arity(args, 0, 0, "host-capacity")
|
|
221
|
+
probe("resource_probe", {"kind": "host_capacity", "paths": CAPACITY_PATHS})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def op_host_storage(args):
|
|
225
|
+
arity(args, 0, 1, "host-storage [area]")
|
|
226
|
+
area = args[0] if args else "root"
|
|
227
|
+
if area not in STORAGE_AREAS:
|
|
228
|
+
raise Refused("host-storage area: " + ", ".join(STORAGE_AREAS))
|
|
229
|
+
probe("storage_probe", {"kind": "host_storage", "area": area})
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def op_docker_usage(args):
|
|
233
|
+
arity(args, 0, 0, "docker-usage")
|
|
234
|
+
probe("storage_probe", {"kind": "docker_usage"})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def op_service_check(args):
|
|
238
|
+
arity(args, 1, 1, "service-check grimoire|mealie")
|
|
239
|
+
if args[0] not in LOOPBACK_CHECKS:
|
|
240
|
+
raise Refused("service-check grimoire|mealie (public routes are edge's route-probe)")
|
|
241
|
+
probe("host_probe", {"kind": "http", "url": LOOPBACK_CHECKS[args[0]], "timeout": 10})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def fixed_probe(module, kind):
|
|
245
|
+
def op(args):
|
|
246
|
+
arity(args, 0, 0, kind.replace("_", "-"))
|
|
247
|
+
probe(module, {"kind": kind})
|
|
248
|
+
return op
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def op_mealie_api(args):
|
|
252
|
+
arity(args, 0, 0, "mealie-api (the key arrives on stdin)")
|
|
253
|
+
key = sys.stdin.read(4096).strip()
|
|
254
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]{16,2048}", key):
|
|
255
|
+
raise Refused("mealie-api needs the Mealie API key on stdin")
|
|
256
|
+
probe("mealie_probe", {"kind": "mealie_api", "url": MEALIE_API, "key": key})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def op_radarr_interface(args):
|
|
260
|
+
arity(args, 0, 0, "radarr-interface")
|
|
261
|
+
probe("radarr_probe", {"kind": "radarr_api", "action": "interface", "host_config": True})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def op_radarr_movie(args):
|
|
265
|
+
usage = "radarr-movie <tt…> [command-id]"
|
|
266
|
+
arity(args, 1, 2, usage)
|
|
267
|
+
request = {"kind": "radarr_api", "action": "movie", "host_config": True, "imdb": need(IMDB, args[0], usage)}
|
|
268
|
+
if len(args) == 2:
|
|
269
|
+
request["command_id"] = int(need(INTEGER, args[1], usage))
|
|
270
|
+
probe("radarr_probe", request)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def op_queue_job(args):
|
|
274
|
+
arity(args, 1, 1, "queue-job <uuid>")
|
|
275
|
+
probe("queue_probe", {"kind": "queue_job", "job_id": need(UUID, args[0], "queue-job <uuid>")})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def op_object_stat(args):
|
|
279
|
+
usage = "object-stat grimoire <relative path>"
|
|
280
|
+
arity(args, 2, 2, usage)
|
|
281
|
+
if args[0] not in OBJECT_ROOTS:
|
|
282
|
+
raise Refused(usage)
|
|
283
|
+
relative = need(OBJECT_PATH, args[1], usage)
|
|
284
|
+
parts = relative.split("/")
|
|
285
|
+
if any(part in ("", ".", "..") or part.startswith(".") for part in parts):
|
|
286
|
+
raise Refused("object path: no hidden files, empty parts, or parent traversal")
|
|
287
|
+
probe("host_probe", {"kind": "object", "root": OBJECT_ROOTS[args[0]], "relative": relative})
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------- manager upgrade
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def manager_container():
|
|
294
|
+
raw = must([DOCKER, "inspect", MANAGER], 30, "docker inspect arcane")
|
|
295
|
+
info = json.loads(raw)[0]
|
|
296
|
+
image_id = info.get("Image")
|
|
297
|
+
image = json.loads(must([DOCKER, "image", "inspect", image_id], 30, "docker image inspect"))[0]
|
|
298
|
+
return {
|
|
299
|
+
"status": info.get("State", {}).get("Status"),
|
|
300
|
+
"started_at": info.get("State", {}).get("StartedAt"),
|
|
301
|
+
"configured_image": info.get("Config", {}).get("Image"),
|
|
302
|
+
"image_id": image_id,
|
|
303
|
+
"repo_digests": [d for d in image.get("RepoDigests") or [] if d.startswith(MANAGER_IMAGE + "@")],
|
|
304
|
+
"revision": (image.get("Config", {}).get("Labels") or {}).get("org.opencontainers.image.revision"),
|
|
305
|
+
"platform": f"{image.get('Os')}/{image.get('Architecture')}",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
310
|
+
def redirect_request(self, *args, **kwargs):
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def local_opener():
|
|
315
|
+
return urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def local_version():
|
|
319
|
+
"""The running manager's version from its unauthenticated /api/version, read as the host probe reads it."""
|
|
320
|
+
try:
|
|
321
|
+
with local_opener().open(LOCAL_API + "/api/version", timeout=10) as response:
|
|
322
|
+
body = json.loads(response.read(65536))
|
|
323
|
+
value = body.get("currentVersion") if isinstance(body, dict) else None
|
|
324
|
+
return value if isinstance(value, str) and VERSION.fullmatch(value) else None
|
|
325
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def local_ui_status():
|
|
330
|
+
opener = local_opener()
|
|
331
|
+
try:
|
|
332
|
+
with opener.open(LOCAL_API + "/", timeout=10) as response:
|
|
333
|
+
return response.status
|
|
334
|
+
except urllib.error.HTTPError as error:
|
|
335
|
+
return error.code
|
|
336
|
+
except (urllib.error.URLError, OSError):
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def container_snapshot():
|
|
341
|
+
ids = must([DOCKER, "ps", "-aq", "--no-trunc"], 30, "docker ps").split()
|
|
342
|
+
if not ids:
|
|
343
|
+
return []
|
|
344
|
+
raw = json.loads(must([DOCKER, "inspect", *ids], 60, "docker inspect"))
|
|
345
|
+
return sorted(
|
|
346
|
+
({"id": c.get("Id"), "name": (c.get("Name") or "").lstrip("/"), "image": c.get("Image"),
|
|
347
|
+
"started_at": c.get("State", {}).get("StartedAt"), "status": c.get("State", {}).get("Status")} for c in raw),
|
|
348
|
+
key=lambda c: c["name"],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def upgrade_dir(stamp, create=False):
|
|
353
|
+
need(STAMP, stamp, "stamp: YYYYMMDDTHHMMSSZ")
|
|
354
|
+
path = UPGRADES / stamp
|
|
355
|
+
if create:
|
|
356
|
+
if path.exists():
|
|
357
|
+
raise Failed(f"{path} already exists")
|
|
358
|
+
root_dir(UPGRADES)
|
|
359
|
+
return root_dir(path)
|
|
360
|
+
if path.is_symlink() or not path.is_dir():
|
|
361
|
+
raise Failed(f"no upgrade record {stamp}")
|
|
362
|
+
return path
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def write_private(path, data):
|
|
366
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
367
|
+
with os.fdopen(descriptor, "wb") as target:
|
|
368
|
+
target.write(data)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def compose_digest():
|
|
372
|
+
match = re.search(re.escape(MANAGER_IMAGE) + r"@(sha256:[0-9a-f]{64})", COMPOSE_FILE.read_text(encoding="utf-8"))
|
|
373
|
+
return match.group(1) if match else None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def op_manager_state(args):
|
|
377
|
+
arity(args, 0, 0, "manager-state")
|
|
378
|
+
emit({"container": manager_container(), "compose_sha256": sha256_file(COMPOSE_FILE), "compose_digest": compose_digest(),
|
|
379
|
+
"version": local_version()})
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def op_manager_prepare(args):
|
|
383
|
+
"""Before the pin commit: records the deployed Compose file, the manager, and every other container."""
|
|
384
|
+
arity(args, 1, 1, "manager-prepare <stamp>")
|
|
385
|
+
path = upgrade_dir(args[0], create=True)
|
|
386
|
+
write_private(path / "compose.before.yaml", COMPOSE_FILE.read_bytes())
|
|
387
|
+
state = {"container": manager_container(), "compose_sha256": sha256_file(COMPOSE_FILE), "version": local_version()}
|
|
388
|
+
write_private(path / "state.before.json", json.dumps(state, sort_keys=True).encode())
|
|
389
|
+
write_private(path / "containers.before.json", json.dumps(container_snapshot(), sort_keys=True).encode())
|
|
390
|
+
emit({"stamp": args[0], "path": str(path), **state})
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def op_manager_pull(args):
|
|
394
|
+
"""After the sync deployed the pin: pulls exactly that digest before any outage."""
|
|
395
|
+
arity(args, 1, 1, "manager-pull <sha256:digest>")
|
|
396
|
+
digest = need(DIGEST, args[0], "manager-pull <sha256:digest>")
|
|
397
|
+
if compose_digest() != digest:
|
|
398
|
+
raise Failed("the deployed Compose file does not pin that digest yet")
|
|
399
|
+
must([DOCKER, "compose", "pull", MANAGER], 900, "docker compose pull arcane", cwd=COMPOSE_DIR)
|
|
400
|
+
ref = f"{MANAGER_IMAGE}@{digest}"
|
|
401
|
+
image = json.loads(must([DOCKER, "image", "inspect", ref], 30, "docker image inspect"))[0]
|
|
402
|
+
emit({"image": ref, "platform": f"{image.get('Os')}/{image.get('Architecture')}",
|
|
403
|
+
"revision": (image.get("Config", {}).get("Labels") or {}).get("org.opencontainers.image.revision")})
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def exclude_env(info):
|
|
407
|
+
name = Path(info.name).name
|
|
408
|
+
return None if name == ".env" or name.startswith(".env.") else info
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def op_manager_backup(args):
|
|
412
|
+
"""Stops only the manager, archives its data volume without .env files, and checks the archive."""
|
|
413
|
+
arity(args, 1, 1, "manager-backup <stamp>")
|
|
414
|
+
path = upgrade_dir(args[0])
|
|
415
|
+
archive = path / "arcane-data.tar.gz"
|
|
416
|
+
if archive.exists():
|
|
417
|
+
raise Failed(f"{archive} already exists")
|
|
418
|
+
must([DOCKER, "compose", "stop", MANAGER], 120, "docker compose stop arcane", cwd=COMPOSE_DIR)
|
|
419
|
+
try:
|
|
420
|
+
descriptor = os.open(archive, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
421
|
+
with os.fdopen(descriptor, "wb") as raw, tarfile.open(fileobj=raw, mode="w:gz") as tar:
|
|
422
|
+
tar.add(str(MANAGER_DATA), arcname=".", filter=exclude_env)
|
|
423
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
424
|
+
members = len(tar.getmembers())
|
|
425
|
+
checksum = sha256_file(archive)
|
|
426
|
+
write_private(path / "SHA256SUMS", f"{checksum} arcane-data.tar.gz\n".encode())
|
|
427
|
+
manifest = {"stamp": args[0], "archive": archive.name, "sha256": checksum, "bytes": archive.stat().st_size,
|
|
428
|
+
"members": members, "source": str(MANAGER_DATA), "excluded": [".env", ".env.*"]}
|
|
429
|
+
write_private(path / "manifest.json", json.dumps(manifest, sort_keys=True).encode())
|
|
430
|
+
except Exception as error:
|
|
431
|
+
run([DOCKER, "compose", "start", MANAGER], 120, cwd=COMPOSE_DIR)
|
|
432
|
+
raise Failed(f"backup failed ({type(error).__name__}); the previous manager was started again") from None
|
|
433
|
+
emit(manifest)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def op_manager_start(args):
|
|
437
|
+
arity(args, 0, 0, "manager-start")
|
|
438
|
+
must([DOCKER, "compose", "up", "-d", "--no-deps", "--pull", "never", MANAGER], 300, "docker compose up arcane", cwd=COMPOSE_DIR)
|
|
439
|
+
emit({"started": MANAGER})
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def snapshot_changes(before, after):
|
|
443
|
+
"""Other containers that restarted, changed image, or vanished while only the manager was replaced."""
|
|
444
|
+
now = {c["name"]: c for c in after}
|
|
445
|
+
changed = []
|
|
446
|
+
for old in before:
|
|
447
|
+
if old["name"] == MANAGER:
|
|
448
|
+
continue
|
|
449
|
+
new = now.get(old["name"])
|
|
450
|
+
if new is None:
|
|
451
|
+
changed.append({"name": old["name"], "change": "missing"})
|
|
452
|
+
elif any(new[k] != old[k] for k in ("id", "image", "started_at", "status")):
|
|
453
|
+
changed.append({"name": old["name"], "change": "restarted or replaced"})
|
|
454
|
+
return changed
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def op_manager_verify(args):
|
|
458
|
+
usage = "manager-verify <stamp> <sha256:digest> <version>"
|
|
459
|
+
arity(args, 3, 3, usage)
|
|
460
|
+
digest = need(DIGEST, args[1], usage)
|
|
461
|
+
version = need(VERSION, args[2], usage).lstrip("v")
|
|
462
|
+
path = upgrade_dir(args[0])
|
|
463
|
+
container = None
|
|
464
|
+
for _ in range(30):
|
|
465
|
+
container = manager_container()
|
|
466
|
+
if container["status"] == "running" and local_version() is not None:
|
|
467
|
+
break
|
|
468
|
+
time.sleep(2)
|
|
469
|
+
observed_version = local_version()
|
|
470
|
+
before = json.loads((path / "containers.before.json").read_text(encoding="utf-8"))
|
|
471
|
+
changes = snapshot_changes(before, container_snapshot())
|
|
472
|
+
ui = local_ui_status()
|
|
473
|
+
checks = [
|
|
474
|
+
{"check": "running", "ok": container["status"] == "running", "observed": container["status"]},
|
|
475
|
+
{"check": "digest", "ok": f"{MANAGER_IMAGE}@{digest}" in container["repo_digests"], "observed": container["repo_digests"]},
|
|
476
|
+
{"check": "version", "ok": (observed_version or "").lstrip("v") == version, "observed": observed_version},
|
|
477
|
+
{"check": "local ui", "ok": ui == 200, "observed": ui},
|
|
478
|
+
{"check": "other containers unchanged", "ok": not changes, "observed": changes},
|
|
479
|
+
]
|
|
480
|
+
emit({"ok": all(c["ok"] for c in checks), "checks": checks})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def op_manager_rollback(args):
|
|
484
|
+
"""R2 of the upgrade rollback: the recorded Compose file and the previous image, nothing else.
|
|
485
|
+
|
|
486
|
+
The data rollback (R3) replaces the manager's data volume, so it stays an
|
|
487
|
+
operator step in arcane/AGENTS.md "Upgrade rollback"; this tool only keeps
|
|
488
|
+
the pre-upgrade archive and its checksum for it.
|
|
489
|
+
"""
|
|
490
|
+
arity(args, 1, 1, "manager-rollback <stamp>")
|
|
491
|
+
path = upgrade_dir(args[0])
|
|
492
|
+
before = path / "compose.before.yaml"
|
|
493
|
+
if not before.is_file():
|
|
494
|
+
raise Failed("no recorded compose.before.yaml")
|
|
495
|
+
# Copying into the existing file keeps its owner and mode, which the GitOps sync manages.
|
|
496
|
+
shutil.copyfile(before, COMPOSE_FILE)
|
|
497
|
+
must([DOCKER, "compose", "pull", MANAGER], 900, "docker compose pull arcane", cwd=COMPOSE_DIR)
|
|
498
|
+
must([DOCKER, "compose", "up", "-d", "--no-deps", MANAGER], 300, "docker compose up arcane", cwd=COMPOSE_DIR)
|
|
499
|
+
emit({"rolled_back": True, "image": compose_digest(), "data_archive": str(path / "arcane-data.tar.gz"),
|
|
500
|
+
"data": "unchanged; restoring the pre-upgrade data is the manual R3 step"})
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------- database dumps
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def database(name):
|
|
507
|
+
if name not in DATABASES:
|
|
508
|
+
raise Refused("stack: " + ", ".join(DATABASES))
|
|
509
|
+
return DATABASES[name]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def psql(db, sql, database_name, timeout=120):
|
|
513
|
+
result = run([DOCKER, "exec", db["db"], "psql", "-U", db["user"], "-d", database_name, "-X", "-q", "-At",
|
|
514
|
+
"-v", "ON_ERROR_STOP=1", "-c", sql], timeout)
|
|
515
|
+
if result.returncode != 0:
|
|
516
|
+
raise Failed(f"psql on {db['db']} failed (exit {result.returncode}); its output was withheld")
|
|
517
|
+
return result.stdout.decode("utf-8", "replace").strip()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def counts(db):
|
|
521
|
+
tables = int(psql(db, "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'", db["database"]))
|
|
522
|
+
rows = None
|
|
523
|
+
if db["table"] is not None and psql(db, f"SELECT to_regclass('public.{db['table']}') IS NOT NULL", db["database"]) == "t":
|
|
524
|
+
rows = int(psql(db, f"SELECT count(*) FROM public.{db['table']}", db["database"]))
|
|
525
|
+
return {"public_tables": tables, "table": db["table"], "rows": rows}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def dumps_of(stack):
|
|
529
|
+
base = DUMPS / stack
|
|
530
|
+
if not base.is_dir():
|
|
531
|
+
return []
|
|
532
|
+
return sorted(p for p in base.iterdir() if p.is_dir() and not p.is_symlink() and STAMP.fullmatch(p.name))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def dump(stack):
|
|
536
|
+
db = database(stack)
|
|
537
|
+
stamp = utc_stamp()
|
|
538
|
+
root_dir(DUMPS)
|
|
539
|
+
path = root_dir(root_dir(DUMPS / stack) / stamp)
|
|
540
|
+
target = path / f"{stack}.sql.gz"
|
|
541
|
+
before = counts(db)
|
|
542
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
543
|
+
with os.fdopen(descriptor, "wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb") as out:
|
|
544
|
+
process = subprocess.Popen([DOCKER, "exec", db["db"], "pg_dump", "-U", db["user"], "-d", db["database"]],
|
|
545
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
546
|
+
shutil.copyfileobj(process.stdout, out)
|
|
547
|
+
code = process.wait(timeout=3600)
|
|
548
|
+
if code != 0:
|
|
549
|
+
raise Failed(f"pg_dump of {stack} failed (exit {code}); {target} is incomplete and has no manifest")
|
|
550
|
+
with gzip.open(target, "rb") as check:
|
|
551
|
+
while check.read(1 << 20):
|
|
552
|
+
pass
|
|
553
|
+
checksum = sha256_file(target)
|
|
554
|
+
write_private(path / "SHA256SUMS", f"{checksum} {target.name}\n".encode())
|
|
555
|
+
info = {"stack": stack, "stamp": stamp, "file": str(target), "bytes": target.stat().st_size, "sha256": checksum, "counts": before}
|
|
556
|
+
write_private(path / "manifest.json", json.dumps(info, sort_keys=True).encode())
|
|
557
|
+
return info
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def op_db_list(args):
|
|
561
|
+
"""Dumps this tool made; it never deletes one, so `over_keep` names those past the last KEEP_DUMPS for an operator."""
|
|
562
|
+
arity(args, 1, 1, "db-list <stack>")
|
|
563
|
+
database(args[0])
|
|
564
|
+
rows = []
|
|
565
|
+
for path in dumps_of(args[0]):
|
|
566
|
+
manifest = path / "manifest.json"
|
|
567
|
+
rows.append(json.loads(manifest.read_text(encoding="utf-8")) if manifest.is_file() else {"stamp": path.name, "manifest": "missing"})
|
|
568
|
+
stamps = [r.get("stamp") for r in rows]
|
|
569
|
+
emit({"stack": args[0], "dumps": rows, "keep": KEEP_DUMPS, "over_keep": stamps[:-KEEP_DUMPS] if len(stamps) > KEEP_DUMPS else []})
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def op_db_dump(args):
|
|
573
|
+
arity(args, 1, 1, "db-dump <stack>")
|
|
574
|
+
emit(dump(args[0]))
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def container_running(name):
|
|
578
|
+
result = run([DOCKER, "inspect", "--format", "{{.State.Running}}", name], 30)
|
|
579
|
+
return result.returncode == 0 and result.stdout.strip() == b"true"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def load_dump(db, path):
|
|
583
|
+
"""Replays one checked dump into the stack's database; psql stops at the first error."""
|
|
584
|
+
with gzip.open(path, "rb") as source:
|
|
585
|
+
process = subprocess.Popen([DOCKER, "exec", "-i", db["db"], "psql", "-U", db["user"], "-d", db["database"],
|
|
586
|
+
"-X", "-q", "-v", "ON_ERROR_STOP=1"],
|
|
587
|
+
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
588
|
+
shutil.copyfileobj(source, process.stdin)
|
|
589
|
+
process.stdin.close()
|
|
590
|
+
code = process.wait(timeout=3600)
|
|
591
|
+
if code != 0:
|
|
592
|
+
raise Failed(f"psql restore exited {code}; its output was withheld")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def op_db_restore(args):
|
|
596
|
+
"""Restores one of this tool's dumps into an empty database, such as a new one after a major-version change.
|
|
597
|
+
|
|
598
|
+
It refuses a database that holds any table, so it never replaces data;
|
|
599
|
+
emptying a database first stays an operator decision.
|
|
600
|
+
"""
|
|
601
|
+
usage = "db-restore <stack> <stamp>"
|
|
602
|
+
arity(args, 2, 2, usage)
|
|
603
|
+
db = database(args[0])
|
|
604
|
+
stamp = need(STAMP, args[1], usage)
|
|
605
|
+
source = DUMPS / args[0] / stamp / f"{args[0]}.sql.gz"
|
|
606
|
+
if not source.is_file() or source.is_symlink():
|
|
607
|
+
raise Failed(f"no dump {stamp} for {args[0]}")
|
|
608
|
+
recorded = (source.parent / "SHA256SUMS").read_text(encoding="utf-8").split()[0]
|
|
609
|
+
if sha256_file(source) != recorded:
|
|
610
|
+
raise Failed("the dump does not match its checksum; nothing was changed")
|
|
611
|
+
before = counts(db)
|
|
612
|
+
if before["public_tables"] != 0:
|
|
613
|
+
raise Failed(f"{db['database']} holds {before['public_tables']} tables; db-restore only fills an empty database")
|
|
614
|
+
app_was_running = container_running(db["app"])
|
|
615
|
+
if app_was_running:
|
|
616
|
+
must([DOCKER, "stop", db["app"]], 120, f"docker stop {db['app']}")
|
|
617
|
+
outcome = {"stack": args[0], "restored": stamp, "before": before}
|
|
618
|
+
try:
|
|
619
|
+
load_dump(db, source)
|
|
620
|
+
outcome["after"] = counts(db)
|
|
621
|
+
outcome["ok"] = True
|
|
622
|
+
except Failed as error:
|
|
623
|
+
outcome["ok"] = False
|
|
624
|
+
outcome["error"] = f"{error}; the database may hold part of the dump"
|
|
625
|
+
finally:
|
|
626
|
+
if app_was_running:
|
|
627
|
+
run([DOCKER, "start", db["app"]], 120)
|
|
628
|
+
outcome["app_started"] = container_running(db["app"])
|
|
629
|
+
emit(outcome)
|
|
630
|
+
if not outcome["ok"]:
|
|
631
|
+
raise Failed("restore failed; see the JSON above")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
COMMANDS = {
|
|
635
|
+
"container-memory": op_container_memory,
|
|
636
|
+
"container-stats": op_container_stats,
|
|
637
|
+
"container-logs": op_container_logs,
|
|
638
|
+
"container-oom": op_container_oom,
|
|
639
|
+
"kernel-oom": op_kernel_oom,
|
|
640
|
+
"host-audit": op_host_audit,
|
|
641
|
+
"host-capacity": op_host_capacity,
|
|
642
|
+
"host-storage": op_host_storage,
|
|
643
|
+
"docker-usage": op_docker_usage,
|
|
644
|
+
"service-check": op_service_check,
|
|
645
|
+
"crawl4ai-info": fixed_probe("host_probe", "crawl4ai_info"),
|
|
646
|
+
"recyclarr-naming": fixed_probe("host_probe", "recyclarr_naming"),
|
|
647
|
+
"arcane-version": fixed_probe("host_probe", "arcane_version"),
|
|
648
|
+
"queue-callback": fixed_probe("host_probe", "queue_callback"),
|
|
649
|
+
"bazarr-providers": fixed_probe("incident_probe", "bazarr_providers"),
|
|
650
|
+
"mealie-api": op_mealie_api,
|
|
651
|
+
"radarr-interface": op_radarr_interface,
|
|
652
|
+
"radarr-movie": op_radarr_movie,
|
|
653
|
+
"queue-job": op_queue_job,
|
|
654
|
+
"object-stat": op_object_stat,
|
|
655
|
+
"manager-state": op_manager_state,
|
|
656
|
+
"manager-prepare": op_manager_prepare,
|
|
657
|
+
"manager-pull": op_manager_pull,
|
|
658
|
+
"manager-backup": op_manager_backup,
|
|
659
|
+
"manager-start": op_manager_start,
|
|
660
|
+
"manager-verify": op_manager_verify,
|
|
661
|
+
"manager-rollback": op_manager_rollback,
|
|
662
|
+
"db-list": op_db_list,
|
|
663
|
+
"db-dump": op_db_dump,
|
|
664
|
+
"db-restore": op_db_restore,
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def main(argv):
|
|
669
|
+
if not argv or argv[0] not in COMMANDS:
|
|
670
|
+
print("refused: unknown operation; known: " + " ".join(sorted(COMMANDS)), file=sys.stderr)
|
|
671
|
+
return 2
|
|
672
|
+
try:
|
|
673
|
+
COMMANDS[argv[0]](argv[1:])
|
|
674
|
+
except Refused as error:
|
|
675
|
+
print(f"refused: {error}", file=sys.stderr)
|
|
676
|
+
return 2
|
|
677
|
+
except Failed as error:
|
|
678
|
+
print(f"failed: {error}", file=sys.stderr)
|
|
679
|
+
return 1
|
|
680
|
+
return 0
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
if __name__ == "__main__":
|
|
684
|
+
os.umask(0o077)
|
|
685
|
+
sys.exit(main(sys.argv[1:]))
|