@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.
- package/caddy/Caddyfile.production +5 -1
- package/caddy/Caddyfile.staging +5 -1
- package/dist/agent-db.js +102 -58
- package/dist/caddy-redirects.d.ts +15 -0
- package/dist/caddy-redirects.d.ts.map +1 -0
- package/dist/caddy-redirects.js +65 -0
- package/dist/caddy-redirects.js.map +1 -0
- package/dist/caddy.d.ts +22 -2
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +66 -17
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +7 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +248 -254
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +54 -2
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +86 -32
- package/dist/compose.js.map +1 -1
- package/dist/gcp-names.d.ts +12 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts +27 -0
- package/dist/gcp-url-map.d.ts.map +1 -0
- package/dist/gcp-url-map.js +62 -0
- package/dist/gcp-url-map.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +20 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +39 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/public-role-isolation.d.ts +19 -0
- package/dist/public-role-isolation.d.ts.map +1 -0
- package/dist/public-role-isolation.js +69 -0
- package/dist/public-role-isolation.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +20 -122
- package/dist/release-manifest.js.map +1 -1
- package/dist/self-hosted-backup.d.ts +86 -0
- package/dist/self-hosted-backup.d.ts.map +1 -0
- package/dist/self-hosted-backup.js +350 -0
- package/dist/self-hosted-backup.js.map +1 -0
- package/dist/self-hosted-release.d.ts +12 -3
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -4
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-start.d.ts +50 -0
- package/dist/self-hosted-start.d.ts.map +1 -0
- package/dist/self-hosted-start.js +59 -0
- package/dist/self-hosted-start.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +30 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +29 -7
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/tar-entries.d.ts +14 -0
- package/dist/tar-entries.d.ts.map +1 -0
- package/dist/tar-entries.js +104 -0
- package/dist/tar-entries.js.map +1 -0
- package/dist/upgrade/converge.d.ts +2 -2
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +44 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +14 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +4 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrader-assess.d.ts +4 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +8 -1
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-job.d.ts +3 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js.map +1 -1
- package/dist/url-map-redirects-access.d.ts +15 -0
- package/dist/url-map-redirects-access.d.ts.map +1 -0
- package/dist/url-map-redirects-access.js +81 -0
- package/dist/url-map-redirects-access.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +3 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +45 -0
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-memory.test.ts +2 -2
- package/src/agent/run.test.ts +55 -0
- package/src/caddy-redirects.docker.test.ts +255 -0
- package/src/caddy-redirects.test.ts +115 -0
- package/src/caddy-redirects.ts +68 -0
- package/src/caddy-validate.test.ts +119 -0
- package/src/caddy.test.ts +108 -0
- package/src/caddy.ts +89 -20
- package/src/cli-routing.test.ts +12 -0
- package/src/cli-routing.ts +7 -0
- package/src/cli.ts +299 -287
- package/src/compose.ts +106 -34
- package/src/gcp-names.ts +23 -0
- package/src/gcp-url-map.test.ts +150 -0
- package/src/gcp-url-map.ts +81 -0
- package/src/index.test.ts +18 -146
- package/src/index.ts +0 -9
- package/src/infra-contract.test.ts +39 -0
- package/src/infra-contract.ts +42 -1
- package/src/public-role-isolation.test.ts +89 -0
- package/src/public-role-isolation.ts +83 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +20 -1
- package/src/release-manifest.ts +39 -158
- package/src/self-hosted-backup.test.ts +344 -0
- package/src/self-hosted-backup.ts +480 -0
- package/src/self-hosted-compose.test.ts +88 -4
- package/src/self-hosted-release.ts +22 -7
- package/src/self-hosted-start.test.ts +68 -0
- package/src/self-hosted-start.ts +123 -0
- package/src/self-hosted-upgrade.ts +35 -8
- package/src/stack-contract.test.ts +2 -2
- package/src/tar-entries.test.ts +52 -0
- package/src/tar-entries.ts +116 -0
- package/src/upgrade/converge.ts +51 -4
- package/src/upgrade/release-images.ts +4 -2
- package/src/upgrade/upgrade.test.ts +78 -1
- package/src/upgrader-assess.test.ts +64 -1
- package/src/upgrader-assess.ts +20 -2
- package/src/upgrader-job.test.ts +3 -0
- package/src/upgrader-job.ts +3 -0
- package/src/url-map-redirects-access.test.ts +88 -0
- package/src/url-map-redirects-access.ts +89 -0
- package/src/wizard.ts +3 -1
- package/src/wizards/gcp.ts +48 -0
- package/stacks/aws/index.ts +34 -2
- package/stacks/azure/index.ts +13 -0
- package/stacks/gcp/index.ts +24 -17
- package/stacks/gcp-firebase/index.ts +7 -0
- package/stacks/self-hosted/Pulumi.yaml +2 -2
- package/stacks/self-hosted/README.md +3 -3
- package/stacks/self-hosted/index.ts +45 -22
- package/dist/redirects-emit.d.ts +0 -52
- package/dist/redirects-emit.d.ts.map +0 -1
- package/dist/redirects-emit.js +0 -111
- package/dist/redirects-emit.js.map +0 -1
- package/src/redirects-emit.ts +0 -175
package/src/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 +
|
|
7
|
-
*
|
|
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:
|
|
195
|
-
|
|
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: ["
|
|
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
|
-
//
|
|
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(
|
|
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
|
+
}
|