@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/compose.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * P14 — production docker-compose.yml generator.
5
5
  *
6
- * Emits the production stack: Postgres + pgBackRest sidecar + Caddy +
7
- * MinIO + the two Caelo services (admin, gateway). Idempotent: same input →
6
+ * Emits the production stack: Postgres + Caddy + the two Caelo services
7
+ * (admin, gateway). Idempotent: same input →
8
8
  * byte-identical output. The CLI's `init` / `up` / `upgrade` sub-commands
9
9
  * write this to `.caelo/docker-compose.yml`.
10
10
  *
@@ -15,8 +15,20 @@
15
15
  * `docker compose pull` change the running release behind the operator's
16
16
  * back. Contributors who want their working tree in the containers ask for
17
17
  * {@link DevImages} explicitly (`init --dev`) — never the default.
18
+ *
19
+ * #707 — no `container_name`: Compose names containers per project, so an
20
+ * install can share a host with other stacks (a fixed `caelo-postgres`
21
+ * collides with the contributor dev stack's container of that name). Every
22
+ * CLI command addresses containers by service name (`docker compose exec`).
23
+ * Backups are `pg_dump`s plus the media and site files (self-hosted-backup.ts);
24
+ * there is no pgBackRest sidecar — it never had access to the data volume.
25
+ * No MinIO either: nothing in Caelo used it (media live in `.caelo/media/`),
26
+ * and its `minio/minio` image is no longer published, so the service made
27
+ * every fresh `docker compose up` fail.
18
28
  */
19
29
 
30
+ import { CADDY_REDIRECTS_FILE } from "@caelo-cms/shared";
31
+ import { caddyRedirectReloadCommand } from "./caddy-redirects.js";
20
32
  import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB, publicSiteUrl } from "./stack-contract.js";
21
33
  import { ghcrRepository, isImageDigest, type ReleaseService } from "./upgrade/release-images.js";
22
34
 
@@ -51,6 +63,13 @@ export const BOOTSTRAP_DIR = "bootstrap";
51
63
  */
52
64
  export const SITE_OUTPUT_ROOT = "/srv/caelo";
53
65
 
66
+ /**
67
+ * #587 — every site's redirect snippet as Caddy sees it (one per deploy
68
+ * target below {@link SITE_OUTPUT_ROOT}); the Caddy container reloads when
69
+ * any of them changes (caddy-redirects.ts).
70
+ */
71
+ export const COMPOSE_REDIRECT_SNIPPETS = `${SITE_OUTPUT_ROOT}/output/*/current/${CADDY_REDIRECTS_FILE}`;
72
+
54
73
  /** `.caelo/<this>/` holds the uploaded media (the admin's local media adapter root). */
55
74
  export const MEDIA_DIR = "media";
56
75
  /** Where {@link MEDIA_DIR} appears in the admin container (its `MEDIA_ROOT_DIR`). */
@@ -59,6 +78,66 @@ export const MEDIA_ROOT = "/data/media";
59
78
  /** The gateway's HTTP port inside the Compose network. */
60
79
  export const COMPOSE_GATEWAY_PORT = 8090;
61
80
 
81
+ /**
82
+ * The Compose services that run Caelo code — what `restore` stops while it
83
+ * rewrites the databases, and what the health checks probe. Workers run
84
+ * inside the admin (CLAUDE.md §11.B Tier 5); there is no orchestrator or
85
+ * runner service.
86
+ */
87
+ export const COMPOSE_APP_SERVICES = ["caelo-admin", "caelo-gateway"] as const;
88
+
89
+ /**
90
+ * Where Caddy reaches the admin and the gateway: their Compose service
91
+ * names. Caddy runs in its own container, so `localhost` there is Caddy
92
+ * itself (issue #707).
93
+ */
94
+ export const COMPOSE_ADMIN_UPSTREAM = `caelo-admin:${COMPOSE_ADMIN_PORT}`;
95
+ export const COMPOSE_GATEWAY_UPSTREAM = `caelo-gateway:${COMPOSE_GATEWAY_PORT}`;
96
+
97
+ /**
98
+ * The Caddy image, pinned to the release the generated Caddyfile is
99
+ * validated against (caddy-validate.test.ts runs `caddy validate` on this
100
+ * exact image in CI). A floating `caddy:2-alpine` could pick up a Caddy
101
+ * that rejects the config on the next `docker compose pull`.
102
+ */
103
+ export const CADDY_IMAGE = "caddy:2.11.7-alpine";
104
+
105
+ /** The host ports Caddy publishes for HTTP and HTTPS. */
106
+ export interface PublishedPorts {
107
+ readonly http: number;
108
+ readonly https: number;
109
+ }
110
+
111
+ /**
112
+ * The Compose project an install's containers, network and volumes belong
113
+ * to, emitted as the file's top-level `name`. Without it Compose names the
114
+ * project after the directory holding the file — `.caelo` → `caelo` for
115
+ * every install, so two installs on one host would share containers and
116
+ * the database volume. New installs get one per domain; installs from
117
+ * before this was stored keep `caelo`, the name their volumes carry.
118
+ */
119
+ export function composeProjectName(domain: string): string {
120
+ return `caelo-${domain
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9]+/g, "-")
123
+ .replace(/^-+|-+$/g, "")}`;
124
+ }
125
+
126
+ /** Whether `name` is a Compose project name (lowercase, digits, `-`, `_`; leading letter or digit). */
127
+ export function isComposeProjectName(name: string): boolean {
128
+ return /^[a-z0-9][a-z0-9_-]*$/.test(name);
129
+ }
130
+
131
+ /** `https://<domain>`, with the published HTTPS port when it is not 443. */
132
+ export function selfHostedSiteUrl(domain: string, httpsPort: number): string {
133
+ return httpsPort === 443 ? publicSiteUrl(domain) : `${publicSiteUrl(domain)}:${httpsPort}`;
134
+ }
135
+
136
+ /** Whether `port` can be published on the host (any TCP port). */
137
+ export function isPublishablePort(port: number): boolean {
138
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
139
+ }
140
+
62
141
  /**
63
142
  * `ghcr.io/caelo-cms/<service>@<digest>` — the only image reference shape a
64
143
  * release-mode Compose file accepts.
@@ -95,8 +174,6 @@ export interface ComposeSpec {
95
174
  /** The Postgres superuser's; only the database container uses it. */
96
175
  readonly postgresPassword: string; // generated by cms-provision; persisted in .caelo/secrets
97
176
  readonly rolePasswords: RolePasswords;
98
- readonly minioRootUser: string;
99
- readonly minioRootPassword: string;
100
177
  /**
101
178
  * P18 — project KEK (32 bytes hex) for at-rest encryption of AI provider
102
179
  * keys + future secrets. Generated per install by cms-provision; persisted
@@ -116,6 +193,13 @@ export interface ComposeSpec {
116
193
  readonly adminMemory?: string;
117
194
  /** Which admin + gateway images run (see {@link ComposeImages}). */
118
195
  readonly images: ComposeImages;
196
+ /**
197
+ * The host ports Caddy listens on (`80` / `443` for a public install —
198
+ * ACME's HTTP and TLS-ALPN challenges need exactly those).
199
+ */
200
+ readonly ports: PublishedPorts;
201
+ /** The Compose project ({@link composeProjectName}). */
202
+ readonly project: string;
119
203
  /**
120
204
  * #696 Phase 4c — the host update agent's database login, when the agent
121
205
  * is set up: Postgres is then also published on `127.0.0.1:<dbPort>` (never
@@ -142,6 +226,15 @@ export function generateDockerCompose(spec: ComposeSpec): string {
142
226
  }
143
227
  const env = (k: string, v: string | undefined): string =>
144
228
  v ? ` ${k}: "${escapeYaml(v)}"` : "";
229
+ for (const port of [spec.ports.http, spec.ports.https]) {
230
+ if (!isPublishablePort(port)) throw new Error(`port ${port} is not a TCP port`);
231
+ }
232
+ if (!isComposeProjectName(spec.project)) {
233
+ throw new Error(`${JSON.stringify(spec.project)} is not a Compose project name`);
234
+ }
235
+ if (spec.ports.http === spec.ports.https) {
236
+ throw new Error(`HTTP and HTTPS cannot share port ${spec.ports.http}`);
237
+ }
145
238
  if (spec.agent && !isAgentDbPort(spec.agent.dbPort)) {
146
239
  throw new Error(`agent dbPort ${spec.agent.dbPort} is not a TCP port between 1024 and 65535`);
147
240
  }
@@ -149,10 +242,11 @@ export function generateDockerCompose(spec: ComposeSpec): string {
149
242
  # Generated by cms-provision — do not edit; re-run \`bunx cms-provision up\`.
150
243
  # Domain: ${spec.domain}
151
244
 
245
+ name: ${spec.project}
246
+
152
247
  services:
153
248
  postgres:
154
249
  image: postgres:16-alpine
155
- container_name: caelo-postgres
156
250
  restart: unless-stopped
157
251
  environment:
158
252
  POSTGRES_PASSWORD: "${escapeYaml(spec.postgresPassword)}"
@@ -170,31 +264,13 @@ ${postgresAgent(spec.agent)} volumes:
170
264
  timeout: 5s
171
265
  retries: 5
172
266
 
173
- pgbackrest:
174
- image: pgbackrest/pgbackrest:latest
175
- container_name: caelo-pgbackrest
176
- restart: unless-stopped
177
- depends_on: [postgres]
178
- volumes:
179
- - caelo-pg-backups:/var/lib/pgbackrest
180
- - ./pgbackrest.conf:/etc/pgbackrest.conf:ro
181
-
182
- minio:
183
- image: minio/minio:latest
184
- container_name: caelo-minio
185
- restart: unless-stopped
186
- command: server /data --console-address ":9001"
187
- environment:
188
- MINIO_ROOT_USER: "${escapeYaml(spec.minioRootUser)}"
189
- MINIO_ROOT_PASSWORD: "${escapeYaml(spec.minioRootPassword)}"
190
- volumes:
191
- - caelo-minio:/data
192
-
193
267
  caddy:
194
- image: caddy:2-alpine
195
- container_name: caelo-caddy
268
+ image: ${CADDY_IMAGE}
269
+ # #587 — Caddy plus a loop that reloads it when a deploy changes a
270
+ # site's _redirects.caddy import (caddy-redirects.ts).
271
+ ${caddyRedirectReloadCommand(COMPOSE_REDIRECT_SNIPPETS)}
196
272
  restart: unless-stopped
197
- ports: ["80:80", "443:443"]
273
+ ports: ["${spec.ports.http}:80", "${spec.ports.https}:443"]
198
274
  volumes:
199
275
  - ./Caddyfile:/etc/caddy/Caddyfile:ro
200
276
  - caelo-caddy-data:/data
@@ -207,8 +283,6 @@ ${gatewayService(spec)}
207
283
 
208
284
  volumes:
209
285
  caelo-pg:
210
- caelo-pg-backups:
211
- caelo-minio:
212
286
  caelo-caddy-data:
213
287
  caelo-caddy-config:
214
288
  `;
@@ -278,7 +352,7 @@ ${serviceRuntime(
278
352
  // `.caelo/bootstrap/pending-token.json`; the admin reads (and deletes) it
279
353
  // from `/app/.caelo/` on first boot (hooks.server.ts). Only that dir is
280
354
  // mounted — never `.caelo/` itself, which holds config.json's superuser
281
- // and MinIO passwords. Without the token /setup accepts any first Owner.
355
+ // password. Without the token /setup accepts any first Owner.
282
356
  // The static site: the admin builds into it (CAELO_OUTPUT_ROOT below),
283
357
  // Caddy serves it read-only from the same host dir. On the host, so a
284
358
  // recreated admin (upgrade) keeps the published site.
@@ -289,7 +363,6 @@ ${serviceRuntime(
289
363
  `./${MEDIA_DIR}:${MEDIA_ROOT}`,
290
364
  ],
291
365
  )}
292
- container_name: caelo-admin
293
366
  restart: unless-stopped
294
367
  depends_on:
295
368
  postgres: { condition: service_healthy }
@@ -298,7 +371,7 @@ ${serviceRuntime(
298
371
  ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_admin")}"
299
372
  PUBLIC_ADMIN_DATABASE_URL: "${dbUrl("admin_role", spec.rolePasswords.admin, "cms_public")}"
300
373
  CAELO_SECRET_KEK: "${escapeYaml(spec.caeloSecretKek)}"
301
- CAELO_SITE_URL: "${escapeYaml(publicSiteUrl(spec.domain))}"
374
+ CAELO_SITE_URL: "${escapeYaml(selfHostedSiteUrl(spec.domain, spec.ports.https))}"
302
375
  PORT: "${COMPOSE_ADMIN_PORT}"
303
376
  CAELO_OUTPUT_ROOT: "${SITE_OUTPUT_ROOT}"
304
377
  MEDIA_ROOT_DIR: "${MEDIA_ROOT}"
@@ -311,7 +384,6 @@ ${env("RESEND_API_KEY", spec.resendApiKey)}`;
311
384
  function gatewayService(spec: ComposeSpec): string {
312
385
  return ` caelo-gateway:
313
386
  ${serviceRuntime(spec.images, "gateway", "bun run apps/api-gateway/src/server.ts")}
314
- container_name: caelo-gateway
315
387
  restart: unless-stopped
316
388
  depends_on:
317
389
  postgres: { condition: service_healthy }
package/src/gcp-names.ts CHANGED
@@ -133,3 +133,26 @@ export function upgraderServiceAccountId(env: string): string {
133
133
  export function upgraderServiceAccountEmail(projectId: string, env: string): string {
134
134
  return `${upgraderServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
135
135
  }
136
+
137
+ /**
138
+ * #587 — id of the project custom role that lets the admin write the
139
+ * redirect rules into the `gcp` stack's URL map (url-map-redirects.ts).
140
+ * Role ids allow letters, digits, `_` and `.`.
141
+ */
142
+ export function urlMapRedirectsRoleId(env: string): string {
143
+ return `caelo_${env.replace(/[^A-Za-z0-9]/g, "_")}_urlMapRedirects`;
144
+ }
145
+
146
+ /**
147
+ * #587 — permissions of {@link urlMapRedirectsRoleId}: read-modify-write of
148
+ * the URL map and waiting for the operation. A URL-map update must be
149
+ * allowed to "use" every backend the map references, hence the `.use` pair.
150
+ */
151
+ export const URL_MAP_REDIRECTS_PERMISSIONS: readonly string[] = [
152
+ "compute.urlMaps.list",
153
+ "compute.urlMaps.get",
154
+ "compute.urlMaps.update",
155
+ "compute.backendBuckets.use",
156
+ "compute.backendServices.use",
157
+ "compute.globalOperations.get",
158
+ ];
@@ -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
+ }