@astrofoundry/pi-astro 0.35.0 → 0.36.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 +3 -3
- package/agents/arcane.md +3 -1
- package/agents/release-reviewer.md +2 -2
- package/extensions/astro-discord/daily-report.test.ts +153 -77
- package/extensions/astro-discord/daily-report.ts +322 -53
- package/extensions/astro-discord/index.ts +180 -51
- package/extensions/astro-discord/reports.ts +0 -2
- package/extensions/astro-discord/tools-report.test.ts +1 -1
- package/extensions/astro-discord/tools-report.ts +15 -3
- package/extensions/specialist-gate/approvals.test.ts +3 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +16 -3
- package/specialists/AGENTS.md +3 -2
- package/specialists/README.md +5 -5
- package/specialists/arcane/run.ts +223 -10
- package/specialists/lib/stack-update.test.ts +101 -0
- package/specialists/lib/stack-update.ts +115 -0
- package/specialists/wrappers.test.ts +1 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { buildHostRemote, compareAudit, HOST_COMMANDS, type AuditFile } from "../lib/arcane-host.ts";
|
|
@@ -7,14 +7,16 @@ import { failedChecks, MANAGER_COMPOSE, MANAGER_IMAGE, normalizeVersion, olderTh
|
|
|
7
7
|
import { readConfig } from "../lib/config.ts";
|
|
8
8
|
import { containerFacts, deploymentChecks, listRows, parseDeploymentArgs, syncFacts, type DeploymentExpectation } from "../lib/deployment.ts";
|
|
9
9
|
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
10
|
+
import { withLock } from "../lib/lock.ts";
|
|
10
11
|
import { main } from "../lib/main.ts";
|
|
11
|
-
import { buildUpdateReport, parseTag, resolveRef, stackServices, type CheckEntry } from "../lib/image-updates.ts";
|
|
12
|
+
import { buildUpdateReport, parseTag, resolveRef, stackServices, type CheckEntry, type UpdateRow } from "../lib/image-updates.ts";
|
|
12
13
|
import { printJson, printRaw } from "../lib/output.ts";
|
|
13
14
|
import { specialistsHome } from "../lib/paths.ts";
|
|
14
15
|
import { run, type RunResult } from "../lib/proc.ts";
|
|
15
16
|
import { HttpRegistry, parseImageRef } from "../lib/registry.ts";
|
|
16
17
|
import { releaseNotesFor } from "../lib/releases.ts";
|
|
17
|
-
import { ensureCheckout, git, REPO_HELP, repoCommand, repoStatus, type RepoSpec } from "../lib/repo.ts";
|
|
18
|
+
import { ensureCheckout, git, REPO_HELP, repoCommand, repoStatus, safeRepoPath, type RepoSpec } from "../lib/repo.ts";
|
|
19
|
+
import { commitMessage, containersProblem, crossesMajor, HOLDS_FILE, nextImageRef, parseHolds, pendingRows, replaceImageRef, type ServiceResult, type StackContainer, type StackStatus } from "../lib/stack-update.ts";
|
|
18
20
|
import { ARCANE_DENIED_KEYS, filterJsonText, stripKeys } from "../lib/sanitize.ts";
|
|
19
21
|
import { readSecret, secretPath } from "../lib/secrets.ts";
|
|
20
22
|
import { sshFixed } from "../lib/ssh.ts";
|
|
@@ -99,8 +101,15 @@ ${Object.entries(HOST_COMMANDS)
|
|
|
99
101
|
.map(([, c]) => ` ${c.help}`)
|
|
100
102
|
.join("\n")}
|
|
101
103
|
update-report [--all] fresh image update check for every stack, with running and latest release versions
|
|
102
|
-
from the registries; JSON with a ready table (--all also lists up-to-date services)
|
|
103
|
-
Checks only: never pulls images or redeploys.
|
|
104
|
+
from the registries; JSON with a ready table (--all also lists up-to-date services) and
|
|
105
|
+
the held stacks of ${HOLDS_FILE}. Checks only: never pulls images or redeploys.
|
|
106
|
+
stack-update <stack> [--confirm]
|
|
107
|
+
apply every pending update of one stack: pinned tags and digests move in the stack's
|
|
108
|
+
Compose file (commit, push, wait for the sync), then the stack is redeployed with a pull
|
|
109
|
+
and checked (containers running and healthy, images current). A failed check after a
|
|
110
|
+
Compose change reverts it and redeploys; a pull alone cannot be undone. Refuses held
|
|
111
|
+
stacks, stopped stacks, and arcane (manager-upgrade). An update across a major version
|
|
112
|
+
needs --confirm, which asks an owner for approval.
|
|
104
113
|
release-notes <stack>[/<service>]
|
|
105
114
|
GitHub release notes between the running and the latest version of one service's image,
|
|
106
115
|
found through the image's org.opencontainers.image.source label
|
|
@@ -160,14 +169,14 @@ function writeCallConfig(config: ArcaneConfig, token: string): string {
|
|
|
160
169
|
return file;
|
|
161
170
|
}
|
|
162
171
|
|
|
163
|
-
type Cli = (args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
172
|
+
type Cli = (args: string[], timeoutMs?: number) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
164
173
|
|
|
165
174
|
/** One federated token and one private config copy for every arcane-cli call of this invocation. */
|
|
166
175
|
async function withCli<T>(config: ArcaneConfig, fn: (cli: Cli) => Promise<T>): Promise<T> {
|
|
167
176
|
const token = await federatedToken(config);
|
|
168
177
|
const callConfig = writeCallConfig(config, token);
|
|
169
178
|
try {
|
|
170
|
-
return await fn((args) => run(config.arcaneCli, ["--config", callConfig, "--no-color", ...args], { env: { ARCANE_TOKEN: token }, timeoutMs
|
|
179
|
+
return await fn((args, timeoutMs = TIMEOUT_MS) => run(config.arcaneCli, ["--config", callConfig, "--no-color", ...args], { env: { ARCANE_TOKEN: token }, timeoutMs }));
|
|
171
180
|
} finally {
|
|
172
181
|
rmSync(dirname(callConfig), { recursive: true, force: true });
|
|
173
182
|
}
|
|
@@ -202,14 +211,30 @@ async function updateReport(config: ArcaneConfig, args: string[]): Promise<numbe
|
|
|
202
211
|
const extra = args.filter((a) => a !== "--all");
|
|
203
212
|
if (extra.length > 0) throw new UsageError(`update-report takes only --all, got: ${extra.join(" ")}`);
|
|
204
213
|
const startedAt = Date.now();
|
|
214
|
+
const holds = await holdsFromOrigin(config);
|
|
205
215
|
return withCli(config, async (cli) => {
|
|
206
216
|
const check = await cliJson(cli, ["images", "updates", "check", "--all"]);
|
|
207
217
|
const projects = await cliJson(cli, ["projects", "list", "--all"]);
|
|
208
|
-
|
|
218
|
+
const report = await buildUpdateReport({ check, projects, registry: new HttpRegistry(), startedAt, all: args.includes("--all") });
|
|
219
|
+
printJson({ ...report, holds: holds.holds, ...(holds.error ? { holdsError: holds.error } : {}) });
|
|
209
220
|
return 0;
|
|
210
221
|
});
|
|
211
222
|
}
|
|
212
223
|
|
|
224
|
+
/** The held stacks as pushed to the GitOps repository, read without touching the checkout's working tree. */
|
|
225
|
+
async function holdsFromOrigin(config: ArcaneConfig): Promise<{ holds: Record<string, string>; error?: string }> {
|
|
226
|
+
try {
|
|
227
|
+
const spec = repoSpec(config);
|
|
228
|
+
const dir = await ensureCheckout(spec);
|
|
229
|
+
await git(spec, dir, ["fetch", "--quiet", "origin", config.gitopsBranch]);
|
|
230
|
+
const listed = (await git(spec, dir, ["ls-tree", "--name-only", `origin/${config.gitopsBranch}`, HOLDS_FILE])).trim();
|
|
231
|
+
if (listed !== HOLDS_FILE) return { holds: {} };
|
|
232
|
+
return { holds: parseHolds(await git(spec, dir, ["show", `origin/${config.gitopsBranch}:${HOLDS_FILE}`])) };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { holds: {}, error: `${HOLDS_FILE} unreadable: ${err instanceof Error ? err.message.slice(0, 200) : String(err)}` };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
213
238
|
async function releaseNotes(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
214
239
|
if (args.length !== 1 || !/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)?$/.test(args[0])) throw new UsageError("release-notes <stack>[/<service>]");
|
|
215
240
|
const [stack, service] = args[0].split("/");
|
|
@@ -370,6 +395,16 @@ const SYNC_WAIT_MS = 30 * MINUTE;
|
|
|
370
395
|
* stopped (nothing was restarted). The exit code is 0 in all three so the
|
|
371
396
|
* caller always gets the answer; usage and precondition errors still fail.
|
|
372
397
|
*/
|
|
398
|
+
/**
|
|
399
|
+
* Everything that writes the arcane-gitops checkout takes turns: an update holds it
|
|
400
|
+
* for up to about two hours (sync and redeploy waits), so the lock outlives that.
|
|
401
|
+
*/
|
|
402
|
+
const CHECKOUT_STALE_MS = 3 * 60 * 60_000;
|
|
403
|
+
|
|
404
|
+
function checkoutLocked<T>(fn: () => Promise<T>): Promise<T> {
|
|
405
|
+
return withLock("arcane-gitops", fn, { staleMs: CHECKOUT_STALE_MS });
|
|
406
|
+
}
|
|
407
|
+
|
|
373
408
|
async function managerUpgrade(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
374
409
|
if (args.length !== 1) throw new UsageError("manager-upgrade <version>");
|
|
375
410
|
const version = normalizeVersion(args[0]);
|
|
@@ -480,20 +515,198 @@ async function managerRollback(args: string[]): Promise<number> {
|
|
|
480
515
|
return 0;
|
|
481
516
|
}
|
|
482
517
|
|
|
518
|
+
// ---------------------------------------------------------------- stack updates
|
|
519
|
+
|
|
520
|
+
/** How long a stack's sync may take to report success at a new commit; syncs run every few minutes. */
|
|
521
|
+
const STACK_SYNC_WAIT_MS = 20 * MINUTE;
|
|
522
|
+
/** arcane-cli allows a project redeploy up to 30 minutes for large pulls. */
|
|
523
|
+
const REDEPLOY_TIMEOUT_MS = 35 * MINUTE;
|
|
524
|
+
/** Containers with a health check get this long to leave "starting". */
|
|
525
|
+
const HEALTH_WAIT_MS = 5 * MINUTE;
|
|
526
|
+
|
|
527
|
+
interface StackState {
|
|
528
|
+
rows: UpdateRow[];
|
|
529
|
+
services: number;
|
|
530
|
+
stopped: boolean;
|
|
531
|
+
composePath: string | null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The stack's services with a fresh check of each of its images, resolved the way update-report resolves them. */
|
|
535
|
+
async function stackState(cli: Cli, stack: string, startedAt: number): Promise<StackState> {
|
|
536
|
+
const projects = await cliJson(cli, ["projects", "list", "--all"]);
|
|
537
|
+
const services = stackServices(projects).filter((s) => s.stack === stack);
|
|
538
|
+
if (services.length === 0) throw new UsageError(`no stack named ${stack}`);
|
|
539
|
+
const project = listRows(projects).find((p) => p.name === stack);
|
|
540
|
+
const sync = listRows(await cliJson(cli, ["gitops", "list", "--all"])).filter((s) => project !== undefined && s.projectId === project.id);
|
|
541
|
+
const composePath = sync.length === 1 && typeof sync[0].composePath === "string" ? sync[0].composePath : null;
|
|
542
|
+
const registry = new HttpRegistry();
|
|
543
|
+
const resolved = new Map<string, Awaited<ReturnType<typeof resolveRef>>>();
|
|
544
|
+
for (const ref of [...new Set(services.map((s) => s.ref).filter((r): r is string => r !== null))]) {
|
|
545
|
+
const entry = parseImageRef(ref).digest ? undefined : ((await cliJson(cli, ["images", "updates", "check", ref])) as CheckEntry);
|
|
546
|
+
resolved.set(ref, await resolveRef(ref, entry, registry, startedAt));
|
|
547
|
+
}
|
|
548
|
+
const rows: UpdateRow[] = services.map((s) => {
|
|
549
|
+
const r = s.ref === null ? undefined : resolved.get(s.ref);
|
|
550
|
+
if (s.ref === null || !r) return { stack, service: s.service, ref: s.ref ?? "", image: "?", kind: "floating", running: "?", latest: "?", status: "unknown", action: "", note: "Arcane lists no image for this service" };
|
|
551
|
+
return { stack, service: s.service, ref: s.ref, image: s.ref, kind: r.kind, running: r.running, latest: r.latest, status: r.status, action: r.action, note: r.note, ...(r.latestDigest ? { latestDigest: r.latestDigest } : {}) };
|
|
552
|
+
});
|
|
553
|
+
return { rows, services: services.length, stopped: services.some((s) => s.stopped), composePath };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The stack's containers as `containers get` reports them, found by the stack's images and kept when Compose says they belong to it. */
|
|
557
|
+
async function stackContainers(cli: Cli, stack: string, refs: readonly string[]): Promise<StackContainer[]> {
|
|
558
|
+
const listed = listRows(await cliJson(cli, ["containers", "list", "--all"])).filter((c) => typeof c.image === "string" && refs.includes(c.image));
|
|
559
|
+
const out: StackContainer[] = [];
|
|
560
|
+
for (const c of listed) {
|
|
561
|
+
if (typeof c.id !== "string") continue;
|
|
562
|
+
const name = Array.isArray(c.names) && typeof c.names[0] === "string" ? c.names[0].replace(/^\//, "") : c.id.slice(0, 12);
|
|
563
|
+
const facts = containerFacts(await cliJson(cli, ["containers", "get", c.id]), name);
|
|
564
|
+
if (facts.project === stack) out.push({ name, running: facts.running, state: facts.state, exitCode: facts.exitCode, health: facts.health });
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Waits until the stack's sync reports success at `commit`; false when it does not within the limit. */
|
|
570
|
+
async function waitForSync(config: ArcaneConfig, stack: string, commit: string): Promise<{ ok: boolean; last: string }> {
|
|
571
|
+
const deadline = Date.now() + STACK_SYNC_WAIT_MS;
|
|
572
|
+
for (;;) {
|
|
573
|
+
const sync = await withCli(config, async (cli) => projectSync(await cliJson(cli, ["projects", "list", "--all"]), await cliJson(cli, ["gitops", "list", "--all"]), stack));
|
|
574
|
+
const sameCommit = sync.commit !== null && (commit.startsWith(sync.commit) || sync.commit.startsWith(commit));
|
|
575
|
+
if (sameCommit && sync.status === "success") return { ok: true, last: `${sync.status} at ${sync.commit}` };
|
|
576
|
+
if (Date.now() > deadline) return { ok: false, last: `${sync.status ?? "none"} at ${sync.commit ?? "none"}` };
|
|
577
|
+
await new Promise((r) => setTimeout(r, 30_000));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function redeploy(config: ArcaneConfig, stack: string): Promise<string | null> {
|
|
582
|
+
const result = await withCli(config, (cli) => cli(["projects", "redeploy", stack, "--yes"], REDEPLOY_TIMEOUT_MS));
|
|
583
|
+
return result.code === 0 ? null : (result.stderr.trim().split("\n").pop() ?? "").slice(0, 300) || `projects redeploy exited ${result.code}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The containers' problem after a redeploy, polled until none remains or the health wait ends. */
|
|
587
|
+
async function settle(config: ArcaneConfig, stack: string, services: number): Promise<string | null> {
|
|
588
|
+
const deadline = Date.now() + HEALTH_WAIT_MS;
|
|
589
|
+
for (;;) {
|
|
590
|
+
const problem = await withCli(config, async (cli) => {
|
|
591
|
+
const refs = stackServices(await cliJson(cli, ["projects", "list", "--all"]))
|
|
592
|
+
.filter((s) => s.stack === stack)
|
|
593
|
+
.map((s) => s.ref)
|
|
594
|
+
.filter((r): r is string => r !== null);
|
|
595
|
+
return containersProblem(await stackContainers(cli, stack, refs), services);
|
|
596
|
+
});
|
|
597
|
+
if (problem === null || Date.now() > deadline) return problem;
|
|
598
|
+
await new Promise((r) => setTimeout(r, 15_000));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* One stack's pending updates, end to end and without judgment: pinned
|
|
604
|
+
* references move in the Compose file through the GitOps repository, then the
|
|
605
|
+
* stack is redeployed with a pull and checked. Prints one JSON answer whose
|
|
606
|
+
* `status` is updated, rolled back, failed, or nothing to do; the exit code is
|
|
607
|
+
* 0 for all of them so the caller always gets the answer.
|
|
608
|
+
*/
|
|
609
|
+
async function stackUpdate(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
610
|
+
const confirmed = args.includes("--confirm");
|
|
611
|
+
const rest = args.filter((a) => a !== "--confirm");
|
|
612
|
+
if (rest.length !== 1 || !NAME.test(rest[0])) throw new UsageError("stack-update <stack> [--confirm]");
|
|
613
|
+
const stack = rest[0];
|
|
614
|
+
if (stack === "arcane") throw new UsageError("arcane is the manager itself: use manager-upgrade (the Upgrade Arcane button)");
|
|
615
|
+
const spec = repoSpec(config);
|
|
616
|
+
const dir = await ensureCheckout(spec);
|
|
617
|
+
await git(spec, dir, ["pull", "--ff-only", "origin", config.gitopsBranch]);
|
|
618
|
+
const repo = await repoStatus(spec, dir);
|
|
619
|
+
if (repo.changed.length > 0) throw new ServiceError(`the arcane checkout has uncommitted changes: ${repo.changed.join(", ")}`);
|
|
620
|
+
if (repo.ahead > 0) throw new ServiceError(`the arcane checkout is ${repo.ahead} commit(s) ahead of origin`);
|
|
621
|
+
const holdsPath = join(dir, HOLDS_FILE);
|
|
622
|
+
const holds = existsSync(holdsPath) ? parseHolds(readFileSync(holdsPath, "utf-8")) : {};
|
|
623
|
+
if (holds[stack]) throw new UsageError(`${stack} is held in ${HOLDS_FILE}: ${holds[stack]}`);
|
|
624
|
+
|
|
625
|
+
const before = await withCli(config, (cli) => stackState(cli, stack, Date.now()));
|
|
626
|
+
if (before.stopped) throw new UsageError(`${stack} is stopped; a redeploy would start it, so start it on purpose first`);
|
|
627
|
+
const pending = pendingRows(before.rows);
|
|
628
|
+
const answer = (status: StackStatus, extra: Record<string, unknown>) => printJson({ status, stack, ...extra });
|
|
629
|
+
if (pending.length === 0) {
|
|
630
|
+
answer("nothing to do", { unknown: before.rows.filter((r) => r.status === "unknown").map((r) => ({ service: r.service, note: r.note })) });
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
const majors = pending.filter((r) => crossesMajor(r.running, r.latest));
|
|
634
|
+
if (majors.length > 0 && !confirmed) {
|
|
635
|
+
throw new UsageError(`${majors.map((r) => `${r.service} ${r.running} -> ${r.latest}`).join(", ")} crosses a major version: summarise release-notes ${stack}/<service> for the user, then repeat with --confirm after their go`);
|
|
636
|
+
}
|
|
637
|
+
const results: ServiceResult[] = pending.map((r) => ({ service: r.service, ref: r.ref, from: r.running, to: r.latest, applied: nextImageRef(r) ?? "pull", result: "unchecked" }));
|
|
638
|
+
const edits = results.filter((r) => r.applied !== "pull");
|
|
639
|
+
|
|
640
|
+
let commit: string | null = null;
|
|
641
|
+
if (edits.length > 0) {
|
|
642
|
+
if (before.composePath === null) throw new ServiceError(`${stack} has no single GitOps sync with a Compose path`);
|
|
643
|
+
const file = safeRepoPath(dir, before.composePath);
|
|
644
|
+
let text = readFileSync(file, "utf-8");
|
|
645
|
+
const moves = new Map(edits.map((e) => [e.ref, e.applied]));
|
|
646
|
+
for (const [from, to] of moves) text = replaceImageRef(text, from, to);
|
|
647
|
+
writeFileSync(file, text);
|
|
648
|
+
await git(spec, dir, ["add", before.composePath]);
|
|
649
|
+
await git(spec, dir, ["commit", "--quiet", "-m", commitMessage(stack, edits.map((e) => ({ service: e.service, from: e.ref, to: e.applied })))]);
|
|
650
|
+
commit = (await git(spec, dir, ["rev-parse", "HEAD"])).trim();
|
|
651
|
+
await git(spec, dir, ["push", "origin", config.gitopsBranch]);
|
|
652
|
+
const synced = await waitForSync(config, stack, commit);
|
|
653
|
+
if (!synced.ok) {
|
|
654
|
+
await git(spec, dir, ["revert", "--no-edit", commit]);
|
|
655
|
+
await git(spec, dir, ["push", "origin", config.gitopsBranch]);
|
|
656
|
+
answer("failed", { reason: `the sync did not report success at ${commit.slice(0, 12)} within ${STACK_SYNC_WAIT_MS / MINUTE} minutes (last: ${synced.last}); the commit was reverted and nothing was redeployed`, commit, services: results });
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let problem = await redeploy(config, stack);
|
|
662
|
+
if (problem === null) problem = await settle(config, stack, before.services);
|
|
663
|
+
if (problem === null) {
|
|
664
|
+
const after = await withCli(config, (cli) => stackState(cli, stack, Date.now()));
|
|
665
|
+
for (const r of results) {
|
|
666
|
+
const now = after.rows.find((x) => x.service === r.service);
|
|
667
|
+
// Current when nothing newer is pending, or when the target runs and a release appeared meanwhile.
|
|
668
|
+
r.result = now !== undefined && (now.status === "up-to-date" || (now.status !== "unknown" && now.running === r.to)) ? "current" : "behind";
|
|
669
|
+
}
|
|
670
|
+
const behind = results.filter((r) => r.result === "behind").map((r) => r.service);
|
|
671
|
+
if (behind.length > 0) problem = `still behind after the redeploy: ${behind.join(", ")}`;
|
|
672
|
+
}
|
|
673
|
+
if (problem === null) {
|
|
674
|
+
answer("updated", { services: results, ...(commit ? { commit } : {}) });
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
if (commit === null) {
|
|
678
|
+
answer("failed", { reason: problem, services: results, note: "the stack was redeployed with pulled images; a pull cannot be rolled back, so check the stack with the arcane specialist" });
|
|
679
|
+
return 0;
|
|
680
|
+
}
|
|
681
|
+
await git(spec, dir, ["revert", "--no-edit", commit]);
|
|
682
|
+
await git(spec, dir, ["push", "origin", config.gitopsBranch]);
|
|
683
|
+
const revert = (await git(spec, dir, ["rev-parse", "HEAD"])).trim();
|
|
684
|
+
const synced = await waitForSync(config, stack, revert);
|
|
685
|
+
const again = synced.ok ? await redeploy(config, stack) : `the revert's sync did not report success (last: ${synced.last})`;
|
|
686
|
+
const settled = again === null ? await settle(config, stack, before.services) : again;
|
|
687
|
+
if (settled === null) {
|
|
688
|
+
answer("rolled back", { reason: problem, services: results, commit, revert, note: "the previous Compose file runs again; images pulled for floating tags stay pulled" });
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
691
|
+
answer("failed", { reason: `${problem}; the rollback did not finish either: ${settled}`, services: results, commit, revert });
|
|
692
|
+
return 0;
|
|
693
|
+
}
|
|
694
|
+
|
|
483
695
|
export async function command(args: string[]): Promise<number> {
|
|
484
696
|
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
485
697
|
printRaw(HELP);
|
|
486
698
|
return 0;
|
|
487
699
|
}
|
|
488
700
|
const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
|
|
489
|
-
if (args[0] === "git") return repoCommand(repoSpec(config), args.slice(1));
|
|
701
|
+
if (args[0] === "git") return checkoutLocked(() => repoCommand(repoSpec(config), args.slice(1)));
|
|
490
702
|
if (args[0] === "update-report") return updateReport(config, args.slice(1));
|
|
491
703
|
if (args[0] === "release-notes") return releaseNotes(config, args.slice(1));
|
|
492
704
|
if (args[0] === "host") return hostCommand(config, args.slice(1));
|
|
493
705
|
if (args[0] === "project-status") return projectStatus(config, args.slice(1));
|
|
494
706
|
if (args[0] === "deployment") return deployment(config, args.slice(1));
|
|
495
|
-
if (args[0] === "manager-upgrade") return managerUpgrade(config, args.slice(1));
|
|
707
|
+
if (args[0] === "manager-upgrade") return checkoutLocked(() => managerUpgrade(config, args.slice(1)));
|
|
496
708
|
if (args[0] === "manager-rollback") return managerRollback(args.slice(1));
|
|
709
|
+
if (args[0] === "stack-update") return checkoutLocked(() => stackUpdate(config, args.slice(1)));
|
|
497
710
|
return passthrough(config, args);
|
|
498
711
|
}
|
|
499
712
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { UpdateRow } from "./image-updates.ts";
|
|
3
|
+
import { commitMessage, containersProblem, crossesMajor, nextImageRef, parseHolds, pendingRows, replaceImageRef, type StackContainer } from "./stack-update.ts";
|
|
4
|
+
|
|
5
|
+
const A = `sha256:${"a".repeat(64)}`;
|
|
6
|
+
const B = `sha256:${"b".repeat(64)}`;
|
|
7
|
+
|
|
8
|
+
function row(over: Partial<UpdateRow>): UpdateRow {
|
|
9
|
+
return { stack: "s", service: "svc", ref: "nginx:1.31.4-alpine", image: "nginx:1.31.4-alpine", kind: "fixed", running: "1.31.4-alpine", latest: "1.31.6-alpine", status: "release", action: "edit tag", note: "", ...over };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("update holds", () => {
|
|
13
|
+
it("reads stack names with a reason", () => {
|
|
14
|
+
expect(parseHolds('{"supabase": " coordinated release: review first "}')).toEqual({ supabase: "coordinated release: review first" });
|
|
15
|
+
expect(parseHolds("{}")).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("refuses a malformed file instead of holding nothing", () => {
|
|
19
|
+
expect(() => parseHolds("not json")).toThrow(/not JSON/);
|
|
20
|
+
expect(() => parseHolds('["supabase"]')).toThrow(/object of stack name/);
|
|
21
|
+
expect(() => parseHolds('{"supabase": ""}')).toThrow(/non-empty reason/);
|
|
22
|
+
expect(() => parseHolds('{"../x": "why"}')).toThrow(/stack name/);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("pending updates", () => {
|
|
27
|
+
it("keeps the rows an update changes and spots a major version", () => {
|
|
28
|
+
const rows = [row({ status: "update" }), row({ status: "release" }), row({ status: "rebuild" }), row({ status: "up-to-date" }), row({ status: "unknown" })];
|
|
29
|
+
expect(pendingRows(rows).map((r) => r.status)).toEqual(["update", "release", "rebuild"]);
|
|
30
|
+
expect(crossesMajor("2.14.3", "3.0.0")).toBe(true);
|
|
31
|
+
expect(crossesMajor("v2.16.1", "v2.17.0")).toBe(false);
|
|
32
|
+
expect(crossesMajor("older build", "3.0.0")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("moves a pinned tag or digest and leaves pulls to the redeploy", () => {
|
|
36
|
+
expect(nextImageRef(row({}))).toBe("nginx:1.31.6-alpine");
|
|
37
|
+
expect(nextImageRef(row({ ref: "registry.local:5000/app:1.2", latest: "1.3" }))).toBe("registry.local:5000/app:1.3");
|
|
38
|
+
expect(nextImageRef(row({ ref: `ghcr.io/x/y@${A}`, action: "edit digest", latestDigest: B }))).toBe(`ghcr.io/x/y@${B}`);
|
|
39
|
+
expect(nextImageRef(row({ ref: `ghcr.io/x/y:v1@${A}`, action: "edit digest", latestDigest: B }))).toBe(`ghcr.io/x/y:v1@${B}`);
|
|
40
|
+
expect(nextImageRef(row({ action: "pull" }))).toBeNull();
|
|
41
|
+
expect(() => nextImageRef(row({ latest: "new build" }))).toThrow(/cannot move/);
|
|
42
|
+
expect(() => nextImageRef(row({ ref: `ghcr.io/x/y@${A}`, action: "edit digest", latestDigest: "sha256:short" }))).toThrow(/new digest/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("Compose rewrite", () => {
|
|
47
|
+
const compose = [
|
|
48
|
+
"services:",
|
|
49
|
+
" db:",
|
|
50
|
+
" image: postgres:18",
|
|
51
|
+
" quoted:",
|
|
52
|
+
' image: "postgres:18" # pinned on purpose',
|
|
53
|
+
" single:",
|
|
54
|
+
" image: 'postgres:18'",
|
|
55
|
+
" longer:",
|
|
56
|
+
" image: postgres:18.1",
|
|
57
|
+
" other:",
|
|
58
|
+
" image: mypostgres:18",
|
|
59
|
+
" mixed:",
|
|
60
|
+
" image: \"postgres:18'",
|
|
61
|
+
" env:",
|
|
62
|
+
" environment:",
|
|
63
|
+
" IMAGE: postgres:18",
|
|
64
|
+
" image_note: image: postgres:18",
|
|
65
|
+
"",
|
|
66
|
+
].join("\n");
|
|
67
|
+
|
|
68
|
+
it("changes only image lines that name exactly the reference", () => {
|
|
69
|
+
const next = replaceImageRef(compose, "postgres:18", "postgres:19").split("\n");
|
|
70
|
+
expect(next[2]).toBe(" image: postgres:19");
|
|
71
|
+
expect(next[4]).toBe(' image: "postgres:19" # pinned on purpose');
|
|
72
|
+
expect(next[6]).toBe(" image: 'postgres:19'");
|
|
73
|
+
expect(next.slice(7)).toEqual(compose.split("\n").slice(7));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("treats the reference as text, not a pattern, and refuses a file without it", () => {
|
|
77
|
+
expect(replaceImageRef(" image: a.b:1+x\n image: aXb:1+x\n", "a.b:1+x", "a.b:2")).toBe(" image: a.b:2\n image: aXb:1+x\n");
|
|
78
|
+
expect(() => replaceImageRef(compose, "postgres:17", "postgres:18")).toThrow(/no "image: postgres:17" line/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("stack health after a redeploy", () => {
|
|
83
|
+
const up = (name: string, over: Partial<StackContainer> = {}): StackContainer => ({ name, running: true, state: "running", exitCode: null, health: "healthy", ...over });
|
|
84
|
+
|
|
85
|
+
it("passes running containers and finished one-shot jobs", () => {
|
|
86
|
+
expect(containersProblem([up("db"), up("app", { health: "none" }), up("init", { running: false, state: "exited", exitCode: 0 })], 3)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("names missing, stopped, unhealthy, and starting containers", () => {
|
|
90
|
+
expect(containersProblem([up("db")], 2)).toBe("1 of 2 services have a container");
|
|
91
|
+
expect(containersProblem([up("db"), up("init", { running: false, state: "exited", exitCode: 1 })], 2)).toBe("not running: init");
|
|
92
|
+
expect(containersProblem([up("db", { health: "unhealthy" }), up("app", { health: "starting" })], 2)).toBe("health: db (unhealthy), app (starting)");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("commit message", () => {
|
|
97
|
+
it("names the stack and every moved reference on one short line", () => {
|
|
98
|
+
expect(commitMessage("media", [{ service: "sonarr", from: "a:1", to: "a:2" }, { service: "radarr", from: "b:1", to: "b:2" }])).toBe("media: sonarr a:1 -> a:2, radarr b:1 -> b:2");
|
|
99
|
+
expect(commitMessage("s", Array.from({ length: 20 }, () => ({ service: "x", from: "y:1", to: "y:2" }))).length).toBe(200);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { UsageError } from "./errors.ts";
|
|
2
|
+
import { parseTag, type UpdateRow } from "./image-updates.ts";
|
|
3
|
+
import { parseImageRef } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
/** Stacks whose updates wait for a person, tracked in the root of the GitOps repository: `{ "<stack>": "<reason>" }`. */
|
|
6
|
+
export const HOLDS_FILE = "update-holds.json";
|
|
7
|
+
const STACK = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
8
|
+
|
|
9
|
+
/** The held stacks with their reasons; a malformed file is an error, never "nothing held". */
|
|
10
|
+
export function parseHolds(text: string): Record<string, string> {
|
|
11
|
+
let value: unknown;
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(text);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new UsageError(`${HOLDS_FILE} is not JSON`);
|
|
16
|
+
}
|
|
17
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new UsageError(`${HOLDS_FILE} must be an object of stack name to reason`);
|
|
18
|
+
const holds: Record<string, string> = {};
|
|
19
|
+
for (const [stack, reason] of Object.entries(value)) {
|
|
20
|
+
if (!STACK.test(stack) || typeof reason !== "string" || reason.trim().length === 0) throw new UsageError(`${HOLDS_FILE}: "${stack.slice(0, 40)}" needs a stack name and a non-empty reason`);
|
|
21
|
+
holds[stack] = reason.trim().slice(0, 200);
|
|
22
|
+
}
|
|
23
|
+
return holds;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when the update moves to a newer major version, read from the first number of both release tags. */
|
|
27
|
+
export function crossesMajor(running: string, latest: string): boolean {
|
|
28
|
+
const a = parseTag(running);
|
|
29
|
+
const b = parseTag(latest);
|
|
30
|
+
return a !== null && b !== null && b.nums[0] > a.nums[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The rows of one stack that an update changes. */
|
|
34
|
+
export function pendingRows(rows: readonly UpdateRow[]): UpdateRow[] {
|
|
35
|
+
return rows.filter((r) => r.status === "update" || r.status === "release" || r.status === "rebuild");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The image reference a pinned row moves to: the newer tag for `edit tag`, the
|
|
40
|
+
* newer digest for `edit digest`; null for rows a redeploy with a pull applies.
|
|
41
|
+
*/
|
|
42
|
+
export function nextImageRef(row: UpdateRow): string | null {
|
|
43
|
+
if (row.action === "edit tag") {
|
|
44
|
+
const ref = parseImageRef(row.ref);
|
|
45
|
+
if (ref.digest || !ref.tag || !parseTag(row.latest)) throw new UsageError(`${row.service}: cannot move ${row.ref} to tag ${row.latest}`);
|
|
46
|
+
if (!row.ref.endsWith(`:${ref.tag}`)) throw new UsageError(`${row.service}: ${row.ref} does not end with its tag`);
|
|
47
|
+
return `${row.ref.slice(0, row.ref.length - ref.tag.length)}${row.latest}`;
|
|
48
|
+
}
|
|
49
|
+
if (row.action === "edit digest") {
|
|
50
|
+
const at = row.ref.indexOf("@");
|
|
51
|
+
if (at < 0 || !row.latestDigest || !/^sha256:[0-9a-f]{64}$/.test(row.latestDigest)) throw new UsageError(`${row.service}: cannot move ${row.ref} to a new digest`);
|
|
52
|
+
return `${row.ref.slice(0, at)}@${row.latestDigest}`;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function escapeRegExp(text: string): string {
|
|
58
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Compose file with every `image:` line that names exactly `oldRef`
|
|
63
|
+
* (quoted or not, with an optional trailing comment) changed to `newRef`.
|
|
64
|
+
* Any other mention of the reference, or a longer reference that starts with
|
|
65
|
+
* it, stays as it is. Refuses a file without such a line.
|
|
66
|
+
*/
|
|
67
|
+
export function replaceImageRef(compose: string, oldRef: string, newRef: string): string {
|
|
68
|
+
const line = new RegExp(`^(\\s*image:\\s*)(["']?)${escapeRegExp(oldRef)}\\2(\\s*(?:#.*)?)$`, "gm");
|
|
69
|
+
let count = 0;
|
|
70
|
+
const next = compose.replace(line, (_all, head: string, quote: string, tail: string) => {
|
|
71
|
+
count++;
|
|
72
|
+
return `${head}${quote}${newRef}${quote}${tail}`;
|
|
73
|
+
});
|
|
74
|
+
if (count === 0) throw new UsageError(`the Compose file has no "image: ${oldRef}" line`);
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type StackStatus = "updated" | "rolled back" | "failed" | "nothing to do";
|
|
79
|
+
|
|
80
|
+
export interface ServiceResult {
|
|
81
|
+
service: string;
|
|
82
|
+
ref: string;
|
|
83
|
+
from: string;
|
|
84
|
+
to: string;
|
|
85
|
+
/** `pull` (redeploy with a pull) or the reference written into the Compose file. */
|
|
86
|
+
applied: string;
|
|
87
|
+
/** After the update: current, still behind, or not checked. */
|
|
88
|
+
result: "current" | "behind" | "unchecked";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One container of the stack after a redeploy, as `containers get` reports it. */
|
|
92
|
+
export interface StackContainer {
|
|
93
|
+
name: string;
|
|
94
|
+
running: boolean | null;
|
|
95
|
+
state: string | null;
|
|
96
|
+
exitCode: number | null;
|
|
97
|
+
health: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why the stack is not healthy yet, or null when every container runs (or, as
|
|
102
|
+
* a one-shot job, exited with code 0) and none is unhealthy or still starting.
|
|
103
|
+
*/
|
|
104
|
+
export function containersProblem(containers: readonly StackContainer[], expected: number): string | null {
|
|
105
|
+
if (containers.length < expected) return `${containers.length} of ${expected} services have a container`;
|
|
106
|
+
const down = containers.filter((c) => c.running !== true && !(c.state === "exited" && c.exitCode === 0)).map((c) => c.name);
|
|
107
|
+
if (down.length > 0) return `not running: ${down.join(", ")}`;
|
|
108
|
+
const sick = containers.filter((c) => c.health === "unhealthy" || c.health === "starting").map((c) => `${c.name} (${c.health})`);
|
|
109
|
+
return sick.length > 0 ? `health: ${sick.join(", ")}` : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A commit subject that names the stack and every reference it changes. */
|
|
113
|
+
export function commitMessage(stack: string, edits: readonly { service: string; from: string; to: string }[]): string {
|
|
114
|
+
return `${stack}: ${edits.map((e) => `${e.service} ${e.from} -> ${e.to}`).join(", ")}`.slice(0, 200);
|
|
115
|
+
}
|
|
@@ -44,6 +44,7 @@ describe("arcane wrapper", () => {
|
|
|
44
44
|
expect(await arcaneCommand(["--help"])).toBe(0);
|
|
45
45
|
expect(String(write.mock.calls[0][0])).toContain("git write");
|
|
46
46
|
expect(String(write.mock.calls[0][0])).toContain("git show");
|
|
47
|
+
expect(String(write.mock.calls[0][0])).toContain("stack-update <stack> [--confirm]");
|
|
47
48
|
expect(String(write.mock.calls[0][0])).not.toMatch(/^\s+gitops /m);
|
|
48
49
|
write.mockRestore();
|
|
49
50
|
});
|