@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
package/src/compose.ts CHANGED
@@ -45,6 +45,8 @@ import {
45
45
  publicSiteUrl,
46
46
  REMOTE_MCP_MODE,
47
47
  REMOTE_MCP_MODE_ENV,
48
+ STAGING_GATEWAY_TRUSTED_PROXY_HOPS,
49
+ STAGING_STORAGE_ENV,
48
50
  TRUSTED_PROXY_HOPS_ENV,
49
51
  } from "./stack-contract.js";
50
52
  import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
@@ -120,6 +122,18 @@ export const MEDIA_ROOT = "/data/media";
120
122
  /** The gateway's HTTP port inside the Compose network. */
121
123
  export const COMPOSE_GATEWAY_PORT = 8090;
122
124
 
125
+ /** #1330 — the staging gateway's HTTP port inside the Compose network. */
126
+ export const COMPOSE_STAGING_GATEWAY_PORT = 8091;
127
+
128
+ /** #1330 — the Compose service name of the staging gateway. */
129
+ export const COMPOSE_STAGING_GATEWAY_SERVICE = "caelo-staging-gateway";
130
+
131
+ /**
132
+ * #1330 — where the staging gateway sees the staged builds: the admin's
133
+ * `output/staging` (host `.caelo/output/output/staging`), mounted read-only.
134
+ */
135
+ export const STAGING_GATEWAY_STORAGE_DIR = "/srv/staging";
136
+
123
137
  /** #1328 — the MCP-Auth-Proxy's HTTP port inside the Compose network. */
124
138
  export const COMPOSE_MCP_AUTH_PROXY_PORT = 8095;
125
139
 
@@ -130,12 +144,17 @@ export const COMPOSE_MCP_AUTH_PROXY_SERVICE = "caelo-mcp-auth-proxy";
130
144
  * The Compose services that touch the databases — what `backup` and
131
145
  * `restore` stop while they read or rewrite them. Workers run inside the
132
146
  * admin (CLAUDE.md §11.B Tier 5); there is no orchestrator or runner
133
- * service. The MCP-Auth-Proxy (#1328) is deliberately not here: it holds no
147
+ * service. The staging gateway (#1330) holds the staging database's login.
148
+ * The MCP-Auth-Proxy (#1328) is deliberately not here: it holds no
134
149
  * database login, so it keeps running, and a release from before it has no
135
150
  * such service for `up` to start. Its health is checked wherever the
136
151
  * install starts (self-hosted-start.ts) or rolls (self-hosted-upgrade.ts).
137
152
  */
138
- export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
153
+ export const COMPOSE_APP_SERVICES = [
154
+ "caelo-admin",
155
+ "caelo-gateway",
156
+ COMPOSE_STAGING_GATEWAY_SERVICE,
157
+ ] as const;
139
158
 
140
159
  /**
141
160
  * Where Caddy reaches the admin and the gateway: their Compose service
@@ -144,6 +163,7 @@ export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
144
163
  */
145
164
  export const COMPOSE_ADMIN_UPSTREAM = `caelo-admin:${COMPOSE_ADMIN_PORT}`;
146
165
  export const COMPOSE_GATEWAY_UPSTREAM = `caelo-gateway:${COMPOSE_GATEWAY_PORT}`;
166
+ export const COMPOSE_STAGING_GATEWAY_UPSTREAM = `${COMPOSE_STAGING_GATEWAY_SERVICE}:${COMPOSE_STAGING_GATEWAY_PORT}`;
147
167
  export const COMPOSE_MCP_AUTH_PROXY_UPSTREAM = `${COMPOSE_MCP_AUTH_PROXY_SERVICE}:${COMPOSE_MCP_AUTH_PROXY_PORT}`;
148
168
 
149
169
  /**
@@ -226,6 +246,15 @@ export interface RolePasswords {
226
246
 
227
247
  export interface ComposeSpec {
228
248
  readonly domain: string;
249
+ /** #650 — the staging site's host; seeds the admin's staging host setting. */
250
+ readonly stagingHost: string;
251
+ /**
252
+ * #650 / #1330 — the staging gate's key (`.caelo/config.json`
253
+ * `stagingGateSecret`, generated once): signs "Open staging" links and
254
+ * derives the crawler credentials. Admin only; the admin publishes it to
255
+ * the staging gateway through the staging projection.
256
+ */
257
+ readonly stagingGateSecret: string;
229
258
  /** The Postgres superuser's; only the database container uses it. */
230
259
  readonly postgresPassword: string; // generated by cms-provision; persisted in .caelo/secrets
231
260
  readonly rolePasswords: RolePasswords;
@@ -378,6 +407,8 @@ ${postgresAgent(spec.agent)} volumes:
378
407
  ${adminService(spec, adminMemoryMiB, env)}
379
408
 
380
409
  ${gatewayService(spec)}
410
+
411
+ ${stagingGatewayService(spec)}
381
412
  ${mcpAuthProxyService(spec.images)}
382
413
  ${schedulerService(spec)}
383
414
  volumes:
@@ -475,6 +506,12 @@ ${serviceRuntime(
475
506
  PUBLIC_STAGING_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public_staging")}"
476
507
  CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
477
508
  CAELO_INTERNAL_SECRET: "${escapeYaml(spec.internalSecret)}"
509
+ # #650 / #1330 — the staging gate's key and the staging host the admin seeds.
510
+ CAELO_STAGING_GATE_SECRET: "${escapeYaml(spec.stagingGateSecret)}"
511
+ CAELO_STAGING_HOST: "${escapeYaml(spec.stagingHost)}"
512
+ # #1330 — the staging gateway on the Compose network: the Stage's
513
+ # serve check asks it whether it sees the build just written.
514
+ CAELO_STAGING_GATEWAY_URL: "http://${COMPOSE_STAGING_GATEWAY_UPSTREAM}"
478
515
  CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
479
516
  PORT: "${COMPOSE_ADMIN_PORT}"
480
517
  CAELO_OUTPUT_ROOT: "${SITE_OUTPUT_ROOT}"
@@ -505,6 +542,34 @@ ${poolEnvLines("gateway")}
505
542
  NODE_ENV: production`;
506
543
  }
507
544
 
545
+ /**
546
+ * #1330 — the staging gateway: the gateway's image with
547
+ * `CAELO_GATEWAY_ENVIRONMENT=staging`. It serves the staged builds, which it
548
+ * reads from the admin's `output/staging` mounted read-only (no other part
549
+ * of the output, nothing writable), behind the staging gate, and staging's
550
+ * own data: `public_staging_role` on `cms_public_staging` is its only
551
+ * login. Caddy sends every request for the staging host here.
552
+ */
553
+ function stagingGatewayService(spec: ComposeSpec): string {
554
+ return ` ${COMPOSE_STAGING_GATEWAY_SERVICE}:
555
+ ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts", [
556
+ DATABASE_CA_MOUNT,
557
+ `./output/output/staging:${STAGING_GATEWAY_STORAGE_DIR}:ro`,
558
+ ])}
559
+ restart: unless-stopped
560
+ depends_on:
561
+ postgres: { condition: service_healthy }
562
+ environment:
563
+ PUBLIC_DATABASE_URL: "${dbUrl("public_staging_role", spec.rolePasswords.publicStaging, "cms_public_staging")}"
564
+ ${GATEWAY_ENVIRONMENT_ENV}: "staging"
565
+ GATEWAY_PORT: "${COMPOSE_STAGING_GATEWAY_PORT}"
566
+ ${STAGING_STORAGE_ENV}: "file://${STAGING_GATEWAY_STORAGE_DIR}"
567
+ # Caddy is the one proxy in front (client-ip.ts in the gateway).
568
+ ${TRUSTED_PROXY_HOPS_ENV}: "${STAGING_GATEWAY_TRUSTED_PROXY_HOPS["self-hosted"]}"
569
+ ${poolEnvLines("gateway")}
570
+ NODE_ENV: production`;
571
+ }
572
+
508
573
  /**
509
574
  * #1328 — the MCP-Auth-Proxy: remote MCP's sign-in broker, its own
510
575
  * container so it never shares a process with visitor handlers. Remote MCP
@@ -49,6 +49,7 @@ export const ConvergeCheckpointSchema = InstallJobStatusSchema.extend({
49
49
  admin: z.string().min(1),
50
50
  gateway: z.string().min(1),
51
51
  // #1328 — absent in a checkpoint an older release wrote.
52
+ "staging-gateway": z.string().min(1).optional(),
52
53
  "mcp-auth-proxy": z.string().min(1).optional(),
53
54
  })
54
55
  .optional(),
@@ -32,6 +32,8 @@ describe("database URL builders (#1338)", () => {
32
32
  caeloSecretKek: "0".repeat(64),
33
33
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
34
34
  internalSecret: "i".repeat(48),
35
+ stagingHost: "staging.example.com",
36
+ stagingGateSecret: "g".repeat(64),
35
37
  diskSize: "20Gi",
36
38
  images: {
37
39
  mode: "release",
@@ -43,7 +45,7 @@ describe("database URL builders (#1338)", () => {
43
45
  });
44
46
  const urls = [...yaml.matchAll(/_DATABASE_URL: "([^"]+)"/g)].map((m) => m[1] as string);
45
47
  // admin: cms_admin, cms_public, cms_public_staging (#1329); gateway: cms_public.
46
- expect(urls).toHaveLength(4);
48
+ expect(urls).toHaveLength(5);
47
49
  for (const url of urls) expect(databaseTlsProblem(url)).toBeNull();
48
50
  });
49
51
 
@@ -111,6 +111,27 @@ describe("readDeployedImageDigests", () => {
111
111
  if (!r.ok) expect(r.error).toContain("not pinned to a digest");
112
112
  });
113
113
 
114
+ it("#1330 — accepts a staging gateway on the gateway's image, refuses one on another", async () => {
115
+ const answers = (staging: string) => ({
116
+ ...firebaseLists(),
117
+ "metadata.name~^caelo-production-staging-gateway": [ok("caelo-production-staging-gateway\n")],
118
+ "describe caelo-production-admin-abc": [ok(`${REG}/admin@${A}`)],
119
+ "describe caelo-production-data-gateway": [ok(`${REG}/gateway@${B}`)],
120
+ "describe caelo-production-gateway-def": [ok(`${REG}/mcp-auth-proxy@${C}`)],
121
+ "describe caelo-production-staging-gateway": [ok(`${REG}/gateway@${staging}`)],
122
+ });
123
+ const same = fakeGcloud(answers(B));
124
+ expect(await readDeployedImageDigests({ ...FIREBASE, run: same.run })).toEqual({
125
+ ok: true,
126
+ digests: { admin: A, gateway: B, "mcp-auth-proxy": C },
127
+ });
128
+ expect(same.calls.some((c) => c.includes("caelo-production-staging-gateway"))).toBe(true);
129
+ const other = fakeGcloud(answers(A));
130
+ const r = await readDeployedImageDigests({ ...FIREBASE, run: other.run });
131
+ expect(r.ok).toBe(false);
132
+ if (!r.ok) expect(r.error).toContain("the staging gateway runs");
133
+ });
134
+
114
135
  it("surfaces a gcloud failure", async () => {
115
136
  const { run } = fakeGcloud({ "services list": [fail("PERMISSION_DENIED")] });
116
137
  const r = await readDeployedImageDigests({ ...FIREBASE, run });
@@ -21,7 +21,9 @@ import {
21
21
  cloudRunServicePrefix,
22
22
  type GcpProvider,
23
23
  RETIRED_SERVICE_ENV,
24
+ SERVICE_IMAGE_NAMES,
24
25
  SERVICE_SECRET_ENV,
26
+ type ServiceImageName,
25
27
  } from "./stack-contract.js";
26
28
  import { liveContainerEnv } from "./stack-converge.js";
27
29
 
@@ -35,7 +37,7 @@ async function findDeployedService(
35
37
  run: GcloudRunner,
36
38
  opts: { provider: GcpProvider; projectId: string; region: string },
37
39
  slug: CloudRunSlug,
38
- ): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
40
+ ): Promise<{ ok: true; name: string } | { ok: false; error: string; missing: boolean }> {
39
41
  const prefix = cloudRunServicePrefix(opts.provider, GCP_STACK_ENV, slug);
40
42
  const list = await run([
41
43
  "run",
@@ -46,7 +48,8 @@ async function findDeployedService(
46
48
  `--filter=metadata.name~^${prefix}`,
47
49
  "--format=value(metadata.name)",
48
50
  ]);
49
- if (!list.ok) return { ok: false, error: `list Cloud Run services: ${list.stderr.trim()}` };
51
+ if (!list.ok)
52
+ return { ok: false, error: `list Cloud Run services: ${list.stderr.trim()}`, missing: false };
50
53
  const names = list.stdout
51
54
  .split("\n")
52
55
  .map((s) => s.trim())
@@ -58,6 +61,7 @@ async function findDeployedService(
58
61
  names.length === 0
59
62
  ? `no ${prefix}* Cloud Run service: the install runs a release from before it (the ${slug} became its own service in #1328). Run \`upgrade\` first: it creates the service and moves the release over.`
60
63
  : `expected one ${prefix}* Cloud Run service, found ${names.join(", ")}`,
64
+ missing: names.length === 0,
61
65
  };
62
66
  }
63
67
  return { ok: true, name: names[0] as string };
@@ -86,7 +90,11 @@ export async function checkDeployedRuntimeEnv(opts: {
86
90
  const run = opts.run ?? defaultGcloud;
87
91
  for (const slug of CLOUD_RUN_SLUGS) {
88
92
  const found = await findDeployedService(run, opts, slug);
89
- if (!found.ok) return found;
93
+ // #1330 — the staging gateway is the CLI's (staging-gateway-setup.ts):
94
+ // an install from before it gets it after this `up`, so a missing one
95
+ // runs no release that could mismatch the env.
96
+ if (!found.ok && slug === "staging-gateway" && found.missing) continue;
97
+ if (!found.ok) return { ok: false, error: found.error };
90
98
  const describe = await run([
91
99
  "run",
92
100
  "services",
@@ -131,8 +139,14 @@ export async function readDeployedImageDigests(opts: {
131
139
  run?: GcloudRunner;
132
140
  }): Promise<{ ok: true; digests: ImageDigests } | { ok: false; error: string }> {
133
141
  const run = opts.run ?? defaultGcloud;
134
- const digests: Record<CloudRunSlug, string> = { admin: "", gateway: "", "mcp-auth-proxy": "" };
135
- for (const slug of CLOUD_RUN_SLUGS) {
142
+ const digests: Record<ServiceImageName, string> = {
143
+ admin: "",
144
+ gateway: "",
145
+ "mcp-auth-proxy": "",
146
+ };
147
+ const digestOf = async (
148
+ slug: CloudRunSlug,
149
+ ): Promise<{ ok: true; digest: string } | { ok: false; error: string; missing: boolean }> => {
136
150
  const found = await findDeployedService(run, opts, slug);
137
151
  if (!found.ok) return found;
138
152
  const image = await run([
@@ -144,15 +158,36 @@ export async function readDeployedImageDigests(opts: {
144
158
  `--project=${opts.projectId}`,
145
159
  "--format=value(spec.template.spec.containers[0].image)",
146
160
  ]);
147
- if (!image.ok) return { ok: false, error: `describe ${found.name}: ${image.stderr.trim()}` };
161
+ if (!image.ok) {
162
+ return { ok: false, error: `describe ${found.name}: ${image.stderr.trim()}`, missing: false };
163
+ }
148
164
  const digest = digestFromImageRef(image.stdout);
149
165
  if (!digest) {
150
166
  return {
151
167
  ok: false,
152
168
  error: `${found.name} runs '${image.stdout.trim()}', which is not pinned to a digest`,
169
+ missing: false,
153
170
  };
154
171
  }
155
- digests[slug] = digest;
172
+ return { ok: true, digest };
173
+ };
174
+ // One digest per release image.
175
+ for (const slug of SERVICE_IMAGE_NAMES) {
176
+ const d = await digestOf(slug);
177
+ if (!d.ok) return { ok: false, error: d.error };
178
+ digests[slug] = d.digest;
179
+ }
180
+ // #1330 — the staging gateway runs the gateway's image and rolls with it:
181
+ // one that runs another image means the services serve two releases, and
182
+ // the wizard must not record (or deploy) either as "the" release. An
183
+ // install from before it has none yet (the CLI creates it after `up`).
184
+ const staging = await digestOf("staging-gateway");
185
+ if (!staging.ok && !staging.missing) return { ok: false, error: staging.error };
186
+ if (staging.ok && staging.digest !== digests.gateway) {
187
+ return {
188
+ ok: false,
189
+ error: `the staging gateway runs ${staging.digest}, but the gateway runs ${digests.gateway}: the two must run the same release image. Run \`upgrade\` first — it rolls both onto one release — then re-run the installer.`,
190
+ };
156
191
  }
157
192
  return { ok: true, digests };
158
193
  }
@@ -162,6 +162,8 @@ describe("self-hosted: the generated compose gateway service (#635)", () => {
162
162
  caeloSecretKek: "0".repeat(64),
163
163
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
164
164
  internalSecret: "i".repeat(48),
165
+ stagingHost: "staging.example.com",
166
+ stagingGateSecret: "g".repeat(64),
165
167
  diskSize: "20Gi",
166
168
  images: {
167
169
  mode: "release" as const,
package/src/gcp-names.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * the CLI's dependencies.
16
16
  */
17
17
 
18
+ import { createHash } from "node:crypto";
19
+
18
20
  /** The Pulumi stack name the wizard provisions and `upgrade` operates on. */
19
21
  export const GCP_STACK_ENV = "production";
20
22
 
@@ -87,6 +89,57 @@ export function mcpAuthProxyServiceAccountEmail(projectId: string, env: string):
87
89
  return `${mcpAuthProxyServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
88
90
  }
89
91
 
92
+ /**
93
+ * #1330 — account id of the service account the staging gateway runs as. It
94
+ * may read the staging database's login password and the staging bucket,
95
+ * and nothing else: no live data, no media, no gate key (the gate key
96
+ * reaches it through the staging projection). Shortened: account ids max
97
+ * out at 30 characters (`caelo-production-stg-gateway-sa` would be 31).
98
+ */
99
+ export function stagingGatewayServiceAccountId(env: string): string {
100
+ return `${gcpNamePrefix(env)}-stg-gw-sa`;
101
+ }
102
+
103
+ /** Email of the service account the staging gateway runs as. */
104
+ export function stagingGatewayServiceAccountEmail(projectId: string, env: string): string {
105
+ return `${stagingGatewayServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
106
+ }
107
+
108
+ /**
109
+ * #1330 — the `gcp` staging gateway's load-balancer resources, CLI-owned
110
+ * (staging-gateway-setup.ts): its serverless NEG, its backend service and
111
+ * the managed certificate for the staging host. None matches a prefix the
112
+ * other lookups use (`…-waf`, `…-admin-backend`, `…-gateway-backend`,
113
+ * `…-mcp-auth-proxy-*`).
114
+ */
115
+ export function stagingGatewayEdgeNames(env: string): {
116
+ readonly neg: string;
117
+ readonly backend: string;
118
+ readonly cert: string;
119
+ readonly pathMatcher: string;
120
+ } {
121
+ const prefix = `${gcpNamePrefix(env)}-staging-gateway`;
122
+ return {
123
+ neg: `${prefix}-neg`,
124
+ backend: `${prefix}-backend`,
125
+ cert: `${gcpNamePrefix(env)}-staging-cert`,
126
+ pathMatcher: "staging",
127
+ };
128
+ }
129
+
130
+ /**
131
+ * #1330 — the second Firebase Hosting site of a `gcp-firebase` install,
132
+ * which serves the staging host through a `**` rewrite to the staging
133
+ * gateway (E13 a of #763). Derived from the live site's id (Hosting site ids
134
+ * are global, and the live one carries a random suffix): `caelo-<env>-stg-`
135
+ * plus six hex characters of its SHA-256, so the admin and the converge,
136
+ * which both know the live site id (`CAELO_FIREBASE_SITE`), name the same
137
+ * site without storing it anywhere.
138
+ */
139
+ export function firebaseStagingSiteId(env: string, liveSiteId: string): string {
140
+ return `${gcpNamePrefix(env)}-stg-${createHash("sha256").update(liveSiteId).digest("hex").slice(0, 6)}`;
141
+ }
142
+
90
143
  /**
91
144
  * Account id of the `gcp` stack's static-publisher service account. Account
92
145
  * ids max out at 30 chars, so the env is shortened.
@@ -46,6 +46,7 @@ describe("gcp stack URL map (Pulumi mocks)", () => {
46
46
  {
47
47
  domain: "example.com",
48
48
  adminDomain: "admin.example.com",
49
+ stagingHost: "staging.example.com",
49
50
  staticBackend: "bucket-id",
50
51
  gatewayBackend: "gateway-id",
51
52
  adminBackend: "admin-id",
@@ -61,6 +62,8 @@ describe("gcp stack URL map (Pulumi mocks)", () => {
61
62
  expect(hostRules).toEqual([
62
63
  { hosts: ["example.com"], pathMatcher: PUBLIC_PATH_MATCHER },
63
64
  { hosts: ["admin.example.com"], pathMatcher: "admin" },
65
+ // #1330 — the staging host, its matcher moved to the staging gateway by the CLI.
66
+ { hosts: ["staging.example.com"], pathMatcher: "staging" },
64
67
  ]);
65
68
  expect(pathMatchers).toEqual([
66
69
  {
@@ -70,7 +73,20 @@ describe("gcp stack URL map (Pulumi mocks)", () => {
70
73
  pathRules: [{ paths: [...GCP_GATEWAY_PATHS], service: "gateway-id" }],
71
74
  },
72
75
  { name: "admin", defaultService: "admin-id" },
76
+ // #1330 — fails closed until the CLI routes it to the staging gateway:
77
+ // a redirect, never a backend (above all not the static bucket).
78
+ {
79
+ name: "staging",
80
+ defaultUrlRedirect: {
81
+ hostRedirect: "example.com",
82
+ pathRedirect: "/",
83
+ httpsRedirect: true,
84
+ redirectResponseCode: "FOUND",
85
+ stripQuery: true,
86
+ },
87
+ },
73
88
  ]);
89
+ expect(JSON.stringify(pathMatchers?.[2])).not.toContain("bucket-id");
74
90
  expect(registered.find((r) => r.name === "caelo-production-url-map")?.type).toBe(
75
91
  "gcp:compute/uRLMap:URLMap",
76
92
  );
@@ -115,16 +131,24 @@ describe("gcp stack URL map (Pulumi mocks)", () => {
115
131
  const { SITE_URL_MAP_IGNORE_CHANGES, siteUrlMapArgs, PUBLIC_PATH_MATCHER } = await import(
116
132
  "./gcp-url-map.js"
117
133
  );
118
- expect(SITE_URL_MAP_IGNORE_CHANGES).toEqual(["pathMatchers[0].pathRules"]);
134
+ expect(SITE_URL_MAP_IGNORE_CHANGES).toEqual([
135
+ "pathMatchers[0].pathRules",
136
+ // #1330 — the staging matcher's target is the CLI's.
137
+ "pathMatchers[2].defaultService",
138
+ "pathMatchers[2].defaultUrlRedirect",
139
+ ]);
119
140
  const args = siteUrlMapArgs({
120
141
  domain: "d",
121
142
  adminDomain: "a.d",
143
+ stagingHost: "s.d",
122
144
  staticBackend: "s",
123
145
  gatewayBackend: "g",
124
146
  adminBackend: "a",
125
147
  });
126
- // Index 0 must stay the public matcher, or the ignore would hit the admin's.
148
+ // Index 0 must stay the public matcher, or the ignore would hit the admin's;
149
+ // index 2 the staging one (the CLI writes it last, staging-gateway-setup.ts).
127
150
  expect((args.pathMatchers as { name: string }[])[0]?.name).toBe(PUBLIC_PATH_MATCHER);
151
+ expect((args.pathMatchers as { name: string }[])[2]?.name).toBe("staging");
128
152
  });
129
153
  });
130
154
 
@@ -26,16 +26,28 @@ import type * as pulumi from "@pulumi/pulumi";
26
26
  /** Name of the site host's path matcher. */
27
27
  export const PUBLIC_PATH_MATCHER = "public";
28
28
 
29
+ /** #1330 — name of the staging host's path matcher (gcp-names.ts stagingGatewayEdgeNames). */
30
+ export const STAGING_PATH_MATCHER = "staging";
31
+
29
32
  /**
30
33
  * The URL map's `ignoreChanges`: the public path matcher (index 0, see
31
- * {@link siteUrlMapArgs}) holds the admin-managed redirect rules.
34
+ * {@link siteUrlMapArgs}) holds the admin-managed redirect rules, and the
35
+ * staging matcher's (index 2) target is the CLI's: the stack creates it as a
36
+ * redirect, and staging-gateway-setup.ts ensureStagingGatewayEdge (#1330)
37
+ * replaces that with the staging gateway's backend once it exists.
32
38
  */
33
- export const SITE_URL_MAP_IGNORE_CHANGES: readonly string[] = ["pathMatchers[0].pathRules"];
39
+ export const SITE_URL_MAP_IGNORE_CHANGES: readonly string[] = [
40
+ "pathMatchers[0].pathRules",
41
+ "pathMatchers[2].defaultService",
42
+ "pathMatchers[2].defaultUrlRedirect",
43
+ ];
34
44
 
35
45
  /** Inputs of {@link siteUrlMapArgs}. */
36
46
  export interface SiteUrlMapInputs {
37
47
  readonly domain: string;
38
48
  readonly adminDomain: string;
49
+ /** #1330 — the protected staging site's host. */
50
+ readonly stagingHost: string;
39
51
  readonly staticBackend: pulumi.Input<string>;
40
52
  readonly gatewayBackend: pulumi.Input<string>;
41
53
  readonly adminBackend: pulumi.Input<string>;
@@ -43,9 +55,17 @@ export interface SiteUrlMapInputs {
43
55
 
44
56
  /**
45
57
  * The URL map's arguments: the site host to the static bucket with `/api/*`
46
- * to the gateway; the admin host to the IAP-gated admin. The public path
47
- * matcher comes first — {@link SITE_URL_MAP_IGNORE_CHANGES} addresses it by
48
- * index.
58
+ * to the gateway; the admin host to the IAP-gated admin; (#1330) the staging
59
+ * host, every path, to the staging gateway. The public path matcher comes
60
+ * first and the staging one third — {@link SITE_URL_MAP_IGNORE_CHANGES}
61
+ * addresses them by index.
62
+ *
63
+ * The staging matcher fails closed until the staging gateway's backend
64
+ * exists (it is the CLI's, created after the first `up`): it serves nothing
65
+ * at all, it redirects every staging request to the live site's home page.
66
+ * Never a backend — above all never the static bucket — so a failed edge
67
+ * step can never show a file on the staging host. The CLI then routes it to
68
+ * the staging gateway, and the stack leaves it there.
49
69
  */
50
70
  export function siteUrlMapArgs(i: SiteUrlMapInputs): gcp.compute.URLMapArgs {
51
71
  return {
@@ -53,6 +73,7 @@ export function siteUrlMapArgs(i: SiteUrlMapInputs): gcp.compute.URLMapArgs {
53
73
  hostRules: [
54
74
  { hosts: [i.domain], pathMatcher: PUBLIC_PATH_MATCHER },
55
75
  { hosts: [i.adminDomain], pathMatcher: "admin" },
76
+ { hosts: [i.stagingHost], pathMatcher: STAGING_PATH_MATCHER },
56
77
  ],
57
78
  pathMatchers: [
58
79
  {
@@ -68,6 +89,16 @@ export function siteUrlMapArgs(i: SiteUrlMapInputs): gcp.compute.URLMapArgs {
68
89
  name: "admin",
69
90
  defaultService: i.adminBackend,
70
91
  },
92
+ {
93
+ name: STAGING_PATH_MATCHER,
94
+ defaultUrlRedirect: {
95
+ hostRedirect: i.domain,
96
+ pathRedirect: "/",
97
+ httpsRedirect: true,
98
+ redirectResponseCode: "FOUND",
99
+ stripQuery: true,
100
+ },
101
+ },
71
102
  ],
72
103
  };
73
104
  }
package/src/index.test.ts CHANGED
@@ -18,7 +18,7 @@ describe("Caddyfile generator", () => {
18
18
  const out = generateCaddyfile({
19
19
  ownerEmail: "owner@example.com",
20
20
  publicSiteRoot: "/srv/caelo/output/production/current",
21
- stagingSiteRoot: "/srv/caelo/output/staging/current",
21
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
22
22
  tls: "acme",
23
23
  adminUpstream: "caelo-admin:5173",
24
24
  gatewayUpstream: "caelo-gateway:8090",
@@ -46,7 +46,7 @@ describe("Caddyfile generator", () => {
46
46
  const out = generateCaddyfile({
47
47
  ownerEmail: "x@y.z",
48
48
  publicSiteRoot: "/srv/caelo/output/production/current",
49
- stagingSiteRoot: "/srv/caelo/output/staging/current",
49
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
50
50
  tls: "acme",
51
51
  adminUpstream: "caelo-admin:5173",
52
52
  gatewayUpstream: "caelo-gateway:8090",
@@ -62,7 +62,7 @@ describe("Caddy cache policy for content-hashed assets", () => {
62
62
  const spec = {
63
63
  ownerEmail: "owner@example.com",
64
64
  publicSiteRoot: "/srv/caelo/output/production/current",
65
- stagingSiteRoot: "/srv/caelo/output/staging/current",
65
+ stagingGatewayUpstream: "caelo-staging-gateway:8091",
66
66
  tls: "acme",
67
67
  adminUpstream: "caelo-admin:5173",
68
68
  gatewayUpstream: "caelo-gateway:8090",
@@ -156,6 +156,8 @@ describe("docker-compose generator", () => {
156
156
  caeloSecretKek: "0".repeat(64),
157
157
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
158
158
  internalSecret: "i".repeat(48),
159
+ stagingHost: "staging.example.com",
160
+ stagingGateSecret: "g".repeat(64),
159
161
  anthropicApiKey: "sk-ant-…",
160
162
  diskSize: "20Gi",
161
163
  images: RELEASE_IMAGES,
@@ -179,6 +181,8 @@ describe("docker-compose generator", () => {
179
181
  caeloSecretKek: "0".repeat(64),
180
182
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
181
183
  internalSecret: "i".repeat(48),
184
+ stagingHost: "staging.example.com",
185
+ stagingGateSecret: "g".repeat(64),
182
186
  diskSize: "20Gi",
183
187
  images: RELEASE_IMAGES,
184
188
  ports: { http: 80, https: 443 },
@@ -205,6 +209,8 @@ describe("docker-compose generator", () => {
205
209
  caeloSecretKek: "0".repeat(64),
206
210
  dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
207
211
  internalSecret: "i".repeat(48),
212
+ stagingHost: "staging.example.com",
213
+ stagingGateSecret: "g".repeat(64),
208
214
  diskSize: "1Gi",
209
215
  images: RELEASE_IMAGES,
210
216
  ports: { http: 80, https: 443 },
@@ -20,8 +20,8 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 18: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8), converge job (9), remote MCP (10), Google sign-in (11), project-level sign-in client (12), edge security headers (13), capture job (14), database verify-full TLS (15), MCP-Auth-Proxy (16), staging database (17), scheduler tick (18)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(18);
23
+ it("is at 20: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8), converge job (9), remote MCP (10), Google sign-in (11), project-level sign-in client (12), edge security headers (13), capture job (14), database verify-full TLS (15), MCP-Auth-Proxy (16), staging database (17), scheduler tick (18), scheduler tick as JSON (19), staging gateway (20)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(20);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
27
  expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
@@ -38,6 +38,8 @@ describe("infrastructure contract steps", () => {
38
38
  expect(INFRA_CONTRACT_STEPS[16]).toContain("#1328");
39
39
  expect(INFRA_CONTRACT_STEPS[17]).toContain("#1329");
40
40
  expect(INFRA_CONTRACT_STEPS[18]).toContain("#1140");
41
+ expect(INFRA_CONTRACT_STEPS[19]).toContain("JSON");
42
+ expect(INFRA_CONTRACT_STEPS[20]).toContain("#1330");
41
43
  });
42
44
 
43
45
  it("lists the pending steps oldest first", () => {
@@ -48,6 +50,6 @@ describe("infrastructure contract steps", () => {
48
50
  });
49
51
 
50
52
  it("names a version a newer release added by number", () => {
51
- expect(infraStepsText(18, 19)).toBe("infrastructure step 19 (see that release's notes)");
53
+ expect(infraStepsText(20, 21)).toBe("infrastructure step 21 (see that release's notes)");
52
54
  });
53
55
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 18;
25
+ export const INFRA_CONTRACT_VERSION = 20;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -150,6 +150,25 @@ export const INFRA_CONTRACT_VERSION = 18;
150
150
  // on the admin. The new admin runs its periodic work and plugin workers
151
151
  // only on that tick, so an in-cloud upgrader must not roll an install
152
152
  // onto it before the tick exists.
153
+ // 19 — the scheduler workflow's tick is a JSON POST. Without a content type
154
+ // SvelteKit refused every tick of contract 18 with 403, so no periodic
155
+ // work ran. The workflow source is only deployed by a converge.
156
+ // 20 — #1330/#1331: the staging gateway (converge release 2 of the gateway
157
+ // hardening). Its service account (secretAccessor on
158
+ // public-staging-role-password only, log/metric writer), its Cloud Run
159
+ // service `caelo-<env>-staging-gateway` (the gateway's image,
160
+ // CAELO_GATEWAY_ENVIRONMENT=staging, allUsers invoker: the gate is its
161
+ // own), object viewer on the staging bucket — on gcp-firebase a new
162
+ // private bucket, which the admin may write (CAELO_STAGING_BUCKET) —
163
+ // and its edge: on gcp-firebase a second Hosting site with one `**`
164
+ // rewrite and the staging host as its custom domain, the old Cloud Run
165
+ // domain mapping deleted; on gcp a serverless NEG, backend service,
166
+ // managed certificate and URL-map host rule. The live gateway loses
167
+ // CAELO_STAGING_GATE_SECRET (the gate key reaches the staging gateway
168
+ // through the staging projection) and the MCP-Auth-Proxy its interim
169
+ // forwarder. New service, account, bucket, IAM and edge resources the
170
+ // in-cloud upgrader may not create, and the app-roll rolls four
171
+ // services from now on.
153
172
 
154
173
  /**
155
174
  * What each contract version adds, in operator terms — the upgrader's
@@ -175,6 +194,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
175
194
  16: "the MCP sign-in service as its own Cloud Run service with its own account (no database access), the visitor gateway as its own service on Firebase installs, and on Google Cloud load-balancer installs the sign-in service's backend and Cloud Armor rule (#1328)",
176
195
  17: "the staging site's own database and its gateway login, kept apart from the live data (#1329)",
177
196
  18: "the scheduler tick that runs the admin's periodic work and plugin workers every few minutes: its service account, workflow and Cloud Scheduler job, and its IAP access (#1140)",
197
+ 19: "the scheduler tick sent as a JSON request, because the admin refused every tick before (#1140)",
198
+ 20: "the staging gateway that serves the staged site behind the staging gate on every address: its service, service account and grants, the private staging bucket, and the staging host's own route (a second Firebase Hosting site, or a host rule and certificate on the load balancer); the live gateway no longer holds the staging gate key (#1330, #1331)",
178
199
  };
179
200
 
180
201
  /**