@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,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release-contract constants that every signed release manifest carries
|
|
5
|
+
* (epic #696). The infrastructure contract number itself lives in
|
|
6
|
+
* infra-contract.ts (shared with the upgrade split's `needsConverge`);
|
|
7
|
+
* this module says which files can change it and the min-from version. They tell an upgrader, without running any code from the
|
|
8
|
+
* new release, whether the release can be installed as an app-roll or
|
|
9
|
+
* needs the operator's own identity.
|
|
10
|
+
*
|
|
11
|
+
* The admin's upgrader job (Phase 2) only rolls images: it holds no IAM
|
|
12
|
+
* write, no secret create, no resource create. Anything a release needs
|
|
13
|
+
* beyond "new image digests + expand-only migrations" must therefore be
|
|
14
|
+
* declared here, so the upgrader refuses it and sends the Owner to the
|
|
15
|
+
* "Run in Cloud Shell" path instead of half-applying it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Oldest installed version a release can be applied to directly. An
|
|
20
|
+
* install below it has to step through an intermediate release first.
|
|
21
|
+
*
|
|
22
|
+
* Raise it when a release depends on a migration or runtime change that
|
|
23
|
+
* an older install has not been through (the expand/contract rule only
|
|
24
|
+
* guarantees N-1 → N).
|
|
25
|
+
*/
|
|
26
|
+
export const RELEASE_MIN_FROM_VERSION = "0.10.0";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Repo-relative paths (a trailing `/` means "everything below") whose
|
|
30
|
+
* changes can alter the infrastructure contract. Tests and READMEs under
|
|
31
|
+
* them are ignored — see {@link isInfraContractPath}.
|
|
32
|
+
*/
|
|
33
|
+
export const INFRA_CONTRACT_PATHS: readonly string[] = [
|
|
34
|
+
"packages/provisioning/stacks/",
|
|
35
|
+
"packages/provisioning/src/stack-contract.ts",
|
|
36
|
+
"packages/provisioning/src/stack-converge.ts",
|
|
37
|
+
"packages/provisioning/src/runtime-secrets.ts",
|
|
38
|
+
"packages/provisioning/src/operator-access.ts",
|
|
39
|
+
"packages/provisioning/src/gcp-names.ts",
|
|
40
|
+
// Upgrade-time mutators outside stack-converge: the converge half of the
|
|
41
|
+
// upgrade (it decides which ensure* steps run), the MCP IAP service
|
|
42
|
+
// account + bindings, the default firewall policy.
|
|
43
|
+
"packages/provisioning/src/upgrade/converge.ts",
|
|
44
|
+
"packages/provisioning/src/mcp-iap.ts",
|
|
45
|
+
"packages/provisioning/src/default-firewall.ts",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a changed repo-relative path can alter the infrastructure
|
|
50
|
+
* contract.
|
|
51
|
+
*
|
|
52
|
+
* @param path - a path as `git diff --name-only` prints it.
|
|
53
|
+
*/
|
|
54
|
+
export function isInfraContractPath(path: string): boolean {
|
|
55
|
+
if (/\.test\.ts$/.test(path) || /(^|\/)README\.md$/.test(path)) return false;
|
|
56
|
+
return INFRA_CONTRACT_PATHS.some((p) => (p.endsWith("/") ? path.startsWith(p) : path === p));
|
|
57
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
|
|
7
|
+
import { isInfraContractPath } from "./release-contract.js";
|
|
8
|
+
import {
|
|
9
|
+
assessAutoInstall,
|
|
10
|
+
channelForVersion,
|
|
11
|
+
compareImageDigests,
|
|
12
|
+
compareSemver,
|
|
13
|
+
fetchVerifiedReleaseManifest,
|
|
14
|
+
RELEASE_MANIFEST_ARTIFACT_TYPE,
|
|
15
|
+
RELEASE_MANIFEST_MEDIA_TYPE,
|
|
16
|
+
RELEASE_OIDC_ISSUER,
|
|
17
|
+
type ReleaseManifest,
|
|
18
|
+
type ReleaseManifestDeps,
|
|
19
|
+
ReleaseManifestSchema,
|
|
20
|
+
releaseManifestSignerIdentity,
|
|
21
|
+
SIGSTORE_BUNDLE_MEDIA_TYPE,
|
|
22
|
+
} from "./release-manifest.js";
|
|
23
|
+
|
|
24
|
+
const d = (c: string) => `sha256:${c.repeat(64)}`;
|
|
25
|
+
|
|
26
|
+
const MANIFEST: ReleaseManifest = {
|
|
27
|
+
manifestVersion: 1,
|
|
28
|
+
version: "0.11.0",
|
|
29
|
+
channel: "stable",
|
|
30
|
+
digests: { admin: d("a"), gateway: d("b"), ops: d("c") },
|
|
31
|
+
minFromVersion: "0.10.0",
|
|
32
|
+
infraContractVersion: 1,
|
|
33
|
+
migrations: {
|
|
34
|
+
contract: false,
|
|
35
|
+
head: "0262_tool_approval_branch",
|
|
36
|
+
lastContractVersion: "0.10.42",
|
|
37
|
+
},
|
|
38
|
+
security: false,
|
|
39
|
+
advisories: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const sha = (b: Uint8Array) => `sha256:${createHash("sha256").update(b).digest("hex")}`;
|
|
43
|
+
const enc = (s: string) => new TextEncoder().encode(s);
|
|
44
|
+
|
|
45
|
+
interface Fixture {
|
|
46
|
+
manifestBody?: string;
|
|
47
|
+
bundleBody?: string;
|
|
48
|
+
artifactType?: string;
|
|
49
|
+
/** Serve this instead of the real manifest blob (digest stays the original). */
|
|
50
|
+
tamperManifestBlob?: string;
|
|
51
|
+
manifestStatus?: number;
|
|
52
|
+
cosignExit?: number;
|
|
53
|
+
dropBundleLayer?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An in-memory OCI registry + cosign stub. Records every cosign call. */
|
|
57
|
+
function fixture(f: Fixture = {}) {
|
|
58
|
+
const manifestBytes = enc(f.manifestBody ?? JSON.stringify(MANIFEST));
|
|
59
|
+
const bundleBytes = enc(
|
|
60
|
+
f.bundleBody ?? '{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}',
|
|
61
|
+
);
|
|
62
|
+
const layers = [
|
|
63
|
+
{ mediaType: RELEASE_MANIFEST_MEDIA_TYPE, digest: sha(manifestBytes) },
|
|
64
|
+
...(f.dropBundleLayer
|
|
65
|
+
? []
|
|
66
|
+
: [{ mediaType: SIGSTORE_BUNDLE_MEDIA_TYPE, digest: sha(bundleBytes) }]),
|
|
67
|
+
];
|
|
68
|
+
const blobs = new Map<string, Uint8Array>([
|
|
69
|
+
[sha(manifestBytes), f.tamperManifestBlob ? enc(f.tamperManifestBlob) : manifestBytes],
|
|
70
|
+
[sha(bundleBytes), bundleBytes],
|
|
71
|
+
]);
|
|
72
|
+
const urls: string[] = [];
|
|
73
|
+
const cosignCalls: (readonly string[])[] = [];
|
|
74
|
+
const deps: ReleaseManifestDeps = {
|
|
75
|
+
registry: "registry.test",
|
|
76
|
+
repository: "caelo/release-manifest",
|
|
77
|
+
fetch: async (url) => {
|
|
78
|
+
urls.push(url);
|
|
79
|
+
const m = /\/v2\/caelo\/release-manifest\/(manifests|blobs)\/(.+)$/.exec(url);
|
|
80
|
+
if (!m) return new Response("", { status: 404 });
|
|
81
|
+
if (m[1] === "manifests") {
|
|
82
|
+
if (f.manifestStatus) return new Response("", { status: f.manifestStatus });
|
|
83
|
+
return Response.json({
|
|
84
|
+
schemaVersion: 2,
|
|
85
|
+
mediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
86
|
+
artifactType: f.artifactType ?? RELEASE_MANIFEST_ARTIFACT_TYPE,
|
|
87
|
+
layers,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const blob = blobs.get(m[2] ?? "");
|
|
91
|
+
return blob ? new Response(blob) : new Response("", { status: 404 });
|
|
92
|
+
},
|
|
93
|
+
cosign: async (args) => {
|
|
94
|
+
cosignCalls.push(args);
|
|
95
|
+
return { exitCode: f.cosignExit ?? 0, stderr: f.cosignExit ? "no matching signatures" : "" };
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return { deps, urls, cosignCalls };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("fetchVerifiedReleaseManifest", () => {
|
|
102
|
+
it("returns the manifest when signature, schema and version check out", async () => {
|
|
103
|
+
const { deps, urls } = fixture();
|
|
104
|
+
const r = await fetchVerifiedReleaseManifest("0.11.0", deps);
|
|
105
|
+
expect(r).toEqual({ ok: true, manifest: MANIFEST });
|
|
106
|
+
expect(urls[0]).toBe("https://registry.test/v2/caelo/release-manifest/manifests/0.11.0");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("pins cosign to release.yml on the exact tag", async () => {
|
|
110
|
+
const { deps, cosignCalls } = fixture();
|
|
111
|
+
await fetchVerifiedReleaseManifest("0.11.0", deps);
|
|
112
|
+
expect(cosignCalls).toHaveLength(1);
|
|
113
|
+
const args = cosignCalls[0] ?? [];
|
|
114
|
+
expect(args[0]).toBe("verify-blob");
|
|
115
|
+
expect(args).toContain("--bundle");
|
|
116
|
+
const id = args[args.indexOf("--certificate-identity") + 1];
|
|
117
|
+
expect(id).toBe(
|
|
118
|
+
"https://github.com/caelo-cms/caelo-cms/.github/workflows/release.yml@refs/tags/v0.11.0",
|
|
119
|
+
);
|
|
120
|
+
expect(args[args.indexOf("--certificate-oidc-issuer") + 1]).toBe(RELEASE_OIDC_ISSUER);
|
|
121
|
+
// An identity regexp would accept other refs; only the exact flag is used.
|
|
122
|
+
expect(args).not.toContain("--certificate-identity-regexp");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("refuses when cosign rejects the signature", async () => {
|
|
126
|
+
const { deps } = fixture({ cosignExit: 1 });
|
|
127
|
+
const r = await fetchVerifiedReleaseManifest("0.11.0", deps);
|
|
128
|
+
expect(r.ok).toBe(false);
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
expect(r.error.code).toBe("signature");
|
|
131
|
+
expect(r.error.message).toContain(releaseManifestSignerIdentity("0.11.0"));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("refuses a blob whose bytes do not match its digest, before calling cosign", async () => {
|
|
136
|
+
const tampered = JSON.stringify({ ...MANIFEST, security: true, advisories: [] });
|
|
137
|
+
const { deps, cosignCalls } = fixture({ tamperManifestBlob: tampered });
|
|
138
|
+
const r = await fetchVerifiedReleaseManifest("0.11.0", deps);
|
|
139
|
+
expect(r.ok ? null : r.error.code).toBe("blob-digest");
|
|
140
|
+
expect(cosignCalls).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("refuses a signed manifest that describes another version (replay)", async () => {
|
|
144
|
+
const { deps } = fixture({ manifestBody: JSON.stringify({ ...MANIFEST, version: "0.9.0" }) });
|
|
145
|
+
const r = await fetchVerifiedReleaseManifest("0.11.0", deps);
|
|
146
|
+
expect(r.ok ? null : r.error.code).toBe("version-mismatch");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses a manifest that fails the schema", async () => {
|
|
150
|
+
const bad = { ...MANIFEST, digests: { admin: "sha256:nope", gateway: d("b"), ops: d("c") } };
|
|
151
|
+
const { deps } = fixture({ manifestBody: JSON.stringify(bad) });
|
|
152
|
+
const r = await fetchVerifiedReleaseManifest("0.11.0", deps);
|
|
153
|
+
expect(r.ok ? null : r.error.code).toBe("schema");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("refuses an artifact of the wrong type or without the bundle layer", async () => {
|
|
157
|
+
const wrongType = await fetchVerifiedReleaseManifest(
|
|
158
|
+
"0.11.0",
|
|
159
|
+
fixture({ artifactType: "application/vnd.oci.image.config.v1+json" }).deps,
|
|
160
|
+
);
|
|
161
|
+
expect(wrongType.ok ? null : wrongType.error.code).toBe("artifact-shape");
|
|
162
|
+
const noBundle = await fetchVerifiedReleaseManifest(
|
|
163
|
+
"0.11.0",
|
|
164
|
+
fixture({ dropBundleLayer: true }).deps,
|
|
165
|
+
);
|
|
166
|
+
expect(noBundle.ok ? null : noBundle.error.code).toBe("artifact-shape");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("reports a missing manifest as a registry error with a next step", async () => {
|
|
170
|
+
const r = await fetchVerifiedReleaseManifest("0.11.0", fixture({ manifestStatus: 404 }).deps);
|
|
171
|
+
expect(r.ok ? null : r.error.code).toBe("registry");
|
|
172
|
+
expect(r.ok ? "" : r.error.message).toContain("HTTP 404");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("rejects a non-SemVer version without touching the network", async () => {
|
|
176
|
+
const { deps, urls } = fixture();
|
|
177
|
+
const r = await fetchVerifiedReleaseManifest("v0.11.0", deps);
|
|
178
|
+
expect(r.ok ? null : r.error.code).toBe("schema");
|
|
179
|
+
expect(urls).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("ReleaseManifestSchema", () => {
|
|
184
|
+
it("accepts the canonical fixture and rejects a non-GHSA advisory", () => {
|
|
185
|
+
expect(ReleaseManifestSchema.safeParse(MANIFEST).success).toBe(true);
|
|
186
|
+
const cve = { ...MANIFEST, security: true, advisories: ["CVE-2026-1234"] };
|
|
187
|
+
expect(ReleaseManifestSchema.safeParse(cve).success).toBe(false);
|
|
188
|
+
const ghsa = { ...MANIFEST, security: true, advisories: ["GHSA-2c2m-4x8q-wx9f"] };
|
|
189
|
+
expect(ReleaseManifestSchema.safeParse(ghsa).success).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("compareImageDigests", () => {
|
|
194
|
+
it("is empty when every resolved digest matches", () => {
|
|
195
|
+
expect(compareImageDigests(MANIFEST, { admin: d("a"), gateway: d("b"), ops: d("c") })).toEqual(
|
|
196
|
+
[],
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("names each mismatching or unresolved image, ignores absent ones", () => {
|
|
201
|
+
expect(compareImageDigests(MANIFEST, { admin: d("f"), gateway: null })).toEqual([
|
|
202
|
+
{ image: "admin", expected: d("a"), actual: d("f") },
|
|
203
|
+
{ image: "gateway", expected: d("b"), actual: null },
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("assessAutoInstall", () => {
|
|
209
|
+
const installed = { version: "0.10.43", infraContractVersion: 1 };
|
|
210
|
+
|
|
211
|
+
it("auto-installs an expand-only release with the same infra contract", () => {
|
|
212
|
+
expect(assessAutoInstall(MANIFEST, installed)).toEqual({
|
|
213
|
+
mode: "auto",
|
|
214
|
+
kind: "normal",
|
|
215
|
+
reasons: [],
|
|
216
|
+
});
|
|
217
|
+
const advisories = ["GHSA-abcd-efgh-jkmp"];
|
|
218
|
+
expect(assessAutoInstall({ ...MANIFEST, security: true, advisories }, installed).kind).toBe(
|
|
219
|
+
"security",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("counts security only with a published advisory behind it (#696 decision 2)", () => {
|
|
224
|
+
expect(assessAutoInstall({ ...MANIFEST, security: true }, installed).kind).toBe("normal");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("downgrades a security release with contract migrations to manual", () => {
|
|
228
|
+
const m = {
|
|
229
|
+
...MANIFEST,
|
|
230
|
+
security: true,
|
|
231
|
+
migrations: { contract: true, head: "x", lastContractVersion: "0.11.0" },
|
|
232
|
+
};
|
|
233
|
+
const a = assessAutoInstall(m, installed);
|
|
234
|
+
expect(a.mode).toBe("manual");
|
|
235
|
+
expect(a.kind).toBe("normal");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("makes skipping a contract release manual, but not upgrading past it from after it", () => {
|
|
239
|
+
const m = {
|
|
240
|
+
...MANIFEST,
|
|
241
|
+
migrations: { ...MANIFEST.migrations, lastContractVersion: "0.10.50" },
|
|
242
|
+
};
|
|
243
|
+
const skipped = assessAutoInstall(m, installed);
|
|
244
|
+
expect(skipped.mode).toBe("manual");
|
|
245
|
+
expect(skipped.reasons[0]).toContain("skips v0.10.50");
|
|
246
|
+
expect(assessAutoInstall(m, { ...installed, version: "0.10.50" }).mode).toBe("auto");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("downgrades an infra contract bump to manual, also when skipped releases carried it", () => {
|
|
250
|
+
const a = assessAutoInstall(
|
|
251
|
+
{ ...MANIFEST, security: true, infraContractVersion: 3 },
|
|
252
|
+
installed,
|
|
253
|
+
);
|
|
254
|
+
expect(a.mode).toBe("manual");
|
|
255
|
+
expect(a.reasons[0]).toContain("1 → 3");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("refuses downgrades, re-installs and versions below minFromVersion", () => {
|
|
259
|
+
expect(assessAutoInstall(MANIFEST, { ...installed, version: "0.11.0" }).mode).toBe("refused");
|
|
260
|
+
expect(assessAutoInstall(MANIFEST, { ...installed, version: "0.12.0" }).mode).toBe("refused");
|
|
261
|
+
expect(assessAutoInstall(MANIFEST, { ...installed, version: "0.9.9" }).mode).toBe("refused");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("compareSemver / channelForVersion", () => {
|
|
266
|
+
it("orders releases and pre-releases per SemVer", () => {
|
|
267
|
+
expect(compareSemver("0.10.43", "0.10.9")).toBeGreaterThan(0);
|
|
268
|
+
expect(compareSemver("0.11.0-rc.1", "0.11.0")).toBeLessThan(0);
|
|
269
|
+
expect(compareSemver("0.11.0-rc.2", "0.11.0-rc.10")).toBeLessThan(0);
|
|
270
|
+
expect(compareSemver("0.11.0-beta.1", "0.11.0-rc.1")).toBeLessThan(0);
|
|
271
|
+
expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
|
|
272
|
+
expect(() => compareSemver("v1.0.0", "1.0.0")).toThrow();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("maps versions to channels", () => {
|
|
276
|
+
expect(channelForVersion("0.11.0")).toBe("stable");
|
|
277
|
+
expect(channelForVersion("0.11.0-rc.1")).toBe("rc");
|
|
278
|
+
expect(channelForVersion("0.11.0-nightly.1")).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("release-contract", () => {
|
|
283
|
+
it("watches the stacks and converge sources, not their tests", () => {
|
|
284
|
+
expect(isInfraContractPath("packages/provisioning/stacks/gcp/index.ts")).toBe(true);
|
|
285
|
+
expect(isInfraContractPath("packages/provisioning/src/stack-converge.ts")).toBe(true);
|
|
286
|
+
expect(isInfraContractPath("packages/provisioning/src/upgrade/converge.ts")).toBe(true);
|
|
287
|
+
expect(isInfraContractPath("packages/provisioning/src/stack-converge.test.ts")).toBe(false);
|
|
288
|
+
expect(isInfraContractPath("packages/provisioning/stacks/gcp/README.md")).toBe(false);
|
|
289
|
+
expect(isInfraContractPath("packages/provisioning/src/cli.ts")).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("keeps INFRA_CONTRACT_VERSION a literal the release scripts can read from a past tag", () => {
|
|
293
|
+
// scripts/release-manifest.ts reads the previous tag's value with a
|
|
294
|
+
// regex over `git show <tag>:<file>`, so it must stay a plain literal.
|
|
295
|
+
const src = readFileSync(new URL("./infra-contract.ts", import.meta.url), "utf8");
|
|
296
|
+
expect(src).toMatch(
|
|
297
|
+
new RegExp(`export const INFRA_CONTRACT_VERSION = ${INFRA_CONTRACT_VERSION};`),
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The signed release manifest (epic #696): one JSON document per release
|
|
5
|
+
* that names the exact image digests plus the facts an upgrader needs to
|
|
6
|
+
* decide whether it may install the release on its own.
|
|
7
|
+
*
|
|
8
|
+
* Trust model. `release.yml` generates the manifest on the release tag,
|
|
9
|
+
* signs it with `cosign sign-blob` (keyless, the workflow's GitHub OIDC
|
|
10
|
+
* identity) and pushes it as an OCI artifact holding two layers — the
|
|
11
|
+
* manifest and its Sigstore bundle. Verification pins the signer to
|
|
12
|
+
* `release.yml` running on the tag `v<version>` of caelo-cms/caelo-cms,
|
|
13
|
+
* so neither a compromised admin nor a registry mirror can mint, alter or
|
|
14
|
+
* replay a manifest: changing a digest, flipping `security`, or serving
|
|
15
|
+
* v0.9's manifest as v0.10's all fail the signature or the version check.
|
|
16
|
+
* The signature rides inside the artifact (not as an OCI referrer), so
|
|
17
|
+
* any plain registry copy keeps it — the failure mode that left mirrored
|
|
18
|
+
* images unsigned (see release-images.yml, "oras copy -r") cannot apply.
|
|
19
|
+
*
|
|
20
|
+
* Errors are values: every failure carries a code plus a message that
|
|
21
|
+
* says what to check next.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import {
|
|
28
|
+
fetchReleaseArtifact,
|
|
29
|
+
isSecurityRelease,
|
|
30
|
+
parseReleaseManifest,
|
|
31
|
+
RELEASE_CHANNELS,
|
|
32
|
+
RELEASE_IMAGES,
|
|
33
|
+
type ReleaseChannel,
|
|
34
|
+
type ReleaseImage,
|
|
35
|
+
type ReleaseManifest,
|
|
36
|
+
} from "@caelo-cms/shared";
|
|
37
|
+
import { RELEASE_OIDC_ISSUER, RELEASE_WORKFLOWS_URL } from "./upgrade/release-images.js";
|
|
38
|
+
|
|
39
|
+
// The manifest's shape and location live in @caelo-cms/shared (the admin's
|
|
40
|
+
// auto-update scheduler reads them too); re-exported so this module stays
|
|
41
|
+
// the CLI's one import for release manifests.
|
|
42
|
+
export {
|
|
43
|
+
RELEASE_CHANNELS,
|
|
44
|
+
RELEASE_IMAGES,
|
|
45
|
+
RELEASE_MANIFEST_ARTIFACT_TYPE,
|
|
46
|
+
RELEASE_MANIFEST_MEDIA_TYPE,
|
|
47
|
+
RELEASE_MANIFEST_REGISTRY,
|
|
48
|
+
RELEASE_MANIFEST_REPOSITORY,
|
|
49
|
+
type ReleaseChannel,
|
|
50
|
+
type ReleaseImage,
|
|
51
|
+
type ReleaseManifest,
|
|
52
|
+
ReleaseManifestSchema,
|
|
53
|
+
SIGSTORE_BUNDLE_MEDIA_TYPE,
|
|
54
|
+
} from "@caelo-cms/shared";
|
|
55
|
+
|
|
56
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
57
|
+
|
|
58
|
+
// The signer constants are shared with the image verification (one source).
|
|
59
|
+
export { RELEASE_OIDC_ISSUER };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The only identity allowed to sign the manifest of `version`: release.yml
|
|
63
|
+
* of caelo-cms/caelo-cms running on that exact tag. Exact, not a regex —
|
|
64
|
+
* a manifest signed on another ref (a branch, another tag) is refused.
|
|
65
|
+
*/
|
|
66
|
+
export function releaseManifestSignerIdentity(version: string): string {
|
|
67
|
+
return `${RELEASE_WORKFLOWS_URL}/release.yml@refs/tags/v${version}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compare two SemVer strings: negative when `a < b`, 0 when equal,
|
|
72
|
+
* positive when `a > b`. A pre-release sorts below its release; two
|
|
73
|
+
* pre-release tags compare by SemVer identifier rules.
|
|
74
|
+
*
|
|
75
|
+
* @throws Error when either argument is not SemVer.
|
|
76
|
+
*/
|
|
77
|
+
export function compareSemver(a: string, b: string): number {
|
|
78
|
+
const ma = SEMVER.exec(a);
|
|
79
|
+
const mb = SEMVER.exec(b);
|
|
80
|
+
if (!ma || !mb) throw new Error(`not a SemVer version: ${ma ? b : a}`);
|
|
81
|
+
for (let i = 1; i <= 3; i++) {
|
|
82
|
+
const d = Number(ma[i]) - Number(mb[i]);
|
|
83
|
+
if (d !== 0) return d;
|
|
84
|
+
}
|
|
85
|
+
const pa = ma[4];
|
|
86
|
+
const pb = mb[4];
|
|
87
|
+
if (pa === undefined || pb === undefined) {
|
|
88
|
+
return pa === pb ? 0 : pa === undefined ? 1 : -1;
|
|
89
|
+
}
|
|
90
|
+
const ia = pa.split(".");
|
|
91
|
+
const ib = pb.split(".");
|
|
92
|
+
for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
|
|
93
|
+
const x = ia[i];
|
|
94
|
+
const y = ib[i];
|
|
95
|
+
if (x === undefined) return -1;
|
|
96
|
+
if (y === undefined) return 1;
|
|
97
|
+
const xn = /^\d+$/.test(x);
|
|
98
|
+
const yn = /^\d+$/.test(y);
|
|
99
|
+
if (xn && yn && Number(x) !== Number(y)) return Number(x) - Number(y);
|
|
100
|
+
if (xn !== yn) return xn ? -1 : 1;
|
|
101
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The channel a version ships on.
|
|
108
|
+
*
|
|
109
|
+
* @returns null for a pre-release whose identifier names no known channel.
|
|
110
|
+
*/
|
|
111
|
+
export function channelForVersion(version: string): ReleaseChannel | null {
|
|
112
|
+
const m = SEMVER.exec(version);
|
|
113
|
+
if (!m) return null;
|
|
114
|
+
if (m[4] === undefined) return "stable";
|
|
115
|
+
const id = m[4].split(".")[0];
|
|
116
|
+
return (RELEASE_CHANNELS as readonly string[]).includes(id ?? "") ? (id as ReleaseChannel) : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Why fetching or verifying a manifest failed. */
|
|
120
|
+
export type ReleaseManifestErrorCode =
|
|
121
|
+
| "registry"
|
|
122
|
+
| "artifact-shape"
|
|
123
|
+
| "blob-digest"
|
|
124
|
+
| "signature"
|
|
125
|
+
| "schema"
|
|
126
|
+
| "version-mismatch";
|
|
127
|
+
|
|
128
|
+
export interface ReleaseManifestError {
|
|
129
|
+
readonly code: ReleaseManifestErrorCode;
|
|
130
|
+
readonly message: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type ReleaseManifestResult =
|
|
134
|
+
| { readonly ok: true; readonly manifest: ReleaseManifest }
|
|
135
|
+
| { readonly ok: false; readonly error: ReleaseManifestError };
|
|
136
|
+
|
|
137
|
+
/** Side effects of {@link fetchVerifiedReleaseManifest}, injectable for tests. */
|
|
138
|
+
export interface ReleaseManifestDeps {
|
|
139
|
+
readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;
|
|
140
|
+
/** Runs `cosign` with `args`; resolves with its exit code + stderr. */
|
|
141
|
+
readonly cosign: (args: readonly string[]) => Promise<{ exitCode: number; stderr: string }>;
|
|
142
|
+
/** Registry host + repository; defaults to the fixed public mirror. */
|
|
143
|
+
readonly registry?: string;
|
|
144
|
+
readonly repository?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `cosign` from PATH. Missing binary → exit code 127 with a hint. */
|
|
148
|
+
async function runCosign(args: readonly string[]): Promise<{ exitCode: number; stderr: string }> {
|
|
149
|
+
try {
|
|
150
|
+
const proc = Bun.spawn(["cosign", ...args], { stdout: "ignore", stderr: "pipe" });
|
|
151
|
+
const stderr = await new Response(proc.stderr).text();
|
|
152
|
+
return { exitCode: await proc.exited, stderr };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return {
|
|
155
|
+
exitCode: 127,
|
|
156
|
+
stderr: `cosign could not be started (${e instanceof Error ? e.message : String(e)}) — install cosign (it ships in the caelo-ops image)`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const defaultDeps: ReleaseManifestDeps = {
|
|
162
|
+
fetch: (url, init) => fetch(url, init),
|
|
163
|
+
cosign: runCosign,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function fail(code: ReleaseManifestErrorCode, message: string): ReleaseManifestResult {
|
|
167
|
+
return { ok: false, error: { code, message } };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Fetch the manifest of `version` from the fixed registry, verify its
|
|
172
|
+
* Sigstore bundle against the release workflow identity for that exact
|
|
173
|
+
* tag, schema-validate it, and check it describes `version`.
|
|
174
|
+
*
|
|
175
|
+
* @param version - SemVer without a leading `v`.
|
|
176
|
+
* @param deps - injected fetch + cosign runner (tests); defaults to the real ones.
|
|
177
|
+
*/
|
|
178
|
+
export async function fetchVerifiedReleaseManifest(
|
|
179
|
+
version: string,
|
|
180
|
+
deps: ReleaseManifestDeps = defaultDeps,
|
|
181
|
+
): Promise<ReleaseManifestResult> {
|
|
182
|
+
if (!SEMVER.test(version)) return fail("schema", `"${version}" is not a SemVer version`);
|
|
183
|
+
const artifact = await fetchReleaseArtifact(version, deps);
|
|
184
|
+
if (!artifact.ok) return { ok: false, error: artifact.error };
|
|
185
|
+
|
|
186
|
+
// cosign verifies files, so the exact bytes go to a private temp dir.
|
|
187
|
+
const dir = await mkdtemp(join(tmpdir(), "caelo-release-manifest-"));
|
|
188
|
+
try {
|
|
189
|
+
const manifestPath = join(dir, "release-manifest.json");
|
|
190
|
+
const bundlePath = join(dir, "release-manifest.json.sigstore.json");
|
|
191
|
+
await writeFile(manifestPath, artifact.manifestBytes, { mode: 0o600 });
|
|
192
|
+
await writeFile(bundlePath, artifact.bundleBytes, { mode: 0o600 });
|
|
193
|
+
const verify = await deps.cosign([
|
|
194
|
+
"verify-blob",
|
|
195
|
+
"--bundle",
|
|
196
|
+
bundlePath,
|
|
197
|
+
"--certificate-identity",
|
|
198
|
+
releaseManifestSignerIdentity(version),
|
|
199
|
+
"--certificate-oidc-issuer",
|
|
200
|
+
RELEASE_OIDC_ISSUER,
|
|
201
|
+
manifestPath,
|
|
202
|
+
]);
|
|
203
|
+
if (verify.exitCode !== 0) {
|
|
204
|
+
return fail(
|
|
205
|
+
"signature",
|
|
206
|
+
`the manifest of v${version} is not signed by ${releaseManifestSignerIdentity(version)} — refuse to install. cosign: ${verify.stderr.trim().slice(0, 500)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
await rm(dir, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const parsed = parseReleaseManifest(artifact.manifestBytes, version);
|
|
214
|
+
if (!parsed.ok) return fail(parsed.code, `signed manifest: ${parsed.message}`);
|
|
215
|
+
return { ok: true, manifest: parsed.manifest };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One image whose resolved digest differs from the manifest's. */
|
|
219
|
+
export interface DigestMismatch {
|
|
220
|
+
readonly image: ReleaseImage;
|
|
221
|
+
readonly expected: string;
|
|
222
|
+
readonly actual: string | null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Compare the digests a registry resolved for the release's tags with the
|
|
227
|
+
* signed manifest. Only images present in `resolved` are compared, except
|
|
228
|
+
* that an explicitly `null` entry (tag did not resolve) is a mismatch.
|
|
229
|
+
*
|
|
230
|
+
* @returns every mismatch; empty means the registry serves the signed images.
|
|
231
|
+
*/
|
|
232
|
+
export function compareImageDigests(
|
|
233
|
+
manifest: ReleaseManifest,
|
|
234
|
+
resolved: Partial<Record<ReleaseImage, string | null>>,
|
|
235
|
+
): DigestMismatch[] {
|
|
236
|
+
const out: DigestMismatch[] = [];
|
|
237
|
+
for (const image of RELEASE_IMAGES) {
|
|
238
|
+
if (!(image in resolved)) continue;
|
|
239
|
+
const actual = resolved[image] ?? null;
|
|
240
|
+
if (actual !== manifest.digests[image]) {
|
|
241
|
+
out.push({ image, expected: manifest.digests[image], actual });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What the installed system looks like, for {@link assessAutoInstall}. */
|
|
248
|
+
export interface InstalledRelease {
|
|
249
|
+
readonly version: string;
|
|
250
|
+
readonly infraContractVersion: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* How an upgrader may treat a release:
|
|
255
|
+
* - `auto` — may install unattended (if the Owner's policy allows the kind).
|
|
256
|
+
* - `manual` — installable only with the operator's own identity
|
|
257
|
+
* ("Run in Cloud Shell"); never unattended.
|
|
258
|
+
* - `refused` — not installable onto this install at all.
|
|
259
|
+
*/
|
|
260
|
+
export type InstallMode = "auto" | "manual" | "refused";
|
|
261
|
+
|
|
262
|
+
export interface InstallAssessment {
|
|
263
|
+
readonly mode: InstallMode;
|
|
264
|
+
/** `security` only when the manifest says so AND the release is otherwise auto-installable. */
|
|
265
|
+
readonly kind: "security" | "normal";
|
|
266
|
+
readonly reasons: readonly string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Decide whether `manifest` can be installed on `installed`, and how.
|
|
271
|
+
*
|
|
272
|
+
* A security release is NOT exempt: if it carries contract migrations
|
|
273
|
+
* (or skips a release that did) or raises the infra contract, it is `manual` like any other release — the
|
|
274
|
+
* manifest still says `security`, but `kind` drops to `normal` so an
|
|
275
|
+
* "auto-install security fixes" policy never applies it unattended.
|
|
276
|
+
*/
|
|
277
|
+
export function assessAutoInstall(
|
|
278
|
+
manifest: ReleaseManifest,
|
|
279
|
+
installed: InstalledRelease,
|
|
280
|
+
): InstallAssessment {
|
|
281
|
+
const refused: string[] = [];
|
|
282
|
+
if (compareSemver(manifest.version, installed.version) <= 0) {
|
|
283
|
+
refused.push(`v${manifest.version} is not newer than the installed v${installed.version}`);
|
|
284
|
+
}
|
|
285
|
+
if (compareSemver(installed.version, manifest.minFromVersion) < 0) {
|
|
286
|
+
refused.push(
|
|
287
|
+
`v${manifest.version} needs at least v${manifest.minFromVersion} installed first (installed: v${installed.version})`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (refused.length > 0) return { mode: "refused", kind: "normal", reasons: refused };
|
|
291
|
+
|
|
292
|
+
const manual: string[] = [];
|
|
293
|
+
if (manifest.infraContractVersion > installed.infraContractVersion) {
|
|
294
|
+
manual.push(
|
|
295
|
+
`the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}); it must run with the operator's own identity`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const lastContract = manifest.migrations.lastContractVersion;
|
|
299
|
+
if (manifest.migrations.contract) {
|
|
300
|
+
manual.push("the release carries contract (destructive) migrations");
|
|
301
|
+
} else if (lastContract !== null && compareSemver(installed.version, lastContract) < 0) {
|
|
302
|
+
manual.push(
|
|
303
|
+
`the upgrade skips v${lastContract}, whose contract (destructive) migrations would run while v${installed.version}'s image still serves`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (manual.length > 0) return { mode: "manual", kind: "normal", reasons: manual };
|
|
307
|
+
// Security counts only with a published advisory behind it (#696
|
|
308
|
+
// decision 2); the release workflow already refuses the flag without one.
|
|
309
|
+
return { mode: "auto", kind: isSecurityRelease(manifest) ? "security" : "normal", reasons: [] };
|
|
310
|
+
}
|