@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
@@ -70,11 +70,13 @@ import {
70
70
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
71
71
  import {
72
72
  findServingImage,
73
+ findTemplateImageDigest,
73
74
  lookupFailure,
74
75
  resolveGcpResourceName,
75
76
  servingImageDigests,
76
77
  } from "./upgrade/gcp-lookup.js";
77
78
  import type { HealthProbe } from "./upgrade/health.js";
79
+ import { platformDigestsOf, type ReleaseImageForms } from "./upgrade/image-forms.js";
78
80
  import { releaseImageRef } from "./upgrade/release-images.js";
79
81
  import { assessUpgradeRequest, type RefusalCode } from "./upgrader-assess.js";
80
82
  import {
@@ -166,6 +168,13 @@ export interface UpgraderJobDeps {
166
168
  readonly iam: IamTester;
167
169
  /** fetchVerifiedReleaseManifest against the fixed registry. */
168
170
  readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
171
+ /**
172
+ * Every digest a release image is known by (#1025): the signed platform
173
+ * digests, else the content-checked index from the fixed mirror
174
+ * (createReleaseImageForms). One instance per run, so each index is read
175
+ * at most once.
176
+ */
177
+ readonly imageForms: ReleaseImageForms;
169
178
  /**
170
179
  * Take the install's state lock (the one `pulumi up`, `state migrate` and
171
180
  * the CLI `upgrade` hold) right before the run's first write; returns its
@@ -384,10 +393,16 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
384
393
  if (!serving.ok) {
385
394
  return stopWith("failed", ["lookup"], `Nothing was changed: ${serving.error}.`);
386
395
  }
387
- if (!recordMatchesServing(meta.imageDigests, serving.digests)) {
396
+ if (!recordMatchesServing(meta, serving.digests)) {
388
397
  // #860 — re-record the serving release ourselves, but only one whose
389
- // admin AND gateway images a signed release manifest names.
390
- const found = await findServingRelease(meta, serving.digests, deps);
398
+ // admin AND gateway images a signed release manifest names, as its
399
+ // index or a platform manifest inside it (#1025).
400
+ const found = await findServingRelease(meta, serving.digests, {
401
+ run: deps.run,
402
+ fetchManifest: deps.fetchManifest,
403
+ imageForms: deps.imageForms,
404
+ hintDigests: () => templateDigests(deps, serving.services),
405
+ });
391
406
  if (!found.ok) {
392
407
  if (found.retryable) return stopWith("failed", ["registry"], found.reason);
393
408
  // #815 — the serving images were read without the lock: a run that
@@ -400,14 +415,17 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
400
415
  }
401
416
  return stopWith("needs_cloud_shell", ["install-state-drift"], found.reason);
402
417
  }
403
- // The manifest's digests, not the serving ones: the signed form is the
404
- // one every later comparison and deploy uses (gcp-lookup.ts ServingImage).
418
+ // The manifest's index digests, not the serving ones: the signed form
419
+ // is the one every deploy pins. The linux/amd64 forms go next to them
420
+ // (#1025), so the next run matches what Cloud Run records directly.
421
+ const { platformDigests: _stale, ...rest } = meta;
405
422
  meta = {
406
- ...meta,
423
+ ...rest,
407
424
  imageDigests: {
408
425
  admin: found.manifest.digests.admin,
409
426
  gateway: found.manifest.digests.gateway,
410
427
  },
428
+ ...(found.platformDigests ? { platformDigests: found.platformDigests } : {}),
411
429
  releaseVersion: found.manifest.version,
412
430
  };
413
431
  // Judged against now, written under the lock once decided (#815).
@@ -562,9 +580,23 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
562
580
  // never "succeeded" while the records name the previous release).
563
581
  recordDigests: async (digests) => {
564
582
  r.phase("record");
583
+ // #1025 — both forms, so the next run recognises what Cloud Run
584
+ // records without a registry lookup. Not knowing the platform form
585
+ // is no failure: the next run then reads the index itself.
586
+ const platform = await platformDigestsOf(digests, deps.imageForms, m);
587
+ if (!platform.ok) {
588
+ r.warn(
589
+ `install.json records ${m.version} by its image indexes only; their linux/amd64 forms could not be read (${platform.message}). The next update reads them from the release registry.`,
590
+ );
591
+ }
565
592
  try {
566
- const fresh = deps.readInstall() ?? meta;
567
- deps.writeInstall({ ...fresh, imageDigests: digests, releaseVersion: m.version });
593
+ const { platformDigests: _previous, ...fresh } = deps.readInstall() ?? meta;
594
+ deps.writeInstall({
595
+ ...fresh,
596
+ imageDigests: digests,
597
+ ...(platform.ok ? { platformDigests: platform.platformDigests } : {}),
598
+ releaseVersion: m.version,
599
+ });
568
600
  r.info(`Recorded ${m.version} as this install's release.`);
569
601
  } catch (e) {
570
602
  recordError = errorText(e);
@@ -659,10 +691,17 @@ export async function recordUpdateEnd(
659
691
  }
660
692
 
661
693
  /** Every digest the revisions serving traffic are known by, per service. */
662
- async function servingDigests(
663
- deps: UpgraderJobDeps,
664
- ): Promise<{ ok: true; digests: ServingDigests } | { ok: false; error: string }> {
694
+ async function servingDigests(deps: UpgraderJobDeps): Promise<
695
+ | {
696
+ ok: true;
697
+ digests: ServingDigests;
698
+ /** The services' deployed names, for the template hints. */
699
+ services: Readonly<Record<RollSlug, string>>;
700
+ }
701
+ | { ok: false; error: string }
702
+ > {
665
703
  const digests: Partial<Record<RollSlug, readonly string[]>> = {};
704
+ const services: Partial<Record<RollSlug, string>> = {};
666
705
  for (const slug of ["admin", "gateway"] as const) {
667
706
  const prefix = `${gcpNamePrefix(deps.env)}-${slug}`;
668
707
  const service = await resolveGcpResourceName(
@@ -678,8 +717,30 @@ async function servingDigests(
678
717
  const image = await findServingImage(deps.run, deps.projectId, deps.region, service.name);
679
718
  if (!image) return { ok: false, error: `could not read which image the ${slug} serves` };
680
719
  digests[slug] = servingImageDigests(image);
720
+ services[slug] = service.name;
721
+ }
722
+ return {
723
+ ok: true,
724
+ digests: digests as ServingDigests,
725
+ services: services as Record<RollSlug, string>,
726
+ };
727
+ }
728
+
729
+ /**
730
+ * The index digests the services' templates pin (#1025): hints for which
731
+ * release to check when the serving revisions name only platform digests,
732
+ * which carry no release tag.
733
+ */
734
+ async function templateDigests(
735
+ deps: UpgraderJobDeps,
736
+ services: Readonly<Record<RollSlug, string>>,
737
+ ): Promise<ServingDigests> {
738
+ const out: Partial<Record<RollSlug, readonly string[]>> = {};
739
+ for (const slug of ["admin", "gateway"] as const) {
740
+ const d = await findTemplateImageDigest(deps.run, deps.projectId, deps.region, services[slug]);
741
+ out[slug] = d ? [d] : [];
681
742
  }
682
- return { ok: true, digests: digests as ServingDigests };
743
+ return out as ServingDigests;
683
744
  }
684
745
 
685
746
  async function recordFacts(
@@ -0,0 +1,202 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Which signed release serves (#860, #1025), with the shapes recorded on
5
+ * live installs: the serving revisions name the linux/amd64 platform
6
+ * manifests (`2e75…`, `d24f…`), the signed v0.10.64 manifest and
7
+ * install.json the indexes (`bcda…`, `fbb0…`) that list them next to one
8
+ * attestation manifest each.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import type { GcloudResult } from "./gcloud.js";
13
+ import type { InstallMetadata } from "./install-state.js";
14
+ import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
15
+ import {
16
+ ADMIN_AMD64,
17
+ ADMIN_ATTESTATION,
18
+ ADMIN_INDEX,
19
+ fakeRegistryFetch,
20
+ GATEWAY_AMD64,
21
+ GATEWAY_INDEX,
22
+ } from "./upgrade/__tests__/live-indexes.js";
23
+ import { createReleaseImageForms } from "./upgrade/image-forms.js";
24
+ import {
25
+ findServingRelease,
26
+ recordMatchesServing,
27
+ type ServingDigests,
28
+ type ServingReleaseDeps,
29
+ } from "./upgrader-records.js";
30
+
31
+ const D = (c: string) => `sha256:${c.repeat(64)}`;
32
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
33
+
34
+ const V064: ReleaseManifest = {
35
+ manifestVersion: 1,
36
+ version: "0.10.64",
37
+ channel: "stable",
38
+ digests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX, ops: D("c") },
39
+ minFromVersion: "0.10.0",
40
+ infraContractVersion: 13,
41
+ migrations: { contract: false, lastContractVersion: null, head: "0338_x" },
42
+ security: false,
43
+ advisories: [],
44
+ };
45
+
46
+ const META: InstallMetadata = {
47
+ installId: "gcp-firebase-acme",
48
+ provider: "gcp-firebase",
49
+ projectId: "acme",
50
+ domain: "acme.com",
51
+ ownerEmail: "owner@acme.com",
52
+ region: "europe-west1",
53
+ createdAt: "2026-01-01T00:00:00Z",
54
+ imageDigests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX },
55
+ releaseVersion: "0.10.64",
56
+ infraContractVersion: 13,
57
+ };
58
+
59
+ /** What the serving revisions read back as on searchviu-website (spec = status). */
60
+ const LIVE_SERVING: ServingDigests = { admin: [ADMIN_AMD64], gateway: [GATEWAY_AMD64] };
61
+
62
+ function deps(
63
+ opts: {
64
+ manifests?: Record<string, ReleaseManifest>;
65
+ calls?: string[];
66
+ registry?: Parameters<typeof fakeRegistryFetch>[1];
67
+ /** `gcloud artifacts docker tags list` answers: version → digest, per slug. */
68
+ tags?: Partial<Record<"admin" | "gateway", Record<string, string>>>;
69
+ hints?: ServingDigests;
70
+ } = {},
71
+ ): ServingReleaseDeps & { fetched: string[] } {
72
+ const fetched: string[] = [];
73
+ const manifests = opts.manifests ?? { "0.10.64": V064 };
74
+ return {
75
+ fetched,
76
+ run: async (args) => {
77
+ const line = args.join(" ");
78
+ const slug = /\/(admin|gateway) /.exec(`${line} `)?.[1] as "admin" | "gateway" | undefined;
79
+ if (line.startsWith("artifacts docker tags list") && slug) {
80
+ const tags = opts.tags?.[slug] ?? {};
81
+ return ok(
82
+ Object.entries(tags)
83
+ .map(
84
+ ([v, d]) => `projects/p/locations/l/repositories/r/packages/${slug}/tags/${v}\t${d}`,
85
+ )
86
+ .join("\n"),
87
+ );
88
+ }
89
+ return ok("");
90
+ },
91
+ fetchManifest: async (version): Promise<ReleaseManifestResult> => {
92
+ fetched.push(version);
93
+ const m = manifests[version];
94
+ return m
95
+ ? { ok: true, manifest: m }
96
+ : {
97
+ ok: false,
98
+ error: { code: "registry", message: `no release manifest for v${version}` },
99
+ };
100
+ },
101
+ imageForms: createReleaseImageForms(fakeRegistryFetch(opts.calls ?? [], opts.registry)),
102
+ ...(opts.hints ? { hintDigests: async () => opts.hints as ServingDigests } : {}),
103
+ };
104
+ }
105
+
106
+ describe("recordMatchesServing", () => {
107
+ it("regression #1025: the index alone does not match what Cloud Run reports", () => {
108
+ expect(recordMatchesServing(META, LIVE_SERVING)).toBe(false);
109
+ });
110
+
111
+ it("matches by the recorded platform forms", () => {
112
+ const both = { ...META, platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 } };
113
+ expect(recordMatchesServing(both, LIVE_SERVING)).toBe(true);
114
+ // Still by the index when a revision names it.
115
+ expect(recordMatchesServing(META, { admin: [ADMIN_INDEX], gateway: [GATEWAY_INDEX] })).toBe(
116
+ true,
117
+ );
118
+ });
119
+
120
+ it("both services must match, and no record never matches", () => {
121
+ const both = { ...META, platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 } };
122
+ expect(recordMatchesServing(both, { admin: [ADMIN_AMD64], gateway: [D("6")] })).toBe(false);
123
+ expect(recordMatchesServing({}, LIVE_SERVING)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe("findServingRelease", () => {
128
+ it("regression #1025: identifies v0.10.64 from the platform digests inside its signed indexes", async () => {
129
+ const calls: string[] = [];
130
+ const d = deps({ calls });
131
+ const r = await findServingRelease(META, LIVE_SERVING, d);
132
+ expect(r).toEqual({
133
+ ok: true,
134
+ manifest: V064,
135
+ platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 },
136
+ });
137
+ expect(d.fetched).toEqual(["0.10.64"]);
138
+ expect(calls).toHaveLength(2);
139
+ });
140
+
141
+ it("an attestation manifest of the index is not a serving image of the release", async () => {
142
+ const r = await findServingRelease(
143
+ META,
144
+ { admin: [ADMIN_ATTESTATION], gateway: [GATEWAY_AMD64] },
145
+ deps(),
146
+ );
147
+ expect(r.ok).toBe(false);
148
+ expect(r.ok ? "" : r.reason).toContain(
149
+ "The gateway serves v0.10.64's image, the admin does not.",
150
+ );
151
+ });
152
+
153
+ it("uses the signed platform digests without reading the index", async () => {
154
+ const calls: string[] = [];
155
+ const signed = {
156
+ ...V064,
157
+ platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64, ops: D("3") },
158
+ };
159
+ const r = await findServingRelease(
160
+ META,
161
+ LIVE_SERVING,
162
+ deps({ calls, manifests: { "0.10.64": signed } }),
163
+ );
164
+ expect(r.ok).toBe(true);
165
+ expect(calls).toEqual([]);
166
+ });
167
+
168
+ it("a digest in no signed index is still refused with the current message", async () => {
169
+ const r = await findServingRelease(META, { admin: [D("7")], gateway: [D("6")] }, deps());
170
+ expect(r).toMatchObject({ ok: false, retryable: false });
171
+ expect(r.ok ? "" : r.reason).toContain(
172
+ "are not the ones install.json names, and they do not belong to one signed Caelo release",
173
+ );
174
+ expect(r.ok ? "" : r.reason).toContain(`admin ${D("7")}`);
175
+ });
176
+
177
+ it("a registry outage reading the index is retryable, a tampered index is not", async () => {
178
+ const down = await findServingRelease(META, LIVE_SERVING, deps({ registry: { status: 503 } }));
179
+ expect(down).toMatchObject({ ok: false, retryable: true });
180
+ expect(down.ok ? "" : down.reason).toContain("could not be reached");
181
+
182
+ const tampered = await findServingRelease(
183
+ META,
184
+ LIVE_SERVING,
185
+ deps({ registry: { tampered: new TextEncoder().encode("{}") } }),
186
+ );
187
+ expect(tampered).toMatchObject({ ok: false, retryable: false });
188
+ expect(tampered.ok ? "" : tampered.reason).toContain("could not be verified");
189
+ });
190
+
191
+ it("finds the release by the template's index when install.json names none", async () => {
192
+ const { releaseVersion: _none, ...unversioned } = META;
193
+ const meta = { ...unversioned, imageDigests: { admin: D("9"), gateway: D("8") } };
194
+ const d = deps({
195
+ tags: { admin: { "0.10.64": ADMIN_INDEX } },
196
+ hints: { admin: [ADMIN_INDEX], gateway: [GATEWAY_INDEX] },
197
+ });
198
+ const r = await findServingRelease(meta, LIVE_SERVING, d);
199
+ expect(r.ok && r.manifest.version).toBe("0.10.64");
200
+ expect(d.fetched).toEqual(["0.10.64"]);
201
+ });
202
+ });
@@ -15,6 +15,12 @@
15
15
  * Images that belong to no signed release are a deployment nobody can
16
16
  * vouch for, so the run stops and says so.
17
17
  *
18
+ * A serving image is a release's image when its digest is one of that
19
+ * image's forms (#1025, upgrade/image-forms.ts): the index the manifest
20
+ * signs, or a platform manifest inside it. Cloud Run records the
21
+ * linux/amd64 platform manifest it resolved, so comparing with the index
22
+ * alone never matched on a live install.
23
+ *
18
24
  * Which releases to try comes from untrusted hints — the version
19
25
  * install.json names, and the release tags pointing at the serving digests
20
26
  * in the fixed mirror (releaseVersionForDigest). A wrong hint costs one
@@ -25,6 +31,7 @@ import type { GcloudRunner } from "./gcloud-retry.js";
25
31
  import type { ImageDigests, InstallMetadata } from "./install-state.js";
26
32
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
27
33
  import type { RollSlug } from "./upgrade/app-roll.js";
34
+ import type { ReleaseImageForms } from "./upgrade/image-forms.js";
28
35
  import { releaseVersionForDigest } from "./upgrade/release-images.js";
29
36
 
30
37
  /** Every digest each serving image is known by (gcp-lookup.ts servingImageDigests). */
@@ -32,18 +39,35 @@ export type ServingDigests = Readonly<Record<RollSlug, readonly string[]>>;
32
39
 
33
40
  const SLUGS: readonly RollSlug[] = ["admin", "gateway"];
34
41
 
35
- /** Whether `recorded` names the images that serve, in any form they are known by. */
42
+ /**
43
+ * Whether install.json names the images that serve: per service, the
44
+ * recorded index digest or its recorded linux/amd64 form (#1025) is one of
45
+ * the digests the serving revision is known by.
46
+ */
36
47
  export function recordMatchesServing(
37
- recorded: ImageDigests | undefined,
48
+ recorded: Pick<InstallMetadata, "imageDigests" | "platformDigests">,
38
49
  serving: ServingDigests,
39
50
  ): boolean {
40
- if (!recorded) return false;
41
- return SLUGS.every((slug) => serving[slug].includes(recorded[slug]));
51
+ const { imageDigests, platformDigests } = recorded;
52
+ if (!imageDigests) return false;
53
+ return SLUGS.every(
54
+ (slug) =>
55
+ serving[slug].includes(imageDigests[slug]) ||
56
+ (platformDigests !== undefined && serving[slug].includes(platformDigests[slug])),
57
+ );
42
58
  }
43
59
 
44
60
  /** What {@link findServingRelease} learned. */
45
61
  export type ServingRelease =
46
- | { readonly ok: true; readonly manifest: ReleaseManifest }
62
+ | {
63
+ readonly ok: true;
64
+ readonly manifest: ReleaseManifest;
65
+ /**
66
+ * The linux/amd64 form of each of its images (#1025), to record next
67
+ * to the index digests; null when it is not known for both.
68
+ */
69
+ readonly platformDigests: ImageDigests | null;
70
+ }
47
71
  | {
48
72
  readonly ok: false;
49
73
  /**
@@ -62,6 +86,15 @@ export interface ServingReleaseDeps {
62
86
  readonly run: GcloudRunner;
63
87
  /** fetchVerifiedReleaseManifest against the fixed registry. */
64
88
  readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
89
+ /** Every digest a release image is known by (#1025; createReleaseImageForms). */
90
+ readonly imageForms: ReleaseImageForms;
91
+ /**
92
+ * The image digests the services' templates name (the index a deploy
93
+ * pinned; after a rollback, possibly another release's). Untrusted hints
94
+ * for which release tags to look up, asked only when nothing else found
95
+ * the serving release.
96
+ */
97
+ readonly hintDigests?: () => Promise<ServingDigests>;
65
98
  }
66
99
 
67
100
  /**
@@ -79,7 +112,7 @@ export async function findServingRelease(
79
112
  const unverified: string[] = [];
80
113
  const unreachable: string[] = [];
81
114
  const tried: string[] = [];
82
- for await (const version of candidateVersions(meta, serving, deps.run)) {
115
+ for await (const version of candidateVersions(meta, serving, deps)) {
83
116
  if (tried.includes(version)) continue;
84
117
  tried.push(version);
85
118
  const fetched = await deps.fetchManifest(version);
@@ -92,12 +125,21 @@ export async function findServingRelease(
92
125
  continue;
93
126
  }
94
127
  const m = fetched.manifest;
95
- const matching = SLUGS.filter((slug) => serving[slug].includes(m.digests[slug]));
96
- if (matching.length === SLUGS.length) return { ok: true, manifest: m };
97
- if (matching.length > 0) {
98
- const other = SLUGS.filter((s) => !matching.includes(s));
128
+ const judged = await judgeRelease(m, serving, deps.imageForms);
129
+ if (judged.kind === "unreadable") {
130
+ // The index could not be read, so this release is neither confirmed
131
+ // nor ruled out: an outage is retried, anything else is a release
132
+ // record that could not be verified.
133
+ (judged.transient ? unreachable : unverified).push(`v${version} (${judged.message})`);
134
+ continue;
135
+ }
136
+ if (judged.matching.length === SLUGS.length) {
137
+ return { ok: true, manifest: m, platformDigests: judged.platformDigests };
138
+ }
139
+ if (judged.matching.length > 0) {
140
+ const other = SLUGS.filter((s) => !judged.matching.includes(s));
99
141
  partial.push(
100
- `the ${matching.join(", ")} serves v${m.version}'s image, the ${other.join(", ")} does not`,
142
+ `the ${judged.matching.join(", ")} serves v${m.version}'s image, the ${other.join(", ")} does not`,
101
143
  );
102
144
  }
103
145
  }
@@ -125,6 +167,51 @@ export async function findServingRelease(
125
167
  };
126
168
  }
127
169
 
170
+ /** Which of release `m`'s images serve, by {@link judgeRelease}. */
171
+ type ReleaseJudgement =
172
+ | {
173
+ readonly kind: "judged";
174
+ readonly matching: readonly RollSlug[];
175
+ readonly platformDigests: ImageDigests | null;
176
+ }
177
+ | { readonly kind: "unreadable"; readonly transient: boolean; readonly message: string };
178
+
179
+ /**
180
+ * Which of `m`'s images serve. A serving digest equal to the signed index
181
+ * matches without a lookup; any other is compared with the image's
182
+ * platform forms (signed, or read from the content-checked index).
183
+ */
184
+ async function judgeRelease(
185
+ m: ReleaseManifest,
186
+ serving: ServingDigests,
187
+ imageForms: ReleaseImageForms,
188
+ ): Promise<ReleaseJudgement> {
189
+ const matching: RollSlug[] = [];
190
+ const platform: Partial<Record<RollSlug, string>> = {};
191
+ for (const slug of SLUGS) {
192
+ if (serving[slug].includes(m.digests[slug])) {
193
+ matching.push(slug);
194
+ const signed = m.platformDigests?.[slug];
195
+ if (signed) platform[slug] = signed;
196
+ continue;
197
+ }
198
+ const forms = await imageForms.of(m, slug);
199
+ if (!forms.ok)
200
+ return { kind: "unreadable", transient: forms.transient, message: forms.message };
201
+ const match = serving[slug].find((d) => forms.digests.includes(d));
202
+ if (match) {
203
+ matching.push(slug);
204
+ if (forms.amd64) platform[slug] = forms.amd64;
205
+ }
206
+ }
207
+ const { admin, gateway } = platform;
208
+ return {
209
+ kind: "judged",
210
+ matching,
211
+ platformDigests: admin && gateway ? { admin, gateway } : null,
212
+ };
213
+ }
214
+
128
215
  /**
129
216
  * Release versions worth checking, most likely first. Lazy: the version
130
217
  * install.json names usually is the serving one, and then no tag listing
@@ -133,17 +220,26 @@ export async function findServingRelease(
133
220
  async function* candidateVersions(
134
221
  meta: InstallMetadata,
135
222
  serving: ServingDigests,
136
- run: GcloudRunner,
223
+ deps: ServingReleaseDeps,
137
224
  ): AsyncGenerator<string> {
138
225
  if (meta.releaseVersion) yield meta.releaseVersion;
139
- // Release tags point at the index digest a deploy pins (the deployed form,
140
- // listed first); the platform digest Cloud Run resolved has no tag.
141
- for (const slug of SLUGS) {
142
- for (const digest of serving[slug]) {
143
- const version = await releaseVersionForDigest(run, slug, digest);
144
- if (version) yield version;
226
+ // Release tags point at the index digest a deploy pins. The platform
227
+ // digest Cloud Run records for a revision (#1025) has no tag, so the
228
+ // indexes the service templates name are asked too: only hints, the
229
+ // signed manifest still decides.
230
+ const asked: string[] = [];
231
+ const tagged = async function* (digests: ServingDigests): AsyncGenerator<string> {
232
+ for (const slug of SLUGS) {
233
+ for (const digest of digests[slug]) {
234
+ if (asked.includes(`${slug}@${digest}`)) continue;
235
+ asked.push(`${slug}@${digest}`);
236
+ const version = await releaseVersionForDigest(deps.run, slug, digest);
237
+ if (version) yield version;
238
+ }
145
239
  }
146
- }
240
+ };
241
+ yield* tagged(serving);
242
+ if (deps.hintDigests) yield* tagged(await deps.hintDigests());
147
243
  }
148
244
 
149
245
  function capitalize(s: string): string {
@@ -120,6 +120,7 @@ import {
120
120
  } from "../state-backend.js";
121
121
  import { acquireStateLock, GcsStateStore } from "../state-store.js";
122
122
  import { lookupFailure, resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
123
+ import { createReleaseImageForms, platformDigestsOf } from "../upgrade/image-forms.js";
123
124
  import {
124
125
  releaseVersionForDigest,
125
126
  resolveOpsImageRef,
@@ -500,7 +501,22 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
500
501
  // the admin can install the next update itself.
501
502
  if (upgraderReady && convergeReady && remoteMcpReady) {
502
503
  const version = releaseVersion;
503
- recordImageDigests(installId, imageDigests, version);
504
+ // #1025 — the linux/amd64 forms Cloud Run reports, next to the indexes,
505
+ // so the upgrader recognises what serves without a registry lookup.
506
+ const platform = await platformDigestsOf(imageDigests, createReleaseImageForms());
507
+ if (!platform.ok) {
508
+ log.warn(
509
+ yellow(
510
+ `install.json records the release by its image indexes only; their linux/amd64 forms could not be read (${platform.message}). The first in-product update reads them from the release registry.`,
511
+ ),
512
+ );
513
+ }
514
+ recordImageDigests(
515
+ installId,
516
+ imageDigests,
517
+ version,
518
+ platform.ok ? platform.platformDigests : null,
519
+ );
504
520
  recordInfraContractVersion(installId, INFRA_CONTRACT_VERSION);
505
521
  if (!version) {
506
522
  log.warn(