@caelo-cms/provisioning 0.11.12 → 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 (165) hide show
  1. package/dist/admin-domain.d.ts +4 -22
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -58
  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 +22 -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 -29
  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/scheduler-setup.d.ts +5 -0
  57. package/dist/scheduler-setup.d.ts.map +1 -1
  58. package/dist/scheduler-setup.js +7 -0
  59. package/dist/scheduler-setup.js.map +1 -1
  60. package/dist/stack-contract.d.ts +61 -20
  61. package/dist/stack-contract.d.ts.map +1 -1
  62. package/dist/stack-contract.js +173 -35
  63. package/dist/stack-contract.js.map +1 -1
  64. package/dist/stack-converge.d.ts.map +1 -1
  65. package/dist/stack-converge.js +4 -2
  66. package/dist/stack-converge.js.map +1 -1
  67. package/dist/staging-admin.d.ts +2 -2
  68. package/dist/staging-admin.d.ts.map +1 -1
  69. package/dist/staging-gateway-setup.d.ts +140 -0
  70. package/dist/staging-gateway-setup.d.ts.map +1 -0
  71. package/dist/staging-gateway-setup.js +848 -0
  72. package/dist/staging-gateway-setup.js.map +1 -0
  73. package/dist/upgrade/app-roll.d.ts +4 -4
  74. package/dist/upgrade/app-roll.d.ts.map +1 -1
  75. package/dist/upgrade/app-roll.js +7 -5
  76. package/dist/upgrade/app-roll.js.map +1 -1
  77. package/dist/upgrade/converge.d.ts.map +1 -1
  78. package/dist/upgrade/converge.js +36 -27
  79. package/dist/upgrade/converge.js.map +1 -1
  80. package/dist/upgrade/image-forms.d.ts +3 -3
  81. package/dist/upgrade/image-forms.d.ts.map +1 -1
  82. package/dist/upgrade/image-forms.js +2 -2
  83. package/dist/upgrade/image-forms.js.map +1 -1
  84. package/dist/upgrade/release-images.d.ts +20 -0
  85. package/dist/upgrade/release-images.d.ts.map +1 -1
  86. package/dist/upgrade/release-images.js +28 -0
  87. package/dist/upgrade/release-images.js.map +1 -1
  88. package/dist/upgrade/upgrade.d.ts +3 -1
  89. package/dist/upgrade/upgrade.d.ts.map +1 -1
  90. package/dist/upgrade/upgrade.js +22 -1
  91. package/dist/upgrade/upgrade.js.map +1 -1
  92. package/dist/upgrader-job.d.ts.map +1 -1
  93. package/dist/upgrader-job.js +30 -5
  94. package/dist/upgrader-job.js.map +1 -1
  95. package/dist/upgrader-records.d.ts +2 -2
  96. package/dist/upgrader-records.d.ts.map +1 -1
  97. package/dist/upgrader-records.js +2 -2
  98. package/dist/upgrader-records.js.map +1 -1
  99. package/dist/wizard.d.ts.map +1 -1
  100. package/dist/wizard.js +3 -3
  101. package/dist/wizard.js.map +1 -1
  102. package/dist/wizards/gcp.d.ts.map +1 -1
  103. package/dist/wizards/gcp.js +48 -9
  104. package/dist/wizards/gcp.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin-domain.test.ts +0 -91
  107. package/src/admin-domain.ts +4 -64
  108. package/src/admin-memory.test.ts +2 -0
  109. package/src/audit-job-setup.test.ts +11 -11
  110. package/src/audit-job-setup.ts +14 -19
  111. package/src/caddy-redirects.docker.test.ts +10 -5
  112. package/src/caddy-redirects.test.ts +7 -5
  113. package/src/caddy-validate.test.ts +1 -1
  114. package/src/caddy.test.ts +12 -6
  115. package/src/caddy.ts +23 -4
  116. package/src/cli.ts +138 -77
  117. package/src/compose.ts +67 -2
  118. package/src/converge-checkpoint.ts +1 -0
  119. package/src/database-tls-builders.test.ts +3 -1
  120. package/src/deployed-release.test.ts +21 -0
  121. package/src/deployed-release.ts +42 -7
  122. package/src/gateway-env-isolation.test.ts +2 -0
  123. package/src/gcp-names.ts +53 -0
  124. package/src/gcp-url-map.test.ts +26 -2
  125. package/src/gcp-url-map.ts +36 -5
  126. package/src/index.test.ts +9 -3
  127. package/src/infra-contract.test.ts +5 -3
  128. package/src/infra-contract.ts +22 -1
  129. package/src/lifecycle.ts +6 -40
  130. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  131. package/src/mcp-auth-proxy-setup.ts +14 -2
  132. package/src/release-manifest.ts +3 -1
  133. package/src/remote-mcp-stack-config.test.ts +1 -0
  134. package/src/remote-mcp.test.ts +5 -1
  135. package/src/remote-mcp.ts +10 -1
  136. package/src/runtime-secrets.test.ts +10 -1
  137. package/src/runtime-secrets.ts +21 -0
  138. package/src/scheduler-setup.test.ts +6 -0
  139. package/src/scheduler-setup.ts +7 -0
  140. package/src/self-hosted-backup.test.ts +6 -4
  141. package/src/self-hosted-compose.test.ts +2 -0
  142. package/src/stack-contract-parity.test.ts +7 -3
  143. package/src/stack-contract.test.ts +73 -13
  144. package/src/stack-contract.ts +195 -42
  145. package/src/stack-converge-secrets.test.ts +7 -2
  146. package/src/stack-converge.test.ts +37 -9
  147. package/src/stack-converge.ts +4 -1
  148. package/src/staging-admin.ts +2 -2
  149. package/src/staging-gateway-setup.test.ts +361 -0
  150. package/src/staging-gateway-setup.ts +1083 -0
  151. package/src/upgrade/app-roll.ts +7 -4
  152. package/src/upgrade/converge.ts +40 -26
  153. package/src/upgrade/image-forms.ts +7 -7
  154. package/src/upgrade/release-images.test.ts +28 -0
  155. package/src/upgrade/release-images.ts +44 -0
  156. package/src/upgrade/upgrade.test.ts +63 -11
  157. package/src/upgrade/upgrade.ts +29 -2
  158. package/src/upgrader-job.test.ts +23 -0
  159. package/src/upgrader-job.ts +45 -11
  160. package/src/upgrader-records.ts +7 -7
  161. package/src/wizard.ts +2 -3
  162. package/src/wizards/gcp.ts +73 -9
  163. package/stacks/gcp/index.ts +47 -6
  164. package/stacks/gcp-firebase/index.ts +44 -12
  165. package/stacks/self-hosted/index.ts +9 -2
@@ -63,14 +63,17 @@ import {
63
63
  type ReleaseManifest,
64
64
  type ReleaseManifestResult,
65
65
  } from "./release-manifest.js";
66
- import { cloudRunServicePrefix } from "./stack-contract.js";
66
+ import {
67
+ cloudRunServicePrefix,
68
+ SERVICE_IMAGE_NAMES,
69
+ type ServiceImageName,
70
+ } from "./stack-contract.js";
67
71
  import { StateLockHeldError } from "./state-store.js";
68
72
  import {
69
73
  type AppRollPlan,
70
74
  executeAppRoll,
71
75
  planAppRoll,
72
76
  type ReleaseImage,
73
- ROLL_ORDER,
74
77
  type RollSlug,
75
78
  } from "./upgrade/app-roll.js";
76
79
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
@@ -699,7 +702,7 @@ export async function recordUpdateEnd(
699
702
  return false;
700
703
  }
701
704
 
702
- /** Every digest the revisions serving traffic are known by, per service. */
705
+ /** Every digest the revisions serving traffic are known by, per release image. */
703
706
  async function servingDigests(
704
707
  deps: UpgraderJobDeps,
705
708
  provider: InstallMetadata["provider"],
@@ -708,16 +711,18 @@ async function servingDigests(
708
711
  ok: true;
709
712
  digests: ServingDigests;
710
713
  /** The services' deployed names, for the template hints. */
711
- services: Readonly<Record<RollSlug, string>>;
714
+ services: Readonly<Record<ServiceImageName, string>>;
712
715
  }
713
716
  | { ok: false; error: string }
714
717
  > {
715
- const digests: Partial<Record<RollSlug, readonly string[]>> = {};
716
- const services: Partial<Record<RollSlug, string>> = {};
718
+ const digests: Partial<Record<ServiceImageName, readonly string[]>> = {};
719
+ const services: Partial<Record<ServiceImageName, string>> = {};
717
720
  if (provider !== "gcp" && provider !== "gcp-firebase") {
718
721
  return { ok: false, error: `the upgrader runs on Google Cloud installs only, not ${provider}` };
719
722
  }
720
- for (const slug of ROLL_ORDER) {
723
+ // The staging gateway (#1330) runs the gateway's image and rolls with
724
+ // it; the images, one service each, name the release.
725
+ for (const slug of SERVICE_IMAGE_NAMES) {
721
726
  // #1328 — which service plays which role differs per provider.
722
727
  const prefix = cloudRunServicePrefix(provider, deps.env, slug);
723
728
  const service = await resolveGcpResourceName(
@@ -735,10 +740,39 @@ async function servingDigests(
735
740
  digests[slug] = servingImageDigests(image);
736
741
  services[slug] = service.name;
737
742
  }
743
+ // #1330 — the staging gateway must serve the very image the gateway
744
+ // serves: it rolls with it, and an install whose staging gateway runs
745
+ // another release is not on one release (the upgrader runs only on
746
+ // installs converged to contract 20, which have it).
747
+ const stagingPrefix = cloudRunServicePrefix(provider, deps.env, "staging-gateway");
748
+ const staging = await resolveGcpResourceName(
749
+ deps.run,
750
+ "run-service",
751
+ stagingPrefix,
752
+ deps.projectId,
753
+ deps.region,
754
+ );
755
+ if (staging.status !== "found") {
756
+ return {
757
+ ok: false,
758
+ error: lookupFailure(staging, `${stagingPrefix}* Cloud Run service`, "job"),
759
+ };
760
+ }
761
+ const stagingImage = await findServingImage(deps.run, deps.projectId, deps.region, staging.name);
762
+ if (!stagingImage) {
763
+ return { ok: false, error: "could not read which image the staging gateway serves" };
764
+ }
765
+ const stagingDigests = servingImageDigests(stagingImage);
766
+ if (!stagingDigests.some((d) => (digests.gateway ?? []).includes(d))) {
767
+ return {
768
+ ok: false,
769
+ error: `the staging gateway (${staging.name}) serves ${stagingDigests[0] ?? "an unknown image"}, but the gateway serves ${digests.gateway?.[0] ?? "an unknown image"}: the install does not run one release. Nothing was changed; an upgrade from Cloud Shell rolls both onto one release.`,
770
+ };
771
+ }
738
772
  return {
739
773
  ok: true,
740
774
  digests: digests as ServingDigests,
741
- services: services as Record<RollSlug, string>,
775
+ services: services as Record<ServiceImageName, string>,
742
776
  };
743
777
  }
744
778
 
@@ -749,10 +783,10 @@ async function servingDigests(
749
783
  */
750
784
  async function templateDigests(
751
785
  deps: UpgraderJobDeps,
752
- services: Readonly<Record<RollSlug, string>>,
786
+ services: Readonly<Record<ServiceImageName, string>>,
753
787
  ): Promise<ServingDigests> {
754
- const out: Partial<Record<RollSlug, readonly string[]>> = {};
755
- for (const slug of ROLL_ORDER) {
788
+ const out: Partial<Record<ServiceImageName, readonly string[]>> = {};
789
+ for (const slug of SERVICE_IMAGE_NAMES) {
756
790
  const d = await findTemplateImageDigest(deps.run, deps.projectId, deps.region, services[slug]);
757
791
  out[slug] = d ? [d] : [];
758
792
  }
@@ -30,16 +30,16 @@
30
30
  import type { GcloudRunner } from "./gcloud-retry.js";
31
31
  import type { ImageDigests, InstallMetadata } from "./install-state.js";
32
32
  import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
33
- import { ROLL_ORDER, type RollSlug } from "./upgrade/app-roll.js";
33
+ import { SERVICE_IMAGE_NAMES, type ServiceImageName } from "./stack-contract.js";
34
34
  import type { ReleaseImageForms } from "./upgrade/image-forms.js";
35
35
  import { releaseVersionForDigest } from "./upgrade/release-images.js";
36
36
 
37
37
  /** Every digest each serving image is known by (gcp-lookup.ts servingImageDigests). */
38
- export type ServingDigests = Readonly<Record<RollSlug, readonly string[]>>;
38
+ export type ServingDigests = Readonly<Record<ServiceImageName, readonly string[]>>;
39
39
 
40
40
  // #1328 — the MCP-Auth-Proxy serves on every install this code runs on (the
41
41
  // upgrader runs only on installs converged to its contract).
42
- const SLUGS: readonly RollSlug[] = ROLL_ORDER;
42
+ const SLUGS: readonly ServiceImageName[] = SERVICE_IMAGE_NAMES;
43
43
 
44
44
  /**
45
45
  * Whether install.json names the images that serve: per service, the
@@ -176,7 +176,7 @@ export async function findServingRelease(
176
176
  type ReleaseJudgement =
177
177
  | {
178
178
  readonly kind: "judged";
179
- readonly matching: readonly RollSlug[];
179
+ readonly matching: readonly ServiceImageName[];
180
180
  readonly platformDigests: ImageDigests | null;
181
181
  }
182
182
  | { readonly kind: "unreadable"; readonly transient: boolean; readonly message: string };
@@ -191,8 +191,8 @@ async function judgeRelease(
191
191
  serving: ServingDigests,
192
192
  imageForms: ReleaseImageForms,
193
193
  ): Promise<ReleaseJudgement> {
194
- const matching: RollSlug[] = [];
195
- const platform: Partial<Record<RollSlug, string>> = {};
194
+ const matching: ServiceImageName[] = [];
195
+ const platform: Partial<Record<ServiceImageName, string>> = {};
196
196
  for (const slug of SLUGS) {
197
197
  const index = m.digests[slug];
198
198
  // #1328 — a release from before the proxy cannot be what serves here.
@@ -257,7 +257,7 @@ function capitalize(s: string): string {
257
257
  }
258
258
 
259
259
  /** `the admin`, `the gateway and the MCP-Auth-Proxy`, … for the refusal text. */
260
- function serviceList(slugs: readonly RollSlug[]): string {
260
+ function serviceList(slugs: readonly ServiceImageName[]): string {
261
261
  const names = slugs.map((s) => (s === "mcp-auth-proxy" ? "the MCP-Auth-Proxy" : `the ${s}`));
262
262
  if (names.length <= 1) return names[0] ?? "";
263
263
  return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
package/src/wizard.ts CHANGED
@@ -154,7 +154,7 @@ export async function runWizard(opts: WizardOptions = {}): Promise<void> {
154
154
  } else {
155
155
  provider = jobModeProvider ?? opts.provider ?? (await pickProvider());
156
156
  domain = opts.domain ?? (await promptDomain());
157
- const stagingDomain = await resolveStagingDomain(domain, provider, opts);
157
+ const stagingDomain = await resolveStagingDomain(domain, opts);
158
158
  ownerEmail = opts.ownerEmail ?? (await promptOwnerEmail());
159
159
  projectIdHint = jobModeProjectId ?? opts.projectId ?? null;
160
160
  installId = deriveInstallId(provider, projectIdHint ?? domain);
@@ -389,11 +389,10 @@ async function promptDomain(): Promise<string> {
389
389
  */
390
390
  async function resolveStagingDomain(
391
391
  domain: string,
392
- provider: Provider,
393
392
  opts: Pick<WizardOptions, "stagingDomain" | "nonInteractive">,
394
393
  ): Promise<string> {
395
394
  const reserved = { liveHosts: [domain, `www.${domain}`], adminHosts: [`admin.${domain}`] };
396
- const problemOf = (host: string) => stagingHostProblem(host, reserved, { provider });
395
+ const problemOf = (host: string) => stagingHostProblem(host, reserved);
397
396
  if (opts.stagingDomain) {
398
397
  const problem = problemOf(opts.stagingDomain);
399
398
  if (problem) {
@@ -129,6 +129,10 @@ import {
129
129
  type RemoteMcpIdentity,
130
130
  sameRemoteMcpIdentity,
131
131
  } from "../stack-contract.js";
132
+ import {
133
+ ensureStagingGatewayEdge,
134
+ ensureStagingGatewayFromLiveGateway,
135
+ } from "../staging-gateway-setup.js";
132
136
  import {
133
137
  chooseInstallBackend,
134
138
  describeBackend,
@@ -521,6 +525,18 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
521
525
 
522
526
  phase("Finishing: operator access, welcome page");
523
527
 
528
+ // === 11.2. The staging gateway and its edge (#1330) ===
529
+ // Before the audit job, which reads the staging bucket this step creates
530
+ // on gcp-firebase.
531
+ const stagingReady = await stepStagingGateway(
532
+ projectId,
533
+ region,
534
+ provider,
535
+ domain,
536
+ installStagingHost(meta),
537
+ imageDigests,
538
+ );
539
+
524
540
  // === 11.3. The quality-audit job: parallel Lighthouse runs (#875) ===
525
541
  // Before the upgrader, which gets actAs on the job's service account.
526
542
  await stepAuditJob(projectId, region, provider);
@@ -573,10 +589,10 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
573
589
  // A fresh install ran every converge step above, so it is at this CLI's
574
590
  // infrastructure contract — recorded only when the upgrader and the
575
591
  // converge job are in place (the steps contract bumps 2 and 9 added) and
576
- // remote MCP's steps (#806, bump 10) and the scheduler tick (#1140, bump
577
- // 18) succeeded. With the release version, the admin can install the next
592
+ // remote MCP's steps (#806, bump 10), the scheduler tick (#1140, bump 18)
593
+ // and the staging gateway (#1330, bump 20) succeeded. With the release version, the admin can install the next
578
594
  // update itself.
579
- if (upgraderReady && convergeReady && remoteMcpReady && schedulerReady) {
595
+ if (upgraderReady && convergeReady && remoteMcpReady && schedulerReady && stagingReady) {
580
596
  const version = releaseVersion;
581
597
  // #1025 — the linux/amd64 forms Cloud Run reports, next to the indexes,
582
598
  // so the upgrader recognises what serves without a registry lookup.
@@ -1465,6 +1481,49 @@ async function stepRemoteMcp(
1465
1481
  return true;
1466
1482
  }
1467
1483
 
1484
+ /**
1485
+ * #1330 — the staging gateway (the live gateway's image, gate on, serving the
1486
+ * staged build from the staging bucket) and the route of the staging host to
1487
+ * it (staging-gateway-setup.ts). The site works without it, so a failure
1488
+ * warns and `upgrade` retries. Prints the DNS records the staging host needs.
1489
+ *
1490
+ * @returns whether both parts are in place
1491
+ */
1492
+ async function stepStagingGateway(
1493
+ projectId: string,
1494
+ region: string,
1495
+ provider: "gcp" | "gcp-firebase",
1496
+ domain: string,
1497
+ stagingHost: string,
1498
+ imageDigests: ImageDigests,
1499
+ ): Promise<boolean> {
1500
+ const s = spinner();
1501
+ s.start(`Setting up the staging gateway behind the staging gate (${stagingHost})...`);
1502
+ const failed = (error: string) => {
1503
+ s.stop(yellow(`Staging gateway not set up: ${error}`));
1504
+ log.warn(
1505
+ `The site works; ${stagingHost} answers nothing until ${bold(selfCommand("upgrade"))} sets it up.`,
1506
+ );
1507
+ return false;
1508
+ };
1509
+ const target = { provider, projectId, region, env: GCP_STACK_ENV, domain, stagingHost };
1510
+ const service = await ensureStagingGatewayFromLiveGateway({
1511
+ ...target,
1512
+ gatewayImageRef: releaseImageRef("gateway", imageDigests.gateway),
1513
+ });
1514
+ const broken = service.find((o) => o.status === "failed");
1515
+ if (broken) return failed(`${broken.id}: ${broken.error ?? ""}`);
1516
+ const edge = await ensureStagingGatewayEdge(target);
1517
+ if (!edge.ok) return failed(edge.error);
1518
+ s.stop(green(`Staging gateway ready: ${stagingHost} is served behind the staging gate`));
1519
+ for (const line of edge.done) log.info(` ${line}`);
1520
+ if (edge.dnsRecords.length > 0) {
1521
+ log.info(` DNS for ${stagingHost} (Settings → Updates shows the same):`);
1522
+ for (const d of edge.dnsRecords) log.info(` ${d.name} ${d.type} ${d.value}`);
1523
+ }
1524
+ return true;
1525
+ }
1526
+
1468
1527
  /**
1469
1528
  * Delete the firewall rules GCP pre-populates on the `default` network
1470
1529
  * (SSH, RDP, ICMP, internal; default-firewall.ts). A customised rule of the
@@ -2436,14 +2495,19 @@ async function stepFinalize(installId: string, setupLink: "print" | "secret"): P
2436
2495
  ` To bind ${cyan(`admin.${meta.domain}`)}, run`,
2437
2496
  ` ${dim(selfCommand("admin-domain", "enable"))}`,
2438
2497
  ` (it walks you through the one-time Search Console verification, prints the DNS record and waits for the certificate).`,
2439
- "",
2440
- bold("Protected staging site:"),
2441
- ` Stage builds render ${cyan(installStagingHost(meta))}; to serve it, run`,
2442
- ` ${dim(selfCommand("staging-domain", "enable"))}`,
2443
- ` It maps the host, turns on password protection and prints the staging login once;`,
2444
- ` add the DNS record it prints. The host serves pages after the first Stage.`,
2445
2498
  ]
2446
2499
  : [];
2500
+ // #1330 — staging is served by its own gateway on every GCP install; the
2501
+ // records it needs were printed by the staging gateway step above.
2502
+ adminNote.push(
2503
+ "",
2504
+ bold("Protected staging site:"),
2505
+ ` ${cyan(installStagingHost(meta))} is served by the staging gateway behind the staging gate,`,
2506
+ " once the DNS records printed above (also under Settings → Updates) point at it.",
2507
+ ` To turn on password protection and print the staging login once, run`,
2508
+ ` ${dim(selfCommand("staging-domain", "enable"))}`,
2509
+ " The host serves pages after the first Stage.",
2510
+ );
2447
2511
 
2448
2512
  note(
2449
2513
  [
@@ -29,6 +29,7 @@ import {
29
29
  MCP_IAP_SERVICE_ACCOUNT_ID,
30
30
  mcpAuthProxyServiceAccountId,
31
31
  runServiceAccountId,
32
+ stagingGatewayServiceAccountId,
32
33
  staticPublisherServiceAccountId,
33
34
  } from "../../dist/gcp-names.js";
34
35
  import { createSiteUrlMap } from "../../dist/gcp-url-map.js";
@@ -513,9 +514,10 @@ new gcp.storage.BucketIAMMember(
513
514
 
514
515
  // v0.2.78 — private staging bucket. Stage uploads here; Confirm-publish
515
516
  // server-side copies to the public static bucket. Never has allUsers
516
- // binding so the staged HTML is reachable ONLY through the admin's
517
- // IAP-gated /_staging-preview/<runId>/ proxy route. The public CDN
518
- // never serves from this bucket.
517
+ // binding: since #1330 the staged build is reachable ONLY through the
518
+ // staging gateway (the staging host, behind the staging gate), whose
519
+ // account may read this bucket and nothing else. The public CDN never
520
+ // serves from it.
519
521
  const stagingBucket = new gcp.storage.Bucket(
520
522
  `${namePrefix}-staging`,
521
523
  {
@@ -570,9 +572,24 @@ const mcpAuthProxySa = new gcp.serviceaccount.Account(
570
572
  },
571
573
  opts,
572
574
  );
575
+ // #1330 — the staging gateway's own SA and (below) its secretAccessor on
576
+ // its one login password, plus the telemetry roles. Its service, its read
577
+ // access to the staging bucket and its edge (gcp: NEG, backend, host rule,
578
+ // certificate; gcp-firebase: the second Hosting site) are the CLI's
579
+ // (staging-gateway-setup.ts, after the first `up` and in every converge).
580
+ const stagingGatewaySa = new gcp.serviceaccount.Account(
581
+ `${namePrefix}-staging-gateway-sa`,
582
+ {
583
+ accountId: stagingGatewayServiceAccountId(env),
584
+ displayName: `Caelo ${env} staging gateway`,
585
+ createIgnoreAlreadyExists: true,
586
+ },
587
+ opts,
588
+ );
573
589
  const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
574
590
  admin: runSa,
575
591
  gateway: gatewaySa,
592
+ "staging-gateway": stagingGatewaySa,
576
593
  "mcp-auth-proxy": mcpAuthProxySa,
577
594
  };
578
595
 
@@ -582,6 +599,7 @@ const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
582
599
  const secretBindings: Record<CloudRunSlug, gcp.secretmanager.SecretIamMember[]> = {
583
600
  admin: [],
584
601
  gateway: [],
602
+ "staging-gateway": [],
585
603
  "mcp-auth-proxy": [],
586
604
  };
587
605
  for (const binding of runtimeSecretBindings()) {
@@ -696,6 +714,24 @@ new gcp.projects.IAMMember(
696
714
  },
697
715
  opts,
698
716
  );
717
+ new gcp.projects.IAMMember(
718
+ `${namePrefix}-staging-gateway-log-writer`,
719
+ {
720
+ project,
721
+ role: "roles/logging.logWriter",
722
+ member: pulumi.interpolate`serviceAccount:${stagingGatewaySa.email}`,
723
+ },
724
+ opts,
725
+ );
726
+ new gcp.projects.IAMMember(
727
+ `${namePrefix}-staging-gateway-metric-writer`,
728
+ {
729
+ project,
730
+ role: "roles/monitoring.metricWriter",
731
+ member: pulumi.interpolate`serviceAccount:${stagingGatewaySa.email}`,
732
+ },
733
+ opts,
734
+ );
699
735
 
700
736
  // `static-publisher` SA used by `bunx @caelo-cms/provisioning deploy` to
701
737
  // upload the static-generator output to the bucket. Pre-v0.2.78 this
@@ -759,8 +795,8 @@ function imageTag(service: string): pulumi.Output<string> {
759
795
  }
760
796
 
761
797
  interface CloudRunArgs {
762
- /** The MCP-Auth-Proxy is CLI-owned on this stack (see mcpAuthProxySa). */
763
- readonly serviceName: Exclude<CloudRunSlug, "mcp-auth-proxy">;
798
+ /** The MCP-Auth-Proxy and the staging gateway are CLI-owned on this stack (see their SAs). */
799
+ readonly serviceName: Exclude<CloudRunSlug, "mcp-auth-proxy" | "staging-gateway">;
764
800
  readonly minInstances: number;
765
801
  readonly maxInstances: number;
766
802
  readonly memory: string;
@@ -1227,6 +1263,7 @@ const urlMap = createSiteUrlMap(
1227
1263
  {
1228
1264
  domain,
1229
1265
  adminDomain,
1266
+ stagingHost,
1230
1267
  staticBackend: staticBackendBucket.id,
1231
1268
  gatewayBackend: gatewayBackendService.id,
1232
1269
  adminBackend: adminBackendService.id,
@@ -1261,10 +1298,14 @@ const adminCert = new gcp.compute.ManagedSslCertificate(
1261
1298
  opts,
1262
1299
  );
1263
1300
 
1301
+ // #1330 — the staging host's own certificate is the CLI's
1302
+ // (staging-gateway-setup.ts, `caelo-<env>-staging-cert`), attached next to
1303
+ // these two after the first `up` and by every converge, so the proxy's
1304
+ // certificate list is left to it after creation.
1264
1305
  const httpsProxy = new gcp.compute.TargetHttpsProxy(
1265
1306
  `${namePrefix}-https-proxy`,
1266
1307
  { urlMap: urlMap.id, sslCertificates: [adminCert.id, managedCert.id] },
1267
- opts,
1308
+ { ...opts, ignoreChanges: ["sslCertificates"] },
1268
1309
  );
1269
1310
 
1270
1311
  new gcp.compute.GlobalForwardingRule(
@@ -33,12 +33,14 @@ import * as random from "@pulumi/random";
33
33
  import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
34
34
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
35
35
  import {
36
+ firebaseStagingSiteId,
36
37
  gatewayServiceAccountId,
37
38
  gcpBucketName,
38
39
  gcpSecretId,
39
40
  MCP_IAP_SERVICE_ACCOUNT_ID,
40
41
  mcpAuthProxyServiceAccountId,
41
42
  runServiceAccountId,
43
+ stagingGatewayServiceAccountId,
42
44
  } from "../../dist/gcp-names.js";
43
45
  import {
44
46
  DEFAULT_ADMIN_MAX_INSTANCES,
@@ -65,7 +67,6 @@ import {
65
67
  defaultStagingHost,
66
68
  fixedCloudRunServiceName,
67
69
  gatewayEnvContract,
68
- INTERIM_FORWARD_ORIGIN_ENV,
69
70
  MCP_AUTH_PROXY_TIMEOUT_SECONDS,
70
71
  mcpAuthProxyEnvContract,
71
72
  REMOTE_MCP_IDENTITY_CONFIG,
@@ -482,9 +483,24 @@ const mcpAuthProxySa = new gcp.serviceaccount.Account(
482
483
  },
483
484
  opts,
484
485
  );
486
+ // #1330 — the staging gateway's own SA and (below) its secretAccessor on
487
+ // its one login password, plus the telemetry roles. Its service, its read
488
+ // access to the staging bucket and its edge (gcp: NEG, backend, host rule,
489
+ // certificate; gcp-firebase: the second Hosting site) are the CLI's
490
+ // (staging-gateway-setup.ts, after the first `up` and in every converge).
491
+ const stagingGatewaySa = new gcp.serviceaccount.Account(
492
+ `${namePrefix}-staging-gateway-sa`,
493
+ {
494
+ accountId: stagingGatewayServiceAccountId(env),
495
+ displayName: `Caelo ${env} staging gateway`,
496
+ createIgnoreAlreadyExists: true,
497
+ },
498
+ opts,
499
+ );
485
500
  const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
486
501
  admin: runSa,
487
502
  gateway: gatewaySa,
503
+ "staging-gateway": stagingGatewaySa,
488
504
  "mcp-auth-proxy": mcpAuthProxySa,
489
505
  };
490
506
 
@@ -494,6 +510,7 @@ const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
494
510
  const secretBindings: Record<CloudRunSlug, gcp.secretmanager.SecretIamMember[]> = {
495
511
  admin: [],
496
512
  gateway: [],
513
+ "staging-gateway": [],
497
514
  "mcp-auth-proxy": [],
498
515
  };
499
516
  for (const binding of runtimeSecretBindings()) {
@@ -593,6 +610,24 @@ new gcp.projects.IAMMember(
593
610
  },
594
611
  opts,
595
612
  );
613
+ new gcp.projects.IAMMember(
614
+ `${namePrefix}-staging-gateway-log-writer`,
615
+ {
616
+ project,
617
+ role: "roles/logging.logWriter",
618
+ member: pulumi.interpolate`serviceAccount:${stagingGatewaySa.email}`,
619
+ },
620
+ opts,
621
+ );
622
+ new gcp.projects.IAMMember(
623
+ `${namePrefix}-staging-gateway-metric-writer`,
624
+ {
625
+ project,
626
+ role: "roles/monitoring.metricWriter",
627
+ member: pulumi.interpolate`serviceAccount:${stagingGatewaySa.email}`,
628
+ },
629
+ opts,
630
+ );
596
631
 
597
632
  // =========================================================================
598
633
  // Tier 1 — Firebase Hosting site
@@ -805,12 +840,10 @@ const gatewaySvc = cloudRunService({
805
840
  memory: "512Mi",
806
841
  // Firebase Hosting cuts a rewritten request at 60 s anyway.
807
842
  timeout: "300s",
808
- // #650 — all traffic: `cms-provision staging-domain enable` maps the
809
- // staging host onto the data gateway (Cloud Run refuses domain-mapped
810
- // requests under an internal-only ingress), and it is invokable by
811
- // allUsers anyway (data-gateway-public-invoker below): every /api request
812
- // passes its rate limits, captcha and honeypot; staging requests pass the
813
- // staging access gate.
843
+ // All traffic: the Firebase rewrite arrives from Google's edge, and it is
844
+ // invokable by allUsers anyway (data-gateway-public-invoker below): every
845
+ // /api request passes its rate limits, captcha and honeypot. Staging has a
846
+ // gateway of its own since #1330 (staging-gateway-setup.ts).
814
847
  ingress: "INGRESS_TRAFFIC_ALL",
815
848
  contractEnv: [...gatewayEnvContract(envContractInputs), ...poolBudget.env.gateway],
816
849
  });
@@ -832,15 +865,11 @@ const mcpAuthProxySvc = cloudRunService({
832
865
  // MCP clients call its run.app URL, never the Firebase rewrite (which
833
866
  // buffers streams and cuts at 60 s).
834
867
  timeout: `${MCP_AUTH_PROXY_TIMEOUT_SECONDS}s`,
835
- // Public: MCP clients reach it directly; until #1331 the staging host's
836
- // domain mapping (#650) still points at this service too, and the proxy
837
- // forwards that traffic to the data gateway (INTERIM_FORWARD_ORIGIN_ENV).
868
+ // Public: MCP clients reach it directly.
838
869
  ingress: "INGRESS_TRAFFIC_ALL",
839
870
  contractEnv: [
840
871
  ...mcpAuthProxyEnvContract({ provider: "gcp-firebase", env }),
841
872
  ...remoteMcpEnv["mcp-auth-proxy"],
842
- // FIXME(issue #1331): goes with the staging cutover.
843
- { name: INTERIM_FORWARD_ORIGIN_ENV, value: gatewaySvc.uri },
844
873
  ],
845
874
  });
846
875
 
@@ -861,6 +890,9 @@ const adminSvc = cloudRunService({
861
890
  ...adminEnvContract({
862
891
  ...envContractInputs,
863
892
  firebaseSiteId,
893
+ firebaseStagingSiteId: pulumi
894
+ .output(firebaseSiteId)
895
+ .apply((id) => firebaseStagingSiteId(env, id)),
864
896
  gatewayService: gatewaySvc.name,
865
897
  }),
866
898
  ...remoteMcpEnv.admin,
@@ -30,6 +30,7 @@ import {
30
30
  COMPOSE_ADMIN_UPSTREAM,
31
31
  COMPOSE_GATEWAY_UPSTREAM,
32
32
  COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
33
+ COMPOSE_STAGING_GATEWAY_UPSTREAM,
33
34
  composeProjectName,
34
35
  generateDockerCompose,
35
36
  SITE_OUTPUT_ROOT,
@@ -89,6 +90,9 @@ const publicRolePassword = pulumi.secret(randomHex(32));
89
90
  const publicStagingRolePassword = pulumi.secret(randomHex(32));
90
91
  // P18 — the KEK that encrypts stored provider keys.
91
92
  const caeloSecretKek = pulumi.secret(randomHex(32));
93
+ // #650 / #1330 — the staging gate's key (admin only; the admin publishes it
94
+ // to the staging gateway through the staging projection).
95
+ const stagingGateSecret = pulumi.secret(randomHex(32));
92
96
 
93
97
  const composeYaml = pulumi
94
98
  .all([
@@ -97,10 +101,13 @@ const composeYaml = pulumi
97
101
  publicRolePassword,
98
102
  publicStagingRolePassword,
99
103
  caeloSecretKek,
104
+ stagingGateSecret,
100
105
  ])
101
- .apply(([pgPw, adminPw, publicPw, publicStagingPw, kek]) =>
106
+ .apply(([pgPw, adminPw, publicPw, publicStagingPw, kek, gateSecret]) =>
102
107
  generateDockerCompose({
103
108
  domain,
109
+ stagingHost,
110
+ stagingGateSecret: gateSecret,
104
111
  postgresPassword: pgPw,
105
112
  rolePasswords: { admin: adminPw, public: publicPw, publicStaging: publicStagingPw },
106
113
  caeloSecretKek: kek,
@@ -126,7 +133,7 @@ const caddyConf = generateCaddyfile({
126
133
  ownerEmail,
127
134
  tls: "acme",
128
135
  publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
129
- stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
136
+ stagingGatewayUpstream: COMPOSE_STAGING_GATEWAY_UPSTREAM,
130
137
  adminUpstream: COMPOSE_ADMIN_UPSTREAM,
131
138
  gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
132
139
  mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,