@caelo-cms/provisioning 0.11.13 → 0.11.14

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 (159) hide show
  1. package/dist/admin-domain.d.ts +4 -46
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -101
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/agent-db.js +29 -5
  6. package/dist/audit-job-setup.d.ts.map +1 -1
  7. package/dist/audit-job-setup.js +14 -19
  8. package/dist/audit-job-setup.js.map +1 -1
  9. package/dist/caddy.d.ts +2 -1
  10. package/dist/caddy.d.ts.map +1 -1
  11. package/dist/caddy.js +21 -3
  12. package/dist/caddy.js.map +1 -1
  13. package/dist/cli.js +111 -62
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compose.d.ts +22 -2
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +53 -3
  18. package/dist/compose.js.map +1 -1
  19. package/dist/converge-checkpoint.d.ts +1 -0
  20. package/dist/converge-checkpoint.d.ts.map +1 -1
  21. package/dist/converge-checkpoint.js +1 -0
  22. package/dist/converge-checkpoint.js.map +1 -1
  23. package/dist/deployed-release.d.ts.map +1 -1
  24. package/dist/deployed-release.js +40 -8
  25. package/dist/deployed-release.js.map +1 -1
  26. package/dist/gcp-names.d.ts +33 -14
  27. package/dist/gcp-names.d.ts.map +1 -1
  28. package/dist/gcp-names.js +43 -0
  29. package/dist/gcp-names.js.map +1 -1
  30. package/dist/gcp-url-map.d.ts +19 -4
  31. package/dist/gcp-url-map.d.ts.map +1 -1
  32. package/dist/gcp-url-map.js +33 -5
  33. package/dist/gcp-url-map.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +18 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/lifecycle.d.ts.map +1 -1
  39. package/dist/lifecycle.js +5 -39
  40. package/dist/lifecycle.js.map +1 -1
  41. package/dist/mcp-auth-proxy-setup.d.ts +8 -2
  42. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  43. package/dist/mcp-auth-proxy-setup.js +4 -0
  44. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  45. package/dist/release-manifest.d.ts +1 -1
  46. package/dist/release-manifest.d.ts.map +1 -1
  47. package/dist/release-manifest.js +2 -0
  48. package/dist/release-manifest.js.map +1 -1
  49. package/dist/remote-mcp.d.ts.map +1 -1
  50. package/dist/remote-mcp.js +11 -1
  51. package/dist/remote-mcp.js.map +1 -1
  52. package/dist/runtime-secrets.d.ts +10 -0
  53. package/dist/runtime-secrets.d.ts.map +1 -1
  54. package/dist/runtime-secrets.js +10 -1
  55. package/dist/runtime-secrets.js.map +1 -1
  56. package/dist/stack-contract.d.ts +61 -20
  57. package/dist/stack-contract.d.ts.map +1 -1
  58. package/dist/stack-contract.js +173 -35
  59. package/dist/stack-contract.js.map +1 -1
  60. package/dist/stack-converge.d.ts.map +1 -1
  61. package/dist/stack-converge.js +4 -2
  62. package/dist/stack-converge.js.map +1 -1
  63. package/dist/staging-admin.d.ts +2 -2
  64. package/dist/staging-admin.d.ts.map +1 -1
  65. package/dist/staging-gateway-setup.d.ts +140 -0
  66. package/dist/staging-gateway-setup.d.ts.map +1 -0
  67. package/dist/staging-gateway-setup.js +848 -0
  68. package/dist/staging-gateway-setup.js.map +1 -0
  69. package/dist/upgrade/app-roll.d.ts +4 -4
  70. package/dist/upgrade/app-roll.d.ts.map +1 -1
  71. package/dist/upgrade/app-roll.js +7 -5
  72. package/dist/upgrade/app-roll.js.map +1 -1
  73. package/dist/upgrade/converge.d.ts.map +1 -1
  74. package/dist/upgrade/converge.js +36 -27
  75. package/dist/upgrade/converge.js.map +1 -1
  76. package/dist/upgrade/image-forms.d.ts +3 -3
  77. package/dist/upgrade/image-forms.d.ts.map +1 -1
  78. package/dist/upgrade/image-forms.js +2 -2
  79. package/dist/upgrade/image-forms.js.map +1 -1
  80. package/dist/upgrade/release-images.d.ts +20 -0
  81. package/dist/upgrade/release-images.d.ts.map +1 -1
  82. package/dist/upgrade/release-images.js +28 -0
  83. package/dist/upgrade/release-images.js.map +1 -1
  84. package/dist/upgrade/upgrade.d.ts +3 -1
  85. package/dist/upgrade/upgrade.d.ts.map +1 -1
  86. package/dist/upgrade/upgrade.js +22 -1
  87. package/dist/upgrade/upgrade.js.map +1 -1
  88. package/dist/upgrader-job.d.ts.map +1 -1
  89. package/dist/upgrader-job.js +30 -5
  90. package/dist/upgrader-job.js.map +1 -1
  91. package/dist/upgrader-records.d.ts +2 -2
  92. package/dist/upgrader-records.d.ts.map +1 -1
  93. package/dist/upgrader-records.js +2 -2
  94. package/dist/upgrader-records.js.map +1 -1
  95. package/dist/wizard.d.ts.map +1 -1
  96. package/dist/wizard.js +3 -3
  97. package/dist/wizard.js.map +1 -1
  98. package/dist/wizards/gcp.d.ts.map +1 -1
  99. package/dist/wizards/gcp.js +48 -9
  100. package/dist/wizards/gcp.js.map +1 -1
  101. package/package.json +2 -2
  102. package/src/admin-domain.test.ts +0 -156
  103. package/src/admin-domain.ts +4 -117
  104. package/src/admin-memory.test.ts +2 -0
  105. package/src/audit-job-setup.test.ts +11 -11
  106. package/src/audit-job-setup.ts +14 -19
  107. package/src/caddy-redirects.docker.test.ts +10 -5
  108. package/src/caddy-redirects.test.ts +7 -5
  109. package/src/caddy-validate.test.ts +1 -1
  110. package/src/caddy.test.ts +12 -6
  111. package/src/caddy.ts +23 -4
  112. package/src/cli.ts +138 -77
  113. package/src/compose.ts +67 -2
  114. package/src/converge-checkpoint.ts +1 -0
  115. package/src/database-tls-builders.test.ts +3 -1
  116. package/src/deployed-release.test.ts +21 -0
  117. package/src/deployed-release.ts +42 -7
  118. package/src/gateway-env-isolation.test.ts +2 -0
  119. package/src/gcp-names.ts +53 -0
  120. package/src/gcp-url-map.test.ts +26 -2
  121. package/src/gcp-url-map.ts +36 -5
  122. package/src/index.test.ts +9 -3
  123. package/src/infra-contract.test.ts +4 -3
  124. package/src/infra-contract.ts +18 -1
  125. package/src/lifecycle.ts +8 -52
  126. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  127. package/src/mcp-auth-proxy-setup.ts +14 -2
  128. package/src/release-manifest.ts +3 -1
  129. package/src/remote-mcp-stack-config.test.ts +1 -0
  130. package/src/remote-mcp.test.ts +5 -1
  131. package/src/remote-mcp.ts +10 -1
  132. package/src/runtime-secrets.test.ts +10 -1
  133. package/src/runtime-secrets.ts +21 -0
  134. package/src/self-hosted-backup.test.ts +6 -4
  135. package/src/self-hosted-compose.test.ts +2 -0
  136. package/src/stack-contract-parity.test.ts +7 -3
  137. package/src/stack-contract.test.ts +73 -13
  138. package/src/stack-contract.ts +195 -42
  139. package/src/stack-converge-secrets.test.ts +7 -2
  140. package/src/stack-converge.test.ts +37 -9
  141. package/src/stack-converge.ts +4 -1
  142. package/src/staging-admin.ts +2 -2
  143. package/src/staging-gateway-setup.test.ts +361 -0
  144. package/src/staging-gateway-setup.ts +1083 -0
  145. package/src/upgrade/app-roll.ts +7 -4
  146. package/src/upgrade/converge.ts +40 -26
  147. package/src/upgrade/image-forms.ts +7 -7
  148. package/src/upgrade/release-images.test.ts +28 -0
  149. package/src/upgrade/release-images.ts +44 -0
  150. package/src/upgrade/upgrade.test.ts +63 -11
  151. package/src/upgrade/upgrade.ts +29 -2
  152. package/src/upgrader-job.test.ts +23 -0
  153. package/src/upgrader-job.ts +45 -11
  154. package/src/upgrader-records.ts +7 -7
  155. package/src/wizard.ts +2 -3
  156. package/src/wizards/gcp.ts +73 -9
  157. package/stacks/gcp/index.ts +47 -6
  158. package/stacks/gcp-firebase/index.ts +44 -12
  159. package/stacks/self-hosted/index.ts +9 -2
@@ -29,6 +29,7 @@ import {
29
29
  gatewayServiceAccountEmail,
30
30
  mcpAuthProxyServiceAccountEmail,
31
31
  runServiceAccountEmail,
32
+ stagingGatewayServiceAccountEmail,
32
33
  } from "../gcp-names.js";
33
34
  import type { ImageDigests } from "../install-state.js";
34
35
  import {
@@ -82,10 +83,10 @@ import type { UpgradeReporter } from "./reporter.js";
82
83
  export type RollSlug = CloudRunSlug;
83
84
 
84
85
  /**
85
- * The roll order: the gateway follows the admin, never the other way round,
86
- * and the MCP-Auth-Proxy comes last (#1328) — on gcp-firebase it forwards
87
- * the visitor traffic still addressed to it to the gateway, which must
88
- * already serve the new release by then.
86
+ * The roll order: the gateways follow the admin, never the other way round
87
+ * (the admin's migrations and projection come first), the staging gateway
88
+ * (#1330) right after the live one with the same image, and the
89
+ * MCP-Auth-Proxy last (#1328).
89
90
  */
90
91
  export const ROLL_ORDER: readonly RollSlug[] = CLOUD_RUN_SLUGS;
91
92
 
@@ -96,6 +97,8 @@ export function rollServiceAccount(slug: RollSlug, projectId: string, env: strin
96
97
  return runServiceAccountEmail(projectId, env);
97
98
  case "gateway":
98
99
  return gatewayServiceAccountEmail(projectId, env);
100
+ case "staging-gateway":
101
+ return stagingGatewayServiceAccountEmail(projectId, env);
99
102
  case "mcp-auth-proxy":
100
103
  return mcpAuthProxyServiceAccountEmail(projectId, env);
101
104
  }
@@ -63,7 +63,6 @@ import {
63
63
  type CloudRunEnvVar,
64
64
  CONVERGE_JOB_ENV_VAR,
65
65
  DATABASE_URL_VARS,
66
- INTERIM_FORWARD_ORIGIN_ENV,
67
66
  MCP_ENV_VAR,
68
67
  OPERATOR_ACCESS_JOB_ENV_VAR,
69
68
  parseSchedulerIntervalMinutes,
@@ -78,10 +77,11 @@ import {
78
77
  removeRetiredIamBindings,
79
78
  } from "../stack-converge.js";
80
79
  import { ensureStagingDatabase } from "../staging-database.js";
80
+ import { ensureStagingGatewayEdge } from "../staging-gateway-setup.js";
81
81
  import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
82
82
  import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
83
83
  import type { AppRollHooks, AppRollPlan, RollPlan, RollSlug } from "./app-roll.js";
84
- import { findServiceUrl, lookupFailure } from "./gcp-lookup.js";
84
+ import { lookupFailure } from "./gcp-lookup.js";
85
85
  import type { UpgradeReporter } from "./reporter.js";
86
86
 
87
87
  /**
@@ -160,6 +160,7 @@ export function startConverge(
160
160
  const services = {
161
161
  admin: service("admin").serviceName,
162
162
  gateway: service("gateway").serviceName,
163
+ "staging-gateway": service("staging-gateway").serviceName,
163
164
  "mcp-auth-proxy": service("mcp-auth-proxy").serviceName,
164
165
  };
165
166
  const target = {
@@ -450,17 +451,41 @@ export function startConverge(
450
451
  incomplete = true;
451
452
  }
452
453
 
453
- async function interimForwardEnv(): Promise<CloudRunEnvVar[]> {
454
- // #1328 — on gcp-firebase the proxy is the service the current live
455
- // release's Firebase rewrite and the staging domain mapping still
456
- // point at; it forwards that traffic to the data gateway, whose URL is
457
- // its origin (stack-contract.ts INTERIM_FORWARD_ORIGIN_ENV). Without it
458
- // the proxy answers the live site's /api/** with 404 until the next
459
- // Publish live, and the staging host with 404 — so this aborts.
460
- if (install.provider !== "gcp-firebase") return [];
461
- const url = await findServiceUrl(run, install.projectId, install.region, services.gateway);
462
- if (!url) throw new Error(`could not read the URL of ${services.gateway}`);
463
- return [{ name: INTERIM_FORWARD_ORIGIN_ENV, value: new URL(url).origin }];
454
+ async function stagingEdge(): Promise<void> {
455
+ // #1330 — route the staging host to the staging gateway and nowhere
456
+ // else: gcp-firebase's second Hosting site (and the old Cloud Run domain
457
+ // mapping deleted), gcp's host rule, backend and certificate
458
+ // (staging-gateway-setup.ts). Only staging depends on it, so a failure
459
+ // warns; the records the staging host needs are printed (Settings →
460
+ // Updates shows them too).
461
+ const s = r.step(`Routing the staging host ${install.stagingHost} to the staging gateway...`);
462
+ const edge = await ensureStagingGatewayEdge(
463
+ {
464
+ provider: install.provider,
465
+ projectId: install.projectId,
466
+ region: install.region,
467
+ env: install.env,
468
+ domain: install.domain,
469
+ stagingHost: install.stagingHost,
470
+ },
471
+ { run, ...(deps.http ? { http: deps.http } : {}) },
472
+ );
473
+ for (const line of edge.done) r.info(` ${line}`);
474
+ if (edge.ok) {
475
+ s.done(`The staging host is served by the staging gateway, behind the staging gate`);
476
+ if (edge.dnsRecords.length > 0) {
477
+ r.info(
478
+ ` DNS for ${install.stagingHost} (add or change at your registrar if it differs; Settings → Updates shows the same):`,
479
+ );
480
+ for (const d of edge.dnsRecords) r.info(` ${d.name} ${d.type} ${d.value}`);
481
+ }
482
+ return;
483
+ }
484
+ s.warned(`The staging host is not routed yet: ${edge.error}`);
485
+ r.warn(
486
+ `The upgrade continues; ${install.stagingHost} answers nothing until this succeeds (the live site is not affected). Re-run upgrade once the cause is fixed.`,
487
+ );
488
+ incomplete = true;
464
489
  }
465
490
 
466
491
  /** The admin's origin behind IAP, once {@link remoteMcpIdentity} read it. */
@@ -847,6 +872,7 @@ export function startConverge(
847
872
  await auditJob();
848
873
  await captureJob();
849
874
  await remoteMcpEdge();
875
+ await stagingEdge();
850
876
  return true;
851
877
  },
852
878
 
@@ -859,19 +885,7 @@ export function startConverge(
859
885
  await convergeJob();
860
886
  await schedulerTick();
861
887
  await operatorAccess();
862
- let forward: CloudRunEnvVar[];
863
- try {
864
- forward = await interimForwardEnv();
865
- } catch (e) {
866
- r.error(
867
- `The MCP-Auth-Proxy cannot be pointed at the data gateway: ${e instanceof Error ? e.message : String(e)}. Nothing was rolled; re-run upgrade once it is fixed.`,
868
- );
869
- return { ok: false };
870
- }
871
- const extraEnv: Partial<Record<RollSlug, CloudRunEnvVar[]>> = {
872
- ...(identity ?? {}),
873
- "mcp-auth-proxy": [...(identity?.["mcp-auth-proxy"] ?? []), ...forward],
874
- };
888
+ const extraEnv: Partial<Record<RollSlug, CloudRunEnvVar[]>> = { ...(identity ?? {}) };
875
889
  return {
876
890
  ok: true,
877
891
  leaveUntouched: [...leaveUntouched],
@@ -30,7 +30,7 @@
30
30
  import { z } from "zod";
31
31
  import type { ImageDigests } from "../install-state.js";
32
32
  import type { ReleaseManifest } from "../release-manifest.js";
33
- import { ROLL_ORDER, type RollSlug } from "./app-roll.js";
33
+ import { SERVICE_IMAGE_NAMES, type ServiceImageName } from "../stack-contract.js";
34
34
  import { fetchRegistryManifest, IMAGE_DIGEST, RELEASE_REGISTRY } from "./release-images.js";
35
35
 
36
36
  /** The digests one release image is known by. */
@@ -58,10 +58,10 @@ export interface ReleaseImageForms {
58
58
  /** The forms of release `m`'s image `slug` (signed platform digests win). */
59
59
  of(
60
60
  m: Pick<ReleaseManifest, "version" | "digests" | "platformDigests">,
61
- slug: RollSlug,
61
+ slug: ServiceImageName,
62
62
  ): Promise<ImageFormsResult>;
63
63
  /** The forms of the index `indexDigest` of image `slug`, read from the mirror. */
64
- ofIndex(slug: RollSlug, indexDigest: string): Promise<ImageFormsResult>;
64
+ ofIndex(slug: ServiceImageName, indexDigest: string): Promise<ImageFormsResult>;
65
65
  }
66
66
 
67
67
  const INDEX_MEDIA_TYPES = new Set([
@@ -133,7 +133,7 @@ export function createReleaseImageForms(fetchImpl: typeof fetch = fetch): Releas
133
133
  const cache = new Map<string, Promise<ImageFormsResult>>();
134
134
  const { region, project, repo } = RELEASE_REGISTRY;
135
135
 
136
- async function lookup(slug: RollSlug, indexDigest: string): Promise<ImageFormsResult> {
136
+ async function lookup(slug: ServiceImageName, indexDigest: string): Promise<ImageFormsResult> {
137
137
  const where = `the ${slug} image index ${indexDigest} in the release mirror`;
138
138
  const fetched = await fetchRegistryManifest(
139
139
  `${region}-docker.pkg.dev`,
@@ -165,7 +165,7 @@ export function createReleaseImageForms(fetchImpl: typeof fetch = fetch): Releas
165
165
  return formsFromIndexJson(indexDigest, json);
166
166
  }
167
167
 
168
- const ofIndex = (slug: RollSlug, indexDigest: string): Promise<ImageFormsResult> => {
168
+ const ofIndex = (slug: ServiceImageName, indexDigest: string): Promise<ImageFormsResult> => {
169
169
  const key = `${slug}@${indexDigest}`;
170
170
  let pending = cache.get(key);
171
171
  if (!pending) {
@@ -212,8 +212,8 @@ export async function platformDigestsOf(
212
212
  | { readonly ok: true; readonly platformDigests: ImageDigests }
213
213
  | { readonly ok: false; readonly message: string }
214
214
  > {
215
- const out: Partial<Record<RollSlug, string>> = {};
216
- for (const slug of ROLL_ORDER) {
215
+ const out: Partial<Record<ServiceImageName, string>> = {};
216
+ for (const slug of SERVICE_IMAGE_NAMES) {
217
217
  const index = digests[slug];
218
218
  // #1328 — digests recorded before the MCP-Auth-Proxy existed carry none.
219
219
  if (!index) continue;
@@ -10,6 +10,7 @@ import {
10
10
  RELEASE_SIGNER_IDENTITY_REGEXP,
11
11
  releaseTag,
12
12
  resolveRegistryDigest,
13
+ resolveReleaseImages,
13
14
  } from "./release-images.js";
14
15
 
15
16
  const DIGEST = `sha256:${"d".repeat(64)}`;
@@ -138,3 +139,30 @@ describe("cosignInstallHint (#816)", () => {
138
139
  expect(cosignInstallHint("darwin")).toContain("brew install cosign");
139
140
  });
140
141
  });
142
+
143
+ describe("resolveReleaseImages (#1330)", () => {
144
+ it("looks up each release image once and gives the staging gateway the gateway's", async () => {
145
+ const asked: string[] = [];
146
+ const r = await resolveReleaseImages("0.12.0", async (image) => {
147
+ asked.push(image);
148
+ return { ok: true, digest: `sha256:${image === "gateway" ? "b" : "a"}${"0".repeat(63)}` };
149
+ });
150
+ expect(asked).toEqual(["admin", "gateway", "mcp-auth-proxy"]);
151
+ expect(r.ok).toBe(true);
152
+ if (!r.ok) return;
153
+ expect(r.images["staging-gateway"]).toEqual(r.images.gateway);
154
+ expect(r.images["staging-gateway"].imageRef).toContain("/gateway@sha256:");
155
+ });
156
+
157
+ it("names the image whose tag is missing", async () => {
158
+ const r = await resolveReleaseImages("0.12.0", async (image) =>
159
+ image === "gateway"
160
+ ? { ok: false, reason: "404" }
161
+ : { ok: true, digest: `sha256:${"a".repeat(64)}` },
162
+ );
163
+ expect(r.ok).toBe(false);
164
+ if (r.ok) return;
165
+ expect(r.error).toContain("gateway:0.12.0");
166
+ expect(r.error).not.toContain("staging-gateway");
167
+ });
168
+ });
@@ -16,6 +16,13 @@
16
16
  import { IMAGE_DIGEST } from "@caelo-cms/shared";
17
17
  import { confirm, isCancel, log, spinner } from "@clack/prompts";
18
18
  import { green, red } from "kleur/colors";
19
+ import {
20
+ CLOUD_RUN_SLUGS,
21
+ type CloudRunSlug,
22
+ SERVICE_IMAGE,
23
+ SERVICE_IMAGE_NAMES,
24
+ type ServiceImageName,
25
+ } from "../stack-contract.js";
19
26
 
20
27
  /** The Caelo services shipped as release images (#1328 added the MCP-Auth-Proxy). */
21
28
  export type ReleaseService = "admin" | "gateway" | "mcp-auth-proxy";
@@ -468,3 +475,40 @@ export async function verifyCosignAll(
468
475
  }
469
476
  return true;
470
477
  }
478
+
479
+ /**
480
+ * Resolve each release image's digest from the public registry, and give
481
+ * every service the image it runs (#1330: the staging gateway runs the
482
+ * gateway's — there is no `staging-gateway` image to look up). Via the
483
+ * registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
484
+ * needs IAM read on Caelo's registry project, which operators don't have.
485
+ *
486
+ * @param resolve - the tag lookup (tests pass a fake)
487
+ */
488
+ export async function resolveReleaseImages(
489
+ tag: string,
490
+ resolve: (image: string, tag: string) => Promise<DigestResult> = resolveTagDigest,
491
+ ): Promise<
492
+ | { ok: true; images: Record<CloudRunSlug, { digest: string; imageRef: string }> }
493
+ | { ok: false; error: string }
494
+ > {
495
+ const byImage: Partial<Record<ServiceImageName, { digest: string; imageRef: string }>> = {};
496
+ for (const image of SERVICE_IMAGE_NAMES) {
497
+ const res = await resolve(image, tag);
498
+ if (!res.ok) {
499
+ const r = RELEASE_REGISTRY;
500
+ return {
501
+ ok: false,
502
+ error:
503
+ `Couldn't resolve image digest for ${image}:${tag}: the public registry returned ${res.reason}.\n` +
504
+ `Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${image}:${tag}\n` +
505
+ "(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
506
+ };
507
+ }
508
+ byImage[image] = { digest: res.digest, imageRef: releaseImageRef(image, res.digest) };
509
+ }
510
+ const images = Object.fromEntries(
511
+ CLOUD_RUN_SLUGS.map((slug) => [slug, byImage[SERVICE_IMAGE[slug]]]),
512
+ ) as Record<CloudRunSlug, { digest: string; imageRef: string }>;
513
+ return { ok: true, images };
514
+ }
@@ -41,6 +41,8 @@ const GATEWAY = "caelo-production-data-gateway";
41
41
  const PROXY = "caelo-production-gateway-bbb";
42
42
  /** gcp: the proxy's fixed name (the gateway keeps its auto-named service). */
43
43
  const GCP_PROXY = "caelo-production-mcp-auth-proxy";
44
+ /** #1330 — the staging gateway, under its fixed name on both providers. */
45
+ const STAGING = "caelo-production-staging-gateway";
44
46
  const SQL = "caelo-production-pg-abc";
45
47
  const BACKUP_ID = "1760000000000";
46
48
  const DIGEST = {
@@ -51,6 +53,8 @@ const DIGEST = {
51
53
  const IMAGES: Record<RollSlug, ReleaseImage> = {
52
54
  admin: { digest: DIGEST.admin, imageRef: `registry/admin@${DIGEST.admin}` },
53
55
  gateway: { digest: DIGEST.gateway, imageRef: `registry/gateway@${DIGEST.gateway}` },
56
+ // #1330 — the staging gateway runs the gateway's image.
57
+ "staging-gateway": { digest: DIGEST.gateway, imageRef: `registry/gateway@${DIGEST.gateway}` },
54
58
  "mcp-auth-proxy": {
55
59
  digest: DIGEST["mcp-auth-proxy"],
56
60
  imageRef: `registry/mcp-auth-proxy@${DIGEST["mcp-auth-proxy"]}`,
@@ -115,6 +119,16 @@ const GATEWAY_JSON = serviceJson("registry/gateway@sha256:old", [
115
119
  secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
116
120
  ]);
117
121
 
122
+ /** #1330 — the staging gateway as the converge deployed it. */
123
+ const STAGING_JSON = serviceJson("registry/gateway@sha256:old", [
124
+ {
125
+ name: "PUBLIC_DATABASE_URL",
126
+ value:
127
+ "postgresql://public_staging_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
128
+ },
129
+ secretRef("PUBLIC_DATABASE_PASSWORD", "public-staging-role-password"),
130
+ ]);
131
+
118
132
  /**
119
133
  * gcp-firebase: the service that was the gateway, before this upgrade turns
120
134
  * it into the MCP-Auth-Proxy — the visitor gateway's database login, the
@@ -150,6 +164,28 @@ function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
150
164
  // On gcp this prefix is the gateway's, on gcp-firebase the proxy's.
151
165
  [/^run services list .*caelo-production-gateway/, ok(`${PROXY}\n`)],
152
166
  [/^run services list .*caelo-production-mcp-auth-proxy/, ok(`${GCP_PROXY}\n`)],
167
+ [/^run services list .*caelo-production-staging-gateway/, ok(`${STAGING}\n`)],
168
+ [new RegExp(`describe ${STAGING} .*--format=json`), ok(STAGING_JSON)],
169
+ // #1330 — the gcp staging edge: one WAF policy, one HTTPS proxy, the site's URL map.
170
+ [/^compute security-policies list/, ok("caelo-production-waf-1\n")],
171
+ [
172
+ /^compute target-https-proxies list/,
173
+ ok(JSON.stringify([{ name: "caelo-production-https-proxy-1", sslCertificates: [] }])),
174
+ ],
175
+ [
176
+ /^compute url-maps list/,
177
+ ok(
178
+ JSON.stringify([
179
+ {
180
+ name: "caelo-production-url-map-1",
181
+ hostRules: [{ hosts: ["acme.com"], pathMatcher: "public" }],
182
+ pathMatchers: [
183
+ { name: "public", pathRules: [{ paths: ["/api/*"], service: "gw-backend" }] },
184
+ ],
185
+ },
186
+ ]),
187
+ ),
188
+ ],
153
189
  [/describe (\S+) .*revisionName/, (l) => ok(`${l.split(" ")[3]}-00001-old\n`)],
154
190
  [new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
155
191
  [new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
@@ -298,7 +334,7 @@ const ANY_WRITE =
298
334
  * install lacks (mcp-auth-proxy-setup.ts). Additive; nothing serves on them.
299
335
  */
300
336
  const PRE_PLAN_WRITE =
301
- /^(iam service-accounts create caelo-production-mcp-proxy-sa |secrets add-iam-policy-binding caelo-production-(mcp-oauth-seal-key|operator-oauth-client) .*--member=serviceAccount:caelo-production-mcp-proxy-sa@|run services replace |run deploy caelo-production-mcp-auth-proxy )/;
337
+ /^(iam service-accounts create caelo-production-(mcp-proxy-sa|stg-gw-sa) |secrets add-iam-policy-binding caelo-production-(mcp-oauth-seal-key|operator-oauth-client) .*--member=serviceAccount:caelo-production-mcp-proxy-sa@|secrets add-iam-policy-binding caelo-production-public-staging-role-password .*--member=serviceAccount:caelo-production-stg-gw-sa@|storage buckets (create|add-iam-policy-binding) gs:\/\/acme-caelo-production-staging |run services replace |run deploy caelo-production-(mcp-auth-proxy|staging-gateway) |run services add-iam-policy-binding caelo-production-staging-gateway .*--member=allUsers )/;
302
338
 
303
339
  const indexOf = (events: string[], pattern: RegExp): number => {
304
340
  const i = events.findIndex((e) => pattern.test(e));
@@ -606,11 +642,14 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
606
642
  .slice(deep)
607
643
  .filter((e) => e.includes("update-traffic") && e.includes("--to-revisions"));
608
644
  // Newest first: the proxy (back to the gateway release it was), the
609
- // gateway, the admin.
645
+ // staging gateway (#1330), the gateway, the admin.
610
646
  expect(rollbacks).toEqual([
611
647
  expect.stringContaining(
612
648
  `${PROXY} --region europe-west1 --project acme --to-revisions ${PROXY}-00001-old=100`,
613
649
  ),
650
+ expect.stringContaining(
651
+ `${STAGING} --region europe-west1 --project acme --to-revisions ${STAGING}-00001-old=100`,
652
+ ),
614
653
  expect.stringContaining(
615
654
  `${GATEWAY} --region europe-west1 --project acme --to-revisions ${GATEWAY}-00001-old=100`,
616
655
  ),
@@ -752,6 +791,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
752
791
  const before = {
753
792
  admin: `${ADMIN}-00000-before`,
754
793
  gateway: `${GATEWAY}-00000-before`,
794
+ "staging-gateway": `${STAGING}-00000-before`,
755
795
  "mcp-auth-proxy": `${PROXY}-00000-before`,
756
796
  };
757
797
  expect(
@@ -772,12 +812,13 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
772
812
  const rollbacks = events.filter((e) => e.includes("--to-revisions"));
773
813
  expect(rollbacks).toEqual([
774
814
  expect.stringContaining(`--to-revisions ${before["mcp-auth-proxy"]}=100`),
815
+ expect.stringContaining(`--to-revisions ${before["staging-gateway"]}=100`),
775
816
  expect.stringContaining(`--to-revisions ${before.gateway}=100`),
776
817
  expect.stringContaining(`--to-revisions ${before.admin}=100`),
777
818
  ]);
778
819
  });
779
820
 
780
- it("#1328 — a checkpoint without the proxy's revision (an older release wrote it) keeps the planned one", async () => {
821
+ it("#1328/#1330 — a checkpoint without the proxy's or staging gateway's revision (an older release wrote it) keeps the planned one", async () => {
781
822
  const events: string[] = [];
782
823
  const { deps } = upgradeDeps(events, { deep: { ok: false, error: "unhealthy" } });
783
824
  const before = { admin: `${ADMIN}-00000-before`, gateway: `${GATEWAY}-00000-before` };
@@ -792,6 +833,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
792
833
  ).toBe(false);
793
834
  expect(events.filter((e) => e.includes("--to-revisions"))).toEqual([
794
835
  expect.stringContaining(`--to-revisions ${PROXY}-00001-old=100`),
836
+ expect.stringContaining(`--to-revisions ${STAGING}-00001-old=100`),
795
837
  expect.stringContaining(`--to-revisions ${before.gateway}=100`),
796
838
  expect.stringContaining(`--to-revisions ${before.admin}=100`),
797
839
  ]);
@@ -916,11 +958,12 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
916
958
  expect(gateway).not.toContain("operator-oauth-client");
917
959
  expect(gateway).not.toContain("--timeout");
918
960
  expect(admin).not.toContain("--timeout");
919
- // No extra revision: everything rides the image rolls.
920
- expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(3);
961
+ // No extra revision: everything rides the image rolls (four services
962
+ // since the staging gateway, #1330).
963
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(4);
921
964
  });
922
965
 
923
- it("#1328 — the gcp-firebase proxy roll leaves the VPC, drops every database login and forwards to the data gateway", async () => {
966
+ it("#1328/#1331 — the gcp-firebase proxy roll leaves the VPC, drops every database login and forwards nothing", async () => {
924
967
  const events: string[] = [];
925
968
  const { deps, calls } = upgradeDeps(events);
926
969
  expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
@@ -931,7 +974,8 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
931
974
  );
932
975
  expect(proxy).toContain("--clear-network");
933
976
  expect(proxy).toContain("CAELO_REMOTE_MCP=available");
934
- expect(proxy).toContain(`CAELO_INTERIM_FORWARD_ORIGIN=https://${GATEWAY}-xyz.a.run.app`);
977
+ // #1331 — the interim forwarder to the data gateway is gone.
978
+ expect(proxy).not.toContain("CAELO_INTERIM_FORWARD_ORIGIN");
935
979
  const removedEnv = proxy.match(/--remove-env-vars=(\S+)/)?.[1]?.split(",") ?? [];
936
980
  const removedSecrets = proxy.match(/--remove-secrets=(\S+)/)?.[1]?.split(",") ?? [];
937
981
  expect(removedEnv).toContain("PUBLIC_DATABASE_URL");
@@ -1031,10 +1075,12 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
1031
1075
  const admin = rolls.find((c) => c.startsWith(`run services update ${ADMIN} `)) ?? "";
1032
1076
  const gateway = rolls.find((c) => c.startsWith(`run services update ${GATEWAY} `)) ?? "";
1033
1077
  const proxy = rolls.find((c) => c.startsWith(`run services update ${PROXY} `)) ?? "";
1078
+ const staging = rolls.find((c) => c.startsWith(`run services update ${STAGING} `)) ?? "";
1034
1079
  expect(gateway).toContain("--cpu-throttling");
1080
+ expect(staging).toContain("--cpu-throttling");
1035
1081
  expect(proxy).toContain("--cpu-throttling");
1036
1082
  expect(admin).not.toContain("cpu-throttling");
1037
- expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(3);
1083
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(4);
1038
1084
  });
1039
1085
 
1040
1086
  it("#806/#1328 — on gcp, moves the remote-MCP paths and the Cloud Armor rule to the proxy's backend before the roll", async () => {
@@ -1122,7 +1168,13 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
1122
1168
  events,
1123
1169
  /^gcloud compute security-policies rules delete 900 --security-policy=caelo-production-waf-1 /,
1124
1170
  );
1125
- expect(puts).toHaveLength(1);
1171
+ // The remote-MCP routes first, then (#1330) the staging host's own rule.
1172
+ expect(puts).toHaveLength(2);
1173
+ const staging = JSON.parse(puts[1] as string) as typeof urlMap;
1174
+ expect(staging.hostRules).toContainEqual({
1175
+ hosts: ["staging.acme.com"],
1176
+ pathMatcher: "staging",
1177
+ });
1126
1178
  const put = JSON.parse(puts[0] as string) as typeof urlMap;
1127
1179
  const rules = put.pathMatchers[0]?.pathRules ?? [];
1128
1180
  expect(rules).toEqual([
@@ -1190,7 +1242,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
1190
1242
  healthProbe: async () => ({ ok: true, probe: throwing }),
1191
1243
  }),
1192
1244
  ).toBe(false);
1193
- expect(events.filter((e) => e.includes("--to-revisions"))).toHaveLength(3);
1245
+ expect(events.filter((e) => e.includes("--to-revisions"))).toHaveLength(4);
1194
1246
  expect(recorded.digests).toEqual([]);
1195
1247
  });
1196
1248
 
@@ -1351,7 +1403,7 @@ describe("app-roll and converge stay on their side", () => {
1351
1403
  );
1352
1404
  expect(result).toEqual({ ok: true, digests: DIGEST });
1353
1405
  expect(calls.filter((c) => CONVERGE_WRITE.test(c))).toEqual([]);
1354
- expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(3);
1406
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(4);
1355
1407
  });
1356
1408
 
1357
1409
  it("#1338: without a converge, a plan whose services dial the database's IP stops before migrations", async () => {
@@ -34,6 +34,7 @@ import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
34
34
  import type { ImageDigests } from "../install-state.js";
35
35
  import { ensureReleaseServices } from "../mcp-auth-proxy-setup.js";
36
36
  import type { HttpFetch } from "../runtime-secrets.js";
37
+ import { SERVICE_IMAGE } from "../stack-contract.js";
37
38
  import {
38
39
  type AppRollPlan,
39
40
  executeAppRoll,
@@ -90,9 +91,11 @@ export interface UpgradeCheckpoint {
90
91
 
91
92
  /**
92
93
  * The rollback target per service. The MCP-Auth-Proxy's is absent in a
93
- * checkpoint a release before #1328 wrote; its planned revision is kept then.
94
+ * checkpoint a release before #1328 wrote, the staging gateway's in one from
95
+ * before #1330; their planned revisions are kept then.
94
96
  */
95
97
  export type PriorRevisions = Readonly<Record<"admin" | "gateway", string>> & {
98
+ readonly "staging-gateway"?: string;
96
99
  readonly "mcp-auth-proxy"?: string;
97
100
  };
98
101
 
@@ -189,7 +192,12 @@ export async function upgradeGcpInstallOutcome(
189
192
  // nothing yet, so the install keeps running as it is when this or
190
193
  // anything after it fails. The gcp proxy runs the new release's image,
191
194
  // so that image's signature is checked before it is deployed.
195
+ //
196
+ // #1330 — and the staging gateway (both providers), deployed with the new
197
+ // release's gateway image when the install has none yet, so that image's
198
+ // signature is checked first too.
192
199
  const proxyImage = images.images["mcp-auth-proxy"];
200
+ const gatewayImage = images.images.gateway;
193
201
  if (
194
202
  target.provider === "gcp" &&
195
203
  !target.skipVerify &&
@@ -198,6 +206,13 @@ export async function upgradeGcpInstallOutcome(
198
206
  sPre.failed("The MCP-Auth-Proxy image's signature did not verify");
199
207
  return failed;
200
208
  }
209
+ if (
210
+ !target.skipVerify &&
211
+ !(await deps.verifyImages([{ slug: "gateway", digest: gatewayImage.digest }]))
212
+ ) {
213
+ sPre.failed("The gateway image's signature did not verify");
214
+ return failed;
215
+ }
201
216
  const ensured = await ensureReleaseServices(
202
217
  {
203
218
  provider: target.provider,
@@ -205,7 +220,9 @@ export async function upgradeGcpInstallOutcome(
205
220
  region: target.region,
206
221
  env: target.env,
207
222
  domain: target.domain,
223
+ stagingHost: target.stagingHost,
208
224
  mcpAuthProxyImageRef: proxyImage.imageRef,
225
+ gatewayImageRef: gatewayImage.imageRef,
209
226
  },
210
227
  { run },
211
228
  );
@@ -244,7 +261,17 @@ export async function upgradeGcpInstallOutcome(
244
261
  // 2. P21 ship 4 — signatures against the Caelo release workflow's
245
262
  // identity: refuses a compromised registry, a typosquat or a fork's image.
246
263
  if (!target.skipVerify) {
247
- if (!(await deps.verifyImages(plan.services))) return failed;
264
+ // One check per release image: the staging gateway (#1330) runs the
265
+ // gateway's, which has no repository of its own to verify against.
266
+ const images = [
267
+ ...new Map(
268
+ plan.services.map((s) => [
269
+ `${SERVICE_IMAGE[s.slug]}@${s.digest}`,
270
+ { slug: SERVICE_IMAGE[s.slug], digest: s.digest },
271
+ ]),
272
+ ).values(),
273
+ ];
274
+ if (!(await deps.verifyImages(images))) return failed;
248
275
  } else {
249
276
  r.warn("--skip-verify set — image signatures NOT verified.");
250
277
  }
@@ -171,6 +171,11 @@ function fakeGcloud(events: string[], overrides: [RegExp, Answer][] = []) {
171
171
  [/^run services list .*caelo-production-admin/, ok(`${ADMIN}\n`)],
172
172
  [/^run services list .*caelo-production-data-gateway/, ok(`${GATEWAY}\n`)],
173
173
  [/^run services list .*caelo-production-gateway/, ok(`${PROXY}\n`)],
174
+ // #1330 — the staging gateway, serving the gateway's image (below).
175
+ [
176
+ /^run services list .*caelo-production-staging-gateway/,
177
+ ok("caelo-production-staging-gateway\n"),
178
+ ],
174
179
  [/describe (\S+) .*revisionName/, (l) => ok(`${l.split(" ")[3]}-00001-old\n`)],
175
180
  [new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
176
181
  [new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
@@ -478,6 +483,24 @@ describe("runUpgraderJob", () => {
478
483
  );
479
484
  });
480
485
 
486
+ it("#1330: a staging gateway that serves another image than the gateway stops the run; nothing changed", async () => {
487
+ const { deps, order, q, written } = setup({
488
+ gcloud: [
489
+ [
490
+ /^run revisions describe caelo-production-staging-gateway/,
491
+ ok(revisionJson(D("9"), P("9"))),
492
+ ],
493
+ ],
494
+ });
495
+ const r = await runUpgraderJob(deps);
496
+ expect(r).toMatchObject({ exitCode: 1, status: "failed" });
497
+ expect(q.end()?.reason).toContain(
498
+ "the staging gateway (caelo-production-staging-gateway) serves",
499
+ );
500
+ expect(order.some((l) => /^gcloud run services update /.test(l))).toBe(false);
501
+ expect(written).toEqual([]);
502
+ });
503
+
481
504
  it("#818: missing grants end needs-Cloud-Shell with EVERY missing permission named; nothing changed", async () => {
482
505
  const { deps, order, q, written } = setup({
483
506
  iamWithheld: {