@caelo-cms/provisioning 0.10.65 → 0.10.66

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.
Files changed (67) hide show
  1. package/dist/agent-db.js +37 -6
  2. package/dist/converge-job-gcp.d.ts.map +1 -1
  3. package/dist/converge-job-gcp.js +1 -1
  4. package/dist/converge-job-gcp.js.map +1 -1
  5. package/dist/install-state.d.ts +17 -2
  6. package/dist/install-state.d.ts.map +1 -1
  7. package/dist/install-state.js +11 -3
  8. package/dist/install-state.js.map +1 -1
  9. package/dist/lifecycle.d.ts +4 -1
  10. package/dist/lifecycle.d.ts.map +1 -1
  11. package/dist/lifecycle.js +12 -2
  12. package/dist/lifecycle.js.map +1 -1
  13. package/dist/release-manifest.d.ts +10 -4
  14. package/dist/release-manifest.d.ts.map +1 -1
  15. package/dist/release-manifest.js +17 -8
  16. package/dist/release-manifest.js.map +1 -1
  17. package/dist/upgrade/__tests__/live-indexes.d.ts +23 -0
  18. package/dist/upgrade/__tests__/live-indexes.d.ts.map +1 -0
  19. package/dist/upgrade/__tests__/live-indexes.js +41 -0
  20. package/dist/upgrade/__tests__/live-indexes.js.map +1 -0
  21. package/dist/upgrade/gcp-lookup.d.ts +22 -13
  22. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  23. package/dist/upgrade/gcp-lookup.js +42 -3
  24. package/dist/upgrade/gcp-lookup.js.map +1 -1
  25. package/dist/upgrade/image-forms.d.ts +55 -0
  26. package/dist/upgrade/image-forms.d.ts.map +1 -0
  27. package/dist/upgrade/image-forms.js +164 -0
  28. package/dist/upgrade/image-forms.js.map +1 -0
  29. package/dist/upgrade/release-images.d.ts +24 -0
  30. package/dist/upgrade/release-images.d.ts.map +1 -1
  31. package/dist/upgrade/release-images.js +30 -0
  32. package/dist/upgrade/release-images.js.map +1 -1
  33. package/dist/upgrader-gcp.d.ts.map +1 -1
  34. package/dist/upgrader-gcp.js +3 -0
  35. package/dist/upgrader-gcp.js.map +1 -1
  36. package/dist/upgrader-job.d.ts +8 -0
  37. package/dist/upgrader-job.d.ts.map +1 -1
  38. package/dist/upgrader-job.js +51 -10
  39. package/dist/upgrader-job.js.map +1 -1
  40. package/dist/upgrader-records.d.ts +27 -2
  41. package/dist/upgrader-records.d.ts.map +1 -1
  42. package/dist/upgrader-records.js +77 -19
  43. package/dist/upgrader-records.js.map +1 -1
  44. package/dist/wizards/gcp.d.ts.map +1 -1
  45. package/dist/wizards/gcp.js +8 -1
  46. package/dist/wizards/gcp.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/converge-job-gcp.ts +1 -0
  49. package/src/install-state.test.ts +32 -0
  50. package/src/install-state.ts +21 -2
  51. package/src/lifecycle.ts +19 -2
  52. package/src/release-manifest.test.ts +27 -2
  53. package/src/release-manifest.ts +20 -7
  54. package/src/upgrade/__tests__/fixtures/admin-0.10.64-index.json +28 -0
  55. package/src/upgrade/__tests__/fixtures/gateway-0.10.64-index.json +28 -0
  56. package/src/upgrade/__tests__/live-indexes.ts +61 -0
  57. package/src/upgrade/gcp-lookup.test.ts +53 -9
  58. package/src/upgrade/gcp-lookup.ts +60 -15
  59. package/src/upgrade/image-forms.test.ts +192 -0
  60. package/src/upgrade/image-forms.ts +226 -0
  61. package/src/upgrade/release-images.ts +49 -0
  62. package/src/upgrader-gcp.ts +3 -0
  63. package/src/upgrader-job.test.ts +153 -0
  64. package/src/upgrader-job.ts +73 -12
  65. package/src/upgrader-records.test.ts +202 -0
  66. package/src/upgrader-records.ts +115 -19
  67. package/src/wizards/gcp.ts +17 -1
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The v0.10.64 admin + gateway image indexes exactly as the release mirror
5
+ * serves them (fixtures/, byte for byte), and a fake `fetch` that serves
6
+ * them by digest — the shapes of #1025: each index lists its linux/amd64
7
+ * manifest plus one buildx attestation manifest, and Cloud Run records the
8
+ * linux/amd64 digest for a serving revision.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ const fixture = (name: string): Uint8Array<ArrayBuffer> =>
15
+ new Uint8Array(readFileSync(join(import.meta.dir, "fixtures", name)));
16
+
17
+ /** v0.10.64 admin: the index the signed manifest names, and what it lists. */
18
+ export const ADMIN_INDEX =
19
+ "sha256:bcda1725a5390c910c56cfedb6f55bc4f38cebb0231e9e9b4297bb5c08aec3e0";
20
+ export const ADMIN_AMD64 =
21
+ "sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656";
22
+ export const ADMIN_ATTESTATION =
23
+ "sha256:06f9defdb92e8252e9faab8fcb8fb340d3949d5cdec09e06b42905ade7ba30c0";
24
+ /** v0.10.64 gateway. */
25
+ export const GATEWAY_INDEX =
26
+ "sha256:fbb0be7a106f75c25e44bca1898fd56af27d11fa599659afe55e4f6ffaeca4a3";
27
+ export const GATEWAY_AMD64 =
28
+ "sha256:d24f2675d43e7891f217938c74475e9c31a64ffda0c96cf4fbb0a3f73e31367e";
29
+
30
+ /** The index bytes, by digest. */
31
+ export const LIVE_INDEXES: Readonly<Record<string, Uint8Array<ArrayBuffer>>> = {
32
+ [ADMIN_INDEX]: fixture("admin-0.10.64-index.json"),
33
+ [GATEWAY_INDEX]: fixture("gateway-0.10.64-index.json"),
34
+ };
35
+
36
+ /** How the fake registry answers. */
37
+ export interface FakeRegistryOptions {
38
+ /** Answer every request with this HTTP status instead. */
39
+ readonly status?: number;
40
+ /** Throw (network failure) instead of answering. */
41
+ readonly unreachable?: boolean;
42
+ /** Serve these bytes for every digest (a tampering registry). */
43
+ readonly tampered?: Uint8Array<ArrayBuffer>;
44
+ /** Index bytes to serve besides {@link LIVE_INDEXES}. */
45
+ readonly extra?: Readonly<Record<string, Uint8Array<ArrayBuffer>>>;
46
+ }
47
+
48
+ /** A `fetch` serving the mirror's `/v2/<repo>/manifests/<digest>`; records each URL in `calls`. */
49
+ export function fakeRegistryFetch(calls: string[], opts: FakeRegistryOptions = {}): typeof fetch {
50
+ const impl = async (input: string | URL | Request): Promise<Response> => {
51
+ const url = String(input instanceof Request ? input.url : input);
52
+ calls.push(url);
53
+ if (opts.unreachable) throw new Error("getaddrinfo ENOTFOUND");
54
+ if (opts.status) return new Response("", { status: opts.status });
55
+ const digest = url.split("/manifests/")[1] ?? "";
56
+ const bytes = opts.tampered ?? opts.extra?.[digest] ?? LIVE_INDEXES[digest];
57
+ if (!bytes) return new Response("", { status: 404 });
58
+ return new Response(bytes, { status: 200 });
59
+ };
60
+ return impl as typeof fetch;
61
+ }
@@ -1,10 +1,11 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * findServingImage against a fake gcloud: the digest a revision was deployed
5
- * with is the release's index digest, Cloud Run's `status.imageDigest` the
6
- * platform manifest inside it (#860). Both must come back, so a recorded
7
- * index digest matches what serves.
4
+ * findServingImage against a fake gcloud. A revision's spec and
5
+ * `status.imageDigest` may name the release's index digest or the
6
+ * platform manifest inside it (#860); on live installs BOTH name the
7
+ * platform manifest (#1025), and only the service template keeps the index
8
+ * (findTemplateImageDigest). Every digest found must come back.
8
9
  */
9
10
 
10
11
  import { describe, expect, it } from "bun:test";
@@ -12,6 +13,7 @@ import type { GcloudResult } from "../gcloud.js";
12
13
  import type { GcloudRunner } from "../gcloud-retry.js";
13
14
  import {
14
15
  findServingImage,
16
+ findTemplateImageDigest,
15
17
  lookupFailure,
16
18
  resolveGcpResourceName,
17
19
  servingImageDigests,
@@ -108,15 +110,15 @@ const revision = (image: string, imageDigest?: string) =>
108
110
  ok(JSON.stringify({ spec: { containers: [{ image }] }, status: { imageDigest } }));
109
111
 
110
112
  describe("findServingImage", () => {
111
- it("returns the deployed index digest and the platform digest Cloud Run resolved", async () => {
113
+ it("returns the spec's index digest and the platform digest Cloud Run resolved", async () => {
112
114
  const image = await findServingImage(
113
115
  gcloud(revision(`${REPO}@${INDEX}`, `${REPO}@${PLATFORM}`)),
114
116
  "acme",
115
117
  "europe-west1",
116
118
  "caelo-production-admin-x",
117
119
  );
118
- expect(image).toEqual({ deployedDigest: INDEX, resolvedDigest: PLATFORM });
119
- expect(servingImageDigests(image ?? { deployedDigest: null, resolvedDigest: null })).toEqual([
120
+ expect(image).toEqual({ specDigest: INDEX, resolvedDigest: PLATFORM });
121
+ expect(servingImageDigests(image ?? { specDigest: null, resolvedDigest: null })).toEqual([
120
122
  INDEX,
121
123
  PLATFORM,
122
124
  ]);
@@ -129,11 +131,26 @@ describe("findServingImage", () => {
129
131
  "europe-west1",
130
132
  "svc",
131
133
  );
132
- expect(image).toEqual({ deployedDigest: null, resolvedDigest: PLATFORM });
134
+ expect(image).toEqual({ specDigest: null, resolvedDigest: PLATFORM });
135
+ });
136
+
137
+ it("regression #1025: the live shape, spec and status both name the platform manifest", async () => {
138
+ // caelo-production-admin-482044c-00015-2vc on searchviu-website, deployed
139
+ // as the v0.10.64 index admin@sha256:bcda… (the service template).
140
+ const live = `${REPO}@sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656`;
141
+ const image = await findServingImage(
142
+ gcloud(revision(live, live)),
143
+ "acme",
144
+ "europe-west1",
145
+ "svc",
146
+ );
147
+ expect(servingImageDigests(image ?? { specDigest: null, resolvedDigest: null })).toEqual([
148
+ "sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656",
149
+ ]);
133
150
  });
134
151
 
135
152
  it("one digest when both name the same image", () => {
136
- expect(servingImageDigests({ deployedDigest: INDEX, resolvedDigest: INDEX })).toEqual([INDEX]);
153
+ expect(servingImageDigests({ specDigest: INDEX, resolvedDigest: INDEX })).toEqual([INDEX]);
137
154
  });
138
155
 
139
156
  it("null when no digest can be read or the output is not JSON", async () => {
@@ -157,3 +174,30 @@ describe("findServingImage", () => {
157
174
  ).toBeNull();
158
175
  });
159
176
  });
177
+
178
+ describe("findTemplateImageDigest (#1025)", () => {
179
+ const service = (image: unknown) =>
180
+ ok(JSON.stringify({ spec: { template: { spec: { containers: [{ image }] } } } }));
181
+ const runner =
182
+ (r: GcloudResult): GcloudRunner =>
183
+ async () =>
184
+ r;
185
+
186
+ it("reads the index digest the template pins", async () => {
187
+ expect(
188
+ await findTemplateImageDigest(runner(service(`${REPO}@${INDEX}`)), "acme", "r", "svc"),
189
+ ).toBe(INDEX);
190
+ });
191
+
192
+ it("null for a tag, an odd shape, bad JSON or a failed describe", async () => {
193
+ for (const r of [
194
+ service(`${REPO}:latest`),
195
+ service(7),
196
+ ok("null"),
197
+ ok("not json"),
198
+ fail("denied"),
199
+ ]) {
200
+ expect(await findTemplateImageDigest(runner(r), "acme", "r", "svc")).toBeNull();
201
+ }
202
+ });
203
+ });
@@ -157,28 +157,30 @@ export async function findCurrentRevision(
157
157
  * by (not the service template's: after a rollback the template names the
158
158
  * failed image while traffic stays on the previous revision).
159
159
  *
160
- * Why two digests (#860): Caelo's release images are OCI image *indexes*
161
- * (buildx adds a provenance/SBOM attestation manifest even to a
162
- * single-platform build), and the signed release manifest, install.json
163
- * and every deploy pin the index digest. Cloud Run's `status.imageDigest`
164
- * names what it resolved and pulled, the linux/amd64 *platform manifest*
165
- * inside that index, so comparing it with a recorded index digest never
166
- * matches. The digest the revision was deployed with
167
- * (`spec.containers[0].image`, `…@sha256:<index>`) is the form everything
168
- * else records; it is content-addressed, so the revision necessarily runs
169
- * that index's image.
160
+ * Which form these digests take (#860, corrected by #1025): Caelo's release
161
+ * images are OCI image *indexes* (buildx adds a provenance/SBOM attestation
162
+ * manifest even to a single-platform build), and the signed release
163
+ * manifest, install.json and every deploy pin the index digest. Cloud Run
164
+ * resolves that index to its linux/amd64 *platform manifest* and records
165
+ * the platform digest — in `status.imageDigest` AND, contrary to what #860
166
+ * assumed, in the revision's `spec.containers[0].image` too: a revision
167
+ * deployed as `admin@sha256:bcda…` (the v0.10.64 index) reads back as
168
+ * `admin@sha256:2e75…` in both fields (live installs, 2026-10-23). Only the
169
+ * service template keeps the index. So these digests are matched against a
170
+ * release's index AND the platform manifests inside it
171
+ * (upgrade/image-forms.ts), never against the index alone.
170
172
  */
171
173
  export interface ServingImage {
172
- /** The digest the revision was deployed with; null when deployed by tag. */
173
- readonly deployedDigest: string | null;
174
+ /** The digest the revision's `spec.containers[0].image` names; null when it names a tag. */
175
+ readonly specDigest: string | null;
174
176
  /** The digest Cloud Run resolved and runs (`status.imageDigest`); null when not reported. */
175
177
  readonly resolvedDigest: string | null;
176
178
  }
177
179
 
178
- /** Every digest `image` is known by, deployed form first. */
180
+ /** Every digest `image` is known by, spec form first. */
179
181
  export function servingImageDigests(image: ServingImage): string[] {
180
182
  const out: string[] = [];
181
- for (const d of [image.deployedDigest, image.resolvedDigest]) {
183
+ for (const d of [image.specDigest, image.resolvedDigest]) {
182
184
  if (d && !out.includes(d)) out.push(d);
183
185
  }
184
186
  return out;
@@ -231,12 +233,55 @@ export async function findServingImage(
231
233
  const digestOf = (ref: string | undefined): string | null =>
232
234
  ref === undefined ? null : (PINNED_DIGEST.exec(ref.trim())?.[1] ?? null);
233
235
  const image: ServingImage = {
234
- deployedDigest: digestOf(parsed.data.spec?.containers?.[0]?.image),
236
+ specDigest: digestOf(parsed.data.spec?.containers?.[0]?.image),
235
237
  resolvedDigest: digestOf(parsed.data.status?.imageDigest),
236
238
  };
237
239
  return servingImageDigests(image).length > 0 ? image : null;
238
240
  }
239
241
 
242
+ /** The fields of `gcloud run services describe --format=json` {@link findTemplateImageDigest} reads. */
243
+ const ServiceJsonSchema = z.object({
244
+ spec: z
245
+ .object({
246
+ template: z
247
+ .object({
248
+ spec: z
249
+ .object({
250
+ containers: z.array(z.object({ image: z.string().optional() })).optional(),
251
+ })
252
+ .optional(),
253
+ })
254
+ .optional(),
255
+ })
256
+ .optional(),
257
+ });
258
+
259
+ /**
260
+ * The digest the service's template pins (the index a deploy named, #1025),
261
+ * or null when it cannot be read or names a tag. NOT what serves — after a
262
+ * rollback the template names the failed image — so only a hint for which
263
+ * release to check (upgrader-records.ts).
264
+ */
265
+ export async function findTemplateImageDigest(
266
+ run: GcloudRunner,
267
+ projectId: string,
268
+ region: string,
269
+ serviceName: string,
270
+ ): Promise<string | null> {
271
+ const stdout = await describeServiceJson(run, projectId, region, serviceName);
272
+ if (stdout === null) return null;
273
+ let json: unknown;
274
+ try {
275
+ json = JSON.parse(stdout);
276
+ } catch {
277
+ return null;
278
+ }
279
+ const parsed = ServiceJsonSchema.safeParse(json);
280
+ if (!parsed.success) return null;
281
+ const ref = parsed.data.spec?.template?.spec?.containers?.[0]?.image;
282
+ return ref === undefined ? null : (PINNED_DIGEST.exec(ref.trim())?.[1] ?? null);
283
+ }
284
+
240
285
  /** The service's own `run.app` URL. */
241
286
  export async function findServiceUrl(
242
287
  run: GcloudRunner,
@@ -0,0 +1,192 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1025: every digest a release image is known by, read from the signed
5
+ * manifest or from the content-checked index in the fixed mirror. Run
6
+ * against the v0.10.64 indexes exactly as the mirror serves them.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ ADMIN_AMD64,
12
+ ADMIN_ATTESTATION,
13
+ ADMIN_INDEX,
14
+ fakeRegistryFetch,
15
+ GATEWAY_AMD64,
16
+ GATEWAY_INDEX,
17
+ LIVE_INDEXES,
18
+ } from "./__tests__/live-indexes.js";
19
+ import { createReleaseImageForms, formsFromIndexJson, platformDigestsOf } from "./image-forms.js";
20
+
21
+ const D = (c: string) => `sha256:${c.repeat(64)}`;
22
+ const MIRROR = "https://europe-west1-docker.pkg.dev/v2/caelo-website/caelo-cms-images";
23
+
24
+ describe("the fixtures", () => {
25
+ it("are the bytes of the indexes they are named after", async () => {
26
+ for (const [digest, bytes] of Object.entries(LIVE_INDEXES)) {
27
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
28
+ expect(`sha256:${Buffer.from(hash).toString("hex")}`).toBe(digest);
29
+ }
30
+ });
31
+ });
32
+
33
+ describe("createReleaseImageForms().ofIndex", () => {
34
+ it("regression #1025: the v0.10.64 admin index lists its linux/amd64 manifest, not the attestation", async () => {
35
+ const calls: string[] = [];
36
+ const forms = createReleaseImageForms(fakeRegistryFetch(calls));
37
+ const r = await forms.ofIndex("admin", ADMIN_INDEX);
38
+ expect(r).toEqual({ ok: true, digests: [ADMIN_INDEX, ADMIN_AMD64], amd64: ADMIN_AMD64 });
39
+ expect(r.ok && r.digests.includes(ADMIN_ATTESTATION)).toBe(false);
40
+ // Fetched by digest from the fixed mirror.
41
+ expect(calls).toEqual([`${MIRROR}/admin/manifests/${ADMIN_INDEX}`]);
42
+ expect(await forms.ofIndex("gateway", GATEWAY_INDEX)).toEqual({
43
+ ok: true,
44
+ digests: [GATEWAY_INDEX, GATEWAY_AMD64],
45
+ amd64: GATEWAY_AMD64,
46
+ });
47
+ });
48
+
49
+ it("reads each index once per instance", async () => {
50
+ const calls: string[] = [];
51
+ const forms = createReleaseImageForms(fakeRegistryFetch(calls));
52
+ await forms.ofIndex("admin", ADMIN_INDEX);
53
+ await forms.ofIndex("admin", ADMIN_INDEX);
54
+ expect(calls).toHaveLength(1);
55
+ });
56
+
57
+ it("refuses bytes that do not hash to the digest asked for (a tampering registry)", async () => {
58
+ const forged = new TextEncoder().encode(
59
+ JSON.stringify({
60
+ mediaType: "application/vnd.oci.image.index.v1+json",
61
+ manifests: [{ digest: D("6"), platform: { os: "linux", architecture: "amd64" } }],
62
+ }),
63
+ );
64
+ const forms = createReleaseImageForms(fakeRegistryFetch([], { tampered: forged }));
65
+ const r = await forms.ofIndex("admin", ADMIN_INDEX);
66
+ expect(r.ok).toBe(false);
67
+ expect(r.ok ? null : r.transient).toBe(false);
68
+ expect(r.ok ? "" : r.message).toContain("does not match its digest");
69
+ });
70
+
71
+ it("an outage is transient, a missing index is not", async () => {
72
+ for (const [opts, transient] of [
73
+ [{ status: 503 }, true],
74
+ [{ status: 429 }, true],
75
+ [{ unreachable: true }, true],
76
+ [{ status: 404 }, false],
77
+ ] as const) {
78
+ const r = await createReleaseImageForms(fakeRegistryFetch([], opts)).ofIndex(
79
+ "admin",
80
+ ADMIN_INDEX,
81
+ );
82
+ expect(r.ok ? null : r.transient).toBe(transient);
83
+ }
84
+ });
85
+
86
+ it("follows the registry's anonymous Bearer challenge", async () => {
87
+ const calls: string[] = [];
88
+ const inner = fakeRegistryFetch(calls);
89
+ const challenged = (async (input: string | URL | Request, init?: RequestInit) => {
90
+ const url = String(input);
91
+ if (url.startsWith("https://token.example/")) {
92
+ return Response.json({ token: "anon" });
93
+ }
94
+ const auth = new Headers(init?.headers).get("authorization");
95
+ if (auth !== "Bearer anon") {
96
+ return new Response("", {
97
+ status: 401,
98
+ headers: { "www-authenticate": 'Bearer realm="https://token.example/t",service="x"' },
99
+ });
100
+ }
101
+ return inner(input, init);
102
+ }) as typeof fetch;
103
+ const r = await createReleaseImageForms(challenged).ofIndex("admin", ADMIN_INDEX);
104
+ expect(r.ok && r.amd64).toBe(ADMIN_AMD64);
105
+ });
106
+ });
107
+
108
+ describe("createReleaseImageForms().of", () => {
109
+ it("takes the signed platform digests without a registry round trip", async () => {
110
+ const calls: string[] = [];
111
+ const forms = createReleaseImageForms(fakeRegistryFetch(calls));
112
+ const m = {
113
+ version: "0.10.66",
114
+ digests: { admin: D("a"), gateway: D("b"), ops: D("c") },
115
+ platformDigests: { admin: D("1"), gateway: D("2"), ops: D("3") },
116
+ };
117
+ expect(await forms.of(m, "gateway")).toEqual({
118
+ ok: true,
119
+ digests: [D("b"), D("2")],
120
+ amd64: D("2"),
121
+ });
122
+ expect(calls).toEqual([]);
123
+ });
124
+
125
+ it("reads the index for a release signed before the field", async () => {
126
+ const forms = createReleaseImageForms(fakeRegistryFetch([]));
127
+ const m = {
128
+ version: "0.10.64",
129
+ digests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX, ops: D("c") },
130
+ };
131
+ expect(await forms.of(m, "admin")).toMatchObject({ ok: true, amd64: ADMIN_AMD64 });
132
+ });
133
+ });
134
+
135
+ describe("formsFromIndexJson", () => {
136
+ it("a plain image manifest is its own platform form", () => {
137
+ const plain = { mediaType: "application/vnd.oci.image.manifest.v1+json", config: {} };
138
+ expect(formsFromIndexJson(D("7"), plain)).toEqual({
139
+ ok: true,
140
+ digests: [D("7")],
141
+ amd64: D("7"),
142
+ });
143
+ });
144
+
145
+ it("an index without a linux/amd64 manifest has no amd64 form", () => {
146
+ const arm = {
147
+ mediaType: "application/vnd.oci.image.index.v1+json",
148
+ manifests: [{ digest: D("8"), platform: { os: "linux", architecture: "arm64" } }],
149
+ };
150
+ expect(formsFromIndexJson(D("7"), arm)).toEqual({
151
+ ok: true,
152
+ digests: [D("7"), D("8")],
153
+ amd64: null,
154
+ });
155
+ });
156
+
157
+ it("an unreadable shape is a non-transient error", () => {
158
+ const r = formsFromIndexJson(D("7"), { manifests: [{ digest: "nope" }] });
159
+ expect(r.ok ? null : r.transient).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe("platformDigestsOf", () => {
164
+ it("reads the linux/amd64 form of both indexes", async () => {
165
+ const forms = createReleaseImageForms(fakeRegistryFetch([]));
166
+ expect(await platformDigestsOf({ admin: ADMIN_INDEX, gateway: GATEWAY_INDEX }, forms)).toEqual({
167
+ ok: true,
168
+ platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 },
169
+ });
170
+ });
171
+
172
+ it("prefers the manifest's signed forms when it signs these very indexes", async () => {
173
+ const calls: string[] = [];
174
+ const forms = createReleaseImageForms(fakeRegistryFetch(calls));
175
+ const m = {
176
+ version: "0.10.66",
177
+ digests: { admin: D("a"), gateway: D("b"), ops: D("c") },
178
+ platformDigests: { admin: D("1"), gateway: D("2"), ops: D("3") },
179
+ };
180
+ expect(await platformDigestsOf({ admin: D("a"), gateway: D("b") }, forms, m)).toEqual({
181
+ ok: true,
182
+ platformDigests: { admin: D("1"), gateway: D("2") },
183
+ });
184
+ expect(calls).toEqual([]);
185
+ });
186
+
187
+ it("says why when an index cannot be read", async () => {
188
+ const forms = createReleaseImageForms(fakeRegistryFetch([], { status: 503 }));
189
+ const r = await platformDigestsOf({ admin: ADMIN_INDEX, gateway: GATEWAY_INDEX }, forms);
190
+ expect(r.ok ? "" : r.message).toContain("HTTP 503");
191
+ });
192
+ });
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Every digest a release image is known by (#1025).
5
+ *
6
+ * Caelo's release images are OCI image *indexes*: buildx adds a
7
+ * provenance/SBOM attestation manifest even to a single-platform build. The
8
+ * signed release manifest, install.json and every deploy pin the index
9
+ * digest, but Cloud Run resolves the index to its linux/amd64 *platform
10
+ * manifest* and records that digest — in the revision's `status.imageDigest`
11
+ * AND in its `spec.containers[0].image` (verified on live installs,
12
+ * 2026-10-23: a revision deployed as `admin@sha256:bcda…` (the v0.10.64
13
+ * index) reads back as `admin@sha256:2e75…`, its linux/amd64 manifest). So
14
+ * "does release X serve?" must compare the serving digest with every form of
15
+ * X's images, not just the index.
16
+ *
17
+ * Where the forms come from, in order of trust:
18
+ * 1. the release manifest's signed `platformDigests` (releases cut after
19
+ * #1025) — no registry round trip;
20
+ * 2. otherwise the index itself, fetched BY DIGEST from the fixed public
21
+ * mirror and checked against that digest before it is read. Content
22
+ * addressing makes it as unforgeable as the signature that named the
23
+ * index: a registry or proxy that altered it fails the check.
24
+ *
25
+ * Only platform manifests count, never the attestation manifests listed next
26
+ * to them (Cloud Run cannot run those). Lookups are cached per instance, so
27
+ * one upgrader run fetches each index at most once.
28
+ */
29
+
30
+ import { z } from "zod";
31
+ import type { ImageDigests } from "../install-state.js";
32
+ import type { ReleaseManifest } from "../release-manifest.js";
33
+ import type { RollSlug } from "./app-roll.js";
34
+ import { fetchRegistryManifest, IMAGE_DIGEST, RELEASE_REGISTRY } from "./release-images.js";
35
+
36
+ /** The digests one release image is known by. */
37
+ export type ImageFormsResult =
38
+ | {
39
+ readonly ok: true;
40
+ /** The index digest first, then each platform manifest it lists. */
41
+ readonly digests: readonly string[];
42
+ /**
43
+ * The linux/amd64 form Cloud Run runs: the signed platform digest, the
44
+ * index's linux/amd64 entry, or the digest itself when it names a plain
45
+ * image manifest. Null when the index lists no linux/amd64 manifest.
46
+ */
47
+ readonly amd64: string | null;
48
+ }
49
+ | {
50
+ readonly ok: false;
51
+ /** A retry may cure it (registry unreachable, 5xx, 429). */
52
+ readonly transient: boolean;
53
+ readonly message: string;
54
+ };
55
+
56
+ /** Finds every form of a release image; see the file comment. */
57
+ export interface ReleaseImageForms {
58
+ /** The forms of release `m`'s image `slug` (signed platform digests win). */
59
+ of(
60
+ m: Pick<ReleaseManifest, "version" | "digests" | "platformDigests">,
61
+ slug: RollSlug,
62
+ ): Promise<ImageFormsResult>;
63
+ /** The forms of the index `indexDigest` of image `slug`, read from the mirror. */
64
+ ofIndex(slug: RollSlug, indexDigest: string): Promise<ImageFormsResult>;
65
+ }
66
+
67
+ const INDEX_MEDIA_TYPES = new Set([
68
+ "application/vnd.oci.image.index.v1+json",
69
+ "application/vnd.docker.distribution.manifest.list.v2+json",
70
+ ]);
71
+
72
+ const digest = z.string().regex(IMAGE_DIGEST);
73
+
74
+ /** The fields of an image index (or plain image manifest) the lookup reads. */
75
+ const ManifestJsonSchema = z.object({
76
+ mediaType: z.string().optional(),
77
+ manifests: z
78
+ .array(
79
+ z.object({
80
+ digest,
81
+ platform: z.object({ os: z.string(), architecture: z.string() }).optional(),
82
+ annotations: z.record(z.string(), z.string()).optional(),
83
+ }),
84
+ )
85
+ .optional(),
86
+ });
87
+
88
+ async function sha256Digest(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
89
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
90
+ return `sha256:${[...hash].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
91
+ }
92
+
93
+ /**
94
+ * Read the forms out of index bytes already checked against `indexDigest`.
95
+ * Exported for tests; production code goes through {@link createReleaseImageForms}.
96
+ */
97
+ export function formsFromIndexJson(indexDigest: string, json: unknown): ImageFormsResult {
98
+ const parsed = ManifestJsonSchema.safeParse(json);
99
+ if (!parsed.success) {
100
+ return {
101
+ ok: false,
102
+ transient: false,
103
+ message: `${indexDigest} is not an image index or manifest: ${parsed.error.message}`,
104
+ };
105
+ }
106
+ const { mediaType, manifests } = parsed.data;
107
+ const isIndex = mediaType ? INDEX_MEDIA_TYPES.has(mediaType) : manifests !== undefined;
108
+ // A plain image manifest is its own platform form.
109
+ if (!isIndex) return { ok: true, digests: [indexDigest], amd64: indexDigest };
110
+ const platforms = (manifests ?? []).filter(
111
+ (m) =>
112
+ m.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest" &&
113
+ m.platform !== undefined &&
114
+ m.platform.os !== "unknown",
115
+ );
116
+ const amd64 = platforms.filter(
117
+ (m) => m.platform?.os === "linux" && m.platform.architecture === "amd64",
118
+ );
119
+ return {
120
+ ok: true,
121
+ digests: [indexDigest, ...platforms.map((m) => m.digest).filter((d) => d !== indexDigest)],
122
+ amd64: amd64.length === 1 ? (amd64[0]?.digest ?? null) : null,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The lookup against the fixed public mirror ({@link RELEASE_REGISTRY}),
128
+ * anonymous, cached per instance.
129
+ *
130
+ * @param fetchImpl - injectable for tests; the real one by default
131
+ */
132
+ export function createReleaseImageForms(fetchImpl: typeof fetch = fetch): ReleaseImageForms {
133
+ const cache = new Map<string, Promise<ImageFormsResult>>();
134
+ const { region, project, repo } = RELEASE_REGISTRY;
135
+
136
+ async function lookup(slug: RollSlug, indexDigest: string): Promise<ImageFormsResult> {
137
+ const where = `the ${slug} image index ${indexDigest} in the release mirror`;
138
+ const fetched = await fetchRegistryManifest(
139
+ `${region}-docker.pkg.dev`,
140
+ `${project}/${repo}/${slug}`,
141
+ indexDigest,
142
+ fetchImpl,
143
+ );
144
+ if (!fetched.ok) {
145
+ return {
146
+ ok: false,
147
+ transient: fetched.transient,
148
+ message: `${where} could not be read (${fetched.reason})`,
149
+ };
150
+ }
151
+ const actual = await sha256Digest(fetched.bytes);
152
+ if (actual !== indexDigest) {
153
+ return {
154
+ ok: false,
155
+ transient: false,
156
+ message: `${where} does not match its digest (${actual} served): the registry or a proxy altered it`,
157
+ };
158
+ }
159
+ let json: unknown;
160
+ try {
161
+ json = JSON.parse(new TextDecoder().decode(fetched.bytes));
162
+ } catch {
163
+ return { ok: false, transient: false, message: `${where} is not JSON` };
164
+ }
165
+ return formsFromIndexJson(indexDigest, json);
166
+ }
167
+
168
+ const ofIndex = (slug: RollSlug, indexDigest: string): Promise<ImageFormsResult> => {
169
+ const key = `${slug}@${indexDigest}`;
170
+ let pending = cache.get(key);
171
+ if (!pending) {
172
+ pending = lookup(slug, indexDigest);
173
+ cache.set(key, pending);
174
+ }
175
+ return pending;
176
+ };
177
+
178
+ return {
179
+ ofIndex,
180
+ of: async (m, slug) => {
181
+ const signed = m.platformDigests?.[slug];
182
+ if (signed) {
183
+ const index = m.digests[slug];
184
+ return { ok: true, digests: [index, ...(signed === index ? [] : [signed])], amd64: signed };
185
+ }
186
+ return ofIndex(slug, m.digests[slug]);
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * The linux/amd64 form of each image in `digests`, to record next to them
193
+ * in install.json (#1025). Taken from `manifest`'s signed platform digests
194
+ * when it signs these very indexes, else from the content-checked index.
195
+ *
196
+ * @returns the forms, or why they are not known (the caller records the
197
+ * index digests without them; the upgrader then reads the index itself)
198
+ */
199
+ export async function platformDigestsOf(
200
+ digests: ImageDigests,
201
+ forms: ReleaseImageForms,
202
+ manifest?: Pick<ReleaseManifest, "version" | "digests" | "platformDigests"> | null,
203
+ ): Promise<
204
+ | { readonly ok: true; readonly platformDigests: ImageDigests }
205
+ | { readonly ok: false; readonly message: string }
206
+ > {
207
+ const out: Partial<Record<RollSlug, string>> = {};
208
+ for (const slug of ["admin", "gateway"] as const) {
209
+ const found =
210
+ manifest && manifest.digests[slug] === digests[slug]
211
+ ? await forms.of(manifest, slug)
212
+ : await forms.ofIndex(slug, digests[slug]);
213
+ if (!found.ok) return { ok: false, message: found.message };
214
+ if (!found.amd64) {
215
+ return {
216
+ ok: false,
217
+ message: `the ${slug} image index ${digests[slug]} lists no single linux/amd64 manifest`,
218
+ };
219
+ }
220
+ out[slug] = found.amd64;
221
+ }
222
+ const { admin, gateway } = out;
223
+ return admin && gateway
224
+ ? { ok: true, platformDigests: { admin, gateway } }
225
+ : { ok: false, message: "the platform digests could not be read" };
226
+ }