@astrofoundry/pi-astro 0.33.1 → 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/restarts.ts +6 -2
- package/extensions/astro-discord/tools-report.test.ts +1 -1
- 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,147 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { buildHostRemote, compareAudit, HOST_COMMANDS } from "./arcane-host.ts";
|
|
7
|
+
import { failedChecks, normalizeVersion, olderThan, pinCompose, pinnedDigest, projectSync, syncReady, utcStamp } from "./arcane-manager.ts";
|
|
8
|
+
import { containerFacts, deploymentChecks, listRows, parseDeploymentArgs, syncFacts } from "./deployment.ts";
|
|
9
|
+
import { UsageError } from "./errors.ts";
|
|
10
|
+
|
|
11
|
+
const OP = join(import.meta.dirname, "..", "entry", "remote", "spc-arcane-op.py");
|
|
12
|
+
const D1 = `sha256:${"a".repeat(64)}`;
|
|
13
|
+
const D2 = `sha256:${"b".repeat(64)}`;
|
|
14
|
+
|
|
15
|
+
describe("Arcane host commands", () => {
|
|
16
|
+
it("match the operations of spc-arcane-op exactly", () => {
|
|
17
|
+
const source = readFileSync(OP, "utf-8");
|
|
18
|
+
const table = source.slice(source.indexOf("COMMANDS = {"), source.indexOf("}", source.indexOf("COMMANDS = {")));
|
|
19
|
+
const names = [...table.matchAll(/^\s+"([a-z0-9-]+)":/gm)].map((m) => m[1]).sort();
|
|
20
|
+
expect(names).toEqual(Object.keys(HOST_COMMANDS).sort());
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("builds fixed remote commands and refuses bad arguments before SSH", () => {
|
|
24
|
+
expect(buildHostRemote(["container-memory", "grimoire-worker-1"]).remote).toBe("container-memory grimoire-worker-1");
|
|
25
|
+
expect(buildHostRemote(["container-logs", "bazarr", "2026-09-06T12:00:00Z", "2026-09-06T12:15:00Z", "bazarr-file"]).remote).toBe(
|
|
26
|
+
"container-logs bazarr 2026-09-06T12:00:00Z 2026-09-06T12:15:00Z bazarr-file",
|
|
27
|
+
);
|
|
28
|
+
expect(buildHostRemote(["db-restore", "mealie", "20261019T120000Z"]).name).toBe("db-restore");
|
|
29
|
+
expect(buildHostRemote(["mealie-api"]).spec.stdinSecret).toBe("MEALIE_API_KEY");
|
|
30
|
+
const bad = [
|
|
31
|
+
[],
|
|
32
|
+
["rm"],
|
|
33
|
+
["container-memory", "a b"],
|
|
34
|
+
["container-logs", "sonarr", "2026-09-06T12:00:00Z", "2026-09-06T12:15:00Z", "bazarr-file"],
|
|
35
|
+
["kernel-oom", "yesterday", "today"],
|
|
36
|
+
["host-storage", "etc"],
|
|
37
|
+
["service-check", "supabase-studio"],
|
|
38
|
+
["radarr-movie", "120737"],
|
|
39
|
+
["queue-job", "X"],
|
|
40
|
+
["object-stat", "grimoire", "../x"],
|
|
41
|
+
["object-stat", "grimoire", "a/.git/config"],
|
|
42
|
+
["db-dump", "supabase"],
|
|
43
|
+
["db-restore", "mealie", "latest"],
|
|
44
|
+
["container-stats", ...Array.from({ length: 9 }, (_, i) => `c${i}`)],
|
|
45
|
+
["manager-start"],
|
|
46
|
+
["manager-rollback", "20261019T120000Z"],
|
|
47
|
+
];
|
|
48
|
+
for (const args of bad) expect(() => buildHostRemote(args), args.join(" ")).toThrow(UsageError);
|
|
49
|
+
expect(buildHostRemote(["manager-rollback", "20261019T120000Z"], true).remote).toBe("manager-rollback 20261019T120000Z");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("audit comparison", () => {
|
|
53
|
+
let dir = "";
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("compares host hashes with host/<name> of the checkout", () => {
|
|
59
|
+
dir = mkdtempSync(join(tmpdir(), "audit-"));
|
|
60
|
+
mkdirSync(join(dir, "host"));
|
|
61
|
+
writeFileSync(join(dir, "host", "arcane-backup.sh"), "echo ok\n");
|
|
62
|
+
const sum = createHash("sha256").update("echo ok\n").digest("hex");
|
|
63
|
+
expect(
|
|
64
|
+
compareAudit(
|
|
65
|
+
[
|
|
66
|
+
{ name: "arcane-backup.sh", state: "present", sha256: sum, mode: 493, uid: 0, gid: 0 },
|
|
67
|
+
{ name: "restic-check.sh", state: "present", sha256: sum },
|
|
68
|
+
{ name: "arcane-backup.timer", state: "missing" },
|
|
69
|
+
],
|
|
70
|
+
dir,
|
|
71
|
+
).map((r) => [r.name, r.match]),
|
|
72
|
+
).toEqual([
|
|
73
|
+
["arcane-backup.sh", true],
|
|
74
|
+
["restic-check.sh", null],
|
|
75
|
+
["arcane-backup.timer", null],
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("manager upgrade helpers", () => {
|
|
82
|
+
const compose = `services:\n arcane:\n image: ghcr.io/getarcaneapp/manager@${D1}\n pull_policy: always\n`;
|
|
83
|
+
|
|
84
|
+
it("pins only the manager digest", () => {
|
|
85
|
+
expect(pinnedDigest(compose)).toBe(D1);
|
|
86
|
+
expect(pinCompose(compose, D2)).toBe(compose.replace(D1, D2));
|
|
87
|
+
expect(() => pinCompose("services: {}\n", D2)).toThrow(UsageError);
|
|
88
|
+
expect(() => pinCompose(compose + compose, D2)).toThrow(UsageError);
|
|
89
|
+
expect(() => pinCompose(compose, "sha256:short")).toThrow(UsageError);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reads versions and stamps", () => {
|
|
93
|
+
expect(normalizeVersion("v2.17.0")).toBe("2.17.0");
|
|
94
|
+
expect(() => normalizeVersion("latest")).toThrow(UsageError);
|
|
95
|
+
expect(olderThan("2.16.1", "2.17.0")).toBe(true);
|
|
96
|
+
expect(olderThan("2.17.0", "2.17.0")).toBe(false);
|
|
97
|
+
expect(olderThan("10.0.0", "9.9.9")).toBe(false);
|
|
98
|
+
expect(utcStamp(new Date("2026-10-19T12:34:56.789Z"))).toBe("20261019T123456Z");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("waits for a successful sync at the pin commit with the committed file", () => {
|
|
102
|
+
const projects = { data: [{ id: "p1", name: "arcane" }] };
|
|
103
|
+
const syncs = { data: [{ projectId: "p1", lastSyncStatus: "failed", lastSyncCommit: "abc1234", lastSyncAt: "t1" }] };
|
|
104
|
+
const sync = projectSync(projects, syncs, "arcane");
|
|
105
|
+
expect(syncReady(sync, "abc1234def", "x", "x")).toBe(false);
|
|
106
|
+
expect(syncReady({ ...sync, status: "success" }, "abc1234def", "x", "x")).toBe(true);
|
|
107
|
+
expect(syncReady({ ...sync, status: "success" }, "abc1234def", "x", "y")).toBe(false);
|
|
108
|
+
expect(syncReady({ ...sync, status: "success", commit: "fff0000" }, "abc1234def", "x", "x")).toBe(false);
|
|
109
|
+
expect(() => projectSync(projects, { data: [] }, "arcane")).toThrow(UsageError);
|
|
110
|
+
expect(failedChecks([{ check: "version", ok: false, observed: "2.16.1" }, { check: "digest", ok: true }])).toBe('version ("2.16.1")');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("deployment checks", () => {
|
|
115
|
+
const expect_ = { project: "grimoire", commit: "abc1234", services: { "grimoire-api-1": D1 }, jobs: { "grimoire-migrate": D2 }, noHealth: [] };
|
|
116
|
+
const sync = syncFacts({ lastSyncCommit: "abc1234ffff", lastSyncStatus: "success", lastSyncAt: "t1" });
|
|
117
|
+
|
|
118
|
+
it("parses the arguments arcane-ops took", () => {
|
|
119
|
+
expect(parseDeploymentArgs(["wait", "grimoire", "--commit", "abc1234", "--container", `grimoire-api-1=${D1}`, "--timeout", "120"])).toMatchObject({
|
|
120
|
+
wait: true,
|
|
121
|
+
timeoutS: 120,
|
|
122
|
+
services: { "grimoire-api-1": D1 },
|
|
123
|
+
});
|
|
124
|
+
expect(() => parseDeploymentArgs(["check", "grimoire", "--container", `a=${D1}`])).toThrow(UsageError);
|
|
125
|
+
expect(() => parseDeploymentArgs(["check", "grimoire", "--commit", "abc1234"])).toThrow(UsageError);
|
|
126
|
+
expect(() => parseDeploymentArgs(["check", "grimoire", "--commit", "abc1234", "--container", "a=latest"])).toThrow(UsageError);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("passes a deployment whose commit, images, state and health all match", () => {
|
|
130
|
+
const api = containerFacts({ data: { imageId: D1, state: { status: "running", running: true, health: { status: "healthy" } }, composeInfo: { projectName: "grimoire" } } }, "grimoire-api-1");
|
|
131
|
+
const job = containerFacts({ imageId: D2, state: { status: "exited", running: false, exitCode: 0 }, composeInfo: { projectName: "grimoire" } }, "grimoire-migrate");
|
|
132
|
+
expect(deploymentChecks(expect_, sync, [api, job], sync).status).toBe("ok");
|
|
133
|
+
const unhealthy = { ...api, health: "unhealthy" };
|
|
134
|
+
expect(deploymentChecks(expect_, sync, [unhealthy, job], sync).status).toBe("failed");
|
|
135
|
+
const noCheck = { ...api, health: "no_check" };
|
|
136
|
+
expect(deploymentChecks(expect_, sync, [noCheck, job], sync).status).toBe("unknown");
|
|
137
|
+
expect(deploymentChecks({ ...expect_, noHealth: ["grimoire-api-1"] }, sync, [noCheck, job], sync).status).toBe("ok");
|
|
138
|
+
expect(deploymentChecks(expect_, sync, [api, job], { ...sync, at: "t2" }).checks.at(-1)).toEqual({ check: "stable_snapshot", status: "unknown" });
|
|
139
|
+
expect(deploymentChecks(expect_, sync, [null, job], sync).status).toBe("failed");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reads list rows wrapped or bare", () => {
|
|
143
|
+
expect(listRows({ data: [{ id: 1 }, null] })).toEqual([{ id: 1 }]);
|
|
144
|
+
expect(listRows([{ id: 2 }])).toEqual([{ id: 2 }]);
|
|
145
|
+
expect(listRows("x")).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { UsageError } from "./errors.ts";
|
|
5
|
+
|
|
6
|
+
const MINUTE = 60_000;
|
|
7
|
+
|
|
8
|
+
export interface HostCommand {
|
|
9
|
+
/** Smallest and largest number of arguments after the name. */
|
|
10
|
+
args: [number, number];
|
|
11
|
+
help: string;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
/** Steps of `manager-upgrade` and `manager-rollback`; never run through `host`. */
|
|
14
|
+
internal?: true;
|
|
15
|
+
/** The wrapper sends a secret on stdin instead of an argument. */
|
|
16
|
+
stdinSecret?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Operations of `spc-arcane-op` on VM 100, reached through the forced-command
|
|
21
|
+
* key of user `spc-arcane`. Names and argument counts must match that file's
|
|
22
|
+
* COMMANDS table (`entry/remote/spc-arcane-op.py`, tracked in the arcane
|
|
23
|
+
* repository's `host/`); the host checks every argument again.
|
|
24
|
+
*/
|
|
25
|
+
export const HOST_COMMANDS: Readonly<Record<string, HostCommand>> = {
|
|
26
|
+
"container-memory": { args: [1, 1], help: "container-memory <name> one memory sample and the configured limits", timeoutMs: MINUTE },
|
|
27
|
+
"container-stats": { args: [0, 8], help: "container-stats [name...] 60 s CPU and memory sample (default: the Grimoire workers)", timeoutMs: 2 * MINUTE },
|
|
28
|
+
"container-logs": { args: [3, 4], help: "container-logs <name> <since> <until> [docker|bazarr-file] sanitized event extracts, UTC window ≤ 1 day", timeoutMs: 2 * MINUTE },
|
|
29
|
+
"container-oom": { args: [3, 3], help: "container-oom <name> <since> <until> OOMKilled flag, limits, retained oom events", timeoutMs: 2 * MINUTE },
|
|
30
|
+
"kernel-oom": { args: [2, 2], help: "kernel-oom <since> <until> sanitized kernel OOM records from the journal", timeoutMs: MINUTE },
|
|
31
|
+
"host-audit": { args: [0, 0], help: "host-audit installed host files and units, compared with the repository's host/", timeoutMs: MINUTE },
|
|
32
|
+
"host-capacity": { args: [0, 0], help: "host-capacity CPU, memory, swap, Docker and Grimoire disk space and inodes", timeoutMs: MINUTE },
|
|
33
|
+
"host-storage": { args: [0, 1], help: "host-storage [area] allocated bytes per directory in a known area (default root)", timeoutMs: 3 * MINUTE },
|
|
34
|
+
"docker-usage": { args: [0, 0], help: "docker-usage docker system df: counts, sizes, reclaimable", timeoutMs: 3 * MINUTE },
|
|
35
|
+
"service-check": { args: [1, 1], help: "service-check grimoire|mealie HTTP status of a guest-local endpoint", timeoutMs: MINUTE },
|
|
36
|
+
"crawl4ai-info": { args: [0, 0], help: "crawl4ai-info crawl4ai package version and browser pool state", timeoutMs: MINUTE },
|
|
37
|
+
"recyclarr-naming": { args: [0, 0], help: "recyclarr-naming whether Recyclarr's configuration syncs naming (flags only)", timeoutMs: MINUTE },
|
|
38
|
+
"arcane-version": { args: [0, 0], help: "arcane-version the manager's version from the guest", timeoutMs: MINUTE },
|
|
39
|
+
"queue-callback": { args: [0, 0], help: "queue-callback whether the Graphile worker posts to the batch callback (boolean)", timeoutMs: MINUTE },
|
|
40
|
+
"bazarr-providers": { args: [0, 0], help: "bazarr-providers Bazarr's enabled subtitle providers", timeoutMs: 2 * MINUTE },
|
|
41
|
+
"mealie-api": { args: [0, 0], help: "mealie-api authenticated Mealie recipe-list check (needs MEALIE_API_KEY)", timeoutMs: MINUTE, stdinSecret: "MEALIE_API_KEY" },
|
|
42
|
+
"radarr-interface": { args: [0, 0], help: "radarr-interface Radarr's queue actions from its interface bundle (host config key; owner approval)", timeoutMs: 2 * MINUTE },
|
|
43
|
+
"radarr-movie": { args: [1, 2], help: "radarr-movie <tt…> [command-id] naming, file, queue and import state of one movie (host config key; owner approval)", timeoutMs: 2 * MINUTE },
|
|
44
|
+
"queue-job": { args: [1, 1], help: "queue-job <uuid> Grimoire queue receipt, attempts, retry time, lock state", timeoutMs: MINUTE },
|
|
45
|
+
"object-stat": { args: [2, 2], help: "object-stat grimoire <path> one object's metadata and age", timeoutMs: MINUTE },
|
|
46
|
+
"db-list": { args: [1, 1], help: "db-list mealie|paperless this tool's dumps, newest last, with checksums and row counts", timeoutMs: MINUTE },
|
|
47
|
+
"db-dump": { args: [1, 1], help: "db-dump mealie|paperless pg_dump into /var/backups/spc-arcane/<stack>/<UTC> (gzip, checked, sha256)", timeoutMs: 60 * MINUTE },
|
|
48
|
+
"db-restore": { args: [2, 2], help: "db-restore <stack> <stamp> fill an EMPTY database from one of these dumps, the app stopped meanwhile (owner approval)", timeoutMs: 60 * MINUTE },
|
|
49
|
+
"manager-state": { args: [0, 0], help: "manager-state", timeoutMs: MINUTE, internal: true },
|
|
50
|
+
"manager-prepare": { args: [1, 1], help: "manager-prepare <stamp>", timeoutMs: 2 * MINUTE, internal: true },
|
|
51
|
+
"manager-pull": { args: [1, 1], help: "manager-pull <digest>", timeoutMs: 16 * MINUTE, internal: true },
|
|
52
|
+
"manager-backup": { args: [1, 1], help: "manager-backup <stamp>", timeoutMs: 30 * MINUTE, internal: true },
|
|
53
|
+
"manager-start": { args: [0, 0], help: "manager-start", timeoutMs: 6 * MINUTE, internal: true },
|
|
54
|
+
"manager-verify": { args: [3, 3], help: "manager-verify <stamp> <digest> <version>", timeoutMs: 3 * MINUTE, internal: true },
|
|
55
|
+
"manager-rollback": { args: [1, 1], help: "manager-rollback <stamp>", timeoutMs: 20 * MINUTE, internal: true },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
59
|
+
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
60
|
+
export const STAMP = /^\d{8}T\d{6}Z$/;
|
|
61
|
+
const IMDB = /^tt\d{1,12}$/;
|
|
62
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
63
|
+
const OBJECT_PATH = /^[A-Za-z0-9_][A-Za-z0-9_./-]{0,255}$/;
|
|
64
|
+
export const DB_STACKS = ["mealie", "paperless"] as const;
|
|
65
|
+
const STORAGE_AREAS = [
|
|
66
|
+
"root", "var", "var-lib", "logs", "docker", "volumes", "container-logs", "containerd", "grimoire",
|
|
67
|
+
"supabase", "nzb-scratch", "nzb-complete", "opt", "cache", "backups", "backup-staging", "backup-grimoire",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function check(ok: boolean, message: string): void {
|
|
71
|
+
if (!ok) throw new UsageError(message);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The remote command for `host <name> [args]`; the same rules as the host, so mistakes fail before SSH. */
|
|
75
|
+
export function buildHostRemote(args: string[], allowInternal = false): { remote: string; spec: HostCommand; name: string } {
|
|
76
|
+
const [name, ...rest] = args;
|
|
77
|
+
const spec = name === undefined ? undefined : HOST_COMMANDS[name];
|
|
78
|
+
if (!spec || name === undefined) {
|
|
79
|
+
throw new UsageError(`unknown host command: ${name ?? "(none)"}; known: ${Object.keys(HOST_COMMANDS).filter((n) => !HOST_COMMANDS[n].internal).join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
if (spec.internal && !allowInternal) throw new UsageError(`${name} is a step of manager-upgrade or manager-rollback, not a command of its own`);
|
|
82
|
+
check(rest.length >= spec.args[0] && rest.length <= spec.args[1], `usage: ${spec.help.split(" ")[0]}`);
|
|
83
|
+
switch (name) {
|
|
84
|
+
case "container-memory":
|
|
85
|
+
case "container-stats":
|
|
86
|
+
check(rest.every((n) => NAME.test(n)), "container names: letters, digits, dot, dash, underscore");
|
|
87
|
+
break;
|
|
88
|
+
case "container-logs":
|
|
89
|
+
case "container-oom":
|
|
90
|
+
check(NAME.test(rest[0]), "container name: letters, digits, dot, dash, underscore");
|
|
91
|
+
check(UTC.test(rest[1]) && UTC.test(rest[2]), "since and until are UTC like 2026-09-06T12:15:00Z");
|
|
92
|
+
if (rest[3] !== undefined) check(rest[3] === "docker" || (rest[3] === "bazarr-file" && rest[0] === "bazarr"), "source: docker, or bazarr-file for bazarr");
|
|
93
|
+
break;
|
|
94
|
+
case "kernel-oom":
|
|
95
|
+
check(UTC.test(rest[0]) && UTC.test(rest[1]), "since and until are UTC like 2026-09-06T12:15:00Z");
|
|
96
|
+
break;
|
|
97
|
+
case "host-storage":
|
|
98
|
+
if (rest[0] !== undefined) check(STORAGE_AREAS.includes(rest[0]), `area: ${STORAGE_AREAS.join(", ")}`);
|
|
99
|
+
break;
|
|
100
|
+
case "service-check":
|
|
101
|
+
check(rest[0] === "grimoire" || rest[0] === "mealie", "service-check grimoire|mealie (public routes: edge route-probe)");
|
|
102
|
+
break;
|
|
103
|
+
case "radarr-movie":
|
|
104
|
+
check(IMDB.test(rest[0]), "an IMDb id like tt0120737");
|
|
105
|
+
if (rest[1] !== undefined) check(/^\d{1,9}$/.test(rest[1]), "command id: digits");
|
|
106
|
+
break;
|
|
107
|
+
case "queue-job":
|
|
108
|
+
check(UUID.test(rest[0]), "a lowercase job UUID");
|
|
109
|
+
break;
|
|
110
|
+
case "object-stat":
|
|
111
|
+
check(rest[0] === "grimoire", "object-stat grimoire <path>");
|
|
112
|
+
check(OBJECT_PATH.test(rest[1]) && !rest[1].split("/").some((p) => p === "" || p === "." || p === ".." || p.startsWith(".")), "a relative path without hidden parts or traversal");
|
|
113
|
+
break;
|
|
114
|
+
case "db-list":
|
|
115
|
+
case "db-dump":
|
|
116
|
+
check((DB_STACKS as readonly string[]).includes(rest[0]), `stack: ${DB_STACKS.join(", ")}`);
|
|
117
|
+
break;
|
|
118
|
+
case "db-restore":
|
|
119
|
+
check((DB_STACKS as readonly string[]).includes(rest[0]), `stack: ${DB_STACKS.join(", ")}`);
|
|
120
|
+
check(STAMP.test(rest[1]), "stamp: YYYYMMDDTHHMMSSZ from db-list");
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return { remote: [name, ...rest].join(" "), spec, name };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface AuditFile {
|
|
129
|
+
name: string;
|
|
130
|
+
state: string;
|
|
131
|
+
sha256?: string;
|
|
132
|
+
mode?: number;
|
|
133
|
+
uid?: number;
|
|
134
|
+
gid?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compares the host's file hashes with `host/<name>` in the specialist's
|
|
139
|
+
* checkout of the arcane repository; `match` is null when either side is missing.
|
|
140
|
+
*/
|
|
141
|
+
export function compareAudit(files: readonly AuditFile[], checkout: string): { name: string; state: string; match: boolean | null; mode?: number; uid?: number; gid?: number }[] {
|
|
142
|
+
return files.map((f) => {
|
|
143
|
+
const source = join(checkout, "host", f.name);
|
|
144
|
+
let expected: string | null = null;
|
|
145
|
+
if (existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
146
|
+
return { name: f.name, state: f.state, match: f.state !== "present" || expected === null ? null : f.sha256 === expected, mode: f.mode, uid: f.uid, gid: f.gid };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { listRows } from "./deployment.ts";
|
|
2
|
+
import { UsageError } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
/** The official manager image; arcane/compose.yaml pins it by digest. */
|
|
5
|
+
export const MANAGER_IMAGE = "ghcr.io/getarcaneapp/manager";
|
|
6
|
+
export const MANAGER_COMPOSE = "arcane/compose.yaml";
|
|
7
|
+
const PIN = /(image:\s*ghcr\.io\/getarcaneapp\/manager@)(sha256:[0-9a-f]{64})/g;
|
|
8
|
+
|
|
9
|
+
/** `2.17.0` from `2.17.0` or `v2.17.0`; anything else is not a release version. */
|
|
10
|
+
export function normalizeVersion(value: string): string {
|
|
11
|
+
const m = /^v?(\d{1,4}\.\d{1,4}\.\d{1,4})$/.exec(value);
|
|
12
|
+
if (!m) throw new UsageError(`not a release version: ${value.slice(0, 40)} (expected 2.17.0 or v2.17.0)`);
|
|
13
|
+
return m[1];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True when version a is older than version b (both plain x.y.z). */
|
|
17
|
+
export function olderThan(a: string, b: string): boolean {
|
|
18
|
+
const pa = a.split(".").map(Number);
|
|
19
|
+
const pb = b.split(".").map(Number);
|
|
20
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The digest arcane/compose.yaml pins, or null when it pins none or several. */
|
|
25
|
+
export function pinnedDigest(compose: string): string | null {
|
|
26
|
+
const all = [...compose.matchAll(PIN)];
|
|
27
|
+
return all.length === 1 ? all[0][2] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** arcane/compose.yaml with only the manager digest replaced; refuses a file that does not pin exactly one. */
|
|
31
|
+
export function pinCompose(compose: string, digest: string): string {
|
|
32
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new UsageError(`not an image digest: ${digest}`);
|
|
33
|
+
if (pinnedDigest(compose) === null) throw new UsageError(`${MANAGER_COMPOSE} does not pin ${MANAGER_IMAGE} by exactly one digest`);
|
|
34
|
+
return compose.replace(PIN, `$1${digest}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `YYYYMMDDTHHMMSSZ`, the record name on the host. */
|
|
38
|
+
export function utcStamp(now: Date): string {
|
|
39
|
+
return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SyncState {
|
|
43
|
+
status: string | null;
|
|
44
|
+
commit: string | null;
|
|
45
|
+
at: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The GitOps sync of one project from `projects list --all` and `gitops list --all` (exactly one, as a deployment needs). */
|
|
49
|
+
export function projectSync(projects: unknown, syncs: unknown, name: string): SyncState {
|
|
50
|
+
const project = listRows(projects).filter((p) => p.name === name);
|
|
51
|
+
if (project.length !== 1) throw new UsageError(`project ${name} is missing or ambiguous`);
|
|
52
|
+
const mine = listRows(syncs).filter((s) => s.projectId === project[0].id);
|
|
53
|
+
if (mine.length !== 1) throw new UsageError(`project ${name} needs exactly one GitOps sync, found ${mine.length}`);
|
|
54
|
+
const s = mine[0];
|
|
55
|
+
const text = (v: unknown) => (typeof v === "string" && v.length > 0 ? v : null);
|
|
56
|
+
return { status: text(s.lastSyncStatus), commit: text(s.lastSyncCommit), at: text(s.lastSyncAt) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ready for the host step when the sync reports success at the pin commit and
|
|
61
|
+
* the deployed Compose file is the committed one. A sync that copied the file
|
|
62
|
+
* but reported failure is not ready; the next scheduled sync must succeed.
|
|
63
|
+
*/
|
|
64
|
+
export function syncReady(sync: SyncState, commit: string, deployedSha256: string, committedSha256: string): boolean {
|
|
65
|
+
const sameCommit = sync.commit !== null && (commit.startsWith(sync.commit) || sync.commit.startsWith(commit));
|
|
66
|
+
return sameCommit && sync.status === "success" && deployedSha256 === committedSha256;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VerifyCheck {
|
|
70
|
+
check: string;
|
|
71
|
+
ok: boolean;
|
|
72
|
+
observed?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The checks that failed, as one line for a report. */
|
|
76
|
+
export function failedChecks(checks: readonly VerifyCheck[]): string {
|
|
77
|
+
return checks
|
|
78
|
+
.filter((c) => !c.ok)
|
|
79
|
+
.map((c) => `${c.check} (${JSON.stringify(c.observed ?? null).slice(0, 80)})`)
|
|
80
|
+
.join(", ");
|
|
81
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { UsageError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
/** One container of `containers get`, reduced to what a deployment check compares. */
|
|
4
|
+
export interface ContainerFacts {
|
|
5
|
+
name: string;
|
|
6
|
+
imageId: string | null;
|
|
7
|
+
state: string | null;
|
|
8
|
+
running: boolean | null;
|
|
9
|
+
exitCode: number | null;
|
|
10
|
+
/** healthy, unhealthy, starting, or no_check when the image defines no health check. */
|
|
11
|
+
health: string;
|
|
12
|
+
project: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SyncFacts {
|
|
16
|
+
commit: string | null;
|
|
17
|
+
status: string | null;
|
|
18
|
+
hasError: boolean;
|
|
19
|
+
hookConfigured: boolean;
|
|
20
|
+
hookStatus: string | null;
|
|
21
|
+
at: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeploymentExpectation {
|
|
25
|
+
project: string;
|
|
26
|
+
commit: string;
|
|
27
|
+
/** Long-running containers and their expected Docker image IDs. */
|
|
28
|
+
services: Record<string, string>;
|
|
29
|
+
/** One-shot containers that must have exited 0, with their image IDs. */
|
|
30
|
+
jobs: Record<string, string>;
|
|
31
|
+
/** Containers whose missing health check is waived explicitly. */
|
|
32
|
+
noHealth: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DeploymentCheck {
|
|
36
|
+
check: string;
|
|
37
|
+
status: "ok" | "failed" | "unknown";
|
|
38
|
+
container?: string;
|
|
39
|
+
expected?: string;
|
|
40
|
+
observed?: unknown;
|
|
41
|
+
waived?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMMIT = /^[0-9a-f]{7,40}$/;
|
|
45
|
+
const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
|
|
46
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
47
|
+
|
|
48
|
+
/** `deployment check|wait <project> --commit <sha> [--container N=sha256:…] [--job N=sha256:…] [--allow-no-health N] [--timeout s]`. */
|
|
49
|
+
export function parseDeploymentArgs(args: string[]): DeploymentExpectation & { wait: boolean; timeoutS: number } {
|
|
50
|
+
const [mode, project, ...rest] = args;
|
|
51
|
+
if (mode !== "check" && mode !== "wait") throw new UsageError("deployment check|wait <project> --commit <sha> [--container NAME=sha256:ID]... [--job NAME=sha256:ID]... [--allow-no-health NAME]... [--timeout seconds]");
|
|
52
|
+
if (project === undefined || !NAME.test(project)) throw new UsageError("deployment needs a project name");
|
|
53
|
+
const out: DeploymentExpectation & { wait: boolean; timeoutS: number } = { project, commit: "", services: {}, jobs: {}, noHealth: [], wait: mode === "wait", timeoutS: 600 };
|
|
54
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
55
|
+
const flag = rest[i];
|
|
56
|
+
const value = rest[i + 1];
|
|
57
|
+
if (value === undefined) throw new UsageError(`${flag} needs a value`);
|
|
58
|
+
if (flag === "--commit") {
|
|
59
|
+
if (!COMMIT.test(value)) throw new UsageError("--commit: a git commit (7 to 40 hex)");
|
|
60
|
+
out.commit = value;
|
|
61
|
+
} else if (flag === "--container" || flag === "--job") {
|
|
62
|
+
const [name, id] = value.split("=");
|
|
63
|
+
if (!NAME.test(name ?? "") || !IMAGE_ID.test(id ?? "")) throw new UsageError(`${flag} NAME=sha256:<64 hex image id>`);
|
|
64
|
+
(flag === "--container" ? out.services : out.jobs)[name] = id;
|
|
65
|
+
} else if (flag === "--allow-no-health") {
|
|
66
|
+
if (!NAME.test(value)) throw new UsageError("--allow-no-health NAME");
|
|
67
|
+
out.noHealth.push(value);
|
|
68
|
+
} else if (flag === "--timeout") {
|
|
69
|
+
const n = Number(value);
|
|
70
|
+
if (!Number.isInteger(n) || n < 10 || n > 1800) throw new UsageError("--timeout: 10 to 1800 seconds");
|
|
71
|
+
out.timeoutS = n;
|
|
72
|
+
} else {
|
|
73
|
+
throw new UsageError(`unknown option ${flag}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (out.commit === "") throw new UsageError("--commit is required");
|
|
77
|
+
if (Object.keys(out.services).length + Object.keys(out.jobs).length === 0) throw new UsageError("name at least one --container or --job");
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The rows of an arcane-cli list in JSON: the array itself or the API body's `data`. */
|
|
82
|
+
export function listRows(value: unknown): Record<string, unknown>[] {
|
|
83
|
+
const list = Array.isArray(value) ? value : (value as { data?: unknown } | null)?.data;
|
|
84
|
+
return Array.isArray(list) ? list.filter((r): r is Record<string, unknown> => typeof r === "object" && r !== null) : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function str(v: unknown): string | null {
|
|
88
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The fields of `containers get <name>` JSON (the API's container detail, inside `data` when wrapped). */
|
|
92
|
+
export function containerFacts(raw: unknown, name: string): ContainerFacts {
|
|
93
|
+
const top = (raw as { data?: unknown } | null)?.data ?? raw;
|
|
94
|
+
const c = (top ?? {}) as Record<string, unknown>;
|
|
95
|
+
const state = (c.state ?? {}) as Record<string, unknown>;
|
|
96
|
+
const health = state.health as { status?: unknown } | null | undefined;
|
|
97
|
+
const healthStatus = health === undefined || health === null ? "no_check" : str(health.status) ?? "unknown";
|
|
98
|
+
const compose = c.composeInfo as { projectName?: unknown } | null | undefined;
|
|
99
|
+
return {
|
|
100
|
+
name,
|
|
101
|
+
imageId: str(c.imageId),
|
|
102
|
+
state: str(state.status),
|
|
103
|
+
running: typeof state.running === "boolean" ? state.running : null,
|
|
104
|
+
exitCode: typeof state.exitCode === "number" ? state.exitCode : null,
|
|
105
|
+
health: healthStatus === "none" ? "no_check" : healthStatus,
|
|
106
|
+
project: str(compose?.projectName),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The sync fields a deployment compares, from one row of `gitops list --all`. */
|
|
111
|
+
export function syncFacts(row: Record<string, unknown>): SyncFacts {
|
|
112
|
+
return {
|
|
113
|
+
commit: str(row.lastSyncCommit),
|
|
114
|
+
status: str(row.lastSyncStatus),
|
|
115
|
+
hasError: Boolean(row.lastSyncError),
|
|
116
|
+
hookConfigured: Boolean(row.preDeployScriptPath),
|
|
117
|
+
hookStatus: str(row.preDeployLastRunStatus),
|
|
118
|
+
at: str(row.lastSyncAt),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The checks of arcane-ops `deployment check`, as one pure comparison. */
|
|
123
|
+
export function deploymentChecks(expect: DeploymentExpectation, sync: SyncFacts, containers: readonly (ContainerFacts | null)[], syncAfter: SyncFacts): { status: "ok" | "failed" | "unknown"; checks: DeploymentCheck[] } {
|
|
124
|
+
const checks: DeploymentCheck[] = [];
|
|
125
|
+
const commitOk = sync.commit !== null && (sync.commit.startsWith(expect.commit) || expect.commit.startsWith(sync.commit));
|
|
126
|
+
checks.push({ check: "commit", status: sync.commit === null ? "unknown" : commitOk ? "ok" : "failed", expected: expect.commit, observed: sync.commit });
|
|
127
|
+
checks.push({ check: "sync", status: sync.status === "success" && !sync.hasError ? "ok" : sync.status === null ? "unknown" : "failed", observed: sync.status });
|
|
128
|
+
if (sync.hookConfigured) checks.push({ check: "hook", status: sync.hookStatus === "success" ? "ok" : sync.hookStatus === null ? "unknown" : "failed", observed: sync.hookStatus });
|
|
129
|
+
const names = [...Object.keys(expect.services), ...Object.keys(expect.jobs)];
|
|
130
|
+
names.forEach((name, i) => {
|
|
131
|
+
const c = containers[i];
|
|
132
|
+
if (!c) {
|
|
133
|
+
checks.push({ check: "container", status: "failed", container: name, observed: "missing" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const expected = expect.services[name] ?? expect.jobs[name];
|
|
137
|
+
checks.push({ check: "project", status: c.project === null ? "unknown" : c.project === expect.project ? "ok" : "failed", container: name, observed: c.project });
|
|
138
|
+
checks.push({ check: "image", status: c.imageId === expected ? "ok" : "failed", container: name, expected, observed: c.imageId });
|
|
139
|
+
if (name in expect.jobs) {
|
|
140
|
+
checks.push({ check: "job_exit", status: c.state === "exited" && c.exitCode === 0 ? "ok" : "failed", container: name, observed: { state: c.state, exitCode: c.exitCode } });
|
|
141
|
+
} else {
|
|
142
|
+
checks.push({ check: "running", status: c.state === "running" && c.running === true ? "ok" : "failed", container: name, observed: c.state });
|
|
143
|
+
const waived = c.health === "no_check" && expect.noHealth.includes(name);
|
|
144
|
+
const status = c.health === "healthy" || waived ? "ok" : c.health === "no_check" || c.health === "unknown" ? "unknown" : "failed";
|
|
145
|
+
checks.push({ check: "health", status, container: name, observed: c.health, waived });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const stable = (["commit", "status", "hasError", "hookStatus", "at"] as const).every((k) => syncAfter[k] === sync[k]);
|
|
149
|
+
if (!stable) checks.push({ check: "stable_snapshot", status: "unknown" });
|
|
150
|
+
const statuses = new Set(checks.map((c) => c.status));
|
|
151
|
+
return { status: statuses.has("failed") ? "failed" : statuses.has("unknown") ? "unknown" : "ok", checks };
|
|
152
|
+
}
|
package/specialists/lib/repo.ts
CHANGED
|
@@ -54,7 +54,8 @@ function gitEnv(spec: RepoSpec): Record<string, string> {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
/** One git command in a specialist checkout, with its deploy key; for wrappers that commit a fixed change of their own. */
|
|
58
|
+
export async function git(spec: RepoSpec, dir: string, args: string[]): Promise<string> {
|
|
58
59
|
const result = await run("/usr/bin/git", ["-C", dir, ...args], { env: gitEnv(spec), timeoutMs: TIMEOUT_MS });
|
|
59
60
|
if (result.code !== 0) throw new ServiceError(`git ${args[0]} failed: ${result.stderr.trim()}`);
|
|
60
61
|
return result.stdout;
|
|
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
|
|
6
6
|
import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
|
|
7
7
|
import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
|
|
8
|
-
import { buildVpsRemote, command as edgeCommand, isTransientSshFailure } from "./edge/run.ts";
|
|
8
|
+
import { buildVpsRemote, command as edgeCommand, isTransientSshFailure, PUBLIC_ROUTES, routeProbe } from "./edge/run.ts";
|
|
9
9
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
10
10
|
import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
|
|
11
11
|
import { MAX_ADD_IDS, buildTrendarrCall, interpretRun, parseImdbIds, trendarrConfig, trendarrError, trendarrWarnings, command as mediaCommand } from "./media/run.ts";
|
|
@@ -184,6 +184,24 @@ describe("dns wrapper", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
describe("edge wrapper", () => {
|
|
187
|
+
it("probes public routes by status only, without following redirects", async () => {
|
|
188
|
+
const seen: string[][] = [];
|
|
189
|
+
const curl = async (args: string[]) => {
|
|
190
|
+
seen.push(args);
|
|
191
|
+
const url = args.at(-1) ?? "";
|
|
192
|
+
return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("studio") ? "302 0.10" : "200 0.05", code: 0 };
|
|
193
|
+
};
|
|
194
|
+
const all = await routeProbe([], curl);
|
|
195
|
+
expect(all).toHaveLength(Object.keys(PUBLIC_ROUTES).length);
|
|
196
|
+
expect(all.every((r) => r.ok)).toBe(true);
|
|
197
|
+
expect(seen.every((a) => !a.includes("--location") && !a.includes("-L") && a.includes("=https"))).toBe(true);
|
|
198
|
+
const [studio] = await routeProbe(["supabase-studio"], async () => ({ stdout: "200 0.1", code: 0 }));
|
|
199
|
+
expect(studio).toMatchObject({ route: "supabase-studio", httpCode: 200, ok: false });
|
|
200
|
+
const [down] = await routeProbe(["mealie-public"], async () => ({ stdout: "000 0", code: 28 }));
|
|
201
|
+
expect(down).toMatchObject({ httpCode: null, ok: false, curlExit: 28 });
|
|
202
|
+
await expect(routeProbe(["evil"], curl)).rejects.toThrow(UsageError);
|
|
203
|
+
});
|
|
204
|
+
|
|
187
205
|
it("maps vps commands and requires reboot confirmation", () => {
|
|
188
206
|
expect(buildVpsRemote(["status"])).toEqual({ remote: "status", stdin: false });
|
|
189
207
|
expect(buildVpsRemote(["deploy-nginx"])).toEqual({ remote: "deploy-nginx", stdin: true });
|