@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.
- package/caddy/Caddyfile.production +4 -0
- package/caddy/Caddyfile.staging +4 -0
- package/dist/adapter.d.ts +4 -6
- package/dist/adapter.d.ts.map +1 -1
- package/dist/admin-internal.d.ts +41 -0
- package/dist/admin-internal.d.ts.map +1 -0
- package/dist/admin-internal.js +169 -0
- package/dist/admin-internal.js.map +1 -0
- package/dist/agent-db.js +93 -20
- package/dist/aws-task-roles.d.ts +27 -0
- package/dist/aws-task-roles.d.ts.map +1 -0
- package/dist/aws-task-roles.js +35 -0
- package/dist/aws-task-roles.js.map +1 -0
- package/dist/caddy-redirects.d.ts +15 -0
- package/dist/caddy-redirects.d.ts.map +1 -0
- package/dist/caddy-redirects.js +65 -0
- package/dist/caddy-redirects.js.map +1 -0
- package/dist/caddy.d.ts +22 -2
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +66 -17
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +7 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +282 -255
- 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 +88 -34
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +3 -2
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +12 -5
- package/dist/deployed-release.js.map +1 -1
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +6 -3
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +2 -2
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +3 -3
- package/dist/gcloud.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 +44 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/owner-reset.d.ts +37 -0
- package/dist/owner-reset.d.ts.map +1 -0
- package/dist/owner-reset.js +80 -0
- package/dist/owner-reset.js.map +1 -0
- package/dist/public-role-isolation.d.ts +19 -0
- package/dist/public-role-isolation.d.ts.map +1 -0
- package/dist/public-role-isolation.js +69 -0
- package/dist/public-role-isolation.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +2 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/self-hosted-backup.d.ts +86 -0
- package/dist/self-hosted-backup.d.ts.map +1 -0
- package/dist/self-hosted-backup.js +350 -0
- package/dist/self-hosted-backup.js.map +1 -0
- package/dist/self-hosted-release.d.ts +12 -3
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -4
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-start.d.ts +50 -0
- package/dist/self-hosted-start.d.ts.map +1 -0
- package/dist/self-hosted-start.js +59 -0
- package/dist/self-hosted-start.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +30 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +29 -7
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +9 -10
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +21 -10
- package/dist/stack-contract.js.map +1 -1
- package/dist/staging-admin.d.ts +12 -20
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js +7 -159
- package/dist/staging-admin.js.map +1 -1
- package/dist/tar-entries.d.ts +14 -0
- package/dist/tar-entries.d.ts.map +1 -0
- package/dist/tar-entries.js +104 -0
- package/dist/tar-entries.js.map +1 -0
- package/dist/upgrade/converge.d.ts +2 -2
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +44 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +2 -1
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/url-map-redirects-access.d.ts +15 -0
- package/dist/url-map-redirects-access.d.ts.map +1 -0
- package/dist/url-map-redirects-access.js +81 -0
- package/dist/url-map-redirects-access.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +3 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.js +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/adapter.ts +4 -6
- package/src/admin-internal.ts +212 -0
- package/src/admin-memory.test.ts +2 -2
- package/src/aws-task-roles.ts +58 -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/checkpoint-secrets.test.ts +0 -1
- package/src/cli-routing.test.ts +12 -0
- package/src/cli-routing.ts +7 -0
- package/src/cli.ts +340 -288
- package/src/compose.ts +108 -36
- package/src/deployed-release.test.ts +22 -4
- package/src/deployed-release.ts +14 -7
- package/src/gateway-credentials.ts +6 -3
- package/src/gateway-env-isolation.test.ts +212 -0
- package/src/gcloud.ts +5 -3
- 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 +24 -152
- package/src/index.ts +0 -9
- package/src/infra-contract.test.ts +40 -0
- package/src/infra-contract.ts +47 -1
- package/src/owner-reset.test.ts +165 -0
- package/src/owner-reset.ts +106 -0
- package/src/public-role-isolation.test.ts +89 -0
- package/src/public-role-isolation.ts +83 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +12 -0
- package/src/release-manifest.ts +2 -1
- package/src/runtime-secrets.test.ts +25 -8
- 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 +11 -20
- package/src/stack-contract.ts +25 -16
- package/src/stack-converge-secrets.test.ts +45 -10
- package/src/stack-converge.test.ts +19 -10
- package/src/staging-admin.ts +18 -181
- package/src/tar-entries.test.ts +52 -0
- package/src/tar-entries.ts +116 -0
- package/src/upgrade/converge.ts +51 -4
- package/src/upgrade/upgrade.test.ts +78 -1
- package/src/upgrader-assess.test.ts +22 -0
- package/src/upgrader-assess.ts +2 -1
- package/src/url-map-redirects-access.test.ts +88 -0
- package/src/url-map-redirects-access.ts +89 -0
- package/src/wizard.ts +3 -1
- package/src/wizards/gcp-cost.ts +1 -1
- package/src/wizards/gcp.ts +48 -0
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +89 -45
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +21 -14
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +26 -22
- package/stacks/gcp-firebase/index.ts +7 -3
- 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,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
|
-
#
|
|
320
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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("
|
|
144
|
+
expect(r.error).toContain("ADMIN_DATABASE_PASSWORD");
|
|
127
145
|
expect(r.error).toContain("Run `upgrade` first");
|
|
128
146
|
}
|
|
129
147
|
});
|
package/src/deployed-release.ts
CHANGED
|
@@ -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
|
|
65
|
-
* current contract no longer gives it
|
|
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 =
|
|
92
|
-
|
|
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
|
|
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 —
|
|
6
|
-
*
|
|
7
|
-
* gateway
|
|
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(
|
|
81
|
-
|
|
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
|
+
];
|