@caelo-cms/provisioning 0.10.48 → 0.10.50

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 (150) hide show
  1. package/caddy/Caddyfile.production +5 -1
  2. package/caddy/Caddyfile.staging +5 -1
  3. package/dist/agent-db.js +102 -58
  4. package/dist/caddy-redirects.d.ts +15 -0
  5. package/dist/caddy-redirects.d.ts.map +1 -0
  6. package/dist/caddy-redirects.js +65 -0
  7. package/dist/caddy-redirects.js.map +1 -0
  8. package/dist/caddy.d.ts +22 -2
  9. package/dist/caddy.d.ts.map +1 -1
  10. package/dist/caddy.js +66 -17
  11. package/dist/caddy.js.map +1 -1
  12. package/dist/cli-routing.d.ts.map +1 -1
  13. package/dist/cli-routing.js +7 -0
  14. package/dist/cli-routing.js.map +1 -1
  15. package/dist/cli.js +248 -254
  16. package/dist/cli.js.map +1 -1
  17. package/dist/compose.d.ts +54 -2
  18. package/dist/compose.d.ts.map +1 -1
  19. package/dist/compose.js +86 -32
  20. package/dist/compose.js.map +1 -1
  21. package/dist/gcp-names.d.ts +12 -0
  22. package/dist/gcp-names.d.ts.map +1 -1
  23. package/dist/gcp-names.js +21 -0
  24. package/dist/gcp-names.js.map +1 -1
  25. package/dist/gcp-url-map.d.ts +27 -0
  26. package/dist/gcp-url-map.d.ts.map +1 -0
  27. package/dist/gcp-url-map.js +62 -0
  28. package/dist/gcp-url-map.js.map +1 -0
  29. package/dist/index.d.ts +0 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +0 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/infra-contract.d.ts +20 -1
  34. package/dist/infra-contract.d.ts.map +1 -1
  35. package/dist/infra-contract.js +39 -1
  36. package/dist/infra-contract.js.map +1 -1
  37. package/dist/public-role-isolation.d.ts +19 -0
  38. package/dist/public-role-isolation.d.ts.map +1 -0
  39. package/dist/public-role-isolation.js +69 -0
  40. package/dist/public-role-isolation.js.map +1 -0
  41. package/dist/release-contract.d.ts.map +1 -1
  42. package/dist/release-contract.js +3 -0
  43. package/dist/release-contract.js.map +1 -1
  44. package/dist/release-manifest.d.ts +2 -41
  45. package/dist/release-manifest.d.ts.map +1 -1
  46. package/dist/release-manifest.js +20 -122
  47. package/dist/release-manifest.js.map +1 -1
  48. package/dist/self-hosted-backup.d.ts +86 -0
  49. package/dist/self-hosted-backup.d.ts.map +1 -0
  50. package/dist/self-hosted-backup.js +350 -0
  51. package/dist/self-hosted-backup.js.map +1 -0
  52. package/dist/self-hosted-release.d.ts +12 -3
  53. package/dist/self-hosted-release.d.ts.map +1 -1
  54. package/dist/self-hosted-release.js +6 -4
  55. package/dist/self-hosted-release.js.map +1 -1
  56. package/dist/self-hosted-start.d.ts +50 -0
  57. package/dist/self-hosted-start.d.ts.map +1 -0
  58. package/dist/self-hosted-start.js +59 -0
  59. package/dist/self-hosted-start.js.map +1 -0
  60. package/dist/self-hosted-upgrade.d.ts +30 -0
  61. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  62. package/dist/self-hosted-upgrade.js +29 -7
  63. package/dist/self-hosted-upgrade.js.map +1 -1
  64. package/dist/tar-entries.d.ts +14 -0
  65. package/dist/tar-entries.d.ts.map +1 -0
  66. package/dist/tar-entries.js +104 -0
  67. package/dist/tar-entries.js.map +1 -0
  68. package/dist/upgrade/converge.d.ts +2 -2
  69. package/dist/upgrade/converge.d.ts.map +1 -1
  70. package/dist/upgrade/converge.js +44 -2
  71. package/dist/upgrade/converge.js.map +1 -1
  72. package/dist/upgrade/release-images.d.ts +14 -2
  73. package/dist/upgrade/release-images.d.ts.map +1 -1
  74. package/dist/upgrade/release-images.js +4 -2
  75. package/dist/upgrade/release-images.js.map +1 -1
  76. package/dist/upgrader-assess.d.ts +4 -1
  77. package/dist/upgrader-assess.d.ts.map +1 -1
  78. package/dist/upgrader-assess.js +8 -1
  79. package/dist/upgrader-assess.js.map +1 -1
  80. package/dist/upgrader-job.d.ts +3 -1
  81. package/dist/upgrader-job.d.ts.map +1 -1
  82. package/dist/upgrader-job.js.map +1 -1
  83. package/dist/url-map-redirects-access.d.ts +15 -0
  84. package/dist/url-map-redirects-access.d.ts.map +1 -0
  85. package/dist/url-map-redirects-access.js +81 -0
  86. package/dist/url-map-redirects-access.js.map +1 -0
  87. package/dist/wizard.d.ts.map +1 -1
  88. package/dist/wizard.js +3 -1
  89. package/dist/wizard.js.map +1 -1
  90. package/dist/wizards/gcp.d.ts.map +1 -1
  91. package/dist/wizards/gcp.js +45 -0
  92. package/dist/wizards/gcp.js.map +1 -1
  93. package/package.json +2 -2
  94. package/src/admin-memory.test.ts +2 -2
  95. package/src/agent/run.test.ts +55 -0
  96. package/src/caddy-redirects.docker.test.ts +255 -0
  97. package/src/caddy-redirects.test.ts +115 -0
  98. package/src/caddy-redirects.ts +68 -0
  99. package/src/caddy-validate.test.ts +119 -0
  100. package/src/caddy.test.ts +108 -0
  101. package/src/caddy.ts +89 -20
  102. package/src/cli-routing.test.ts +12 -0
  103. package/src/cli-routing.ts +7 -0
  104. package/src/cli.ts +299 -287
  105. package/src/compose.ts +106 -34
  106. package/src/gcp-names.ts +23 -0
  107. package/src/gcp-url-map.test.ts +150 -0
  108. package/src/gcp-url-map.ts +81 -0
  109. package/src/index.test.ts +18 -146
  110. package/src/index.ts +0 -9
  111. package/src/infra-contract.test.ts +39 -0
  112. package/src/infra-contract.ts +42 -1
  113. package/src/public-role-isolation.test.ts +89 -0
  114. package/src/public-role-isolation.ts +83 -0
  115. package/src/release-contract.ts +3 -0
  116. package/src/release-manifest.test.ts +20 -1
  117. package/src/release-manifest.ts +39 -158
  118. package/src/self-hosted-backup.test.ts +344 -0
  119. package/src/self-hosted-backup.ts +480 -0
  120. package/src/self-hosted-compose.test.ts +88 -4
  121. package/src/self-hosted-release.ts +22 -7
  122. package/src/self-hosted-start.test.ts +68 -0
  123. package/src/self-hosted-start.ts +123 -0
  124. package/src/self-hosted-upgrade.ts +35 -8
  125. package/src/stack-contract.test.ts +2 -2
  126. package/src/tar-entries.test.ts +52 -0
  127. package/src/tar-entries.ts +116 -0
  128. package/src/upgrade/converge.ts +51 -4
  129. package/src/upgrade/release-images.ts +4 -2
  130. package/src/upgrade/upgrade.test.ts +78 -1
  131. package/src/upgrader-assess.test.ts +64 -1
  132. package/src/upgrader-assess.ts +20 -2
  133. package/src/upgrader-job.test.ts +3 -0
  134. package/src/upgrader-job.ts +3 -0
  135. package/src/url-map-redirects-access.test.ts +88 -0
  136. package/src/url-map-redirects-access.ts +89 -0
  137. package/src/wizard.ts +3 -1
  138. package/src/wizards/gcp.ts +48 -0
  139. package/stacks/aws/index.ts +34 -2
  140. package/stacks/azure/index.ts +13 -0
  141. package/stacks/gcp/index.ts +24 -17
  142. package/stacks/gcp-firebase/index.ts +7 -0
  143. package/stacks/self-hosted/Pulumi.yaml +2 -2
  144. package/stacks/self-hosted/README.md +3 -3
  145. package/stacks/self-hosted/index.ts +45 -22
  146. package/dist/redirects-emit.d.ts +0 -52
  147. package/dist/redirects-emit.d.ts.map +0 -1
  148. package/dist/redirects-emit.js +0 -111
  149. package/dist/redirects-emit.js.map +0 -1
  150. package/src/redirects-emit.ts +0 -175
package/src/index.test.ts CHANGED
@@ -8,12 +8,6 @@ import { generateBootstrapToken } from "./bootstrap-token.js";
8
8
  import { generateCaddyfile } from "./caddy.js";
9
9
  import { loadCdnCopyAdapter, selfHostedCdnCopy } from "./cdn-copy.js";
10
10
  import { generateDockerCompose } from "./compose.js";
11
- import {
12
- emitRedirectsAzureFrontDoor,
13
- emitRedirectsCloudFront,
14
- emitRedirectsCloudflare,
15
- type RedirectRow,
16
- } from "./redirects-emit.js";
17
11
 
18
12
  describe("Caddyfile generator", () => {
19
13
  it("emits a vhost per domain + email block", () => {
@@ -21,8 +15,9 @@ describe("Caddyfile generator", () => {
21
15
  ownerEmail: "owner@example.com",
22
16
  publicSiteRoot: "/srv/caelo/output/production/current",
23
17
  stagingSiteRoot: "/srv/caelo/output/staging/current",
24
- adminPort: 5173,
25
- gatewayPort: 8090,
18
+ tls: "acme",
19
+ adminUpstream: "caelo-admin:5173",
20
+ gatewayUpstream: "caelo-gateway:8090",
26
21
  domains: [
27
22
  { hostname: "example.com", kind: "public", env: "production" },
28
23
  { hostname: "staging.example.com", kind: "public", env: "staging" },
@@ -47,12 +42,13 @@ describe("Caddyfile generator", () => {
47
42
  ownerEmail: "x@y.z",
48
43
  publicSiteRoot: "/srv/caelo/output/production/current",
49
44
  stagingSiteRoot: "/srv/caelo/output/staging/current",
50
- adminPort: 5173,
51
- gatewayPort: 8090,
45
+ tls: "acme",
46
+ adminUpstream: "caelo-admin:5173",
47
+ gatewayUpstream: "caelo-gateway:8090",
52
48
  domains: [],
53
49
  });
54
50
  expect(out).toContain(":8081 {");
55
- expect(out).toContain("reverse_proxy localhost:5173");
51
+ expect(out).toContain("reverse_proxy caelo-admin:5173");
56
52
  });
57
53
  });
58
54
 
@@ -61,8 +57,9 @@ describe("Caddy cache policy for content-hashed assets", () => {
61
57
  ownerEmail: "owner@example.com",
62
58
  publicSiteRoot: "/srv/caelo/output/production/current",
63
59
  stagingSiteRoot: "/srv/caelo/output/staging/current",
64
- adminPort: 5173,
65
- gatewayPort: 8090,
60
+ tls: "acme",
61
+ adminUpstream: "caelo-admin:5173",
62
+ gatewayUpstream: "caelo-gateway:8090",
66
63
  domains: [
67
64
  { hostname: "example.com", kind: "public", env: "production" },
68
65
  { hostname: "admin.example.com", kind: "admin", env: "production" },
@@ -119,19 +116,18 @@ describe("docker-compose generator", () => {
119
116
  domain: "example.com",
120
117
  postgresPassword: "supersecret",
121
118
  rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
122
- minioRootUser: "caelo",
123
- minioRootPassword: "miniopass",
124
119
  caeloSecretKek: "0".repeat(64),
125
120
  anthropicApiKey: "sk-ant-…",
126
121
  diskSize: "20Gi",
127
122
  images: RELEASE_IMAGES,
123
+ ports: { http: 80, https: 443 },
124
+ project: "caelo-example-com",
128
125
  });
129
- expect(out).toContain("caelo-postgres");
130
- expect(out).toContain("caelo-caddy");
126
+ expect(out).toContain(" postgres:\n");
127
+ expect(out).toContain(" caddy:\n");
131
128
  expect(out).toContain("caelo-admin");
132
129
  expect(out).toContain("caelo-gateway");
133
130
  expect(out).toContain("supersecret");
134
- expect(out).toContain("miniopass");
135
131
  expect(out).toContain("ANTHROPIC_API_KEY");
136
132
  expect(out).toContain("CAELO_SECRET_KEK");
137
133
  });
@@ -141,11 +137,11 @@ describe("docker-compose generator", () => {
141
137
  domain: "example.com",
142
138
  postgresPassword: "supersecret",
143
139
  rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
144
- minioRootUser: "caelo",
145
- minioRootPassword: "miniopass",
146
140
  caeloSecretKek: "0".repeat(64),
147
141
  diskSize: "20Gi",
148
142
  images: RELEASE_IMAGES,
143
+ ports: { http: 80, https: 443 },
144
+ project: "caelo-example-com",
149
145
  });
150
146
  const gateway = out.slice(out.indexOf("caelo-gateway:"), out.indexOf("\nvolumes:"));
151
147
  expect(gateway).toContain(
@@ -164,11 +160,11 @@ describe("docker-compose generator", () => {
164
160
  domain: "example.com",
165
161
  postgresPassword: 'pa"ss',
166
162
  rolePasswords: { admin: "a", public: "b", gateway: "g" },
167
- minioRootUser: "caelo",
168
- minioRootPassword: "x",
169
163
  caeloSecretKek: "0".repeat(64),
170
164
  diskSize: "1Gi",
171
165
  images: RELEASE_IMAGES,
166
+ ports: { http: 80, https: 443 },
167
+ project: "caelo-example-com",
172
168
  });
173
169
  expect(out).toContain('pa\\"ss');
174
170
  });
@@ -190,130 +186,6 @@ describe("bootstrap token generator", () => {
190
186
  });
191
187
  });
192
188
 
193
- describe("redirects-emit", () => {
194
- const rows: RedirectRow[] = [
195
- { fromPath: "/old", toPath: "/new", statusCode: 301 },
196
- { fromPath: "/blog/2024", toPath: "/blog", statusCode: 302 },
197
- { fromPath: "/legacy", toPath: "/modern", statusCode: 308 },
198
- ];
199
-
200
- describe("emitRedirectsCloudflare", () => {
201
- it("emits one line per row in `<from> <to> <status>` format", () => {
202
- const out = emitRedirectsCloudflare(rows);
203
- expect(out).toContain("/old /new 301\n");
204
- expect(out).toContain("/blog/2024 /blog 302\n");
205
- expect(out).toContain("/legacy /modern 308\n");
206
- expect(out.startsWith("# Generated by @caelo-cms/provisioning")).toBe(true);
207
- });
208
-
209
- it("empty rows → header only, no trailing newline noise", () => {
210
- const out = emitRedirectsCloudflare([]);
211
- expect(out).toBe("# Generated by @caelo-cms/provisioning — do not edit by hand.\n");
212
- });
213
-
214
- it("is byte-identical for the same input (deterministic)", () => {
215
- expect(emitRedirectsCloudflare(rows)).toBe(emitRedirectsCloudflare([...rows]));
216
- });
217
- });
218
-
219
- describe("emitRedirectsCloudFront", () => {
220
- it("returns parseable JSON config + a Lambda source that consumes it", () => {
221
- const out = emitRedirectsCloudFront(rows);
222
- const parsed = JSON.parse(out.jsonConfig) as {
223
- redirects: Array<{ from: string; to: string; status: number }>;
224
- };
225
- expect(parsed.redirects).toHaveLength(3);
226
- expect(parsed.redirects[0]).toEqual({ from: "/old", to: "/new", status: 301 });
227
- expect(out.lambdaSource).toContain("exports.handler");
228
- expect(out.lambdaSource).toContain('require("./redirects.json")');
229
- });
230
-
231
- it("preserves status codes verbatim (301/302/307/308)", () => {
232
- const allStatuses: RedirectRow[] = [
233
- { fromPath: "/a", toPath: "/x", statusCode: 301 },
234
- { fromPath: "/b", toPath: "/x", statusCode: 302 },
235
- { fromPath: "/c", toPath: "/x", statusCode: 307 },
236
- { fromPath: "/d", toPath: "/x", statusCode: 308 },
237
- ];
238
- const out = emitRedirectsCloudFront(allStatuses);
239
- const parsed = JSON.parse(out.jsonConfig) as {
240
- redirects: Array<{ status: number }>;
241
- };
242
- expect(parsed.redirects.map((r) => r.status)).toEqual([301, 302, 307, 308]);
243
- });
244
- });
245
-
246
- describe("emitRedirectsAzureFrontDoor", () => {
247
- it("emits one rule per row with monotonically-increasing order", () => {
248
- const out = emitRedirectsAzureFrontDoor(rows);
249
- expect(out).toHaveLength(3);
250
- expect(out[0]?.name).toBe("redirect-1");
251
- expect(out[0]?.order).toBe(1);
252
- expect(out[2]?.order).toBe(3);
253
- });
254
-
255
- it("maps status codes to Front Door redirectType vocabulary", () => {
256
- const out = emitRedirectsAzureFrontDoor([
257
- { fromPath: "/a", toPath: "/x", statusCode: 301 },
258
- { fromPath: "/b", toPath: "/x", statusCode: 302 },
259
- { fromPath: "/c", toPath: "/x", statusCode: 307 },
260
- { fromPath: "/d", toPath: "/x", statusCode: 308 },
261
- ]);
262
- expect(out.map((r) => r.actions[0]?.parameters.redirectType)).toEqual([
263
- "Moved",
264
- "Found",
265
- "TemporaryRedirect",
266
- "PermanentRedirect",
267
- ]);
268
- });
269
-
270
- it("RequestUri condition matches the from path exactly", () => {
271
- const out = emitRedirectsAzureFrontDoor(rows);
272
- expect(out[1]?.conditions[0]?.parameters.matchValues).toEqual(["/blog/2024"]);
273
- expect(out[1]?.actions[0]?.parameters.destinationPath).toBe("/blog");
274
- });
275
- });
276
-
277
- describe("path-validation guard (review-pass #9)", () => {
278
- it("Cloudflare emitter rejects fromPath with whitespace", () => {
279
- expect(() =>
280
- emitRedirectsCloudflare([{ fromPath: "/old path", toPath: "/new", statusCode: 301 }]),
281
- ).toThrow(/contains whitespace/);
282
- });
283
-
284
- it("CloudFront emitter rejects toPath with newline", () => {
285
- expect(() =>
286
- emitRedirectsCloudFront([{ fromPath: "/old", toPath: "/new\nlies", statusCode: 301 }]),
287
- ).toThrow(/contains whitespace/);
288
- });
289
-
290
- it("every emitter rejects an off-site or URL-embedding toPath (#657)", () => {
291
- for (const toPath of ["//evil.example/x", "/https://example.com/x"]) {
292
- const rows = [{ fromPath: "/old", toPath, statusCode: 301 as const }];
293
- expect(() => emitRedirectsCloudflare(rows)).toThrow(/redirects-emit: toPath/);
294
- expect(() => emitRedirectsCloudFront(rows)).toThrow(/redirects-emit: toPath/);
295
- expect(() => emitRedirectsAzureFrontDoor(rows)).toThrow(/redirects-emit: toPath/);
296
- }
297
- });
298
-
299
- it("Azure emitter rejects tab in path", () => {
300
- expect(() =>
301
- emitRedirectsAzureFrontDoor([{ fromPath: "/old\there", toPath: "/x", statusCode: 301 }]),
302
- ).toThrow(/contains whitespace/);
303
- });
304
-
305
- it("hyphens, dots, slashes, unicode in path segments are accepted", () => {
306
- // Regression guard for the original buggy regex `[\s -]` which
307
- // rejected hyphens.
308
- expect(() =>
309
- emitRedirectsCloudflare([
310
- { fromPath: "/blog/spring-launch", toPath: "/blog/sommer-2026", statusCode: 301 },
311
- ]),
312
- ).not.toThrow();
313
- });
314
- });
315
- });
316
-
317
189
  describe("cdn-copy adapter loader", () => {
318
190
  it("returns the no-op self-hosted adapter when provider is unset/empty/self-hosted", async () => {
319
191
  expect(await loadCdnCopyAdapter(undefined)).toBe(selfHostedCdnCopy);
package/src/index.ts CHANGED
@@ -47,15 +47,6 @@ export {
47
47
  } from "./cdn-copy.js";
48
48
  export { type ComposeSpec, generateDockerCompose, type RolePasswords } from "./compose.js";
49
49
  export { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
50
- export {
51
- type CloudFrontRedirectArtifact,
52
- emitRedirectsAzureFrontDoor,
53
- emitRedirectsCloudFront,
54
- emitRedirectsCloudflare,
55
- type FrontDoorRule,
56
- type RedirectRow,
57
- type RedirectStatusCode,
58
- } from "./redirects-emit.js";
59
50
  export {
60
51
  INFRA_CONTRACT_PATHS,
61
52
  isInfraContractPath,
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Every infrastructure contract version names the step it adds, so the
5
+ * upgrader's refusal tells the Owner what the Cloud Shell run will change.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import {
10
+ INFRA_CONTRACT_STEPS,
11
+ INFRA_CONTRACT_VERSION,
12
+ infraStepsText,
13
+ pendingInfraSteps,
14
+ } from "./infra-contract.js";
15
+
16
+ describe("infrastructure contract steps", () => {
17
+ it("describes every version up to the current one, and no other", () => {
18
+ expect(Object.keys(INFRA_CONTRACT_STEPS).map(Number)).toEqual(
19
+ Array.from({ length: INFRA_CONTRACT_VERSION }, (_, i) => i + 1),
20
+ );
21
+ });
22
+
23
+ it("is at 4: the public_role revoke (3) and the URL-map redirects role (4) are separate", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(4);
25
+ expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
+ expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
+ });
28
+
29
+ it("lists the pending steps oldest first", () => {
30
+ expect(pendingInfraSteps(2, 4)).toEqual([INFRA_CONTRACT_STEPS[3], INFRA_CONTRACT_STEPS[4]]);
31
+ expect(pendingInfraSteps(3, 4)).toEqual([INFRA_CONTRACT_STEPS[4]]);
32
+ expect(pendingInfraSteps(4, 4)).toEqual([]);
33
+ expect(pendingInfraSteps(0, 1)).toEqual([INFRA_CONTRACT_STEPS[1]]);
34
+ });
35
+
36
+ it("names a version a newer release added by number", () => {
37
+ expect(infraStepsText(4, 5)).toBe("infrastructure step 5 (see that release's notes)");
38
+ });
39
+ });
@@ -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 = 2;
25
+ export const INFRA_CONTRACT_VERSION = 4;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -30,3 +30,44 @@ export const INFRA_CONTRACT_VERSION = 2;
30
30
  // 2 — the upgrader job (#696 Phase 2): its service account + scoped
31
31
  // grants, the Cloud SQL backup role, its IAM database user, the job,
32
32
  // the admin's run-only grant and CAELO_UPGRADER_JOB on the admin.
33
+ // 3 — public_role out of cloudsqlsuperuser (#633): the Admin API revoke
34
+ // runs as the operator; migration 0278's role audit refuses an install
35
+ // that has not had it, so the in-cloud upgrader must not get there
36
+ // first.
37
+ // 4 — #587: on `gcp`, the admin's custom role for writing the redirects
38
+ // table into the URL map (url-map-redirects-access.ts).
39
+
40
+ /**
41
+ * What each contract version adds, in operator terms — the upgrader's
42
+ * refusal names the steps an install is missing. Keep one entry per version
43
+ * (the history above says why each bump happened).
44
+ */
45
+ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
46
+ 1: "the converge/app-roll split",
47
+ 2: "the in-cloud upgrader job and its scoped permissions",
48
+ 3: "public_role out of the Cloud SQL superuser group (#633)",
49
+ 4: "the admin's permission to serve redirects from the load balancer (#587)",
50
+ };
51
+
52
+ /**
53
+ * The steps an install at contract `installed` lacks for `target`, oldest
54
+ * first. A release newer than this code can carry versions it does not know
55
+ * (the upgrader reads the NEW release's manifest); those are named by number.
56
+ *
57
+ * @example
58
+ * pendingInfraSteps(2, 4)
59
+ * // → ["public_role out of the Cloud SQL superuser group (#633)",
60
+ * // "the admin's permission to serve redirects from the load balancer (#587)"]
61
+ */
62
+ export function pendingInfraSteps(installed: number, target: number): string[] {
63
+ const steps: string[] = [];
64
+ for (let v = Math.max(installed, 0) + 1; v <= target; v += 1) {
65
+ steps.push(INFRA_CONTRACT_STEPS[v] ?? `infrastructure step ${v} (see that release's notes)`);
66
+ }
67
+ return steps;
68
+ }
69
+
70
+ /** {@link pendingInfraSteps} as one sentence fragment for messages. */
71
+ export function infraStepsText(installed: number, target: number): string {
72
+ return pendingInfraSteps(installed, target).join("; ");
73
+ }
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudResult } from "./gcloud.js";
5
+ import type { GcloudRunner } from "./gcloud-retry.js";
6
+ import {
7
+ revokePublicRoleMemberships,
8
+ revokePublicRoleMembershipsArgs,
9
+ } from "./public-role-isolation.js";
10
+
11
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
12
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
13
+ const noSleep = async () => {};
14
+
15
+ function fake(answers: (line: string) => GcloudResult) {
16
+ const calls: string[] = [];
17
+ const run: GcloudRunner = async (args) => {
18
+ const line = args.join(" ");
19
+ calls.push(line);
20
+ return answers(line);
21
+ };
22
+ return { run, calls };
23
+ }
24
+
25
+ describe("revokePublicRoleMembershipsArgs (#633)", () => {
26
+ it("revokes every database role of the built-in public_role user", () => {
27
+ expect(
28
+ revokePublicRoleMembershipsArgs({ projectId: "acme", sqlInstance: "caelo-production-pg-1" }),
29
+ ).toEqual([
30
+ "sql",
31
+ "users",
32
+ "assign-roles",
33
+ "public_role",
34
+ "--instance=caelo-production-pg-1",
35
+ "--type=BUILT_IN",
36
+ "--revoke-existing-roles",
37
+ "--project=acme",
38
+ "--quiet",
39
+ ]);
40
+ });
41
+
42
+ it("grants nothing in exchange (no --database-roles)", () => {
43
+ const args = revokePublicRoleMembershipsArgs({ projectId: "p", sqlInstance: "i" });
44
+ expect(args.some((a) => a.startsWith("--database-roles"))).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("revokePublicRoleMemberships", () => {
49
+ const install = { projectId: "acme", env: "production" };
50
+
51
+ it("finds the instance, then revokes", async () => {
52
+ const { run, calls } = fake((l) => (l.startsWith("sql instances list") ? ok("pg-1\n") : ok()));
53
+ const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
54
+ expect(r).toEqual({
55
+ id: "public_role database role memberships",
56
+ status: "applied",
57
+ detail: "no database roles left on pg-1",
58
+ });
59
+ expect(calls[1]).toBe(
60
+ "sql users assign-roles public_role --instance=pg-1 --type=BUILT_IN --revoke-existing-roles --project=acme --quiet",
61
+ );
62
+ });
63
+
64
+ it("is idempotent: a second run issues the same revoke and succeeds", async () => {
65
+ const { run, calls } = fake((l) => (l.startsWith("sql instances list") ? ok("pg-1\n") : ok()));
66
+ await revokePublicRoleMemberships(install, { run, sleep: noSleep });
67
+ const again = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
68
+ expect(again.status).toBe("applied");
69
+ expect(calls[1]).toBe(calls[3] as string);
70
+ });
71
+
72
+ it("fails without an instance and never calls the revoke", async () => {
73
+ const { run, calls } = fake(() => ok(""));
74
+ const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
75
+ expect(r.status).toBe("failed");
76
+ expect(r.error).toBe("the Cloud SQL instance was not found");
77
+ expect(calls).toHaveLength(1);
78
+ });
79
+
80
+ it("reports a refused revoke with the permission it needs", async () => {
81
+ const { run } = fake((l) =>
82
+ l.startsWith("sql instances list") ? ok("pg-1\n") : fail("PERMISSION_DENIED: nope"),
83
+ );
84
+ const r = await revokePublicRoleMemberships(install, { run, sleep: noSleep });
85
+ expect(r.status).toBe("failed");
86
+ expect(r.error).toContain("PERMISSION_DENIED: nope");
87
+ expect(r.error).toContain("cloudsql.users.update");
88
+ });
89
+ });
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Take `public_role` out of `cloudsqlsuperuser` (issue #633, CLAUDE.md §2).
5
+ *
6
+ * The stacks declare `public_role` as a Cloud SQL Admin API user
7
+ * (`gcp.sql.User`), and Cloud SQL makes every such user a member of
8
+ * `cloudsqlsuperuser` — the group that owns cms_admin and cms_public. That
9
+ * gave the API gateway's visitor-facing login the owner group's rights.
10
+ *
11
+ * Why the Admin API and not SQL: on PostgreSQL 16+ a role membership can
12
+ * only be revoked by the role that granted it (or a superuser), and here
13
+ * that is Cloud SQL's own administrator, not `admin_role` — a `REVOKE` from
14
+ * the migration job would leave the membership in place with a warning.
15
+ * `users.update` with `revokeExistingRoles` runs as that administrator. It
16
+ * is also the one path that works the same for a fresh install and for the
17
+ * installs out there, with no change to the Pulumi resource (re-creating the
18
+ * user in SQL would mean deleting a user the stacks' state owns, with
19
+ * dependent grants in both databases, while the gateway serves on it).
20
+ *
21
+ * Revoking every role leaves `public_role` with none, which is exactly its
22
+ * contract: its rights are object grants in cms_public, nothing else.
23
+ *
24
+ * The CLI cannot reach the private-IP database to check the result, so the
25
+ * check runs where SQL runs: the migration job's role audit
26
+ * (packages/migrations/src/role-audit.ts), which fails the migration — and
27
+ * with it the install or upgrade, before any traffic moves — while
28
+ * `public_role` is still a member of any role. This step therefore runs
29
+ * BEFORE migrations, on every install pass and every `upgrade`. Idempotent:
30
+ * revoking from a role that has none changes nothing.
31
+ */
32
+
33
+ import { findSqlInstance } from "./gateway-credentials.js";
34
+ import { gcloud as defaultGcloud } from "./gcloud.js";
35
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
36
+ import type { EnsureOutcome } from "./runtime-secrets.js";
37
+
38
+ /** The gcloud call that leaves `public_role` with no database roles. */
39
+ export function revokePublicRoleMembershipsArgs(target: {
40
+ readonly projectId: string;
41
+ readonly sqlInstance: string;
42
+ }): string[] {
43
+ return [
44
+ "sql",
45
+ "users",
46
+ "assign-roles",
47
+ "public_role",
48
+ `--instance=${target.sqlInstance}`,
49
+ "--type=BUILT_IN",
50
+ "--revoke-existing-roles",
51
+ `--project=${target.projectId}`,
52
+ "--quiet",
53
+ ];
54
+ }
55
+
56
+ /**
57
+ * Revoke every database role the Admin API gave `public_role`. Reports
58
+ * instead of throwing; `failed` names the next step.
59
+ */
60
+ export async function revokePublicRoleMemberships(
61
+ install: { readonly projectId: string; readonly env: string },
62
+ deps: { readonly run?: GcloudRunner; readonly sleep?: Sleep } = {},
63
+ ): Promise<EnsureOutcome> {
64
+ const run = deps.run ?? defaultGcloud;
65
+ const id = "public_role database role memberships";
66
+ const sqlInstance = await findSqlInstance(install, { run });
67
+ if (!sqlInstance) {
68
+ return { id, status: "failed", error: "the Cloud SQL instance was not found" };
69
+ }
70
+ const r = await runWithRetry(
71
+ run,
72
+ deps.sleep ?? realSleep,
73
+ revokePublicRoleMembershipsArgs({ projectId: install.projectId, sqlInstance }),
74
+ );
75
+ if (!r.ok) {
76
+ return {
77
+ id,
78
+ status: "failed",
79
+ error: `gcloud sql users assign-roles public_role --revoke-existing-roles on ${sqlInstance}: ${r.stderr.trim()}. The account running this needs cloudsql.users.update on the project; re-run once it has.`,
80
+ };
81
+ }
82
+ return { id, status: "applied", detail: `no database roles left on ${sqlInstance}` };
83
+ }
@@ -43,6 +43,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
43
43
  "packages/provisioning/src/upgrade/converge.ts",
44
44
  "packages/provisioning/src/mcp-iap.ts",
45
45
  "packages/provisioning/src/default-firewall.ts",
46
+ // #587 — the admin's URL-map redirects role and the URL map's shape.
47
+ "packages/provisioning/src/url-map-redirects-access.ts",
48
+ "packages/provisioning/src/gcp-url-map.ts",
46
49
  ];
47
50
 
48
51
  /**
@@ -214,7 +214,14 @@ describe("assessAutoInstall", () => {
214
214
  kind: "normal",
215
215
  reasons: [],
216
216
  });
217
- expect(assessAutoInstall({ ...MANIFEST, security: true }, installed).kind).toBe("security");
217
+ const advisories = ["GHSA-abcd-efgh-jkmp"];
218
+ expect(assessAutoInstall({ ...MANIFEST, security: true, advisories }, installed).kind).toBe(
219
+ "security",
220
+ );
221
+ });
222
+
223
+ it("counts security only with a published advisory behind it (#696 decision 2)", () => {
224
+ expect(assessAutoInstall({ ...MANIFEST, security: true }, installed).kind).toBe("normal");
218
225
  });
219
226
 
220
227
  it("downgrades a security release with contract migrations to manual", () => {
@@ -246,6 +253,18 @@ describe("assessAutoInstall", () => {
246
253
  );
247
254
  expect(a.mode).toBe("manual");
248
255
  expect(a.reasons[0]).toContain("1 → 3");
256
+ expect(a.reasons[0]).toContain("public_role out of the Cloud SQL superuser group (#633)");
257
+ });
258
+
259
+ it("names both pending infrastructure steps for a 2 → 4 bump (#633, #587)", () => {
260
+ const a = assessAutoInstall(
261
+ { ...MANIFEST, infraContractVersion: 4 },
262
+ { ...installed, infraContractVersion: 2 },
263
+ );
264
+ expect(a.mode).toBe("manual");
265
+ expect(a.reasons[0]).toContain(
266
+ "2 → 4: public_role out of the Cloud SQL superuser group (#633); the admin's permission to serve redirects from the load balancer (#587)",
267
+ );
249
268
  });
250
269
 
251
270
  it("refuses downgrades, re-installs and versions below minFromVersion", () => {