@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
@@ -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 19: 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)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(19);
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");
@@ -39,6 +39,7 @@ describe("infrastructure contract steps", () => {
39
39
  expect(INFRA_CONTRACT_STEPS[17]).toContain("#1329");
40
40
  expect(INFRA_CONTRACT_STEPS[18]).toContain("#1140");
41
41
  expect(INFRA_CONTRACT_STEPS[19]).toContain("JSON");
42
+ expect(INFRA_CONTRACT_STEPS[20]).toContain("#1330");
42
43
  });
43
44
 
44
45
  it("lists the pending steps oldest first", () => {
@@ -49,6 +50,6 @@ describe("infrastructure contract steps", () => {
49
50
  });
50
51
 
51
52
  it("names a version a newer release added by number", () => {
52
- expect(infraStepsText(19, 20)).toBe("infrastructure step 20 (see that release's notes)");
53
+ expect(infraStepsText(20, 21)).toBe("infrastructure step 21 (see that release's notes)");
53
54
  });
54
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 = 19;
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):
@@ -153,6 +153,22 @@ export const INFRA_CONTRACT_VERSION = 19;
153
153
  // 19 — the scheduler workflow's tick is a JSON POST. Without a content type
154
154
  // SvelteKit refused every tick of contract 18 with 403, so no periodic
155
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.
156
172
 
157
173
  /**
158
174
  * What each contract version adds, in operator terms — the upgrader's
@@ -179,6 +195,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
179
195
  17: "the staging site's own database and its gateway login, kept apart from the live data (#1329)",
180
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)",
181
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)",
182
199
  };
183
200
 
184
201
  /**
package/src/lifecycle.ts CHANGED
@@ -20,7 +20,6 @@ import { join } from "node:path";
20
20
  import { provisioningUpgradeCommand } from "@caelo-cms/shared";
21
21
  import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
22
22
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
23
- import { stagingDomainState } from "./admin-domain.js";
24
23
  import { activeAccount, checkGcloudAuth, gcloud } from "./gcloud.js";
25
24
  import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
26
25
  import { GCP_STACK_ENV } from "./gcp-names.js";
@@ -70,12 +69,7 @@ import {
70
69
  resolveBackend,
71
70
  } from "./state-backend.js";
72
71
  import { acquireStateLock, realSyncGcloud, releaseOnSignal } from "./state-store.js";
73
- import {
74
- type AppRollPlan,
75
- type ReleaseImage,
76
- ROLL_ORDER,
77
- type RollSlug,
78
- } from "./upgrade/app-roll.js";
72
+ import type { AppRollPlan, RollSlug } from "./upgrade/app-roll.js";
79
73
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
80
74
  import { findServiceUrl, lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
81
75
  import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
@@ -85,12 +79,10 @@ import {
85
79
  canonicalReleaseTag,
86
80
  ensureCosignAvailable,
87
81
  fetchLatestReleaseTag,
88
- RELEASE_REGISTRY,
89
- releaseImageRef,
90
82
  releaseTag,
91
83
  releaseVersionForDigest,
92
84
  resolveOpsImageRef,
93
- resolveTagDigest,
85
+ resolveReleaseImages,
94
86
  verifyCosignAll,
95
87
  } from "./upgrade/release-images.js";
96
88
  import { clackReporter } from "./upgrade/reporter.js";
@@ -464,32 +456,6 @@ export async function upgradeCommand(opts: UpgradeOpts = {}): Promise<void> {
464
456
  );
465
457
  }
466
458
 
467
- /**
468
- * Resolve both release images' digests from the public registry. Via the
469
- * registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
470
- * needs IAM read on Caelo's registry project, which operators don't have.
471
- */
472
- async function resolveReleaseImages(
473
- tag: string,
474
- ): Promise<{ ok: true; images: Record<RollSlug, ReleaseImage> } | { ok: false; error: string }> {
475
- const images: Partial<Record<RollSlug, ReleaseImage>> = {};
476
- for (const slug of ROLL_ORDER) {
477
- const res = await resolveTagDigest(slug, tag);
478
- if (!res.ok) {
479
- const r = RELEASE_REGISTRY;
480
- return {
481
- ok: false,
482
- error:
483
- `Couldn't resolve image digest for ${slug}:${tag}: the public registry returned ${res.reason}.\n` +
484
- `Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}:${tag}\n` +
485
- "(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
486
- };
487
- }
488
- images[slug] = { digest: res.digest, imageRef: releaseImageRef(slug, res.digest) };
489
- }
490
- return { ok: true, images: images as Record<RollSlug, ReleaseImage> };
491
- }
492
-
493
459
  /**
494
460
  * `upgrade`'s step of the keyless provisioner migration (#696,
495
461
  * provisioner-identity.ts) — a converge step (upgrade/converge.ts runs it
@@ -686,22 +652,12 @@ async function upgradeLockedInstall(
686
652
  // #696 — upgrade never moves Pulumi state; it says how to.
687
653
  const stateNotice = localStateNotice(meta);
688
654
  if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
689
- if (ok && provider === "gcp-firebase") {
690
- // #650 — staging builds render the staging host; the gateway serves it
691
- // once it is mapped. Only an install without the mapping is told to run
692
- // `staging-domain enable`.
693
- const staging = await stagingDomainState({ projectId, region, stagingHost, run });
694
- if (staging.status === "mapped") {
695
- log.info(`Protected staging site: https://${stagingHost}/`);
696
- } else {
697
- const unknown =
698
- staging.status === "unknown"
699
- ? ` (could not check whether ${bold(stagingHost)} is mapped: ${staging.reason})`
700
- : "";
701
- log.info(
702
- `Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host${unknown} (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`,
703
- );
704
- }
655
+ if (ok) {
656
+ // #1330 — the converge routed the staging host to the staging gateway
657
+ // and printed its DNS records (Settings → Updates shows them as well).
658
+ log.info(
659
+ `Protected staging site: https://${stagingHost}/ — served by the staging gateway behind the staging gate. Password protection and other access: admin → Settings → Staging access (or ${bold("cms-provision staging-domain enable")}).`,
660
+ );
705
661
  }
706
662
  return ok;
707
663
  }
@@ -44,8 +44,17 @@ const firebase: ReleaseServicesTarget = {
44
44
  region: "europe-west1",
45
45
  env: "production",
46
46
  domain: "acme.com",
47
+ stagingHost: "staging.acme.com",
47
48
  mcpAuthProxyImageRef: `${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`,
49
+ gatewayImageRef: `${IMAGE}/gateway@sha256:${"b".repeat(64)}`,
48
50
  };
51
+
52
+ /**
53
+ * The outcomes of the #1328 part; the staging gateway's (#1330) come after
54
+ * them and are covered by staging-gateway-setup.test.ts.
55
+ */
56
+ const proxyPart = (outcomes: readonly { id: string; status: string }[]) =>
57
+ outcomes.filter((o) => !/staging gateway/.test(o.id)).map((o) => o.status);
49
58
  const gcp: ReleaseServicesTarget = { ...firebase, provider: "gcp" };
50
59
 
51
60
  /** The old gateway service as `gcloud run services describe --format=json` shows it. */
@@ -210,7 +219,7 @@ describe("ensureReleaseServices", () => {
210
219
  };
211
220
  },
212
221
  });
213
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
222
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "present", "applied"]);
214
223
  expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toEqual([
215
224
  `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
216
225
  `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
@@ -237,7 +246,7 @@ describe("ensureReleaseServices", () => {
237
246
  "run services describe caelo-production-data-gateway": ok("caelo-production-data-gateway\n"),
238
247
  });
239
248
  const outcomes = await ensureReleaseServices(firebase, { run });
240
- expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
249
+ expect(proxyPart(outcomes)).toEqual(["present", "present", "present", "present"]);
241
250
  expect(calls.some((c) => c.includes("replace") || c.includes("create"))).toBe(false);
242
251
  });
243
252
 
@@ -267,7 +276,7 @@ describe("ensureReleaseServices", () => {
267
276
  "run deploy caelo-production-mcp-auth-proxy": ok(),
268
277
  });
269
278
  const outcomes = await ensureReleaseServices(gcp, { run });
270
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
279
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "present", "applied"]);
271
280
  expect(calls).toContain(gcpProxyDeployArgs(gcp).join(" "));
272
281
  });
273
282
 
@@ -280,7 +289,7 @@ describe("ensureReleaseServices", () => {
280
289
  ),
281
290
  });
282
291
  const outcomes = await ensureReleaseServices(gcp, { run });
283
- expect(outcomes.at(-1)?.status).toBe("present");
292
+ expect(proxyPart(outcomes).at(-1)).toBe("present");
284
293
  expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
285
294
  });
286
295
 
@@ -292,7 +301,7 @@ describe("ensureReleaseServices", () => {
292
301
  ),
293
302
  });
294
303
  const outcomes = await ensureReleaseServices(gcp, { run });
295
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "failed"]);
304
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "failed"]);
296
305
  expect(calls.some((c) => c.startsWith("run "))).toBe(false);
297
306
  });
298
307
 
@@ -320,7 +329,7 @@ describe("ensureReleaseServices", () => {
320
329
  pauses.push(ms);
321
330
  },
322
331
  });
323
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
332
+ expect(proxyPart(outcomes)).toEqual(["applied", "present", "present", "present"]);
324
333
  expect(pauses.length).toBe(2);
325
334
  expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toHaveLength(2);
326
335
  });
@@ -49,6 +49,7 @@ import {
49
49
  serviceSecrets,
50
50
  } from "./stack-contract.js";
51
51
  import { listFlag } from "./stack-converge.js";
52
+ import { ensureStagingGatewayFromLiveGateway } from "./staging-gateway-setup.js";
52
53
  import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
53
54
 
54
55
  /** The install the services are ensured on. */
@@ -61,8 +62,15 @@ export interface ReleaseServicesTarget {
61
62
  readonly domain: string;
62
63
  /** The release's digest-pinned MCP-Auth-Proxy image (`…/mcp-auth-proxy@sha256:…`). */
63
64
  readonly mcpAuthProxyImageRef: string;
65
+ /** #1330 — the release's digest-pinned gateway image, which the staging gateway runs too. */
66
+ readonly gatewayImageRef: string;
67
+ /** #1330 — the staging host (install.json `stagingDomain`). */
68
+ readonly stagingHost: string;
64
69
  }
65
70
 
71
+ /** What the `gcp` proxy's own service needs. */
72
+ export type ProxyServiceTarget = Omit<ReleaseServicesTarget, "gatewayImageRef" | "stagingHost">;
73
+
66
74
  export interface ReleaseServicesDeps {
67
75
  readonly run?: GcloudRunner;
68
76
  /** Writes a service spec for `gcloud run services replace`; returns its path and a cleanup. */
@@ -95,6 +103,10 @@ export async function ensureReleaseServices(
95
103
  ? await ensureDataGatewayService(target, run, deps.writeSpecFile ?? writeTempSpec)
96
104
  : await ensureMcpAuthProxyServiceGcp(target, run),
97
105
  );
106
+ if (outcomes.some((o) => o.status === "failed")) return outcomes;
107
+ outcomes.push(
108
+ ...(await ensureStagingGatewayFromLiveGateway(target, { run, sleep: deps.sleep ?? realSleep })),
109
+ );
98
110
  return outcomes;
99
111
  }
100
112
 
@@ -317,7 +329,7 @@ function gcpProxyEnv(target: Pick<ReleaseServicesTarget, "env" | "domain">): Clo
317
329
  * invokable without a Google identity (MCP clients have none), request-based
318
330
  * CPU, no VPC, and its Google sign-in client file as a secret volume.
319
331
  */
320
- export function gcpProxyDeployArgs(target: ReleaseServicesTarget): string[] {
332
+ export function gcpProxyDeployArgs(target: ProxyServiceTarget): string[] {
321
333
  const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
322
334
  const env = gcpProxyEnv(target);
323
335
  const plain = env.flatMap((e) => ("value" in e ? [`${e.name}=${e.value}`] : []));
@@ -356,7 +368,7 @@ export function gcpProxyDeployArgs(target: ReleaseServicesTarget): string[] {
356
368
  * app-roll moves it onto each release's image after that.
357
369
  */
358
370
  export async function ensureMcpAuthProxyServiceGcp(
359
- target: ReleaseServicesTarget,
371
+ target: ProxyServiceTarget,
360
372
  run: GcloudRunner,
361
373
  ): Promise<EnsureOutcome> {
362
374
  const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
@@ -254,7 +254,7 @@ export function manifestServiceImages(m: Pick<ReleaseManifest, "version" | "dige
254
254
  | {
255
255
  readonly ok: true;
256
256
  readonly images: Readonly<
257
- Record<"admin" | "gateway" | "mcp-auth-proxy", ManifestServiceImage>
257
+ Record<"admin" | "gateway" | "staging-gateway" | "mcp-auth-proxy", ManifestServiceImage>
258
258
  >;
259
259
  }
260
260
  | { readonly ok: false; readonly error: string } {
@@ -274,6 +274,8 @@ export function manifestServiceImages(m: Pick<ReleaseManifest, "version" | "dige
274
274
  images: {
275
275
  admin: image("admin", m.digests.admin),
276
276
  gateway: image("gateway", m.digests.gateway),
277
+ // #1330 — the staging gateway runs the live gateway's image.
278
+ "staging-gateway": image("gateway", m.digests.gateway),
277
279
  "mcp-auth-proxy": image("mcp-auth-proxy", proxy),
278
280
  },
279
281
  };
@@ -96,6 +96,7 @@ describe("remote-MCP identity as stack config (#1082)", () => {
96
96
  expect(remoteMcpIdentityStackEnv(undefined)).toEqual({
97
97
  admin: [],
98
98
  gateway: [],
99
+ "staging-gateway": [],
99
100
  "mcp-auth-proxy": [],
100
101
  });
101
102
  });
@@ -413,6 +413,7 @@ const firebase: RemoteMcpTarget = {
413
413
  services: {
414
414
  admin: "caelo-production-admin-aaa",
415
415
  gateway: "caelo-production-data-gateway",
416
+ "staging-gateway": "caelo-production-staging-gateway",
416
417
  "mcp-auth-proxy": "caelo-production-gateway-bbb",
417
418
  },
418
419
  };
@@ -606,7 +607,10 @@ describe("checkRemoteMcpIdentityEnv (#1082)", () => {
606
607
  ),
607
608
  });
608
609
  const r = await checkRemoteMcpIdentityEnv(firebase, identity, { run });
609
- expect(r).toEqual({ ok: true, drift: { admin: [], gateway: [], "mcp-auth-proxy": [] } });
610
+ expect(r).toEqual({
611
+ ok: true,
612
+ drift: { admin: [], gateway: [], "staging-gateway": [], "mcp-auth-proxy": [] },
613
+ });
610
614
  expect(calls.some((c) => c.startsWith("run services update"))).toBe(false);
611
615
  });
612
616
 
package/src/remote-mcp.ts CHANGED
@@ -345,8 +345,17 @@ export async function checkRemoteMcpIdentityEnv(
345
345
  ): Promise<RemoteMcpResult<{ readonly drift: Record<CloudRunSlug, EnvChange[]> }>> {
346
346
  const run = deps.run ?? defaultGcloud;
347
347
  const desired = remoteMcpIdentityEnv(identity);
348
- const drift: Record<CloudRunSlug, EnvChange[]> = { admin: [], gateway: [], "mcp-auth-proxy": [] };
348
+ const drift: Record<CloudRunSlug, EnvChange[]> = {
349
+ admin: [],
350
+ gateway: [],
351
+ "staging-gateway": [],
352
+ "mcp-auth-proxy": [],
353
+ };
349
354
  for (const slug of CLOUD_RUN_SLUGS) {
355
+ // Nothing to check where the identity names nothing (the visitor
356
+ // gateways), and the staging gateway is the CLI's (#1330), not the
357
+ // stack's: it may not exist yet right after a first `up`.
358
+ if (desired[slug].length === 0) continue;
350
359
  const described = await describeService(target, target.services[slug], run);
351
360
  if (!described.ok) return described;
352
361
  const plan = planEnvUpdate(liveContainerEnv(described.json), desired[slug]);
@@ -323,7 +323,12 @@ describe("rotateRuntimeSecret", () => {
323
323
  projectId: "acme",
324
324
  region: "europe-west1",
325
325
  env: "production",
326
- services: { admin: "adm-svc", gateway: "gw-svc", "mcp-auth-proxy": "px-svc" },
326
+ services: {
327
+ admin: "adm-svc",
328
+ gateway: "gw-svc",
329
+ "staging-gateway": "stg-svc",
330
+ "mcp-auth-proxy": "px-svc",
331
+ },
327
332
  sqlInstance: "caelo-production-pg-1",
328
333
  };
329
334
  const ref = (name: string, secret: string, version = "latest") => ({
@@ -347,6 +352,10 @@ describe("rotateRuntimeSecret", () => {
347
352
  "run services describe gw-svc": [
348
353
  service([ref("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
349
354
  ],
355
+ // #1330 — the staging gateway reads its own login password.
356
+ "run services describe stg-svc": [
357
+ service([ref("PUBLIC_DATABASE_PASSWORD", "public-staging-role-password")]),
358
+ ],
350
359
  // #1328 — the proxy reads none of the rotatable database secrets.
351
360
  "run services describe px-svc": [
352
361
  service([ref("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key")]),
@@ -30,6 +30,8 @@ import {
30
30
  gcpSecretId,
31
31
  mcpAuthProxyServiceAccountEmail,
32
32
  mcpAuthProxyServiceAccountId,
33
+ stagingGatewayServiceAccountEmail,
34
+ stagingGatewayServiceAccountId,
33
35
  } from "./gcp-names.js";
34
36
  import {
35
37
  CLI_GENERATED_SECRETS,
@@ -123,6 +125,25 @@ export function ensureMcpAuthProxyServiceAccount(
123
125
  );
124
126
  }
125
127
 
128
+ /**
129
+ * #1330 — create the staging gateway's run SA unless it exists. Its rights:
130
+ * `secretAccessor` on `public-staging-role-password` (runtimeSecretBindings),
131
+ * the log and metric writer, and object viewer on the staging bucket
132
+ * (staging-gateway-setup.ts) — nothing of the live site's.
133
+ */
134
+ export function ensureStagingGatewayServiceAccount(
135
+ install: { readonly projectId: string; readonly env: string },
136
+ deps: RuntimeDeps = {},
137
+ ): Promise<EnsureOutcome> {
138
+ return ensureServiceAccount(
139
+ install.projectId,
140
+ stagingGatewayServiceAccountId(install.env),
141
+ stagingGatewayServiceAccountEmail(install.projectId, install.env),
142
+ `Caelo ${install.env} staging gateway`,
143
+ deps,
144
+ );
145
+ }
146
+
126
147
  async function ensureServiceAccount(
127
148
  projectId: string,
128
149
  accountId: string,
@@ -109,12 +109,12 @@ describe("backupSelfHosted", () => {
109
109
  expect(out).toEqual({ ok: true, path: to });
110
110
  expect(probe(r.calls.map((c) => c.cmd))).toEqual([
111
111
  "exec -T postgres pg_isready -U caelo",
112
- "stop caelo-admin caelo-gateway",
112
+ "stop caelo-admin caelo-gateway caelo-staging-gateway",
113
113
  "exec -T postgres pg_dumpall -U caelo --globals-only",
114
114
  "exec -T postgres pg_dump -U caelo -Fc cms_admin",
115
115
  "exec -T postgres pg_dump -U caelo -Fc cms_public",
116
116
  "run --rm --no-deps -T --entrypoint tar caelo-admin -C / -cf - srv/caelo data/media",
117
- "up -d caelo-admin caelo-gateway",
117
+ "up -d caelo-admin caelo-gateway caelo-staging-gateway",
118
118
  "exec -T caelo-admin bun -e <probe>",
119
119
  "exec -T caelo-gateway bun -e <probe>",
120
120
  ]);
@@ -146,7 +146,9 @@ describe("backupSelfHosted", () => {
146
146
  ...quiet,
147
147
  });
148
148
  expect(out.ok).toBe(false);
149
- expect(r.calls.map((c) => c.cmd)).toContain("up -d caelo-admin caelo-gateway");
149
+ expect(r.calls.map((c) => c.cmd)).toContain(
150
+ "up -d caelo-admin caelo-gateway caelo-staging-gateway",
151
+ );
150
152
  });
151
153
 
152
154
  it("refuses when Postgres is not running and changes nothing", async () => {
@@ -194,7 +196,7 @@ describe("restoreSelfHosted", () => {
194
196
  const out = await restoreSelfHosted(archive, restoreDeps(r));
195
197
  expect(out.ok).toBe(true);
196
198
  expect(probe(r.calls.map((c) => c.cmd))).toEqual([
197
- "stop caelo-admin caelo-gateway",
199
+ "stop caelo-admin caelo-gateway caelo-staging-gateway",
198
200
  "up -d --wait postgres",
199
201
  "exec -T postgres psql -U caelo -d postgres -v ON_ERROR_STOP=1 -c DROP DATABASE IF EXISTS cms_admin WITH (FORCE) -c DROP DATABASE IF EXISTS cms_public WITH (FORCE)",
200
202
  "<converge roles>",