@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
@@ -167,6 +167,55 @@ export async function resolveRegistryDigest(
167
167
  }
168
168
  }
169
169
 
170
+ /** What {@link fetchRegistryManifest} read. */
171
+ export type RegistryManifestResult =
172
+ | { readonly ok: true; readonly bytes: Uint8Array<ArrayBuffer> }
173
+ | {
174
+ readonly ok: false;
175
+ /**
176
+ * True when a retry may cure it: the registry could not be reached,
177
+ * timed out, rate-limited (429/408), failed (5xx) or would not hand
178
+ * out a pull token. False when it answered that the manifest does not
179
+ * exist (404 and other 4xx).
180
+ */
181
+ readonly transient: boolean;
182
+ readonly reason: string;
183
+ };
184
+
185
+ /**
186
+ * GET the manifest (or index) `<registryHost>/<repository>@<reference>`
187
+ * anonymously, through the same Bearer-challenge handling as
188
+ * {@link resolveRegistryDigest}. Returns the raw bytes: a caller that asked
189
+ * by digest checks them against it (content addressing is the trust here).
190
+ *
191
+ * @example fetchRegistryManifest("europe-west1-docker.pkg.dev", "caelo-website/caelo-cms-images/admin", "sha256:…")
192
+ */
193
+ export async function fetchRegistryManifest(
194
+ registryHost: string,
195
+ repository: string,
196
+ reference: string,
197
+ fetchImpl: typeof fetch = fetch,
198
+ ): Promise<RegistryManifestResult> {
199
+ const url = `https://${registryHost}/v2/${repository}/manifests/${reference}`;
200
+ try {
201
+ let res = await fetchImpl(url, { headers: { Accept: MANIFEST_ACCEPT } });
202
+ if (res.status === 401) {
203
+ const token = await anonymousPullToken(res.headers.get("www-authenticate"), fetchImpl);
204
+ if (!token.ok) return { ok: false, transient: true, reason: token.reason };
205
+ res = await fetchImpl(url, {
206
+ headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token.token}` },
207
+ });
208
+ }
209
+ if (!res.ok) {
210
+ const transient = res.status >= 500 || res.status === 429 || res.status === 408;
211
+ return { ok: false, transient, reason: `${url}: HTTP ${res.status} ${res.statusText}` };
212
+ }
213
+ return { ok: true, bytes: new Uint8Array(await res.arrayBuffer()) };
214
+ } catch (e) {
215
+ return { ok: false, transient: true, reason: e instanceof Error ? e.message : String(e) };
216
+ }
217
+ }
218
+
170
219
  /** Follow a registry's Bearer challenge to an anonymous pull token. */
171
220
  async function anonymousPullToken(
172
221
  challenge: string | null,
@@ -28,6 +28,7 @@ import { acquireBucketStateLock, GcsStateStore, realSyncGcloud } from "./state-s
28
28
  import type { AppRollPlan } from "./upgrade/app-roll.js";
29
29
  import { findServiceUrl } from "./upgrade/gcp-lookup.js";
30
30
  import { createIapHealthProbe } from "./upgrade/health.js";
31
+ import { createReleaseImageForms } from "./upgrade/image-forms.js";
31
32
  import { type IamTester, restIamTester } from "./upgrader-iam-preflight.js";
32
33
  import type { UpdateQueue, UpgraderJobDeps } from "./upgrader-job.js";
33
34
  import type { JobLog } from "./upgrader-reporter.js";
@@ -134,6 +135,8 @@ export function gcpUpgraderDeps(
134
135
  iam: gcloudIamTester(run, http),
135
136
  // The default deps: the fixed registry and signer, never overridden.
136
137
  fetchManifest: (version) => fetchVerifiedReleaseManifest(version),
138
+ // #1025 — the fixed public mirror, read by digest and content-checked.
139
+ imageForms: createReleaseImageForms(),
137
140
  // #815 — the lock names this execution, so a later run can break it
138
141
  // once Cloud Run says this one has ended (killed at its task timeout,
139
142
  // OOM). Outside Cloud Run there is no execution to vouch for it.
@@ -15,7 +15,19 @@ import type { GcloudRunner } from "./gcloud-retry.js";
15
15
  import type { InstallMetadata } from "./install-state.js";
16
16
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
17
17
  import { StateLockHeldError } from "./state-store.js";
18
+ import {
19
+ ADMIN_AMD64,
20
+ ADMIN_INDEX,
21
+ fakeRegistryFetch,
22
+ GATEWAY_AMD64,
23
+ GATEWAY_INDEX,
24
+ } from "./upgrade/__tests__/live-indexes.js";
18
25
  import type { HealthOutcome, HealthProbe } from "./upgrade/health.js";
26
+ import {
27
+ createReleaseImageForms,
28
+ type ImageFormsResult,
29
+ type ReleaseImageForms,
30
+ } from "./upgrade/image-forms.js";
19
31
  import { type IamTester, resourceName } from "./upgrader-iam-preflight.js";
20
32
  import {
21
33
  type ClaimedUpdate,
@@ -212,6 +224,11 @@ function setup(
212
224
  lockDenied?: boolean;
213
225
  /** The lock broke a dead run's lock on the way, with this message. */
214
226
  brokeStaleLock?: string;
227
+ /**
228
+ * The release mirror (#1025): by default index `D(c)` lists platform
229
+ * manifest `P(c)`; `down` makes every index read fail as an outage.
230
+ */
231
+ imageForms?: ReleaseImageForms | "down";
215
232
  /** IAM's answer: permissions withheld per resource name. */
216
233
  iamWithheld?: Record<string, readonly string[]>;
217
234
  /** A resource IAM cannot be asked about. */
@@ -246,6 +263,10 @@ function setup(
246
263
  }
247
264
  return opts.manifest ?? { ok: true, manifest: MANIFEST };
248
265
  },
266
+ imageForms:
267
+ typeof opts.imageForms === "object"
268
+ ? opts.imageForms
269
+ : fakeImageForms(order, opts.imageForms === "down"),
249
270
  lockState: (onBreak) => {
250
271
  order.push("lock");
251
272
  if (opts.locked) throw new StateLockHeldError("held by caelo installer");
@@ -271,6 +292,28 @@ function setup(
271
292
  return { deps, order, q, written };
272
293
  }
273
294
 
295
+ /**
296
+ * The release mirror's indexes as {@link ReleaseImageForms}: index `D(c)`
297
+ * lists platform manifest `P(c)` (#1025). Signed platform digests win, as
298
+ * in createReleaseImageForms.
299
+ */
300
+ function fakeImageForms(order: string[], down: boolean): ReleaseImageForms {
301
+ const ofIndex = async (slug: string, index: string): Promise<ImageFormsResult> => {
302
+ order.push(`index ${slug}@${index}`);
303
+ if (down) return { ok: false, transient: true, message: "the mirror answered HTTP 503" };
304
+ const platform = P(index.slice(-1));
305
+ return { ok: true, digests: [index, platform], amd64: platform };
306
+ };
307
+ return {
308
+ ofIndex,
309
+ of: async (m, slug) => {
310
+ const signed = m.platformDigests?.[slug];
311
+ if (signed) return { ok: true, digests: [m.digests[slug], signed], amd64: signed };
312
+ return ofIndex(slug, m.digests[slug]);
313
+ },
314
+ };
315
+ }
316
+
274
317
  /** IAM granting everything asked except `withheld` per resource; `error` names a resource it cannot be asked about. */
275
318
  function fakeIam(
276
319
  order: string[],
@@ -765,6 +808,116 @@ describe("runUpgraderJob", () => {
765
808
  expect(order).not.toContain("manifest 0.11.0");
766
809
  });
767
810
 
811
+ describe("regression #1025: Cloud Run's revision spec names the platform manifest", () => {
812
+ /** Serving revisions whose spec AND status name only the platform digests (the live shape). */
813
+ const servingPlatform = (admin: string, gateway: string): [RegExp, Answer] => [
814
+ /^run revisions describe (\S+)/,
815
+ (l) => {
816
+ const d = l.includes("-admin-") ? admin : gateway;
817
+ return ok(revisionJson(d, d));
818
+ },
819
+ ];
820
+
821
+ it("the live v0.10.64 shapes: identified by the signed indexes, records both forms, installs", async () => {
822
+ const calls: string[] = [];
823
+ const v064 = {
824
+ ...MANIFEST,
825
+ version: "0.10.64",
826
+ digests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX, ops: D("c") },
827
+ };
828
+ const { deps, q, written } = setup({
829
+ meta: {
830
+ ...META,
831
+ imageDigests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX },
832
+ releaseVersion: "0.10.64",
833
+ },
834
+ manifests: { "0.10.64": { ok: true, manifest: v064 } },
835
+ gcloud: [servingPlatform(ADMIN_AMD64, GATEWAY_AMD64)],
836
+ imageForms: createReleaseImageForms(fakeRegistryFetch(calls)),
837
+ });
838
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
839
+ expect(written[0]).toMatchObject({
840
+ releaseVersion: "0.10.64",
841
+ imageDigests: { admin: ADMIN_INDEX, gateway: GATEWAY_INDEX },
842
+ platformDigests: { admin: ADMIN_AMD64, gateway: GATEWAY_AMD64 },
843
+ });
844
+ expect(q.events.some((e) => e.message.startsWith("0.10.64 → 0.11.0"))).toBe(true);
845
+ // Each index read once, by digest, from the fixed mirror.
846
+ expect(calls.filter((c) => c.endsWith(`/admin/manifests/${ADMIN_INDEX}`))).toHaveLength(1);
847
+ expect(calls.filter((c) => c.endsWith(`/gateway/manifests/${GATEWAY_INDEX}`))).toHaveLength(
848
+ 1,
849
+ );
850
+ });
851
+
852
+ it("install.json with both forms matches directly: no drift, no lookup", async () => {
853
+ const { deps, order, q } = setup({
854
+ meta: { ...META, platformDigests: { admin: P("1"), gateway: P("2") } },
855
+ gcloud: [servingPlatform(P("1"), P("2"))],
856
+ });
857
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
858
+ expect(order.filter((l) => l.startsWith("manifest"))).toEqual(["manifest 0.11.0"]);
859
+ // Only the target's index is read (to record its platform forms), never the serving one's.
860
+ expect(order.filter((l) => l.startsWith("index "))).toEqual([
861
+ `index admin@${D("a")}`,
862
+ `index gateway@${D("b")}`,
863
+ ]);
864
+ expect(q.events.some((e) => e.message.includes("refreshed its install records"))).toBe(false);
865
+ });
866
+
867
+ it("the roll records the new release's platform forms, signed ones first", async () => {
868
+ const signed = {
869
+ ...MANIFEST,
870
+ platformDigests: { admin: P("x"), gateway: P("y"), ops: P("z") },
871
+ };
872
+ const { deps, order, written } = setup({ manifest: { ok: true, manifest: signed } });
873
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
874
+ expect(written.at(-1)).toMatchObject({
875
+ releaseVersion: "0.11.0",
876
+ imageDigests: { admin: D("a"), gateway: D("b") },
877
+ platformDigests: { admin: P("x"), gateway: P("y") },
878
+ });
879
+ expect(order.some((l) => l.startsWith("index "))).toBe(false);
880
+ });
881
+
882
+ it("a stale platform record is dropped when the platform forms cannot be read", async () => {
883
+ const { deps, q, written } = setup({
884
+ meta: { ...META, platformDigests: { admin: P("1"), gateway: P("2") } },
885
+ imageForms: "down",
886
+ });
887
+ expect((await runUpgraderJob(deps)).status).toBe("succeeded");
888
+ expect(written.at(-1)?.imageDigests).toEqual({ admin: D("a"), gateway: D("b") });
889
+ expect(written.at(-1)?.platformDigests).toBeUndefined();
890
+ expect(q.events.some((e) => e.level === "warn" && e.message.includes("linux/amd64"))).toBe(
891
+ true,
892
+ );
893
+ });
894
+
895
+ it("a platform digest in no signed index is still refused with the current message", async () => {
896
+ const { deps, q, written } = setup({
897
+ gcloud: [servingPlatform(P("7"), P("6"))],
898
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
899
+ });
900
+ expect((await runUpgraderJob(deps)).status).toBe("needs_cloud_shell");
901
+ const end = q.end();
902
+ expect(end?.codes).toEqual(["install-state-drift"]);
903
+ expect(end?.reason).toContain("do not belong to one signed Caelo release");
904
+ expect(end?.reason).toContain(`admin ${P("7")}`);
905
+ expect(written).toEqual([]);
906
+ });
907
+
908
+ it("an index the mirror cannot serve fails retryably, never as drift", async () => {
909
+ const { deps, q, written } = setup({
910
+ gcloud: [servingPlatform(P("1"), P("2"))],
911
+ manifests: { "0.10.45": manifestOf("0.10.45", D("1"), D("2")) },
912
+ imageForms: "down",
913
+ });
914
+ expect((await runUpgraderJob(deps)).status).toBe("failed");
915
+ expect(q.end()?.codes).toEqual(["registry"]);
916
+ expect(q.end()?.reason).toContain("HTTP 503");
917
+ expect(written).toEqual([]);
918
+ });
919
+ });
920
+
768
921
  it("regression #865: drift whose release manifest the registry cannot serve fails retryably, not install-state-drift", async () => {
769
922
  const { deps, order, q, written } = setup({
770
923
  meta: {
@@ -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
+ });