@caelo-cms/provisioning 0.10.49 → 0.10.51

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 (191) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/adapter.d.ts +4 -6
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/admin-internal.d.ts +41 -0
  6. package/dist/admin-internal.d.ts.map +1 -0
  7. package/dist/admin-internal.js +169 -0
  8. package/dist/admin-internal.js.map +1 -0
  9. package/dist/agent-db.js +93 -20
  10. package/dist/aws-task-roles.d.ts +27 -0
  11. package/dist/aws-task-roles.d.ts.map +1 -0
  12. package/dist/aws-task-roles.js +35 -0
  13. package/dist/aws-task-roles.js.map +1 -0
  14. package/dist/caddy-redirects.d.ts +15 -0
  15. package/dist/caddy-redirects.d.ts.map +1 -0
  16. package/dist/caddy-redirects.js +65 -0
  17. package/dist/caddy-redirects.js.map +1 -0
  18. package/dist/caddy.d.ts +22 -2
  19. package/dist/caddy.d.ts.map +1 -1
  20. package/dist/caddy.js +66 -17
  21. package/dist/caddy.js.map +1 -1
  22. package/dist/cli-routing.d.ts.map +1 -1
  23. package/dist/cli-routing.js +7 -0
  24. package/dist/cli-routing.js.map +1 -1
  25. package/dist/cli.js +282 -255
  26. package/dist/cli.js.map +1 -1
  27. package/dist/compose.d.ts +54 -2
  28. package/dist/compose.d.ts.map +1 -1
  29. package/dist/compose.js +88 -34
  30. package/dist/compose.js.map +1 -1
  31. package/dist/deployed-release.d.ts +3 -2
  32. package/dist/deployed-release.d.ts.map +1 -1
  33. package/dist/deployed-release.js +12 -5
  34. package/dist/deployed-release.js.map +1 -1
  35. package/dist/gateway-credentials.d.ts.map +1 -1
  36. package/dist/gateway-credentials.js +6 -3
  37. package/dist/gateway-credentials.js.map +1 -1
  38. package/dist/gcloud.d.ts +2 -2
  39. package/dist/gcloud.d.ts.map +1 -1
  40. package/dist/gcloud.js +3 -3
  41. package/dist/gcloud.js.map +1 -1
  42. package/dist/gcp-names.d.ts +12 -0
  43. package/dist/gcp-names.d.ts.map +1 -1
  44. package/dist/gcp-names.js +21 -0
  45. package/dist/gcp-names.js.map +1 -1
  46. package/dist/gcp-url-map.d.ts +27 -0
  47. package/dist/gcp-url-map.d.ts.map +1 -0
  48. package/dist/gcp-url-map.js +62 -0
  49. package/dist/gcp-url-map.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/infra-contract.d.ts +20 -1
  55. package/dist/infra-contract.d.ts.map +1 -1
  56. package/dist/infra-contract.js +44 -1
  57. package/dist/infra-contract.js.map +1 -1
  58. package/dist/owner-reset.d.ts +37 -0
  59. package/dist/owner-reset.d.ts.map +1 -0
  60. package/dist/owner-reset.js +80 -0
  61. package/dist/owner-reset.js.map +1 -0
  62. package/dist/public-role-isolation.d.ts +19 -0
  63. package/dist/public-role-isolation.d.ts.map +1 -0
  64. package/dist/public-role-isolation.js +69 -0
  65. package/dist/public-role-isolation.js.map +1 -0
  66. package/dist/release-contract.d.ts.map +1 -1
  67. package/dist/release-contract.js +3 -0
  68. package/dist/release-contract.js.map +1 -1
  69. package/dist/release-manifest.d.ts.map +1 -1
  70. package/dist/release-manifest.js +2 -1
  71. package/dist/release-manifest.js.map +1 -1
  72. package/dist/self-hosted-backup.d.ts +86 -0
  73. package/dist/self-hosted-backup.d.ts.map +1 -0
  74. package/dist/self-hosted-backup.js +350 -0
  75. package/dist/self-hosted-backup.js.map +1 -0
  76. package/dist/self-hosted-release.d.ts +12 -3
  77. package/dist/self-hosted-release.d.ts.map +1 -1
  78. package/dist/self-hosted-release.js +6 -4
  79. package/dist/self-hosted-release.js.map +1 -1
  80. package/dist/self-hosted-start.d.ts +50 -0
  81. package/dist/self-hosted-start.d.ts.map +1 -0
  82. package/dist/self-hosted-start.js +59 -0
  83. package/dist/self-hosted-start.js.map +1 -0
  84. package/dist/self-hosted-upgrade.d.ts +30 -0
  85. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  86. package/dist/self-hosted-upgrade.js +29 -7
  87. package/dist/self-hosted-upgrade.js.map +1 -1
  88. package/dist/stack-contract.d.ts +9 -10
  89. package/dist/stack-contract.d.ts.map +1 -1
  90. package/dist/stack-contract.js +21 -10
  91. package/dist/stack-contract.js.map +1 -1
  92. package/dist/staging-admin.d.ts +12 -20
  93. package/dist/staging-admin.d.ts.map +1 -1
  94. package/dist/staging-admin.js +7 -159
  95. package/dist/staging-admin.js.map +1 -1
  96. package/dist/tar-entries.d.ts +14 -0
  97. package/dist/tar-entries.d.ts.map +1 -0
  98. package/dist/tar-entries.js +104 -0
  99. package/dist/tar-entries.js.map +1 -0
  100. package/dist/upgrade/converge.d.ts +2 -2
  101. package/dist/upgrade/converge.d.ts.map +1 -1
  102. package/dist/upgrade/converge.js +44 -2
  103. package/dist/upgrade/converge.js.map +1 -1
  104. package/dist/upgrader-assess.d.ts.map +1 -1
  105. package/dist/upgrader-assess.js +2 -1
  106. package/dist/upgrader-assess.js.map +1 -1
  107. package/dist/url-map-redirects-access.d.ts +15 -0
  108. package/dist/url-map-redirects-access.d.ts.map +1 -0
  109. package/dist/url-map-redirects-access.js +81 -0
  110. package/dist/url-map-redirects-access.js.map +1 -0
  111. package/dist/wizard.d.ts.map +1 -1
  112. package/dist/wizard.js +3 -1
  113. package/dist/wizard.js.map +1 -1
  114. package/dist/wizards/gcp-cost.js +1 -1
  115. package/dist/wizards/gcp.d.ts.map +1 -1
  116. package/dist/wizards/gcp.js +45 -0
  117. package/dist/wizards/gcp.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/adapter.ts +4 -6
  120. package/src/admin-internal.ts +212 -0
  121. package/src/admin-memory.test.ts +2 -2
  122. package/src/aws-task-roles.ts +58 -0
  123. package/src/caddy-redirects.docker.test.ts +255 -0
  124. package/src/caddy-redirects.test.ts +115 -0
  125. package/src/caddy-redirects.ts +68 -0
  126. package/src/caddy-validate.test.ts +119 -0
  127. package/src/caddy.test.ts +108 -0
  128. package/src/caddy.ts +89 -20
  129. package/src/checkpoint-secrets.test.ts +0 -1
  130. package/src/cli-routing.test.ts +12 -0
  131. package/src/cli-routing.ts +7 -0
  132. package/src/cli.ts +340 -288
  133. package/src/compose.ts +108 -36
  134. package/src/deployed-release.test.ts +22 -4
  135. package/src/deployed-release.ts +14 -7
  136. package/src/gateway-credentials.ts +6 -3
  137. package/src/gateway-env-isolation.test.ts +212 -0
  138. package/src/gcloud.ts +5 -3
  139. package/src/gcp-names.ts +23 -0
  140. package/src/gcp-url-map.test.ts +150 -0
  141. package/src/gcp-url-map.ts +81 -0
  142. package/src/index.test.ts +24 -152
  143. package/src/index.ts +0 -9
  144. package/src/infra-contract.test.ts +40 -0
  145. package/src/infra-contract.ts +47 -1
  146. package/src/owner-reset.test.ts +165 -0
  147. package/src/owner-reset.ts +106 -0
  148. package/src/public-role-isolation.test.ts +89 -0
  149. package/src/public-role-isolation.ts +83 -0
  150. package/src/release-contract.ts +3 -0
  151. package/src/release-manifest.test.ts +12 -0
  152. package/src/release-manifest.ts +2 -1
  153. package/src/runtime-secrets.test.ts +25 -8
  154. package/src/self-hosted-backup.test.ts +344 -0
  155. package/src/self-hosted-backup.ts +480 -0
  156. package/src/self-hosted-compose.test.ts +88 -4
  157. package/src/self-hosted-release.ts +22 -7
  158. package/src/self-hosted-start.test.ts +68 -0
  159. package/src/self-hosted-start.ts +123 -0
  160. package/src/self-hosted-upgrade.ts +35 -8
  161. package/src/stack-contract.test.ts +11 -20
  162. package/src/stack-contract.ts +25 -16
  163. package/src/stack-converge-secrets.test.ts +45 -10
  164. package/src/stack-converge.test.ts +19 -10
  165. package/src/staging-admin.ts +18 -181
  166. package/src/tar-entries.test.ts +52 -0
  167. package/src/tar-entries.ts +116 -0
  168. package/src/upgrade/converge.ts +51 -4
  169. package/src/upgrade/upgrade.test.ts +78 -1
  170. package/src/upgrader-assess.test.ts +22 -0
  171. package/src/upgrader-assess.ts +2 -1
  172. package/src/url-map-redirects-access.test.ts +88 -0
  173. package/src/url-map-redirects-access.ts +89 -0
  174. package/src/wizard.ts +3 -1
  175. package/src/wizards/gcp-cost.ts +1 -1
  176. package/src/wizards/gcp.ts +48 -0
  177. package/stacks/aws/README.md +1 -1
  178. package/stacks/aws/index.ts +89 -45
  179. package/stacks/azure/README.md +1 -1
  180. package/stacks/azure/index.ts +21 -14
  181. package/stacks/gcp/README.md +1 -1
  182. package/stacks/gcp/index.ts +26 -22
  183. package/stacks/gcp-firebase/index.ts +7 -3
  184. package/stacks/self-hosted/Pulumi.yaml +2 -2
  185. package/stacks/self-hosted/README.md +3 -3
  186. package/stacks/self-hosted/index.ts +45 -22
  187. package/dist/redirects-emit.d.ts +0 -52
  188. package/dist/redirects-emit.d.ts.map +0 -1
  189. package/dist/redirects-emit.js +0 -111
  190. package/dist/redirects-emit.js.map +0 -1
  191. package/src/redirects-emit.ts +0 -175
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #587 — the `gcp` stack's URL map under Pulumi mocks: host routing, the
5
+ * public path matcher the admin writes redirect rules into, the field the
6
+ * stack ignores after creation; the admin's custom role is the CLI's.
7
+ */
8
+
9
+ import { beforeAll, describe, expect, it } from "bun:test";
10
+ import { readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { GCP_PUBLIC_BASE_PATHS, isGcpRedirectRule, withGcpRedirects } from "@caelo-cms/shared";
13
+ import * as pulumi from "@pulumi/pulumi";
14
+ import { URL_MAP_REDIRECTS_PERMISSIONS, urlMapRedirectsRoleId } from "./gcp-names.js";
15
+
16
+ interface Registered {
17
+ readonly type: string;
18
+ readonly name: string;
19
+ readonly inputs: Record<string, unknown>;
20
+ }
21
+ const registered: Registered[] = [];
22
+
23
+ beforeAll(() => {
24
+ pulumi.runtime.setMocks(
25
+ {
26
+ newResource: (args) => {
27
+ registered.push({ type: args.type, name: args.name, inputs: args.inputs });
28
+ return { id: `${args.name}-id`, state: args.inputs };
29
+ },
30
+ call: (args) => args.inputs,
31
+ },
32
+ "caelo",
33
+ "production",
34
+ false,
35
+ );
36
+ });
37
+
38
+ const resolved = <T>(o: pulumi.Output<T>): Promise<T> => new Promise((r) => o.apply(r));
39
+
40
+ describe("gcp stack URL map (Pulumi mocks)", () => {
41
+ it("routes the site host to the bucket with /api/* to the gateway, the admin host to the admin", async () => {
42
+ // Imported after setMocks so the resource registers against the mock monitor.
43
+ const { createSiteUrlMap, PUBLIC_PATH_MATCHER } = await import("./gcp-url-map.js");
44
+ const map = createSiteUrlMap(
45
+ "caelo-production-url-map",
46
+ {
47
+ domain: "example.com",
48
+ adminDomain: "admin.example.com",
49
+ staticBackend: "bucket-id",
50
+ gatewayBackend: "gateway-id",
51
+ adminBackend: "admin-id",
52
+ },
53
+ {},
54
+ );
55
+ const [hostRules, pathMatchers, defaultService] = await Promise.all([
56
+ resolved(map.hostRules),
57
+ resolved(map.pathMatchers),
58
+ resolved(map.defaultService),
59
+ ]);
60
+ expect(defaultService).toBe("bucket-id");
61
+ expect(hostRules).toEqual([
62
+ { hosts: ["example.com"], pathMatcher: PUBLIC_PATH_MATCHER },
63
+ { hosts: ["admin.example.com"], pathMatcher: "admin" },
64
+ ]);
65
+ expect(pathMatchers).toEqual([
66
+ {
67
+ name: PUBLIC_PATH_MATCHER,
68
+ defaultService: "bucket-id",
69
+ pathRules: [{ paths: [...GCP_PUBLIC_BASE_PATHS], service: "gateway-id" }],
70
+ },
71
+ { name: "admin", defaultService: "admin-id" },
72
+ ]);
73
+ expect(registered.find((r) => r.name === "caelo-production-url-map")?.type).toBe(
74
+ "gcp:compute/uRLMap:URLMap",
75
+ );
76
+
77
+ // The rules the admin writes on Confirm-publish fit this matcher and keep
78
+ // its routing — the base the Stage-time budget check assumes.
79
+ const first = pathMatchers?.[0];
80
+ if (!first) throw new Error("no public path matcher");
81
+ const withRedirects = withGcpRedirects(
82
+ {
83
+ name: first.name,
84
+ pathRules: (first.pathRules ?? []).map((r) => ({
85
+ paths: r.paths ?? [],
86
+ service: r.service ?? "",
87
+ })),
88
+ },
89
+ {
90
+ redirects: [
91
+ {
92
+ fromPath: "/old",
93
+ matchPaths: ["/old", "/old/"],
94
+ location: "/new/",
95
+ statusCode: 301,
96
+ preserveQuery: true,
97
+ },
98
+ ],
99
+ gone: [],
100
+ shadowedByBuild: [],
101
+ },
102
+ );
103
+ expect(withRedirects.pathRules?.filter((r) => !isGcpRedirectRule(r))).toEqual([
104
+ { paths: ["/api/*"], service: "gateway-id" },
105
+ ]);
106
+ });
107
+
108
+ it("ignores the public path matcher's path rules after creation (the admin owns the redirects)", async () => {
109
+ const { SITE_URL_MAP_IGNORE_CHANGES, siteUrlMapArgs, PUBLIC_PATH_MATCHER } = await import(
110
+ "./gcp-url-map.js"
111
+ );
112
+ expect(SITE_URL_MAP_IGNORE_CHANGES).toEqual(["pathMatchers[0].pathRules"]);
113
+ const args = siteUrlMapArgs({
114
+ domain: "d",
115
+ adminDomain: "a.d",
116
+ staticBackend: "s",
117
+ gatewayBackend: "g",
118
+ adminBackend: "a",
119
+ });
120
+ // Index 0 must stay the public matcher, or the ignore would hit the admin's.
121
+ expect((args.pathMatchers as { name: string }[])[0]?.name).toBe(PUBLIC_PATH_MATCHER);
122
+ });
123
+ });
124
+
125
+ describe("gcp stack wiring (source)", () => {
126
+ const src = readFileSync(resolve(import.meta.dir, "../stacks/gcp/index.ts"), "utf8");
127
+
128
+ it("builds the URL map through createSiteUrlMap", () => {
129
+ expect(src).toMatch(/const urlMap = createSiteUrlMap\(\s*`\$\{namePrefix\}-url-map`/);
130
+ expect(src).not.toMatch(/new gcp\.compute\.URLMap\(\s*`\$\{namePrefix\}-url-map`/);
131
+ });
132
+
133
+ it("leaves the admin's URL-map redirects role to the CLI (installer + upgrade)", () => {
134
+ expect(src).not.toContain("IAMCustomRole");
135
+ expect(src).not.toContain("urlMapRedirects");
136
+ });
137
+
138
+ it("names a valid custom role id with only the permissions the writer needs", () => {
139
+ expect(urlMapRedirectsRoleId("production")).toBe("caelo_production_urlMapRedirects");
140
+ expect(urlMapRedirectsRoleId("production")).toMatch(/^[A-Za-z0-9_.]{3,64}$/);
141
+ expect([...URL_MAP_REDIRECTS_PERMISSIONS].sort()).toEqual([
142
+ "compute.backendBuckets.use",
143
+ "compute.backendServices.use",
144
+ "compute.globalOperations.get",
145
+ "compute.urlMaps.get",
146
+ "compute.urlMaps.list",
147
+ "compute.urlMaps.update",
148
+ ]);
149
+ });
150
+ });
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The `gcp` stack's URL map, and who may write its redirect rules (#587).
5
+ *
6
+ * The admin writes the redirects table into the site host's path matcher
7
+ * as `urlRedirect` path rules on every Confirm-publish (admin-core
8
+ * deploy/gcp-url-map-redirects.ts). Those rules change at runtime, not with
9
+ * `pulumi up`, so the stack must not own them: the URL map ignores changes
10
+ * to the public path matcher's `pathRules` after creation. Otherwise a
11
+ * `pulumi refresh` + `up` would wipe every redirect until the next publish.
12
+ * The admin's writer keeps every non-redirect rule (`/api/*`) as it finds
13
+ * it.
14
+ *
15
+ * The admin's run SA gets a project custom role with only the permissions
16
+ * that read-modify-write needs (`URL_MAP_REDIRECTS_PERMISSIONS`, gcp-names.ts);
17
+ * the CLI creates it (url-map-redirects-access.ts), not the stack.
18
+ *
19
+ * Imported by the stack from `dist/`; keep it free of CLI dependencies.
20
+ */
21
+
22
+ import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
23
+ import * as gcp from "@pulumi/gcp";
24
+ import type * as pulumi from "@pulumi/pulumi";
25
+
26
+ /** Name of the site host's path matcher. */
27
+ export const PUBLIC_PATH_MATCHER = "public";
28
+
29
+ /**
30
+ * The URL map's `ignoreChanges`: the public path matcher (index 0, see
31
+ * {@link siteUrlMapArgs}) holds the admin-managed redirect rules.
32
+ */
33
+ export const SITE_URL_MAP_IGNORE_CHANGES: readonly string[] = ["pathMatchers[0].pathRules"];
34
+
35
+ /** Inputs of {@link siteUrlMapArgs}. */
36
+ export interface SiteUrlMapInputs {
37
+ readonly domain: string;
38
+ readonly adminDomain: string;
39
+ readonly staticBackend: pulumi.Input<string>;
40
+ readonly gatewayBackend: pulumi.Input<string>;
41
+ readonly adminBackend: pulumi.Input<string>;
42
+ }
43
+
44
+ /**
45
+ * 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.
49
+ */
50
+ export function siteUrlMapArgs(i: SiteUrlMapInputs): gcp.compute.URLMapArgs {
51
+ return {
52
+ defaultService: i.staticBackend,
53
+ hostRules: [
54
+ { hosts: [i.domain], pathMatcher: PUBLIC_PATH_MATCHER },
55
+ { hosts: [i.adminDomain], pathMatcher: "admin" },
56
+ ],
57
+ pathMatchers: [
58
+ {
59
+ name: PUBLIC_PATH_MATCHER,
60
+ defaultService: i.staticBackend,
61
+ pathRules: [{ paths: [...GCP_PUBLIC_BASE_PATHS], service: i.gatewayBackend }],
62
+ },
63
+ {
64
+ name: "admin",
65
+ defaultService: i.adminBackend,
66
+ },
67
+ ],
68
+ };
69
+ }
70
+
71
+ /** The stack's URL map (see the module comment for the ignored field). */
72
+ export function createSiteUrlMap(
73
+ name: string,
74
+ inputs: SiteUrlMapInputs,
75
+ opts: pulumi.CustomResourceOptions,
76
+ ): gcp.compute.URLMap {
77
+ return new gcp.compute.URLMap(name, siteUrlMapArgs(inputs), {
78
+ ...opts,
79
+ ignoreChanges: [...(opts.ignoreChanges ?? []), ...SITE_URL_MAP_IGNORE_CHANGES],
80
+ });
81
+ }
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,43 +116,42 @@ 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
  });
138
134
 
139
- it("gives the gateway gateway_role + public_role and no admin credential (#613)", () => {
135
+ it("gives the gateway public_role only and no cms_admin credential (#613, #635)", () => {
140
136
  const out = generateDockerCompose({
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
- expect(gateway).toContain(
152
- 'GATEWAY_DATABASE_URL: "postgres://gateway_role:gatewaypw@postgres:5432/cms_admin"',
153
- );
154
147
  expect(gateway).toContain(
155
148
  'PUBLIC_DATABASE_URL: "postgres://public_role:publicpw@postgres:5432/cms_public"',
156
149
  );
157
- expect(gateway).not.toMatch(/ADMIN_DATABASE|admin_role|adminpw|supersecret|CAELO_SECRET_KEK/);
158
- // bootstrap.sh creates the three roles from these.
150
+ expect(gateway).not.toMatch(
151
+ /ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
152
+ );
153
+ // bootstrap.sh still creates the three roles from these (gateway_role
154
+ // until the contract release drops it).
159
155
  expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
160
156
  });
161
157
 
@@ -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,40 @@
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 5: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(5);
25
+ expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
+ expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
+ expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
28
+ });
29
+
30
+ it("lists the pending steps oldest first", () => {
31
+ expect(pendingInfraSteps(2, 4)).toEqual([INFRA_CONTRACT_STEPS[3], INFRA_CONTRACT_STEPS[4]]);
32
+ expect(pendingInfraSteps(3, 4)).toEqual([INFRA_CONTRACT_STEPS[4]]);
33
+ expect(pendingInfraSteps(4, 4)).toEqual([]);
34
+ expect(pendingInfraSteps(0, 1)).toEqual([INFRA_CONTRACT_STEPS[1]]);
35
+ });
36
+
37
+ it("names a version a newer release added by number", () => {
38
+ expect(infraStepsText(5, 6)).toBe("infrastructure step 6 (see that release's notes)");
39
+ });
40
+ });
@@ -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 = 5;
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,49 @@ 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
+ // 5 — #635: the gateway has no cms_admin login. Its GATEWAY_DATABASE_URL /
40
+ // GATEWAY_DATABASE_PASSWORD go, and its SA loses access to the
41
+ // gateway-role-password secret (a retired IAM binding, removed after
42
+ // the roll — the in-cloud upgrader cannot change IAM).
43
+
44
+ /**
45
+ * What each contract version adds, in operator terms — the upgrader's
46
+ * refusal names the steps an install is missing. Keep one entry per version
47
+ * (the history above says why each bump happened).
48
+ */
49
+ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
50
+ 1: "the converge/app-roll split",
51
+ 2: "the in-cloud upgrader job and its scoped permissions",
52
+ 3: "public_role out of the Cloud SQL superuser group (#633)",
53
+ 4: "the admin's permission to serve redirects from the load balancer (#587)",
54
+ 5: "the gateway's cms_admin login removed from its env and IAM (#635)",
55
+ };
56
+
57
+ /**
58
+ * The steps an install at contract `installed` lacks for `target`, oldest
59
+ * first. A release newer than this code can carry versions it does not know
60
+ * (the upgrader reads the NEW release's manifest); those are named by number.
61
+ *
62
+ * @example
63
+ * pendingInfraSteps(2, 4)
64
+ * // → ["public_role out of the Cloud SQL superuser group (#633)",
65
+ * // "the admin's permission to serve redirects from the load balancer (#587)"]
66
+ */
67
+ export function pendingInfraSteps(installed: number, target: number): string[] {
68
+ const steps: string[] = [];
69
+ for (let v = Math.max(installed, 0) + 1; v <= target; v += 1) {
70
+ steps.push(INFRA_CONTRACT_STEPS[v] ?? `infrastructure step ${v} (see that release's notes)`);
71
+ }
72
+ return steps;
73
+ }
74
+
75
+ /** {@link pendingInfraSteps} as one sentence fragment for messages. */
76
+ export function infraStepsText(installed: number, target: number): string {
77
+ return pendingInfraSteps(installed, target).join("; ");
78
+ }