@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
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,13 +384,12 @@ ${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 }
318
390
  environment:
319
- # Its own two logins only — no authoring credential (#613, CLAUDE.md §2).
320
- GATEWAY_DATABASE_URL: "${dbUrl("gateway_role", spec.rolePasswords.gateway, "cms_admin")}"
391
+ # public_role on cms_public is its only database login (#613, #635,
392
+ # CLAUDE.md §2: the public side has no path to the authoring database).
321
393
  PUBLIC_DATABASE_URL: "${dbUrl("public_role", spec.rolePasswords.public, "cms_public")}"
322
394
  GATEWAY_PORT: "${COMPOSE_GATEWAY_PORT}"
323
395
  NODE_ENV: production`;
@@ -93,6 +93,21 @@ describe("checkDeployedRuntimeEnv", () => {
93
93
  });
94
94
 
95
95
  it("passes once both services read the password from Secret Manager", async () => {
96
+ const { run } = fakeGcloud({
97
+ ...lists(),
98
+ "describe caelo-production-admin-abc": [
99
+ svc([secret("ADMIN_DATABASE_PASSWORD"), secret("PUBLIC_ADMIN_DATABASE_PASSWORD")]),
100
+ ],
101
+ "describe caelo-production-gateway-def": [
102
+ svc([secret("PUBLIC_DATABASE_PASSWORD", "public-role-password")]),
103
+ ],
104
+ });
105
+ expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
106
+ ok: true,
107
+ });
108
+ });
109
+
110
+ it("stops a wizard re-run on a gateway from before #635 (it needs its gateway_role pool)", async () => {
96
111
  const { run } = fakeGcloud({
97
112
  ...lists(),
98
113
  "describe caelo-production-admin-abc": [
@@ -105,9 +120,12 @@ describe("checkDeployedRuntimeEnv", () => {
105
120
  ]),
106
121
  ],
107
122
  });
108
- expect(await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run })).toEqual({
109
- ok: true,
110
- });
123
+ const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
124
+ expect(r.ok).toBe(false);
125
+ if (!r.ok) {
126
+ expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
127
+ expect(r.error).toContain("Run `upgrade` first");
128
+ }
111
129
  });
112
130
 
113
131
  it("stops a wizard re-run on a gateway from before #613 (it needs the admin_role pool)", async () => {
@@ -123,7 +141,7 @@ describe("checkDeployedRuntimeEnv", () => {
123
141
  const r = await checkDeployedRuntimeEnv({ projectId: "p", region: "r", run });
124
142
  expect(r.ok).toBe(false);
125
143
  if (!r.ok) {
126
- expect(r.error).toContain("GATEWAY_DATABASE_PASSWORD");
144
+ expect(r.error).toContain("ADMIN_DATABASE_PASSWORD");
127
145
  expect(r.error).toContain("Run `upgrade` first");
128
146
  }
129
147
  });
@@ -15,7 +15,7 @@ import { gcloud as defaultGcloud } from "./gcloud.js";
15
15
  import type { GcloudRunner } from "./gcloud-retry.js";
16
16
  import { GCP_STACK_ENV, gcpNamePrefix } from "./gcp-names.js";
17
17
  import type { ImageDigests } from "./install-state.js";
18
- import { type CloudRunSlug, SERVICE_SECRET_ENV } from "./stack-contract.js";
18
+ import { type CloudRunSlug, RETIRED_SERVICE_ENV, SERVICE_SECRET_ENV } from "./stack-contract.js";
19
19
  import { liveContainerEnv } from "./stack-converge.js";
20
20
 
21
21
  /** The `sha256:…` digest an image reference pins, or null for a tag reference. */
@@ -61,8 +61,9 @@ async function findDeployedService(
61
61
  * reads the password from the URL). The wizard keeps the release an install
62
62
  * runs, so re-running it on an install `upgrade` hasn't moved over yet would
63
63
  * pair the old release with env it can't connect with. The same holds for a
64
- * gateway from before #613: its release needs the admin_role pool the
65
- * current contract no longer gives it (no GATEWAY_DATABASE_PASSWORD yet).
64
+ * gateway from before #613 or #635: its release needs a cms_admin pool
65
+ * (admin_role, then gateway_role) the current contract no longer gives it —
66
+ * it still carries the retired database vars.
66
67
  * Such an install has to go through `upgrade` first, which moves the image
67
68
  * and the env in one revision.
68
69
  */
@@ -88,13 +89,19 @@ export async function checkDeployedRuntimeEnv(opts: {
88
89
  return { ok: false, error: `describe ${found.name}: ${describe.stderr.trim()}` };
89
90
  }
90
91
  const env = liveContainerEnv(describe.stdout);
91
- const missing = Object.keys(SERVICE_SECRET_ENV[slug]).filter(
92
- (name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
93
- );
92
+ const missing = [
93
+ ...Object.keys(SERVICE_SECRET_ENV[slug]).filter(
94
+ (name) => name.endsWith("_PASSWORD") && env.get(name)?.kind !== "secret",
95
+ ),
96
+ // A database var the contract retired (the gateway's ADMIN_* before
97
+ // #613, its GATEWAY_* before #635) means the running release still
98
+ // needs that login.
99
+ ...RETIRED_SERVICE_ENV[slug].filter((name) => /DATABASE/.test(name) && env.has(name)),
100
+ ];
94
101
  if (missing.length > 0) {
95
102
  return {
96
103
  ok: false,
97
- error: `${found.name} does not read ${missing.join(", ")} from Secret Manager — its release predates the current database env (Secret Manager runtime secrets; for the gateway, its own database logins, #613). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
104
+ error: `${found.name} does not match the current database env (${missing.join(", ")}) — its release predates it (Secret Manager runtime secrets; for the gateway, its own database login, #613/#635). Run \`upgrade\` first: it moves the release and its env over together. Re-running the installer now would give the running release a database env it can't connect with.`,
98
105
  };
99
106
  }
100
107
  }
@@ -2,9 +2,12 @@
2
2
 
3
3
  /**
4
4
  * Moving an existing install's API gateway off admin_role (issue #613),
5
- * as `cms-provision upgrade` does it. The env side — GATEWAY_DATABASE_URL
6
- * and the per-role password secrets in, ADMIN_DATABASE_* out — rides the
7
- * gateway's roll (stack-contract.ts). The database side is ordered around
5
+ * as `cms-provision upgrade` does it. The env side — the per-role password
6
+ * secrets in, ADMIN_DATABASE_* out (and, since #635, GATEWAY_DATABASE_* out
7
+ * too: the gateway has no cms_admin login at all) — rides the gateway's
8
+ * roll (stack-contract.ts). gateway_role keeps getting its password until
9
+ * the contract release drops the role, because the previous release's
10
+ * gateway logs in with it until it is rolled. The database side is ordered around
8
11
  * that roll so /api/* keeps working:
9
12
  *
10
13
  * 1. after migrations (which create `gateway_role` without a password):
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #635 guardrail (c): on EVERY adapter, the API gateway's service
5
+ * env holds no cms_admin credential of any role, AND its identity cannot
6
+ * read a secret that unlocks one (CLAUDE.md §2: the public side has no path
7
+ * to cms_admin). Env strings alone are not enough: a role that may read
8
+ * admin_role's password is a path even when no env var names it.
9
+ *
10
+ * gcp, gcp-firebase the env contract (stack-contract.ts), every IAM
11
+ * binding the gateway SA gets (contract + stack source)
12
+ * aws the gateway's own ECS task role and the secrets its
13
+ * policy covers (aws-task-roles.ts + stack source)
14
+ * azure the gateway container app's env, and no identity or
15
+ * Key Vault access for any container app
16
+ * self-hosted the generated compose gateway service: env, secrets,
17
+ * mounts
18
+ *
19
+ * The gateway itself refuses to boot on such an env
20
+ * (apps/api-gateway `adminDatabaseAccessInEnv`); this catches it at review
21
+ * time, before a release can ship it.
22
+ */
23
+
24
+ import { describe, expect, it } from "bun:test";
25
+ import { readFileSync } from "node:fs";
26
+ import { resolve } from "node:path";
27
+ import { AWS_SERVICE_SECRETS, secretsReadPolicy } from "./aws-task-roles.js";
28
+ import { generateDockerCompose } from "./compose.js";
29
+ import {
30
+ databaseUrls,
31
+ gatewayEnvContract,
32
+ RETIRED_IAM_BINDINGS,
33
+ RETIRED_SERVICE_ENV,
34
+ runtimeSecretBindings,
35
+ SERVICE_SECRET_ENV,
36
+ stackIamInvariants,
37
+ } from "./stack-contract.js";
38
+
39
+ /** Anything in a gateway env value or name that reaches the authoring database. */
40
+ const ADMIN_ACCESS = /cms_admin|admin_role|gateway_role|ADMIN_DATABASE|GATEWAY_DATABASE/;
41
+
42
+ /** Secrets that unlock a cms_admin login. */
43
+ const ADMIN_SECRETS = ["postgres-password", "gateway-role-password"];
44
+
45
+ /** The secrets the gateway reads on GCP: its own login and the staging gate key. */
46
+ const GCP_GATEWAY_SECRETS = ["public-role-password", "staging-gate-secret"];
47
+
48
+ /** The source of a stack program. */
49
+ function stack(provider: "aws" | "azure" | "gcp" | "gcp-firebase"): string {
50
+ return readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
51
+ }
52
+
53
+ describe("gcp + gcp-firebase: the gateway env contract (#635)", () => {
54
+ const env = gatewayEnvContract({
55
+ provider: "gcp",
56
+ projectId: "acme",
57
+ env: "production",
58
+ domain: "example.com",
59
+ region: "europe-west1",
60
+ stagingHost: "staging.example.com",
61
+ databaseUrls: databaseUrls("10.20.0.3"),
62
+ });
63
+
64
+ it("carries no cms_admin URL, role or credential name", () => {
65
+ for (const v of env) {
66
+ expect(v.name).not.toMatch(ADMIN_ACCESS);
67
+ if ("value" in v && typeof v.value === "string") expect(v.value).not.toMatch(ADMIN_ACCESS);
68
+ }
69
+ expect(env.find((v) => v.name === "PUBLIC_DATABASE_URL")).toEqual({
70
+ name: "PUBLIC_DATABASE_URL",
71
+ value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
72
+ });
73
+ });
74
+
75
+ it("references no secret that unlocks a cms_admin login", () => {
76
+ for (const secret of Object.values(SERVICE_SECRET_ENV.gateway)) {
77
+ expect(ADMIN_SECRETS).not.toContain(secret);
78
+ }
79
+ const gatewayBindings = runtimeSecretBindings().filter((b) => b.service === "gateway");
80
+ for (const b of gatewayBindings) expect(ADMIN_SECRETS).not.toContain(b.secret);
81
+ });
82
+
83
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
84
+ it(`${provider}: the gateway SA's IAM bindings reach no cms_admin secret (review finding)`, () => {
85
+ const gateway = stackIamInvariants(provider).filter((b) => b.member === "gateway-sa");
86
+ for (const b of gateway) {
87
+ if (b.role === "roles/secretmanager.secretAccessor") {
88
+ // Per secret, never project-wide (that would cover every secret).
89
+ expect(b.target.kind).toBe("secret");
90
+ if (b.target.kind === "secret") expect(GCP_GATEWAY_SECRETS).toContain(b.target.name);
91
+ } else {
92
+ expect(["roles/logging.logWriter", "roles/monitoring.metricWriter"]).toContain(b.role);
93
+ }
94
+ }
95
+ // The stack declares no other binding for the gateway SA: the secret
96
+ // bindings go through runtimeSecretBindings, the two telemetry ones
97
+ // name it directly.
98
+ const direct = stack(provider).match(/serviceAccount:\$\{gatewaySa\.email\}/g) ?? [];
99
+ expect(direct).toHaveLength(2);
100
+ expect(stack(provider)).toContain("for (const binding of runtimeSecretBindings())");
101
+ });
102
+ }
103
+
104
+ it("upgrade strips the old credentials and the gateway SA's access to them", () => {
105
+ for (const name of [
106
+ "ADMIN_DATABASE_URL",
107
+ "ADMIN_DATABASE_PASSWORD",
108
+ "GATEWAY_DATABASE_URL",
109
+ "GATEWAY_DATABASE_PASSWORD",
110
+ ]) {
111
+ expect(RETIRED_SERVICE_ENV.gateway).toContain(name);
112
+ }
113
+ for (const secret of ADMIN_SECRETS) {
114
+ expect(
115
+ RETIRED_IAM_BINDINGS.some(
116
+ (b) =>
117
+ b.member === "gateway-sa" && b.target.kind === "secret" && b.target.name === secret,
118
+ ),
119
+ ).toBe(true);
120
+ }
121
+ });
122
+ });
123
+
124
+ describe("self-hosted: the generated compose gateway service (#635)", () => {
125
+ it("holds public_role on cms_public and nothing else", () => {
126
+ const out = generateDockerCompose({
127
+ domain: "example.com",
128
+ postgresPassword: "superpw",
129
+ rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
130
+ caeloSecretKek: "0".repeat(64),
131
+ diskSize: "20Gi",
132
+ images: {
133
+ mode: "release" as const,
134
+ admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
135
+ gateway: `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`,
136
+ },
137
+ ports: { http: 80, https: 443 },
138
+ project: "caelo-example-com",
139
+ });
140
+ const start = out.indexOf(" caelo-gateway:");
141
+ const end = out.indexOf("\n caelo-", start + 1);
142
+ const gateway = out.slice(start, end === -1 ? undefined : end);
143
+ expect(gateway).toContain("PUBLIC_DATABASE_URL:");
144
+ expect(gateway).not.toMatch(ADMIN_ACCESS);
145
+ expect(gateway).not.toMatch(/superpw|adminpw|gatewaypw/);
146
+ // No other way in: no Compose secrets, no env file, no mounts (a
147
+ // mounted file is read by no env check), and exactly its own env.
148
+ expect(gateway).not.toMatch(/^ {4}(secrets|env_file|volumes):/m);
149
+ const env = gateway.slice(gateway.indexOf(" environment:"));
150
+ const keys = [...env.matchAll(/^ {6}([A-Z_][A-Z0-9_]*):/gm)].map((m) => m[1]);
151
+ expect(keys.sort()).toEqual(["GATEWAY_PORT", "NODE_ENV", "PUBLIC_DATABASE_URL"]);
152
+ expect(out).not.toMatch(/^secrets:/m);
153
+ });
154
+ });
155
+
156
+ describe("azure: the gateway container app (#635)", () => {
157
+ it("gets PUBLIC_DATABASE_URL only", () => {
158
+ const src = stack("azure");
159
+ const start = src.indexOf('serviceName: "gateway"');
160
+ expect(start).toBeGreaterThan(-1);
161
+ const block = src.slice(start, src.indexOf("});", start));
162
+ expect(block).toContain('{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }');
163
+ expect(block).not.toMatch(/adminDatabase|gatewayDatabase|ADMIN_DATABASE|GATEWAY_DATABASE/);
164
+ });
165
+
166
+ it("builds no cms_admin URL for any role but the admin's", () => {
167
+ expect(stack("azure")).not.toMatch(/gateway_role:\$\{/);
168
+ });
169
+
170
+ it("gives no container app an identity or Key Vault access (review finding)", () => {
171
+ // The vault uses RBAC authorisation: without a role assignment or a
172
+ // managed identity no container app can read any vault secret, so the
173
+ // gateway's only credential is the public_role URL in its env.
174
+ const src = stack("azure");
175
+ expect(src).toContain("enableRbacAuthorization: true");
176
+ expect(src).not.toMatch(/RoleAssignment|accessPolicies|identity\s*:|keyVaultUrl|secretRef/);
177
+ });
178
+ });
179
+
180
+ describe("aws: the stack (#635)", () => {
181
+ it("builds no gateway_role URL and exports no gateway cms_admin DSN", () => {
182
+ const src = stack("aws");
183
+ expect(src).not.toMatch(/gateway_role:\$\{/);
184
+ expect(src).not.toMatch(/gatewayDatabaseUrl/);
185
+ });
186
+
187
+ it("the gateway's task role reads only the public_role password (review finding)", () => {
188
+ expect(AWS_SERVICE_SECRETS.gateway).toEqual(["public-role-password"]);
189
+ for (const secret of ADMIN_SECRETS) expect(AWS_SERVICE_SECRETS.gateway).not.toContain(secret);
190
+ });
191
+
192
+ it("each service gets its own task role whose policy is built from that table", () => {
193
+ const src = stack("aws");
194
+ expect(src).toContain('gateway: taskRole("gateway")');
195
+ expect(src).toContain('admin: taskRole("admin")');
196
+ // One RolePolicy, inside taskRole, scoped by AWS_SERVICE_SECRETS[service]:
197
+ // no policy anywhere lists secret ARNs by hand.
198
+ expect(src.match(/new aws\.iam\.RolePolicy\(/g)).toHaveLength(1);
199
+ expect(src).toContain(".all(AWS_SERVICE_SECRETS[service].map((name) => secretArns[name]))");
200
+ expect(src).not.toMatch(/GetSecretValue/);
201
+ });
202
+
203
+ it("secretsReadPolicy grants GetSecretValue on exactly the given ARNs", () => {
204
+ expect(JSON.parse(secretsReadPolicy(["arn:a", "arn:b"]))).toEqual({
205
+ Version: "2012-10-17",
206
+ Statement: [
207
+ { Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: ["arn:a", "arn:b"] },
208
+ ],
209
+ });
210
+ expect(() => secretsReadPolicy([])).toThrow();
211
+ });
212
+ });
package/src/gcloud.ts CHANGED
@@ -75,10 +75,12 @@ export async function gcloud(
75
75
 
76
76
  /**
77
77
  * Active gcloud account. Returns `null` when no account is logged in
78
- * — caller prompts `gcloud auth login`.
78
+ * — caller prompts `gcloud auth login`. `run` is injectable for tests.
79
79
  */
80
- export async function activeAccount(): Promise<string | null> {
81
- const r = await gcloud(["auth", "list", "--format=value(account)", "--filter=status:ACTIVE"]);
80
+ export async function activeAccount(
81
+ run: (args: string[]) => Promise<GcloudResult> = gcloud,
82
+ ): Promise<string | null> {
83
+ const r = await run(["auth", "list", "--format=value(account)", "--filter=status:ACTIVE"]);
82
84
  if (!r.ok) return null;
83
85
  const value = r.stdout.trim();
84
86
  return value.length > 0 ? value : null;
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
+ ];