@caelo-cms/provisioning 0.10.47 → 0.10.49
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/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
canonicalDigest,
|
|
6
|
+
canonicalReleaseTag,
|
|
7
|
+
cosignVerifyArgv,
|
|
8
|
+
parseBearerChallenge,
|
|
9
|
+
RELEASE_SIGNER_IDENTITY_REGEXP,
|
|
10
|
+
releaseTag,
|
|
11
|
+
resolveRegistryDigest,
|
|
12
|
+
} from "./release-images.js";
|
|
13
|
+
|
|
14
|
+
const DIGEST = `sha256:${"d".repeat(64)}`;
|
|
15
|
+
|
|
16
|
+
/** A fetch fake that records requests and answers from `handler`. */
|
|
17
|
+
function fakeFetch(handler: (url: string, init?: RequestInit) => Response) {
|
|
18
|
+
const calls: { url: string; init?: RequestInit }[] = [];
|
|
19
|
+
const impl = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
20
|
+
const url = String(input);
|
|
21
|
+
calls.push({ url, ...(init ? { init } : {}) });
|
|
22
|
+
return handler(url, init);
|
|
23
|
+
}) as typeof fetch;
|
|
24
|
+
return { impl, calls };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("resolveRegistryDigest", () => {
|
|
28
|
+
it("reads the digest straight from an anonymous registry (Artifact Registry)", async () => {
|
|
29
|
+
const f = fakeFetch(
|
|
30
|
+
() => new Response(null, { status: 200, headers: { "docker-content-digest": DIGEST } }),
|
|
31
|
+
);
|
|
32
|
+
const r = await resolveRegistryDigest(
|
|
33
|
+
"europe-west1-docker.pkg.dev",
|
|
34
|
+
"p/r/admin",
|
|
35
|
+
"1.2.3",
|
|
36
|
+
f.impl,
|
|
37
|
+
);
|
|
38
|
+
expect(r).toEqual({ ok: true, digest: DIGEST });
|
|
39
|
+
expect(f.calls[0]?.url).toBe(
|
|
40
|
+
"https://europe-west1-docker.pkg.dev/v2/p/r/admin/manifests/1.2.3",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("follows GHCR's Bearer challenge to an anonymous pull token", async () => {
|
|
45
|
+
const f = fakeFetch((url, init) => {
|
|
46
|
+
if (url.startsWith("https://ghcr.io/token")) {
|
|
47
|
+
return Response.json({ token: "anon-token" });
|
|
48
|
+
}
|
|
49
|
+
const auth = new Headers(init?.headers).get("authorization");
|
|
50
|
+
if (auth !== "Bearer anon-token") {
|
|
51
|
+
return new Response(null, {
|
|
52
|
+
status: 401,
|
|
53
|
+
headers: {
|
|
54
|
+
"www-authenticate":
|
|
55
|
+
'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:caelo-cms/admin:pull"',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return new Response(null, { status: 200, headers: { "docker-content-digest": DIGEST } });
|
|
60
|
+
});
|
|
61
|
+
const r = await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "0.10.43", f.impl);
|
|
62
|
+
expect(r).toEqual({ ok: true, digest: DIGEST });
|
|
63
|
+
const tokenUrl = new URL(f.calls[1]?.url ?? "");
|
|
64
|
+
expect(tokenUrl.searchParams.get("scope")).toBe("repository:caelo-cms/admin:pull");
|
|
65
|
+
expect(tokenUrl.searchParams.get("service")).toBe("ghcr.io");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports a missing tag and a malformed digest header", async () => {
|
|
69
|
+
const missing = fakeFetch(() => new Response(null, { status: 404, statusText: "Not Found" }));
|
|
70
|
+
expect(
|
|
71
|
+
await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "9.9.9", missing.impl),
|
|
72
|
+
).toEqual({ ok: false, reason: "HTTP 404 Not Found" });
|
|
73
|
+
const bad = fakeFetch(
|
|
74
|
+
() => new Response(null, { status: 200, headers: { "docker-content-digest": "md5:x" } }),
|
|
75
|
+
);
|
|
76
|
+
const r = await resolveRegistryDigest("ghcr.io", "caelo-cms/admin", "1", bad.impl);
|
|
77
|
+
expect(r.ok).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("parseBearerChallenge", () => {
|
|
82
|
+
it("parses the parameters and rejects non-Bearer challenges", () => {
|
|
83
|
+
expect(parseBearerChallenge('Bearer realm="https://r/t",service="s"')).toEqual({
|
|
84
|
+
realm: "https://r/t",
|
|
85
|
+
service: "s",
|
|
86
|
+
});
|
|
87
|
+
expect(parseBearerChallenge('Basic realm="x"')).toBeNull();
|
|
88
|
+
expect(parseBearerChallenge(null)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("releaseTag", () => {
|
|
93
|
+
it("strips a leading v and maps channels", () => {
|
|
94
|
+
expect(releaseTag({ version: "v0.10.43" })).toBe("0.10.43");
|
|
95
|
+
expect(releaseTag({ version: "0.10.43" })).toBe("0.10.43");
|
|
96
|
+
expect(releaseTag({ channel: "rc" })).toBe("rc");
|
|
97
|
+
expect(releaseTag({})).toBe("latest");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("cosignVerifyArgv", () => {
|
|
102
|
+
it("pins the Caelo release workflow identity and GitHub's OIDC issuer", () => {
|
|
103
|
+
const argv = cosignVerifyArgv("ghcr.io/caelo-cms/admin@x");
|
|
104
|
+
expect(argv.slice(0, 3)).toEqual(["cosign", "verify", "ghcr.io/caelo-cms/admin@x"]);
|
|
105
|
+
expect(argv).toContain(RELEASE_SIGNER_IDENTITY_REGEXP);
|
|
106
|
+
expect(argv).toContain("https://token.actions.githubusercontent.com");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("canonical values from the network", () => {
|
|
111
|
+
it("accepts well-formed digests and release tags only", () => {
|
|
112
|
+
expect(canonicalDigest(`sha256:${"e".repeat(64)}`)).toBe(`sha256:${"e".repeat(64)}`);
|
|
113
|
+
expect(canonicalDigest("sha256:../../etc")).toBeNull();
|
|
114
|
+
expect(canonicalReleaseTag("v0.10.43")).toBe("v0.10.43");
|
|
115
|
+
expect(canonicalReleaseTag("0.11.0-rc.1")).toBe("0.11.0-rc.1");
|
|
116
|
+
expect(canonicalReleaseTag("v1.0.0/../x")).toBeNull();
|
|
117
|
+
expect(canonicalReleaseTag("latest")).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caelo release images — where they live, how a tag becomes a digest, and
|
|
5
|
+
* which signer identity cosign must see. The ONE module for this, shared by
|
|
6
|
+
* every install path that runs the prebuilt images (CLAUDE.md §11.C):
|
|
7
|
+
* - the GCP `upgrade` (Artifact Registry mirror, {@link RELEASE_REGISTRY});
|
|
8
|
+
* - the self-hosted Compose install + upgrade (GHCR, the canonical
|
|
9
|
+
* registry, {@link ghcrRepository}).
|
|
10
|
+
* Both registries carry the SAME digests: release-images.yml pushes to GHCR,
|
|
11
|
+
* signs, then `oras copy -r`s image + signature to the mirror. So one
|
|
12
|
+
* resolver and one cosign identity serve both. The in-cloud upgrader job
|
|
13
|
+
* (epic #696) takes the digests from the signed release manifest instead.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { IMAGE_DIGEST } from "@caelo-cms/shared";
|
|
17
|
+
import { confirm, isCancel, log, spinner } from "@clack/prompts";
|
|
18
|
+
import { green, red } from "kleur/colors";
|
|
19
|
+
|
|
20
|
+
/** The two Caelo services shipped as release images. */
|
|
21
|
+
export type ReleaseService = "admin" | "gateway";
|
|
22
|
+
|
|
23
|
+
/** The services every install runs, in roll order (admin first, then gateway). */
|
|
24
|
+
export const RELEASE_SERVICES: readonly ReleaseService[] = ["admin", "gateway"];
|
|
25
|
+
|
|
26
|
+
/** GHCR — the canonical registry (release-images.yml), used by self-hosted Compose. */
|
|
27
|
+
export const GHCR_REGISTRY = "ghcr.io";
|
|
28
|
+
export const GHCR_NAMESPACE = "caelo-cms";
|
|
29
|
+
|
|
30
|
+
/** `ghcr.io/caelo-cms/<service>` — the repository of a service's release image. */
|
|
31
|
+
export function ghcrRepository(service: ReleaseService): string {
|
|
32
|
+
return `${GHCR_REGISTRY}/${GHCR_NAMESPACE}/${service}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where Caelo's release workflows live; every signer identity starts here. */
|
|
36
|
+
export const RELEASE_WORKFLOWS_URL = "https://github.com/caelo-cms/caelo-cms/.github/workflows";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The keyless signer cosign must see on every release image: one of the two
|
|
40
|
+
* release workflows of the caelo-cms repo, via GitHub's OIDC issuer. A
|
|
41
|
+
* different identity means a registry compromise, a typosquat, or a fork's
|
|
42
|
+
* image — every caller refuses to run it.
|
|
43
|
+
*/
|
|
44
|
+
export const RELEASE_SIGNER_IDENTITY_REGEXP = `${RELEASE_WORKFLOWS_URL}/(release-images|release).yml@.*`;
|
|
45
|
+
|
|
46
|
+
/** OIDC issuer of every Caelo release signature — images and release manifest (GitHub Actions). */
|
|
47
|
+
export const RELEASE_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `cosign verify` argv for one image reference pinned by digest.
|
|
51
|
+
*
|
|
52
|
+
* @example cosignVerifyArgv("ghcr.io/caelo-cms/admin@sha256:…")
|
|
53
|
+
*/
|
|
54
|
+
export function cosignVerifyArgv(imageRef: string): string[] {
|
|
55
|
+
return [
|
|
56
|
+
"cosign",
|
|
57
|
+
"verify",
|
|
58
|
+
imageRef,
|
|
59
|
+
"--certificate-identity-regexp",
|
|
60
|
+
RELEASE_SIGNER_IDENTITY_REGEXP,
|
|
61
|
+
"--certificate-oidc-issuer",
|
|
62
|
+
RELEASE_OIDC_ISSUER,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A `sha256:<64 hex>` image / blob digest — one definition, shared with
|
|
67
|
+
// the release manifest schema.
|
|
68
|
+
export { IMAGE_DIGEST };
|
|
69
|
+
|
|
70
|
+
/** Whether `s` is a well-formed `sha256:<64 hex>` digest. */
|
|
71
|
+
export function isImageDigest(s: string): boolean {
|
|
72
|
+
return IMAGE_DIGEST.test(s);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HEX_DIGITS = "0123456789abcdef";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `s` rebuilt character by character from a constant alphabet when it is a
|
|
79
|
+
* well-formed digest, else null. The digest comes from a registry's HTTP
|
|
80
|
+
* header and ends up in files (release.json, the Compose file); rebuilding
|
|
81
|
+
* it — rather than passing the checked string on — guarantees that nothing
|
|
82
|
+
* but `sha256:` and 64 hex digits from our own constants is ever written.
|
|
83
|
+
*/
|
|
84
|
+
export function canonicalDigest(s: string): string | null {
|
|
85
|
+
if (!isImageDigest(s)) return null;
|
|
86
|
+
const hex = [...s.slice("sha256:".length)].map((c) => HEX_DIGITS[HEX_DIGITS.indexOf(c)]);
|
|
87
|
+
return `sha256:${hex.join("")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const TAG_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-v";
|
|
91
|
+
const RELEASE_TAG = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A semver release tag (`v0.10.43`, `0.11.0-rc.1`) rebuilt from a constant
|
|
95
|
+
* alphabet, else null. The tag comes from the GitHub API and is recorded in
|
|
96
|
+
* release.json, so — like {@link canonicalDigest} — only our own characters
|
|
97
|
+
* reach the file.
|
|
98
|
+
*/
|
|
99
|
+
export function canonicalReleaseTag(s: string): string | null {
|
|
100
|
+
if (s.length > 64 || !RELEASE_TAG.test(s)) return null;
|
|
101
|
+
return [...s].map((c) => TAG_CHARS[TAG_CHARS.indexOf(c)]).join("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The registry tag for an upgrade request: an explicit version (a leading
|
|
106
|
+
* `v` dropped, as release tags are `vX.Y.Z` but image tags `X.Y.Z`), else
|
|
107
|
+
* the channel's moving tag, else `latest`.
|
|
108
|
+
*/
|
|
109
|
+
export function releaseTag(opts: {
|
|
110
|
+
readonly version?: string;
|
|
111
|
+
readonly channel?: "stable" | "rc" | "beta";
|
|
112
|
+
}): string {
|
|
113
|
+
if (opts.version) return opts.version.startsWith("v") ? opts.version.slice(1) : opts.version;
|
|
114
|
+
if (opts.channel === "rc") return "rc";
|
|
115
|
+
if (opts.channel === "beta") return "beta";
|
|
116
|
+
return "latest";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type DigestResult = { ok: true; digest: string } | { ok: false; reason: string };
|
|
120
|
+
|
|
121
|
+
/** Manifest media types: single-arch manifests and multi-arch indexes, Docker + OCI. */
|
|
122
|
+
const MANIFEST_ACCEPT = [
|
|
123
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
124
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
125
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
126
|
+
"application/vnd.oci.image.index.v1+json",
|
|
127
|
+
].join(",");
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve `<registryHost>/<repository>:<tag>` to the digest a `docker pull`
|
|
131
|
+
* of that tag would fetch, through the Docker Registry V2 API anonymously.
|
|
132
|
+
*
|
|
133
|
+
* The images are public, but registries differ in how they say so: Artifact
|
|
134
|
+
* Registry answers the manifest HEAD directly, GHCR answers 401 with a
|
|
135
|
+
* `WWW-Authenticate: Bearer realm=…,service=…,scope=…` challenge whose realm
|
|
136
|
+
* hands out an anonymous pull token. Both paths are handled here, so callers
|
|
137
|
+
* never need registry credentials (end users have none for Caelo's).
|
|
138
|
+
*
|
|
139
|
+
* @param repository - path below the host, e.g. `caelo-cms/admin`.
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveRegistryDigest(
|
|
142
|
+
registryHost: string,
|
|
143
|
+
repository: string,
|
|
144
|
+
tag: string,
|
|
145
|
+
fetchImpl: typeof fetch = fetch,
|
|
146
|
+
): Promise<DigestResult> {
|
|
147
|
+
const url = `https://${registryHost}/v2/${repository}/manifests/${tag}`;
|
|
148
|
+
try {
|
|
149
|
+
let res = await fetchImpl(url, { method: "HEAD", headers: { Accept: MANIFEST_ACCEPT } });
|
|
150
|
+
if (res.status === 401) {
|
|
151
|
+
const token = await anonymousPullToken(res.headers.get("www-authenticate"), fetchImpl);
|
|
152
|
+
if (!token.ok) return token;
|
|
153
|
+
res = await fetchImpl(url, {
|
|
154
|
+
method: "HEAD",
|
|
155
|
+
headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token.token}` },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!res.ok) return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
|
|
159
|
+
const header = res.headers.get("docker-content-digest");
|
|
160
|
+
const digest = header ? canonicalDigest(header) : null;
|
|
161
|
+
if (!digest) {
|
|
162
|
+
return { ok: false, reason: `unexpected digest header: ${header ?? "(missing)"}` };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, digest };
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Follow a registry's Bearer challenge to an anonymous pull token. */
|
|
171
|
+
async function anonymousPullToken(
|
|
172
|
+
challenge: string | null,
|
|
173
|
+
fetchImpl: typeof fetch,
|
|
174
|
+
): Promise<{ ok: true; token: string } | { ok: false; reason: string }> {
|
|
175
|
+
const params = parseBearerChallenge(challenge);
|
|
176
|
+
if (!params?.realm) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
reason: `registry demanded auth without a Bearer challenge (${challenge})`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const tokenUrl = new URL(params.realm);
|
|
183
|
+
if (params.service) tokenUrl.searchParams.set("service", params.service);
|
|
184
|
+
if (params.scope) tokenUrl.searchParams.set("scope", params.scope);
|
|
185
|
+
const res = await fetchImpl(tokenUrl.toString());
|
|
186
|
+
if (!res.ok) return { ok: false, reason: `token endpoint HTTP ${res.status} ${res.statusText}` };
|
|
187
|
+
const body = (await res.json()) as { token?: string; access_token?: string };
|
|
188
|
+
const token = body.token ?? body.access_token;
|
|
189
|
+
return token ? { ok: true, token } : { ok: false, reason: "token endpoint returned no token" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The key/value pairs of a `Bearer k="v",k2="v2"` challenge, or null when
|
|
194
|
+
* it is not a Bearer challenge.
|
|
195
|
+
*
|
|
196
|
+
* @example parseBearerChallenge('Bearer realm="https://ghcr.io/token",service="ghcr.io"')
|
|
197
|
+
* // → { realm: "https://ghcr.io/token", service: "ghcr.io" }
|
|
198
|
+
*/
|
|
199
|
+
export function parseBearerChallenge(header: string | null): Record<string, string> | null {
|
|
200
|
+
if (!header || !/^Bearer\s/i.test(header)) return null;
|
|
201
|
+
const out: Record<string, string> = {};
|
|
202
|
+
for (const m of header.slice(header.indexOf(" ") + 1).matchAll(/(\w+)="([^"]*)"/g)) {
|
|
203
|
+
const [, key, value] = m;
|
|
204
|
+
if (key && value !== undefined) out[key] = value;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The tag of the newest stable Caelo release on GitHub (e.g. `v0.10.43`),
|
|
211
|
+
* or an error reason. Used to turn "upgrade to latest" into an exact
|
|
212
|
+
* version before anything is resolved, so the version an install records is
|
|
213
|
+
* a real release, never the moving `latest` tag.
|
|
214
|
+
*/
|
|
215
|
+
export async function fetchLatestReleaseTag(
|
|
216
|
+
fetchImpl: typeof fetch = fetch,
|
|
217
|
+
): Promise<{ ok: true; tag: string } | { ok: false; reason: string }> {
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetchImpl(
|
|
220
|
+
"https://api.github.com/repos/caelo-cms/caelo-cms/releases/latest",
|
|
221
|
+
{ headers: { Accept: "application/vnd.github+json" } },
|
|
222
|
+
);
|
|
223
|
+
if (!res.ok) return { ok: false, reason: `GitHub API HTTP ${res.status} ${res.statusText}` };
|
|
224
|
+
const json = (await res.json()) as { tag_name?: string };
|
|
225
|
+
const tag = json.tag_name ? canonicalReleaseTag(json.tag_name) : null;
|
|
226
|
+
return tag
|
|
227
|
+
? { ok: true, tag }
|
|
228
|
+
: { ok: false, reason: `GitHub returned no usable release tag (${json.tag_name})` };
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Where Caelo's release images live (public Artifact Registry repo). */
|
|
235
|
+
export const RELEASE_REGISTRY = {
|
|
236
|
+
region: "europe-west1",
|
|
237
|
+
project: "caelo-website",
|
|
238
|
+
repo: "caelo-cms-images",
|
|
239
|
+
} as const;
|
|
240
|
+
|
|
241
|
+
/** The digest-pinned reference of release image `slug@digest` in the Artifact Registry mirror. */
|
|
242
|
+
export function releaseImageRef(slug: string, digest: string): string {
|
|
243
|
+
const r = RELEASE_REGISTRY;
|
|
244
|
+
return `${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}@${digest}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The release version (`0.11.0`) whose image `slug` has `digest` in the
|
|
249
|
+
* public mirror, from its tags (`gcloud artifacts docker tags list`, which
|
|
250
|
+
* needs a signed-in gcloud). Null when no exact-version tag points at it
|
|
251
|
+
* (a `:main` dev image, a pruned tag) — the caller then records none.
|
|
252
|
+
*/
|
|
253
|
+
export async function releaseVersionForDigest(
|
|
254
|
+
run: (args: string[]) => Promise<{ ok: boolean; stdout: string }>,
|
|
255
|
+
slug: string,
|
|
256
|
+
digest: string,
|
|
257
|
+
): Promise<string | null> {
|
|
258
|
+
const r = RELEASE_REGISTRY;
|
|
259
|
+
const list = await run([
|
|
260
|
+
"artifacts",
|
|
261
|
+
"docker",
|
|
262
|
+
"tags",
|
|
263
|
+
"list",
|
|
264
|
+
`${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}`,
|
|
265
|
+
"--format=value(tag,version)",
|
|
266
|
+
]);
|
|
267
|
+
if (!list.ok) return null;
|
|
268
|
+
for (const line of list.stdout.split("\n")) {
|
|
269
|
+
const [tag, version] = line.trim().split(/\s+/);
|
|
270
|
+
const name = tag?.split("/").pop() ?? "";
|
|
271
|
+
if (version === digest && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(name)) return name;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The digest-pinned caelo-ops image of release tag `tag`, or null when it has none. */
|
|
277
|
+
export async function resolveOpsImageRef(tag: string): Promise<string | null> {
|
|
278
|
+
const res = await resolveTagDigest("ops", tag);
|
|
279
|
+
return res.ok ? releaseImageRef("ops", res.digest) : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve a tag (e.g. `0.2.6` or `latest`) to its digest in the public
|
|
284
|
+
* Artifact Registry mirror, anonymously through the Registry V2 API
|
|
285
|
+
* ({@link resolveRegistryDigest}) — the caller's gcloud session needs no IAM
|
|
286
|
+
* access to the registry's project.
|
|
287
|
+
*/
|
|
288
|
+
export function resolveTagDigest(image: string, tag: string): Promise<DigestResult> {
|
|
289
|
+
const { region, project, repo } = RELEASE_REGISTRY;
|
|
290
|
+
return resolveRegistryDigest(`${region}-docker.pkg.dev`, `${project}/${repo}/${image}`, tag);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function probeBinary(argv: string[]): boolean {
|
|
294
|
+
try {
|
|
295
|
+
const r = Bun.spawnSync(argv, { stdout: "pipe", stderr: "pipe" });
|
|
296
|
+
return r.exitCode === 0;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Ensure cosign is on PATH. If not — and the operator is on macOS with
|
|
304
|
+
* brew available — offer to `brew install cosign` interactively. Falls
|
|
305
|
+
* back to printing the platform install URL + the --skip-verify
|
|
306
|
+
* escape hatch when auto-install isn't possible.
|
|
307
|
+
*
|
|
308
|
+
* Returns true if cosign is callable after this function returns.
|
|
309
|
+
*/
|
|
310
|
+
async function ensureCosignAvailable(): Promise<boolean> {
|
|
311
|
+
if (probeBinary(["cosign", "version"])) return true;
|
|
312
|
+
|
|
313
|
+
log.error(red("cosign not found on PATH — required for image-signature verification."));
|
|
314
|
+
|
|
315
|
+
// macOS auto-install offer. Only when `brew` is itself on PATH —
|
|
316
|
+
// otherwise the operator is on a stripped-down install where
|
|
317
|
+
// homebrew was never set up, and the manual instructions are more
|
|
318
|
+
// useful than a failing brew shell-out.
|
|
319
|
+
if (process.platform === "darwin" && probeBinary(["brew", "--version"])) {
|
|
320
|
+
const proceed = await confirm({
|
|
321
|
+
message: "Install cosign via brew now? (one-shot: brew install cosign)",
|
|
322
|
+
initialValue: true,
|
|
323
|
+
});
|
|
324
|
+
if (!isCancel(proceed) && proceed === true) {
|
|
325
|
+
const s = spinner();
|
|
326
|
+
s.start("brew install cosign");
|
|
327
|
+
const installResult = Bun.spawnSync(["brew", "install", "cosign"], {
|
|
328
|
+
stdout: "pipe",
|
|
329
|
+
stderr: "pipe",
|
|
330
|
+
});
|
|
331
|
+
if (installResult.exitCode === 0 && probeBinary(["cosign", "version"])) {
|
|
332
|
+
s.stop(green("cosign installed ✓"));
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
const stderr = new TextDecoder().decode(installResult.stderr);
|
|
336
|
+
s.stop(red("brew install cosign failed"));
|
|
337
|
+
log.error(stderr.trim().slice(0, 500));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
log.warn(
|
|
342
|
+
"Install cosign:\n" +
|
|
343
|
+
" • brew install cosign (macOS)\n" +
|
|
344
|
+
" • https://docs.sigstore.dev/cosign/installation/ (other)\n" +
|
|
345
|
+
"Or pass --skip-verify to roll without signature checks (NOT recommended for production).",
|
|
346
|
+
);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* P21 ship 4 — verify cosign keyless signatures on every planned
|
|
352
|
+
* image digest. Sigstore Fulcio + Rekor; the certificate identity
|
|
353
|
+
* must match the Caelo release-images workflow. A mismatch means
|
|
354
|
+
* either a registry compromise, a typosquat, or the operator pointed
|
|
355
|
+
* at a fork's image. Either way: refuse to roll.
|
|
356
|
+
*
|
|
357
|
+
* Returns true on success (or skips with a clear error and returns
|
|
358
|
+
* false if cosign isn't installed / verification fails).
|
|
359
|
+
*/
|
|
360
|
+
export async function verifyCosignAll(
|
|
361
|
+
plans: readonly { readonly slug: string; readonly digest: string }[],
|
|
362
|
+
): Promise<boolean> {
|
|
363
|
+
// Probe cosign. Bun.spawnSync THROWS ENOENT when the binary is
|
|
364
|
+
// missing (instead of returning a non-zero exit), so the probe
|
|
365
|
+
// needs try/catch — relying on `exitCode !== 0` would surface a
|
|
366
|
+
// confusing stack trace to the operator instead of the actionable
|
|
367
|
+
// "install cosign" hint.
|
|
368
|
+
if (!(await ensureCosignAvailable())) return false;
|
|
369
|
+
|
|
370
|
+
for (const plan of plans) {
|
|
371
|
+
const s = spinner();
|
|
372
|
+
s.start(`Verifying cosign signature for ${plan.slug}@${plan.digest.slice(0, 19)}...`);
|
|
373
|
+
const fullRef = releaseImageRef(plan.slug, plan.digest);
|
|
374
|
+
const verify = Bun.spawnSync(cosignVerifyArgv(fullRef), { stdout: "pipe", stderr: "pipe" });
|
|
375
|
+
if (verify.exitCode !== 0) {
|
|
376
|
+
const stderr = new TextDecoder().decode(verify.stderr);
|
|
377
|
+
s.stop(red(`cosign verify FAILED for ${plan.slug}`));
|
|
378
|
+
log.error(
|
|
379
|
+
red(
|
|
380
|
+
`${plan.slug}@${plan.digest.slice(0, 19)} signature does NOT match the Caelo release workflow identity.\n` +
|
|
381
|
+
"Either:\n" +
|
|
382
|
+
" • the registry was compromised, OR\n" +
|
|
383
|
+
" • you're targeting a fork's image (verify your install's registry path), OR\n" +
|
|
384
|
+
" • cosign / sigstore had a transient outage (retry).\n" +
|
|
385
|
+
`cosign stderr: ${stderr.trim().slice(0, 500)}`,
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
s.stop(green(`${plan.slug} signature verified ✓`));
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How the upgrade phases (converge.ts, app-roll.ts) tell the operator what
|
|
5
|
+
* they do. The CLI renders it with clack spinners; the in-cloud upgrader job
|
|
6
|
+
* (epic #696) will write the same lines to its run log, and the tests record
|
|
7
|
+
* them. The phases never import a terminal UI themselves.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { log, spinner } from "@clack/prompts";
|
|
11
|
+
import { green, red, yellow } from "kleur/colors";
|
|
12
|
+
|
|
13
|
+
/** One long-running step: started once, ended exactly once. */
|
|
14
|
+
export interface ReportStep {
|
|
15
|
+
done(message: string): void;
|
|
16
|
+
failed(message: string): void;
|
|
17
|
+
/** Ended without success, but the upgrade continues. */
|
|
18
|
+
warned(message: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UpgradeReporter {
|
|
22
|
+
step(label: string): ReportStep;
|
|
23
|
+
info(message: string): void;
|
|
24
|
+
warn(message: string): void;
|
|
25
|
+
error(message: string): void;
|
|
26
|
+
success(message: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The CLI's reporter: a clack spinner per step, colored log lines. */
|
|
30
|
+
export function clackReporter(): UpgradeReporter {
|
|
31
|
+
return {
|
|
32
|
+
step(label) {
|
|
33
|
+
const s = spinner();
|
|
34
|
+
s.start(label);
|
|
35
|
+
return {
|
|
36
|
+
done: (m) => s.stop(green(m)),
|
|
37
|
+
failed: (m) => s.stop(red(m)),
|
|
38
|
+
warned: (m) => s.stop(yellow(m)),
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
info: (m) => log.info(m),
|
|
42
|
+
warn: (m) => log.warn(yellow(m)),
|
|
43
|
+
error: (m) => log.error(red(m)),
|
|
44
|
+
success: (m) => log.success(m),
|
|
45
|
+
};
|
|
46
|
+
}
|