@caelo-cms/provisioning 0.10.64 → 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 +103 -56
  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
@@ -78,6 +78,16 @@ export interface InstallMetadata {
78
78
  * installs that predate it.
79
79
  */
80
80
  imageDigests?: ImageDigests;
81
+ /**
82
+ * #1025 — the linux/amd64 platform manifest inside each index of
83
+ * `imageDigests`: the form Cloud Run records for a serving revision
84
+ * (upgrade/image-forms.ts). Recorded next to the index digests from the
85
+ * signed release manifest or the content-checked index, so the upgrader
86
+ * matches the serving images without a registry lookup. Belongs to
87
+ * `imageDigests`: dropped whenever those change without it. Absent on
88
+ * installs recorded before it; the upgrader then reads the index.
89
+ */
90
+ platformDigests?: ImageDigests;
81
91
  /**
82
92
  * #696 — the Caelo release (`0.11.0`) those digests belong to, recorded
83
93
  * next to them by `upgrade` and the upgrader job. The upgrader job reads
@@ -182,11 +192,17 @@ export function resumedMetadata(
182
192
  };
183
193
  }
184
194
 
185
- /** Record the image digests an install now runs (see {@link InstallMetadata.imageDigests}). */
195
+ /**
196
+ * Record the image digests an install now runs (see
197
+ * {@link InstallMetadata.imageDigests}).
198
+ *
199
+ * @param platformDigests - their linux/amd64 forms (#1025), when known
200
+ */
186
201
  export function recordImageDigests(
187
202
  installId: string,
188
203
  digests: ImageDigests,
189
204
  releaseVersion?: string | null,
205
+ platformDigests?: ImageDigests | null,
190
206
  ): void {
191
207
  const meta = readMetadata(installId);
192
208
  if (!meta) throw new Error(`No install.json for install '${installId}'.`);
@@ -196,11 +212,14 @@ export function recordImageDigests(
196
212
  // refuses on none and judges downgrades by it).
197
213
  const same =
198
214
  meta.imageDigests?.admin === digests.admin && meta.imageDigests?.gateway === digests.gateway;
199
- const { releaseVersion: previous, ...rest } = meta;
215
+ const { releaseVersion: previous, platformDigests: previousPlatform, ...rest } = meta;
200
216
  const version = releaseVersion ?? (same ? previous : undefined);
217
+ // The platform forms belong to their indexes just as the version does.
218
+ const platform = platformDigests ?? (same ? previousPlatform : undefined);
201
219
  writeMetadata(installId, {
202
220
  ...rest,
203
221
  imageDigests: { admin: digests.admin, gateway: digests.gateway },
222
+ ...(platform ? { platformDigests: { admin: platform.admin, gateway: platform.gateway } } : {}),
204
223
  ...(version ? { releaseVersion: version } : {}),
205
224
  });
206
225
  }
package/src/lifecycle.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  provisionerServiceAccountEmail,
49
49
  } from "./provisioner-identity.js";
50
50
  import { assertRegionUnchanged } from "./regions.js";
51
+ import type { ReleaseManifest } from "./release-manifest.js";
51
52
  import {
52
53
  type HttpFetch,
53
54
  ROTATABLE_SECRETS,
@@ -76,6 +77,7 @@ import {
76
77
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
77
78
  import { findServiceUrl, lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
78
79
  import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
80
+ import { createReleaseImageForms, platformDigestsOf } from "./upgrade/image-forms.js";
79
81
  import { recordRolledDigests } from "./upgrade/record-rolled.js";
80
82
  import {
81
83
  canonicalReleaseTag,
@@ -729,20 +731,35 @@ async function gcpHealthProbe(
729
731
  * install's install.json and Pulumi stack. The converge job (#819,
730
732
  * converge-job-gcp.ts) records through the same function as the terminal.
731
733
  *
734
+ * @param manifest - the verified manifest of the rolled release, when the
735
+ * caller holds it: its signed platform digests save the index lookup
732
736
  * @returns whether every record was written; false fails the upgrade.
733
737
  */
734
- export function recordRolledRelease(
738
+ export async function recordRolledRelease(
735
739
  installId: string,
736
740
  provider: "gcp" | "gcp-firebase",
737
741
  digests: ImageDigests,
738
742
  releaseVersion: string | null,
743
+ manifest?: ReleaseManifest | null,
739
744
  ): Promise<boolean> {
740
745
  // The local copy always names where the state lives (install-state.ts
741
746
  // stateStoreFor), and reading it cannot fail on bucket access.
742
747
  const local = readLocalMetadata(installId);
743
748
  const bucket = local?.stateBackend?.kind === "gcs" ? local.stateBackend.bucket : null;
749
+ // #1025 — record the linux/amd64 forms Cloud Run reports next to the
750
+ // indexes. Not knowing them is no failed upgrade: the upgrader job then
751
+ // reads the index from the release registry itself.
752
+ const platform = await platformDigestsOf(digests, createReleaseImageForms(), manifest);
753
+ if (!platform.ok) {
754
+ log.warn(
755
+ yellow(
756
+ `install.json records the release by its image indexes only; their linux/amd64 forms could not be read (${platform.message}).`,
757
+ ),
758
+ );
759
+ }
744
760
  return recordRolledDigests(digests, releaseVersion, {
745
- recordInstall: (d, v) => recordImageDigests(installId, d, v),
761
+ recordInstall: (d, v) =>
762
+ recordImageDigests(installId, d, v, platform.ok ? platform.platformDigests : null),
746
763
  pinStack: async (d) => {
747
764
  const meta = readMetadata(installId);
748
765
  if (!meta) throw new Error(`no install.json for '${installId}'`);
@@ -230,6 +230,16 @@ describe("ReleaseManifestSchema", () => {
230
230
  const ghsa = { ...MANIFEST, security: true, advisories: ["GHSA-2c2m-4x8q-wx9f"] };
231
231
  expect(ReleaseManifestSchema.safeParse(ghsa).success).toBe(true);
232
232
  });
233
+
234
+ it("#1025: platformDigests is optional, and must name all three images when present", () => {
235
+ // The canonical fixture has none: manifests signed before the field stay valid.
236
+ expect("platformDigests" in MANIFEST).toBe(false);
237
+ const platformDigests = { admin: d("1"), gateway: d("2"), ops: d("3") };
238
+ const parsed = ReleaseManifestSchema.safeParse({ ...MANIFEST, platformDigests });
239
+ expect(parsed.success && parsed.data.platformDigests).toEqual(platformDigests);
240
+ const partial = { ...MANIFEST, platformDigests: { admin: d("1"), gateway: d("2") } };
241
+ expect(ReleaseManifestSchema.safeParse(partial).success).toBe(false);
242
+ });
233
243
  });
234
244
 
235
245
  describe("compareImageDigests", () => {
@@ -241,8 +251,23 @@ describe("compareImageDigests", () => {
241
251
 
242
252
  it("names each mismatching or unresolved image, ignores absent ones", () => {
243
253
  expect(compareImageDigests(MANIFEST, { admin: d("f"), gateway: null })).toEqual([
244
- { image: "admin", expected: d("a"), actual: d("f") },
245
- { image: "gateway", expected: d("b"), actual: null },
254
+ { image: "admin", form: "index", expected: d("a"), actual: d("f") },
255
+ { image: "gateway", form: "index", expected: d("b"), actual: null },
256
+ ]);
257
+ });
258
+
259
+ it("#1025: compares the linux/amd64 platform digests the manifest signs", () => {
260
+ const withPlatform = {
261
+ ...MANIFEST,
262
+ platformDigests: { admin: d("1"), gateway: d("2"), ops: d("3") },
263
+ };
264
+ expect(compareImageDigests(withPlatform, {}, { admin: d("1"), ops: d("3") })).toEqual([]);
265
+ expect(compareImageDigests(withPlatform, {}, { gateway: d("9") })).toEqual([
266
+ { image: "gateway", form: "platform", expected: d("2"), actual: d("9") },
267
+ ]);
268
+ // A manifest without the field cannot vouch for a platform digest.
269
+ expect(compareImageDigests(MANIFEST, {}, { admin: d("1") })).toEqual([
270
+ { image: "admin", form: "platform", expected: null, actual: d("1") },
246
271
  ]);
247
272
  });
248
273
  });
@@ -221,27 +221,40 @@ export async function fetchVerifiedReleaseManifest(
221
221
  /** One image whose resolved digest differs from the manifest's. */
222
222
  export interface DigestMismatch {
223
223
  readonly image: ReleaseImage;
224
- readonly expected: string;
224
+ /** Which digest differs: the image index, or its linux/amd64 manifest (#1025). */
225
+ readonly form: "index" | "platform";
226
+ /** The manifest's digest; null when the manifest has no platform digests. */
227
+ readonly expected: string | null;
225
228
  readonly actual: string | null;
226
229
  }
227
230
 
228
231
  /**
229
232
  * Compare the digests a registry resolved for the release's tags with the
230
- * signed manifest. Only images present in `resolved` are compared, except
231
- * that an explicitly `null` entry (tag did not resolve) is a mismatch.
233
+ * signed manifest. Only images present in `resolved` (or `resolvedPlatform`)
234
+ * are compared, except that an explicitly `null` entry (tag did not
235
+ * resolve) is a mismatch.
232
236
  *
237
+ * @param resolvedPlatform - each index's linux/amd64 manifest, compared
238
+ * with the manifest's `platformDigests` (#1025)
233
239
  * @returns every mismatch; empty means the registry serves the signed images.
234
240
  */
235
241
  export function compareImageDigests(
236
242
  manifest: ReleaseManifest,
237
243
  resolved: Partial<Record<ReleaseImage, string | null>>,
244
+ resolvedPlatform: Partial<Record<ReleaseImage, string | null>> = {},
238
245
  ): DigestMismatch[] {
239
246
  const out: DigestMismatch[] = [];
240
247
  for (const image of RELEASE_IMAGES) {
241
- if (!(image in resolved)) continue;
242
- const actual = resolved[image] ?? null;
243
- if (actual !== manifest.digests[image]) {
244
- out.push({ image, expected: manifest.digests[image], actual });
248
+ if (image in resolved) {
249
+ const actual = resolved[image] ?? null;
250
+ if (actual !== manifest.digests[image]) {
251
+ out.push({ image, form: "index", expected: manifest.digests[image], actual });
252
+ }
253
+ }
254
+ if (image in resolvedPlatform) {
255
+ const actual = resolvedPlatform[image] ?? null;
256
+ const expected = manifest.platformDigests?.[image] ?? null;
257
+ if (actual !== expected) out.push({ image, form: "platform", expected, actual });
245
258
  }
246
259
  }
247
260
  return out;
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "mediaType": "application/vnd.oci.image.index.v1+json",
4
+ "manifests": [
5
+ {
6
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
7
+ "digest": "sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656",
8
+ "size": 4673,
9
+ "platform": {
10
+ "architecture": "amd64",
11
+ "os": "linux"
12
+ }
13
+ },
14
+ {
15
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
16
+ "digest": "sha256:06f9defdb92e8252e9faab8fcb8fb340d3949d5cdec09e06b42905ade7ba30c0",
17
+ "size": 1113,
18
+ "annotations": {
19
+ "vnd.docker.reference.digest": "sha256:2e75b714662ec6b17d32666b546378e2850920950076c4e911deddc05596e656",
20
+ "vnd.docker.reference.type": "attestation-manifest"
21
+ },
22
+ "platform": {
23
+ "architecture": "unknown",
24
+ "os": "unknown"
25
+ }
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "mediaType": "application/vnd.oci.image.index.v1+json",
4
+ "manifests": [
5
+ {
6
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
7
+ "digest": "sha256:d24f2675d43e7891f217938c74475e9c31a64ffda0c96cf4fbb0a3f73e31367e",
8
+ "size": 3527,
9
+ "platform": {
10
+ "architecture": "amd64",
11
+ "os": "linux"
12
+ }
13
+ },
14
+ {
15
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
16
+ "digest": "sha256:a5a3180c2f8e971ad8650026b1e9734f2d7289d74c12179e194b8d48380e2738",
17
+ "size": 1113,
18
+ "annotations": {
19
+ "vnd.docker.reference.digest": "sha256:d24f2675d43e7891f217938c74475e9c31a64ffda0c96cf4fbb0a3f73e31367e",
20
+ "vnd.docker.reference.type": "attestation-manifest"
21
+ },
22
+ "platform": {
23
+ "architecture": "unknown",
24
+ "os": "unknown"
25
+ }
26
+ }
27
+ ]
28
+ }
@@ -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,