@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
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { buildHostRemote, compareAudit, HOST_COMMANDS, type AuditFile } from "../lib/arcane-host.ts";
|
|
6
|
+
import { failedChecks, MANAGER_COMPOSE, MANAGER_IMAGE, normalizeVersion, olderThan, pinCompose, projectSync, syncReady, utcStamp, type VerifyCheck } from "../lib/arcane-manager.ts";
|
|
4
7
|
import { readConfig } from "../lib/config.ts";
|
|
8
|
+
import { containerFacts, deploymentChecks, listRows, parseDeploymentArgs, syncFacts, type DeploymentExpectation } from "../lib/deployment.ts";
|
|
5
9
|
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
6
10
|
import { main } from "../lib/main.ts";
|
|
7
11
|
import { buildUpdateReport, parseTag, resolveRef, stackServices, type CheckEntry } from "../lib/image-updates.ts";
|
|
8
12
|
import { printJson, printRaw } from "../lib/output.ts";
|
|
9
|
-
import {
|
|
13
|
+
import { specialistsHome } from "../lib/paths.ts";
|
|
14
|
+
import { run, type RunResult } from "../lib/proc.ts";
|
|
10
15
|
import { HttpRegistry, parseImageRef } from "../lib/registry.ts";
|
|
11
16
|
import { releaseNotesFor } from "../lib/releases.ts";
|
|
12
|
-
import { REPO_HELP, repoCommand, type RepoSpec } from "../lib/repo.ts";
|
|
17
|
+
import { ensureCheckout, git, REPO_HELP, repoCommand, repoStatus, type RepoSpec } from "../lib/repo.ts";
|
|
13
18
|
import { ARCANE_DENIED_KEYS, filterJsonText, stripKeys } from "../lib/sanitize.ts";
|
|
14
|
-
import { readSecret } from "../lib/secrets.ts";
|
|
19
|
+
import { readSecret, secretPath } from "../lib/secrets.ts";
|
|
20
|
+
import { sshFixed } from "../lib/ssh.ts";
|
|
15
21
|
import { mintAccessToken, parseServiceAccountKey } from "../lib/zitadel.ts";
|
|
16
22
|
|
|
17
23
|
const SERVICE = "arcane";
|
|
@@ -36,6 +42,14 @@ const SHAPE = {
|
|
|
36
42
|
gitopsBranch: "string",
|
|
37
43
|
} as const;
|
|
38
44
|
|
|
45
|
+
/** The Arcane VM through the forced-command key of user `spc-arcane`; read only by commands that use the host. */
|
|
46
|
+
interface HostConfig extends Record<string, string> {
|
|
47
|
+
hostAddress: string;
|
|
48
|
+
hostUser: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HOST_SHAPE = { hostAddress: "string", hostUser: "string" } as const;
|
|
52
|
+
|
|
39
53
|
/** CLI paths that would alter the wrapper's own setup or the binary; everything else passes through. */
|
|
40
54
|
export const REFUSED_PREFIXES: readonly string[] = ["config", "auth", "self-update", "completion"];
|
|
41
55
|
|
|
@@ -69,6 +83,21 @@ const HELP = `arcane specialist
|
|
|
69
83
|
|
|
70
84
|
<arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
|
|
71
85
|
(arcane-cli gitops ... manages the server-side syncs)
|
|
86
|
+
project-status [name] projects with their GitOps sync: commit, state, hook state (no error text)
|
|
87
|
+
deployment check|wait <project> --commit <sha> [--container NAME=sha256:ID]... [--job NAME=sha256:ID]...
|
|
88
|
+
[--allow-no-health NAME]... [--timeout s]
|
|
89
|
+
compare the sync commit and state, hook, and each container's project, image ID,
|
|
90
|
+
state and health; wait polls every 10 s (default 600 s); the answer's status says ok
|
|
91
|
+
manager-upgrade <version> upgrade Arcane itself (arcane/AGENTS.md "Manager upgrades"): digest pin commit, file-only
|
|
92
|
+
sync, pull, cold backup, restart of the manager only, checks; a failed check rolls back
|
|
93
|
+
the image and Compose file (the data stays; R3 is manual). Owner approval.
|
|
94
|
+
manager-rollback <stamp> put back the Compose file and image recorded at <stamp>; revert the pin in git yourself.
|
|
95
|
+
Owner approval.
|
|
96
|
+
host <command> [args] fixed operations on the Arcane VM (sanitized; raw logs never leave the guest):
|
|
97
|
+
${Object.entries(HOST_COMMANDS)
|
|
98
|
+
.filter(([, c]) => !c.internal)
|
|
99
|
+
.map(([, c]) => ` ${c.help}`)
|
|
100
|
+
.join("\n")}
|
|
72
101
|
update-report [--all] fresh image update check for every stack, with running and latest release versions
|
|
73
102
|
from the registries; JSON with a ready table (--all also lists up-to-date services).
|
|
74
103
|
Checks only: never pulls images or redeploys.
|
|
@@ -209,6 +238,248 @@ async function releaseNotes(config: ArcaneConfig, args: string[]): Promise<numbe
|
|
|
209
238
|
});
|
|
210
239
|
}
|
|
211
240
|
|
|
241
|
+
// ---------------------------------------------------------------- Arcane VM
|
|
242
|
+
|
|
243
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
244
|
+
|
|
245
|
+
async function hostCall(args: string[], allowInternal: boolean): Promise<RunResult> {
|
|
246
|
+
const { remote, spec } = buildHostRemote(args, allowInternal);
|
|
247
|
+
const host = readConfig<HostConfig>(SERVICE, HOST_SHAPE);
|
|
248
|
+
const input = spec.stdinSecret ? readSecret(SERVICE, spec.stdinSecret) : undefined;
|
|
249
|
+
const target = { host: host.hostAddress, user: host.hostUser, keyFile: secretPath(SERVICE, "SSH_KEY_ARCANE_HOST"), knownHostsFile: join(specialistsHome(), "config", "known_hosts") };
|
|
250
|
+
return sshFixed(target, remote, spec.timeoutMs, input);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One host operation whose answer is JSON; its refusal or failure text becomes the error. */
|
|
254
|
+
async function hostJson(args: string[]): Promise<Record<string, unknown>> {
|
|
255
|
+
const result = await hostCall(args, true);
|
|
256
|
+
if (result.code !== 0) throw new ServiceError(`host ${args[0]}: ${result.stderr.trim() || `exited ${result.code}`}`);
|
|
257
|
+
try {
|
|
258
|
+
const value: unknown = JSON.parse(result.stdout);
|
|
259
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("not an object");
|
|
260
|
+
if ((value as { error?: unknown }).error === true) throw new ServiceError(`host ${args[0]}: the check could not complete; its details stay on the guest`);
|
|
261
|
+
return value as Record<string, unknown>;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err instanceof ServiceError) throw err;
|
|
264
|
+
throw new ServiceError(`host ${args[0]}: output is not a JSON object`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function hostCommand(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
269
|
+
if (args[0] === "host-audit") {
|
|
270
|
+
if (args.length !== 1) throw new UsageError("host host-audit takes no arguments");
|
|
271
|
+
const audit = await hostJson(["host-audit"]);
|
|
272
|
+
const spec = repoSpec(config);
|
|
273
|
+
const dir = await ensureCheckout(spec);
|
|
274
|
+
let pulled = true;
|
|
275
|
+
try {
|
|
276
|
+
await git(spec, dir, ["pull", "--ff-only", "origin", config.gitopsBranch]);
|
|
277
|
+
} catch {
|
|
278
|
+
pulled = false;
|
|
279
|
+
}
|
|
280
|
+
const files = compareAudit(Array.isArray(audit.files) ? (audit.files as AuditFile[]) : [], dir);
|
|
281
|
+
printJson({ files, units: audit.units, comparedWith: pulled ? `origin/${config.gitopsBranch}` : "the local checkout (pull failed)" });
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
const result = await hostCall(args, false);
|
|
285
|
+
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
286
|
+
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `host ${args[0]} exited ${result.code}`);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function projectStatus(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
291
|
+
if (args.length > 1 || (args[0] !== undefined && !NAME.test(args[0]))) throw new UsageError("project-status [name]");
|
|
292
|
+
return withCli(config, async (cli) => {
|
|
293
|
+
const projects = listRows(await cliJson(cli, ["projects", "list", "--all"]));
|
|
294
|
+
const syncs = listRows(await cliJson(cli, ["gitops", "list", "--all"]));
|
|
295
|
+
const picked = args[0] === undefined ? projects : projects.filter((p) => p.name === args[0]);
|
|
296
|
+
if (args[0] !== undefined && picked.length !== 1) throw new UsageError(`no single project named ${args[0]}`);
|
|
297
|
+
printJson({
|
|
298
|
+
projects: picked.map((p) => ({
|
|
299
|
+
name: p.name,
|
|
300
|
+
status: p.status,
|
|
301
|
+
running: p.runningCount,
|
|
302
|
+
services: p.serviceCount,
|
|
303
|
+
syncs: syncs
|
|
304
|
+
.filter((s) => s.projectId === p.id)
|
|
305
|
+
.map((s) => ({ name: s.name, autoSync: s.autoSync, redeployAfterSync: s.redeployAfterSync, pullImageAfterSync: s.pullImageAfterSync, ...syncFacts(s) })),
|
|
306
|
+
})),
|
|
307
|
+
});
|
|
308
|
+
return 0;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function deploymentSnapshot(config: ArcaneConfig, expect: DeploymentExpectation): Promise<Record<string, unknown>> {
|
|
313
|
+
return withCli(config, async (cli) => {
|
|
314
|
+
const project = listRows(await cliJson(cli, ["projects", "list", "--all"])).filter((p) => p.name === expect.project);
|
|
315
|
+
if (project.length !== 1) throw new UsageError(`no single project named ${expect.project}`);
|
|
316
|
+
const mine = listRows(await cliJson(cli, ["gitops", "list", "--all"])).filter((s) => s.projectId === project[0].id);
|
|
317
|
+
if (mine.length !== 1) throw new UsageError(`project ${expect.project} needs exactly one GitOps sync, found ${mine.length}`);
|
|
318
|
+
const containers = [];
|
|
319
|
+
for (const name of [...Object.keys(expect.services), ...Object.keys(expect.jobs)]) {
|
|
320
|
+
try {
|
|
321
|
+
containers.push(containerFacts(await cliJson(cli, ["containers", "get", name]), name));
|
|
322
|
+
} catch {
|
|
323
|
+
containers.push(null);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const after = listRows(await cliJson(cli, ["gitops", "list", "--all"])).find((s) => s.id === mine[0].id) ?? {};
|
|
327
|
+
return { project: expect.project, ...deploymentChecks(expect, syncFacts(mine[0]), containers, syncFacts(after)) };
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** One federated token per check, because a wait can outlive the token's five minutes. */
|
|
332
|
+
async function deployment(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
333
|
+
const expect = parseDeploymentArgs(args);
|
|
334
|
+
const deadline = Date.now() + expect.timeoutS * 1000;
|
|
335
|
+
for (;;) {
|
|
336
|
+
const snapshot = await deploymentSnapshot(config, expect);
|
|
337
|
+
if (snapshot.status === "ok" || !expect.wait) {
|
|
338
|
+
printJson(snapshot);
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
if (Date.now() + 10_000 > deadline) {
|
|
342
|
+
printJson({ status: "timeout", lastCheck: snapshot });
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const RELEASE_COMMIT_URL = "https://api.github.com/repos/getarcaneapp/arcane/commits/";
|
|
350
|
+
|
|
351
|
+
/** The commit the official release tag points to, which the image's revision label must name. */
|
|
352
|
+
async function releaseCommit(tag: string): Promise<string> {
|
|
353
|
+
const res = await fetch(`${RELEASE_COMMIT_URL}${encodeURIComponent(tag)}`, { headers: { accept: "application/vnd.github+json", "user-agent": "pi-astro-arcane" }, signal: AbortSignal.timeout(15_000) });
|
|
354
|
+
if (!res.ok) throw new ServiceError(`GitHub returned ${res.status} for release ${tag}`);
|
|
355
|
+
const sha = ((await res.json()) as { sha?: unknown }).sha;
|
|
356
|
+
if (typeof sha !== "string" || !/^[0-9a-f]{40}$/.test(sha)) throw new ServiceError(`GitHub returned no commit for ${tag}`);
|
|
357
|
+
return sha;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function sha256(text: string): string {
|
|
361
|
+
return createHash("sha256").update(text).digest("hex");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const MINUTE = 60_000;
|
|
365
|
+
const SYNC_WAIT_MS = 30 * MINUTE;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* arcane/AGENTS.md "Manager upgrades", end to end. Prints one JSON answer with
|
|
369
|
+
* `status`: upgraded, rolled back (the new image failed its checks), or
|
|
370
|
+
* stopped (nothing was restarted). The exit code is 0 in all three so the
|
|
371
|
+
* caller always gets the answer; usage and precondition errors still fail.
|
|
372
|
+
*/
|
|
373
|
+
async function managerUpgrade(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
374
|
+
if (args.length !== 1) throw new UsageError("manager-upgrade <version>");
|
|
375
|
+
const version = normalizeVersion(args[0]);
|
|
376
|
+
const tag = `v${version}`;
|
|
377
|
+
const registry = new HttpRegistry();
|
|
378
|
+
const ref = parseImageRef(`${MANAGER_IMAGE}:${tag}`);
|
|
379
|
+
const digest = await registry.digest(ref, tag);
|
|
380
|
+
if (digest === null) throw new ServiceError(`${MANAGER_IMAGE}:${tag} does not exist`);
|
|
381
|
+
const revision = (await registry.labels(ref, tag))["org.opencontainers.image.revision"] ?? null;
|
|
382
|
+
const commitOfTag = await releaseCommit(tag);
|
|
383
|
+
if (revision !== commitOfTag) throw new ServiceError(`image revision ${revision ?? "(none)"} is not the commit of release ${tag} (${commitOfTag})`);
|
|
384
|
+
|
|
385
|
+
const state = await hostJson(["manager-state"]);
|
|
386
|
+
const runningVersion = typeof state.version === "string" ? normalizeVersion(state.version) : null;
|
|
387
|
+
if (runningVersion === null) throw new ServiceError("the running manager reports no version; check it before an upgrade");
|
|
388
|
+
if (!olderThan(runningVersion, version)) throw new UsageError(`Arcane runs ${runningVersion}; ${version} is not newer`);
|
|
389
|
+
const container = state.container as { platform?: unknown } | undefined;
|
|
390
|
+
|
|
391
|
+
const spec = repoSpec(config);
|
|
392
|
+
const dir = await ensureCheckout(spec);
|
|
393
|
+
await git(spec, dir, ["pull", "--ff-only", "origin", config.gitopsBranch]);
|
|
394
|
+
const status = await repoStatus(spec, dir);
|
|
395
|
+
if (status.changed.length > 0) throw new ServiceError(`the arcane checkout has uncommitted changes: ${status.changed.join(", ")}`);
|
|
396
|
+
const file = join(dir, MANAGER_COMPOSE);
|
|
397
|
+
const pinned = pinCompose(readFileSync(file, "utf-8"), digest);
|
|
398
|
+
|
|
399
|
+
const stamp = utcStamp(new Date());
|
|
400
|
+
const steps: Record<string, unknown> = { from: runningVersion, to: version, digest, revision, stamp };
|
|
401
|
+
await hostJson(["manager-prepare", stamp]);
|
|
402
|
+
writeFileSync(file, pinned);
|
|
403
|
+
await git(spec, dir, ["add", MANAGER_COMPOSE]);
|
|
404
|
+
await git(spec, dir, ["commit", "--quiet", "-m", `arcane: manager ${tag} (${digest})`]);
|
|
405
|
+
const commit = (await git(spec, dir, ["rev-parse", "HEAD"])).trim();
|
|
406
|
+
await git(spec, dir, ["push", "origin", config.gitopsBranch]);
|
|
407
|
+
steps.commit = commit;
|
|
408
|
+
|
|
409
|
+
const stop = async (reason: string): Promise<number> => {
|
|
410
|
+
await git(spec, dir, ["revert", "--no-edit", commit]);
|
|
411
|
+
await git(spec, dir, ["push", "origin", config.gitopsBranch]);
|
|
412
|
+
printJson({ status: "stopped", reason, ...steps, revertedPin: true, note: "the manager was not restarted; the next file-only sync puts the previous Compose file back" });
|
|
413
|
+
return 0;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const deadline = Date.now() + SYNC_WAIT_MS;
|
|
417
|
+
for (;;) {
|
|
418
|
+
try {
|
|
419
|
+
const sync = await withCli(config, async (cli) => projectSync(await cliJson(cli, ["projects", "list", "--all"]), await cliJson(cli, ["gitops", "list", "--all"]), "arcane"));
|
|
420
|
+
const deployed = await hostJson(["manager-state"]);
|
|
421
|
+
if (syncReady(sync, commit, String(deployed.compose_sha256), sha256(pinned))) break;
|
|
422
|
+
if (Date.now() > deadline) return stop(`the file-only sync did not report success at ${commit.slice(0, 12)} with the committed Compose file within 30 minutes (last: ${sync.status ?? "none"} at ${sync.commit ?? "none"})`);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
return stop(`waiting for the sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
425
|
+
}
|
|
426
|
+
await new Promise((r) => setTimeout(r, 30_000));
|
|
427
|
+
}
|
|
428
|
+
let pulled: Record<string, unknown>;
|
|
429
|
+
try {
|
|
430
|
+
pulled = await hostJson(["manager-pull", digest]);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
return stop(err instanceof Error ? err.message : String(err));
|
|
433
|
+
}
|
|
434
|
+
if (pulled.revision !== revision || pulled.platform !== container?.platform) {
|
|
435
|
+
return stop(`the pulled image is ${String(pulled.platform)} at ${String(pulled.revision)}, expected ${String(container?.platform)} at ${revision}`);
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
steps.backup = await hostJson(["manager-backup", stamp]);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
return stop(err instanceof Error ? err.message : String(err));
|
|
441
|
+
}
|
|
442
|
+
let checks: VerifyCheck[];
|
|
443
|
+
try {
|
|
444
|
+
await hostJson(["manager-start"]);
|
|
445
|
+
const verify = await hostJson(["manager-verify", stamp, digest, version]);
|
|
446
|
+
checks = (Array.isArray(verify.checks) ? verify.checks : []) as VerifyCheck[];
|
|
447
|
+
} catch (err) {
|
|
448
|
+
checks = [{ check: "start and verify", ok: false, observed: err instanceof Error ? err.message : String(err) }];
|
|
449
|
+
}
|
|
450
|
+
let cliOk = true;
|
|
451
|
+
try {
|
|
452
|
+
await withCli(config, (cli) => cliJson(cli, ["version"]));
|
|
453
|
+
} catch {
|
|
454
|
+
cliOk = false;
|
|
455
|
+
}
|
|
456
|
+
checks.push({ check: "federated arcane-cli login", ok: cliOk });
|
|
457
|
+
if (checks.length > 1 && checks.every((c) => c.ok)) {
|
|
458
|
+
printJson({ status: "upgraded", ...steps, checks, arcaneCli: version, note: `install the matching arcane-cli: specialist-install arcane-cli ${version}` });
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
const reason = failedChecks(checks) || "the checks returned nothing";
|
|
462
|
+
await git(spec, dir, ["revert", "--no-edit", commit]);
|
|
463
|
+
await git(spec, dir, ["push", "origin", config.gitopsBranch]);
|
|
464
|
+
const rollback = await hostJson(["manager-rollback", stamp]);
|
|
465
|
+
printJson({
|
|
466
|
+
status: "rolled back",
|
|
467
|
+
reason,
|
|
468
|
+
...steps,
|
|
469
|
+
checks,
|
|
470
|
+
rollback,
|
|
471
|
+
revertedPin: true,
|
|
472
|
+
note: "the previous image and Compose file run again; if the new version migrated the database, restore the pre-upgrade data by hand (arcane/AGENTS.md, Upgrade rollback)",
|
|
473
|
+
});
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function managerRollback(args: string[]): Promise<number> {
|
|
478
|
+
if (args.length !== 1) throw new UsageError("manager-rollback <stamp>");
|
|
479
|
+
printJson(await hostJson(["manager-rollback", args[0]]));
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
212
483
|
export async function command(args: string[]): Promise<number> {
|
|
213
484
|
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
214
485
|
printRaw(HELP);
|
|
@@ -218,6 +489,11 @@ export async function command(args: string[]): Promise<number> {
|
|
|
218
489
|
if (args[0] === "git") return repoCommand(repoSpec(config), args.slice(1));
|
|
219
490
|
if (args[0] === "update-report") return updateReport(config, args.slice(1));
|
|
220
491
|
if (args[0] === "release-notes") return releaseNotes(config, args.slice(1));
|
|
492
|
+
if (args[0] === "host") return hostCommand(config, args.slice(1));
|
|
493
|
+
if (args[0] === "project-status") return projectStatus(config, args.slice(1));
|
|
494
|
+
if (args[0] === "deployment") return deployment(config, args.slice(1));
|
|
495
|
+
if (args[0] === "manager-upgrade") return managerUpgrade(config, args.slice(1));
|
|
496
|
+
if (args[0] === "manager-rollback") return managerRollback(args.slice(1));
|
|
221
497
|
return passthrough(config, args);
|
|
222
498
|
}
|
|
223
499
|
|
package/specialists/edge/run.ts
CHANGED
|
@@ -73,9 +73,29 @@ const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
|
|
|
73
73
|
/** Debian package names: lower-case letters, digits, plus, minus, period. */
|
|
74
74
|
const PACKAGE = /^[a-z0-9][a-z0-9+.-]{0,63}$/;
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Public routes of services on the Arcane VM, checked from Cortex through
|
|
78
|
+
* public DNS the way users reach them: status code only, no redirect
|
|
79
|
+
* followed, no credentials. `expect` holds the codes a healthy route returns
|
|
80
|
+
* (the Supabase settings endpoint wants an API key, so 401; Studio redirects
|
|
81
|
+
* to the Pomerium sign-in; the rest answer 200, as arcane-ops judged them).
|
|
82
|
+
*/
|
|
83
|
+
export const PUBLIC_ROUTES: Readonly<Record<string, { url: string; expect: readonly number[] }>> = {
|
|
84
|
+
"supabase-auth-settings": { url: "https://supabase.37pla.net/auth/v1/settings", expect: [401] },
|
|
85
|
+
"supabase-jwks": { url: "https://supabase.37pla.net/auth/v1/.well-known/jwks.json", expect: [200] },
|
|
86
|
+
"supabase-openid": { url: "https://supabase.37pla.net/auth/v1/.well-known/openid-configuration", expect: [200] },
|
|
87
|
+
"supabase-oauth-discovery": { url: "https://supabase.37pla.net/auth/v1/.well-known/oauth-authorization-server", expect: [200] },
|
|
88
|
+
"supabase-studio": { url: "https://supabase-studio.37pla.net/", expect: [301, 302, 303, 307, 308] },
|
|
89
|
+
"grimoire-public": { url: "https://cli.grimoire.monadeo.com/v1/auth/config", expect: [200] },
|
|
90
|
+
"grimoire-public-health": { url: "https://cli.grimoire.monadeo.com/healthz", expect: [200] },
|
|
91
|
+
"mealie-public": { url: "https://mealie.37pla.net/api/recipes?page=1&perPage=1", expect: [200] },
|
|
92
|
+
};
|
|
93
|
+
|
|
76
94
|
const HELP = `edge specialist (Frontdoor VPS through an IAP tunnel)
|
|
77
95
|
|
|
78
96
|
probe GET the public OpenID discovery document through the VPS address
|
|
97
|
+
route-probe [name...] HTTP status of public routes from Cortex, no redirects, no credentials:
|
|
98
|
+
${Object.keys(PUBLIC_ROUTES).join(", ")}
|
|
79
99
|
vps <command> [args] fixed operations through the forced-command SSH key
|
|
80
100
|
${Object.entries(VPS_COMMANDS)
|
|
81
101
|
.map(([name, c]) => ` ${name.padEnd(14)} ${c.help}`)
|
|
@@ -206,11 +226,31 @@ async function probe(config: EdgeConfig): Promise<number> {
|
|
|
206
226
|
return ok ? 0 : 1;
|
|
207
227
|
}
|
|
208
228
|
|
|
229
|
+
/** One status check per route; redirects are reported, never followed. */
|
|
230
|
+
export async function routeProbe(names: string[], curl: (args: string[]) => Promise<{ stdout: string; code: number }>): Promise<Record<string, unknown>[]> {
|
|
231
|
+
const unknown = names.filter((n) => !(n in PUBLIC_ROUTES));
|
|
232
|
+
if (unknown.length > 0) throw new UsageError(`unknown route: ${unknown.join(", ")}; known: ${Object.keys(PUBLIC_ROUTES).join(", ")}`);
|
|
233
|
+
const picked = names.length > 0 ? names : Object.keys(PUBLIC_ROUTES);
|
|
234
|
+
return Promise.all(
|
|
235
|
+
picked.map(async (name) => {
|
|
236
|
+
const route = PUBLIC_ROUTES[name];
|
|
237
|
+
const result = await curl(["--silent", "--output", "/dev/null", "--max-time", "10", "--proto", "=https", "--write-out", "%{http_code} %{time_total}", route.url]);
|
|
238
|
+
const [code, seconds] = result.stdout.trim().split(" ");
|
|
239
|
+
const status = Number(code);
|
|
240
|
+
return { route: name, url: route.url, httpCode: Number.isInteger(status) && status > 0 ? status : null, expected: route.expect, ok: result.code === 0 && route.expect.includes(status), seconds: seconds ? Number(seconds) : null, curlExit: result.code };
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
209
245
|
export async function command(args: string[]): Promise<number> {
|
|
210
246
|
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
211
247
|
printRaw(HELP);
|
|
212
248
|
return 0;
|
|
213
249
|
}
|
|
250
|
+
if (args[0] === "route-probe") {
|
|
251
|
+
printJson(await routeProbe(args.slice(1), (curlArgs) => run("/usr/bin/curl", curlArgs, { timeoutMs: 20_000 })));
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
214
254
|
const config = readConfig<EdgeConfig>(SERVICE, SHAPE);
|
|
215
255
|
switch (args[0]) {
|
|
216
256
|
case "probe":
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Forced command for the arcane specialist's key on VM 100 (user spc-arcane, no docker group).
|
|
3
|
+
# authorized_keys line:
|
|
4
|
+
# command="/usr/local/sbin/spc-arcane-host-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-arcane-host@cortex
|
|
5
|
+
# The command is split into words without globbing and handed to the root-owned
|
|
6
|
+
# spc-arcane-op, the only command its sudoers entry allows; that file checks the
|
|
7
|
+
# operation name and every argument. Standard input passes through (mealie-api).
|
|
8
|
+
set -eu
|
|
9
|
+
cmd="${SSH_ORIGINAL_COMMAND:-}"
|
|
10
|
+
case "$cmd" in
|
|
11
|
+
"" | *[!A-Za-z0-9\ ._:/+-]*) echo "refused: characters outside A-Z a-z 0-9 space . _ : / + -" >&2; exit 2 ;;
|
|
12
|
+
esac
|
|
13
|
+
set -f
|
|
14
|
+
# shellcheck disable=SC2086
|
|
15
|
+
exec /usr/bin/sudo -n /usr/local/sbin/spc-arcane-op $cmd
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# One-time setup of the spc-arcane user on VM 100 for the arcane specialist.
|
|
3
|
+
# Run as root with these files in /tmp/spc-arcane: spc-arcane-host-entry,
|
|
4
|
+
# spc-arcane-op, spc-arcane-sudoers, spc-arcane-host.pub, and the arcane-ops
|
|
5
|
+
# probes host_probe.py, resource_probe.py, incident_probe.py, kernel_probe.py,
|
|
6
|
+
# storage_probe.py, queue_probe.py, mealie_probe.py, radarr_probe.py.
|
|
7
|
+
# Idempotent; reinstalling updates the files and keeps the user.
|
|
8
|
+
set -eu
|
|
9
|
+
src=/tmp/spc-arcane
|
|
10
|
+
probes="host_probe resource_probe incident_probe kernel_probe storage_probe queue_probe mealie_probe radarr_probe"
|
|
11
|
+
for f in spc-arcane-host-entry spc-arcane-op spc-arcane-sudoers spc-arcane-host.pub; do
|
|
12
|
+
[ -f "$src/$f" ] || { echo "missing $src/$f" >&2; exit 2; }
|
|
13
|
+
done
|
|
14
|
+
for p in $probes; do
|
|
15
|
+
[ -f "$src/$p.py" ] || { echo "missing $src/$p.py" >&2; exit 2; }
|
|
16
|
+
done
|
|
17
|
+
visudo -cf "$src/spc-arcane-sudoers"
|
|
18
|
+
id spc-arcane >/dev/null 2>&1 || useradd --system --create-home --home-dir /var/lib/spc-arcane --shell /bin/sh spc-arcane
|
|
19
|
+
install -d -o root -g root -m 0755 /usr/local/lib/spc-arcane
|
|
20
|
+
for p in $probes; do
|
|
21
|
+
install -o root -g root -m 0644 "$src/$p.py" "/usr/local/lib/spc-arcane/$p.py"
|
|
22
|
+
done
|
|
23
|
+
install -o root -g root -m 0755 "$src/spc-arcane-op" /usr/local/sbin/spc-arcane-op
|
|
24
|
+
install -o root -g root -m 0755 "$src/spc-arcane-host-entry" /usr/local/sbin/spc-arcane-host-entry
|
|
25
|
+
install -o root -g root -m 0440 "$src/spc-arcane-sudoers" /etc/sudoers.d/spc-arcane
|
|
26
|
+
install -d -o root -g root -m 0700 /var/backups/spc-arcane
|
|
27
|
+
install -d -o spc-arcane -g spc-arcane -m 0700 /var/lib/spc-arcane/.ssh
|
|
28
|
+
printf 'command="/usr/local/sbin/spc-arcane-host-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding %s\n' "$(cat "$src/spc-arcane-host.pub")" > /var/lib/spc-arcane/.ssh/authorized_keys
|
|
29
|
+
chown spc-arcane:spc-arcane /var/lib/spc-arcane/.ssh/authorized_keys
|
|
30
|
+
chmod 0600 /var/lib/spc-arcane/.ssh/authorized_keys
|
|
31
|
+
rm -rf "$src"
|
|
32
|
+
echo "spc-arcane ready: $(id spc-arcane)"
|
|
33
|
+
echo "groups (must not include docker): $(id -nG spc-arcane)"
|
|
34
|
+
echo "sshd key sources and user filters:"
|
|
35
|
+
sshd -T 2>/dev/null | grep -Ei '^(authorizedkeysfile|authorizedkeyscommand|pubkeyauthentication|allowusers|allowgroups|denyusers|denygroups) ' || true
|