@caelo-cms/provisioning 0.10.49 → 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.
- package/caddy/Caddyfile.production +4 -0
- package/caddy/Caddyfile.staging +4 -0
- package/dist/agent-db.js +4 -0
- package/dist/caddy-redirects.d.ts +15 -0
- package/dist/caddy-redirects.d.ts.map +1 -0
- package/dist/caddy-redirects.js +65 -0
- package/dist/caddy-redirects.js.map +1 -0
- package/dist/caddy.d.ts +22 -2
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +66 -17
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +7 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +248 -254
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +54 -2
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +86 -32
- package/dist/compose.js.map +1 -1
- package/dist/gcp-names.d.ts +12 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts +27 -0
- package/dist/gcp-url-map.d.ts.map +1 -0
- package/dist/gcp-url-map.js +62 -0
- package/dist/gcp-url-map.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +20 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +39 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/public-role-isolation.d.ts +19 -0
- package/dist/public-role-isolation.d.ts.map +1 -0
- package/dist/public-role-isolation.js +69 -0
- package/dist/public-role-isolation.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +2 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/self-hosted-backup.d.ts +86 -0
- package/dist/self-hosted-backup.d.ts.map +1 -0
- package/dist/self-hosted-backup.js +350 -0
- package/dist/self-hosted-backup.js.map +1 -0
- package/dist/self-hosted-release.d.ts +12 -3
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -4
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-start.d.ts +50 -0
- package/dist/self-hosted-start.d.ts.map +1 -0
- package/dist/self-hosted-start.js +59 -0
- package/dist/self-hosted-start.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +30 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +29 -7
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/tar-entries.d.ts +14 -0
- package/dist/tar-entries.d.ts.map +1 -0
- package/dist/tar-entries.js +104 -0
- package/dist/tar-entries.js.map +1 -0
- package/dist/upgrade/converge.d.ts +2 -2
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +44 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +2 -1
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/url-map-redirects-access.d.ts +15 -0
- package/dist/url-map-redirects-access.d.ts.map +1 -0
- package/dist/url-map-redirects-access.js +81 -0
- package/dist/url-map-redirects-access.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +3 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +45 -0
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-memory.test.ts +2 -2
- package/src/caddy-redirects.docker.test.ts +255 -0
- package/src/caddy-redirects.test.ts +115 -0
- package/src/caddy-redirects.ts +68 -0
- package/src/caddy-validate.test.ts +119 -0
- package/src/caddy.test.ts +108 -0
- package/src/caddy.ts +89 -20
- package/src/cli-routing.test.ts +12 -0
- package/src/cli-routing.ts +7 -0
- package/src/cli.ts +299 -287
- package/src/compose.ts +106 -34
- package/src/gcp-names.ts +23 -0
- package/src/gcp-url-map.test.ts +150 -0
- package/src/gcp-url-map.ts +81 -0
- package/src/index.test.ts +18 -146
- package/src/index.ts +0 -9
- package/src/infra-contract.test.ts +39 -0
- package/src/infra-contract.ts +42 -1
- package/src/public-role-isolation.test.ts +89 -0
- package/src/public-role-isolation.ts +83 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +12 -0
- package/src/release-manifest.ts +2 -1
- package/src/self-hosted-backup.test.ts +344 -0
- package/src/self-hosted-backup.ts +480 -0
- package/src/self-hosted-compose.test.ts +88 -4
- package/src/self-hosted-release.ts +22 -7
- package/src/self-hosted-start.test.ts +68 -0
- package/src/self-hosted-start.ts +123 -0
- package/src/self-hosted-upgrade.ts +35 -8
- package/src/stack-contract.test.ts +2 -2
- package/src/tar-entries.test.ts +52 -0
- package/src/tar-entries.ts +116 -0
- package/src/upgrade/converge.ts +51 -4
- package/src/upgrade/upgrade.test.ts +78 -1
- package/src/upgrader-assess.test.ts +22 -0
- package/src/upgrader-assess.ts +2 -1
- package/src/url-map-redirects-access.test.ts +88 -0
- package/src/url-map-redirects-access.ts +89 -0
- package/src/wizard.ts +3 -1
- package/src/wizards/gcp.ts +48 -0
- package/stacks/aws/index.ts +34 -2
- package/stacks/azure/index.ts +13 -0
- package/stacks/gcp/index.ts +24 -17
- package/stacks/gcp-firebase/index.ts +7 -0
- package/stacks/self-hosted/Pulumi.yaml +2 -2
- package/stacks/self-hosted/README.md +3 -3
- package/stacks/self-hosted/index.ts +45 -22
- package/dist/redirects-emit.d.ts +0 -52
- package/dist/redirects-emit.d.ts.map +0 -1
- package/dist/redirects-emit.js +0 -111
- package/dist/redirects-emit.js.map +0 -1
- 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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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("
|
|
130
|
-
expect(out).toContain("
|
|
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
|
+
});
|
package/src/infra-contract.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/release-contract.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -253,6 +253,18 @@ describe("assessAutoInstall", () => {
|
|
|
253
253
|
);
|
|
254
254
|
expect(a.mode).toBe("manual");
|
|
255
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
|
+
);
|
|
256
268
|
});
|
|
257
269
|
|
|
258
270
|
it("refuses downgrades, re-installs and versions below minFromVersion", () => {
|
package/src/release-manifest.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type ReleaseImage,
|
|
35
35
|
type ReleaseManifest,
|
|
36
36
|
} from "@caelo-cms/shared";
|
|
37
|
+
import { infraStepsText } from "./infra-contract.js";
|
|
37
38
|
import { RELEASE_OIDC_ISSUER, RELEASE_WORKFLOWS_URL } from "./upgrade/release-images.js";
|
|
38
39
|
|
|
39
40
|
// The manifest's shape and location live in @caelo-cms/shared (the admin's
|
|
@@ -292,7 +293,7 @@ export function assessAutoInstall(
|
|
|
292
293
|
const manual: string[] = [];
|
|
293
294
|
if (manifest.infraContractVersion > installed.infraContractVersion) {
|
|
294
295
|
manual.push(
|
|
295
|
-
`the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}); it must run with the operator's own identity`,
|
|
296
|
+
`the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(installed.infraContractVersion, manifest.infraContractVersion)}); it must run with the operator's own identity`,
|
|
296
297
|
);
|
|
297
298
|
}
|
|
298
299
|
const lastContract = manifest.migrations.lastContractVersion;
|