@caelo-cms/provisioning 0.10.29 → 0.10.30
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/dist/admin-domain.d.ts +54 -0
- package/dist/admin-domain.d.ts.map +1 -0
- package/dist/admin-domain.js +122 -0
- package/dist/admin-domain.js.map +1 -0
- package/dist/cli.js +65 -6
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -9
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +17 -1
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +67 -0
- package/dist/deployed-release.d.ts.map +1 -0
- package/dist/deployed-release.js +144 -0
- package/dist/deployed-release.js.map +1 -0
- package/dist/gcloud-retry.d.ts +16 -0
- package/dist/gcloud-retry.d.ts.map +1 -0
- package/dist/gcloud-retry.js +17 -0
- package/dist/gcloud-retry.js.map +1 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +2 -0
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +58 -0
- package/dist/gcp-names.d.ts.map +1 -0
- package/dist/gcp-names.js +88 -0
- package/dist/gcp-names.js.map +1 -0
- package/dist/install-state.d.ts +31 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +38 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +314 -46
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +5 -26
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +6 -18
- package/dist/mcp-iap.js.map +1 -1
- package/dist/migration-runner.d.ts +25 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +60 -19
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +83 -0
- package/dist/operator-access.d.ts.map +1 -0
- package/dist/operator-access.js +562 -0
- package/dist/operator-access.js.map +1 -0
- package/dist/runtime-secrets.d.ts +128 -0
- package/dist/runtime-secrets.d.ts.map +1 -0
- package/dist/runtime-secrets.js +444 -0
- package/dist/runtime-secrets.js.map +1 -0
- package/dist/stack-contract.d.ts +231 -0
- package/dist/stack-contract.d.ts.map +1 -0
- package/dist/stack-contract.js +455 -0
- package/dist/stack-contract.js.map +1 -0
- package/dist/stack-converge.d.ts +183 -0
- package/dist/stack-converge.d.ts.map +1 -0
- package/dist/stack-converge.js +513 -0
- package/dist/stack-converge.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +8 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts +6 -10
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +34 -2
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +32 -1
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +60 -5
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +79 -16
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +3 -3
- package/stacks/aws/Pulumi.yaml +4 -0
- package/stacks/aws/index.ts +11 -0
- package/stacks/azure/Pulumi.yaml +4 -0
- package/stacks/azure/index.ts +10 -1
- package/stacks/gcp/Pulumi.yaml +4 -3
- package/stacks/gcp/README.md +13 -2
- package/stacks/gcp/index.ts +200 -140
- package/stacks/gcp-firebase/Pulumi.yaml +4 -0
- package/stacks/gcp-firebase/index.ts +165 -75
- package/stacks/self-hosted/Pulumi.yaml +4 -0
- package/stacks/self-hosted/index.ts +3 -0
package/stacks/gcp/index.ts
CHANGED
|
@@ -22,6 +22,25 @@ import * as pulumi from "@pulumi/pulumi";
|
|
|
22
22
|
import * as random from "@pulumi/random";
|
|
23
23
|
import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
|
|
24
24
|
import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
|
|
25
|
+
import {
|
|
26
|
+
gatewayServiceAccountId,
|
|
27
|
+
gcpBucketName,
|
|
28
|
+
gcpSecretId,
|
|
29
|
+
MCP_IAP_SERVICE_ACCOUNT_ID,
|
|
30
|
+
runServiceAccountId,
|
|
31
|
+
staticPublisherServiceAccountId,
|
|
32
|
+
} from "../../dist/gcp-names.js";
|
|
33
|
+
import {
|
|
34
|
+
ADMIN_MEMORY_DEFAULT,
|
|
35
|
+
adminEnvContract,
|
|
36
|
+
type CloudRunEnvVar,
|
|
37
|
+
type CloudRunSlug,
|
|
38
|
+
databaseUrls,
|
|
39
|
+
gatewayEnvContract,
|
|
40
|
+
type RuntimeSecret,
|
|
41
|
+
runtimeSecretBindings,
|
|
42
|
+
STATIC_CDN_POLICY,
|
|
43
|
+
} from "../../dist/stack-contract.js";
|
|
25
44
|
|
|
26
45
|
const cfg = new pulumi.Config();
|
|
27
46
|
const domain = cfg.require("domain");
|
|
@@ -57,6 +76,9 @@ const cloudSqlEdition = cfg.get("cloudSqlEdition") ?? "ENTERPRISE";
|
|
|
57
76
|
// wants to `caelo-cms destroy` shouldn't hit a wall).
|
|
58
77
|
const deletionProtection = cfg.getBoolean("deletionProtection") ?? false;
|
|
59
78
|
const adminMinInstances = Number.parseInt(cfg.get("adminMinInstances") ?? "0", 10);
|
|
79
|
+
// #553 — the admin also runs the Lighthouse quality audit; same knob and
|
|
80
|
+
// default on every adapter (stack-contract.ts ADMIN_MEMORY_DEFAULT).
|
|
81
|
+
const adminMemory = cfg.get("adminMemory") ?? ADMIN_MEMORY_DEFAULT;
|
|
60
82
|
// adminMaxInstances default is intentionally low. Each admin process holds
|
|
61
83
|
// TWO Bun SQL pools (admin_role on cms_admin + admin_role on cms_public),
|
|
62
84
|
// each with default pool-max=10 → up to ~20 connections per instance under
|
|
@@ -140,7 +162,7 @@ const sqlPeering = new gcp.servicenetworking.Connection(
|
|
|
140
162
|
);
|
|
141
163
|
|
|
142
164
|
// =========================================================================
|
|
143
|
-
// Secret Manager — never env-var literal;
|
|
165
|
+
// Secret Manager — never env-var literal; Cloud Run reads each via secretKeyRef
|
|
144
166
|
// =========================================================================
|
|
145
167
|
|
|
146
168
|
// v0.2.81 — secrets MUST be generated by a Pulumi resource that
|
|
@@ -160,15 +182,9 @@ const sqlPeering = new gcp.servicenetworking.Connection(
|
|
|
160
182
|
// one — don't pass `opts` (which carries gcpProvider). Pulumi
|
|
161
183
|
// auto-loads the random provider on first reference.
|
|
162
184
|
const pgPasswordBytes = new random.RandomBytes(`${namePrefix}-pg-password-bytes`, { length: 32 });
|
|
163
|
-
const csrfSecretBytes = new random.RandomBytes(`${namePrefix}-csrf-secret-bytes`, { length: 32 });
|
|
164
|
-
const cookieSecretBytes = new random.RandomBytes(`${namePrefix}-cookie-secret-bytes`, {
|
|
165
|
-
length: 32,
|
|
166
|
-
});
|
|
167
185
|
const kekBytes = new random.RandomBytes(`${namePrefix}-secret-kek-bytes`, { length: 32 });
|
|
168
186
|
|
|
169
187
|
const postgresPassword = pulumi.secret(pgPasswordBytes.hex);
|
|
170
|
-
const csrfSecret = pulumi.secret(csrfSecretBytes.hex);
|
|
171
|
-
const cookieSecret = pulumi.secret(cookieSecretBytes.hex);
|
|
172
188
|
// P18 — project KEK (32 bytes hex) encrypts AI provider API keys
|
|
173
189
|
// + future at-rest secrets in cms_admin. Stable across `pulumi up`
|
|
174
190
|
// (see comment above). Cloud Run binds it as CAELO_SECRET_KEK env
|
|
@@ -181,25 +197,30 @@ const caeloSecretKek = pulumi.secret(kekBytes.hex);
|
|
|
181
197
|
// Secret + SecretVersion, but the Cloud Run service never read it
|
|
182
198
|
// (no env-var mount existed). Dead code path removed.
|
|
183
199
|
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
200
|
+
// csrf-secret, cookie-secret and resend-api-key were dropped for the same
|
|
201
|
+
// reason: CSRF secrets are per session (in cms_admin), the gateway's cookie
|
|
202
|
+
// secret lives in site_settings, and the Resend key in the email config —
|
|
203
|
+
// no service ever read the three Secret Manager secrets. `pulumi up` deletes
|
|
204
|
+
// them; `cms-provision upgrade` leaves them in place (nothing reads them).
|
|
205
|
+
//
|
|
206
|
+
// internal-secret (CAELO_INTERNAL_SECRET) and tool-approval-secret
|
|
207
|
+
// (CAELO_TOOL_APPROVAL_SECRET) are generated by the CLI, not here
|
|
208
|
+
// (src/runtime-secrets.ts, stack-contract.ts CLI_GENERATED_SECRETS): the
|
|
209
|
+
// wizard creates them before `pulumi up`, `upgrade` on installs that predate
|
|
210
|
+
// them. This program only references them by id, so a plain `pulumi up`
|
|
211
|
+
// needs them to exist (run the wizard or `cms-provision upgrade` once).
|
|
188
212
|
|
|
189
213
|
interface MadeSecret {
|
|
190
214
|
resource: gcp.secretmanager.Secret;
|
|
191
|
-
version: gcp.secretmanager.SecretVersion
|
|
215
|
+
version: gcp.secretmanager.SecretVersion;
|
|
192
216
|
}
|
|
193
217
|
|
|
194
|
-
function makeSecret(name: string, value: pulumi.Output<string>
|
|
218
|
+
function makeSecret(name: string, value: pulumi.Output<string>): MadeSecret {
|
|
195
219
|
const resource = new gcp.secretmanager.Secret(
|
|
196
220
|
`${namePrefix}-${name}`,
|
|
197
|
-
{ secretId:
|
|
221
|
+
{ secretId: gcpSecretId(env, name), replication: secretReplication },
|
|
198
222
|
opts,
|
|
199
223
|
);
|
|
200
|
-
if (value === null) {
|
|
201
|
-
return { resource, version: null };
|
|
202
|
-
}
|
|
203
224
|
const version = new gcp.secretmanager.SecretVersion(
|
|
204
225
|
`${namePrefix}-${name}-v1`,
|
|
205
226
|
{ secret: resource.name, secretData: value },
|
|
@@ -209,17 +230,17 @@ function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecr
|
|
|
209
230
|
}
|
|
210
231
|
|
|
211
232
|
const pgSecret = makeSecret("postgres-password", postgresPassword);
|
|
212
|
-
const csrfSecretRes = makeSecret("csrf-secret", csrfSecret);
|
|
213
|
-
const cookieSecretRes = makeSecret("cookie-secret", cookieSecret);
|
|
214
233
|
const kekSecret = makeSecret("secret-kek", caeloSecretKek);
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
|
|
235
|
+
// The id each runtime secret is bound by: the Pulumi resource where this
|
|
236
|
+
// program owns the secret (so bindings wait for it), the CLI-generated id
|
|
237
|
+
// otherwise.
|
|
238
|
+
const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
|
|
239
|
+
"postgres-password": pgSecret.resource.secretId,
|
|
240
|
+
"secret-kek": kekSecret.resource.secretId,
|
|
241
|
+
"internal-secret": gcpSecretId(env, "internal-secret"),
|
|
242
|
+
"tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
|
|
243
|
+
};
|
|
223
244
|
|
|
224
245
|
// =========================================================================
|
|
225
246
|
// Tier 4 — Cloud SQL Postgres (private IP only; HA + retention configurable)
|
|
@@ -242,7 +263,12 @@ const sqlInstance = new gcp.sql.DatabaseInstance(
|
|
|
242
263
|
// pool-max=10, two pools per admin process). Override at
|
|
243
264
|
// instance level so the cap matches the Cloud Run scale-out
|
|
244
265
|
// headroom defined by adminMaxInstances + gatewayMaxInstances.
|
|
245
|
-
databaseFlags: [
|
|
266
|
+
databaseFlags: [
|
|
267
|
+
{ name: "max_connections", value: maxConnections.toString() },
|
|
268
|
+
// The operator-access sync job logs in as its service account (Cloud
|
|
269
|
+
// SQL IAM database authentication, no password). No restart needed.
|
|
270
|
+
{ name: "cloudsql.iam_authentication", value: "on" },
|
|
271
|
+
],
|
|
246
272
|
backupConfiguration: {
|
|
247
273
|
enabled: true,
|
|
248
274
|
pointInTimeRecoveryEnabled: cloudSqlHa,
|
|
@@ -291,21 +317,10 @@ const cmsPublicDb = new gcp.sql.Database(
|
|
|
291
317
|
opts,
|
|
292
318
|
);
|
|
293
319
|
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// admin_role on cms_public — admin's second pool: cross-DB reads of
|
|
299
|
-
// plugin data + DDL for plugin schemas + migration runs. Wired to the
|
|
300
|
-
// admin (and migration job) via PUBLIC_ADMIN_DATABASE_URL.
|
|
301
|
-
const publicAdminDatabaseUrl = pulumi
|
|
302
|
-
.all([sqlInstance.privateIpAddress, postgresPassword])
|
|
303
|
-
.apply(([host, pw]) => `postgresql://admin_role:${pw}@${host}:5432/cms_public?sslmode=require`);
|
|
304
|
-
// public_role on cms_public — write-limited gateway role. Wired to the
|
|
305
|
-
// gateway via PUBLIC_DATABASE_URL.
|
|
306
|
-
const publicDatabaseUrl = pulumi
|
|
307
|
-
.all([sqlInstance.privateIpAddress, postgresPassword])
|
|
308
|
-
.apply(([host, pw]) => `postgresql://public_role:${pw}@${host}:5432/cms_public?sslmode=require`);
|
|
320
|
+
// Password-less connection URLs (stack-contract.ts databaseUrls); the
|
|
321
|
+
// services get the password as a Secret Manager reference
|
|
322
|
+
// (SERVICE_SECRET_ENV), so no revision ever carries it.
|
|
323
|
+
const dbUrls = sqlInstance.privateIpAddress.apply(databaseUrls);
|
|
309
324
|
|
|
310
325
|
// Reference pgPublicUser so Pulumi knows it's part of the dependency
|
|
311
326
|
// graph (Cloud Run env-var deps don't see this directly).
|
|
@@ -318,7 +333,7 @@ void pgPublicUser;
|
|
|
318
333
|
const mediaBucket = new gcp.storage.Bucket(
|
|
319
334
|
`${namePrefix}-media`,
|
|
320
335
|
{
|
|
321
|
-
name:
|
|
336
|
+
name: gcpBucketName(project, env, "media"),
|
|
322
337
|
location: region.toUpperCase(),
|
|
323
338
|
uniformBucketLevelAccess: true,
|
|
324
339
|
forceDestroy: env !== "production",
|
|
@@ -330,7 +345,7 @@ const mediaBucket = new gcp.storage.Bucket(
|
|
|
330
345
|
const staticBucket = new gcp.storage.Bucket(
|
|
331
346
|
`${namePrefix}-static`,
|
|
332
347
|
{
|
|
333
|
-
name:
|
|
348
|
+
name: gcpBucketName(project, env, "static"),
|
|
334
349
|
location: region.toUpperCase(),
|
|
335
350
|
uniformBucketLevelAccess: true,
|
|
336
351
|
forceDestroy: env !== "production",
|
|
@@ -366,7 +381,7 @@ new gcp.storage.BucketIAMMember(
|
|
|
366
381
|
const stagingBucket = new gcp.storage.Bucket(
|
|
367
382
|
`${namePrefix}-staging`,
|
|
368
383
|
{
|
|
369
|
-
name:
|
|
384
|
+
name: gcpBucketName(project, env, "staging"),
|
|
370
385
|
location: region.toUpperCase(),
|
|
371
386
|
uniformBucketLevelAccess: true,
|
|
372
387
|
forceDestroy: env !== "production",
|
|
@@ -384,32 +399,48 @@ const stagingBucket = new gcp.storage.Bucket(
|
|
|
384
399
|
const runSa = new gcp.serviceaccount.Account(
|
|
385
400
|
`${namePrefix}-run-sa`,
|
|
386
401
|
{
|
|
387
|
-
accountId:
|
|
402
|
+
accountId: runServiceAccountId(env),
|
|
388
403
|
displayName: `Caelo ${env} Cloud Run service account`,
|
|
389
404
|
},
|
|
390
405
|
opts,
|
|
391
406
|
);
|
|
392
407
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{
|
|
399
|
-
{
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
408
|
+
// The gateway runs as its own SA so it can read only the secrets it needs
|
|
409
|
+
// (stack-contract.ts SERVICE_SECRET_ENV) — never the KEK or the admin-only
|
|
410
|
+
// secrets. `cms-provision upgrade` creates it on older installs, hence
|
|
411
|
+
// createIgnoreAlreadyExists.
|
|
412
|
+
const gatewaySa = new gcp.serviceaccount.Account(
|
|
413
|
+
`${namePrefix}-gateway-sa`,
|
|
414
|
+
{
|
|
415
|
+
accountId: gatewayServiceAccountId(env),
|
|
416
|
+
displayName: `Caelo ${env} gateway`,
|
|
417
|
+
createIgnoreAlreadyExists: true,
|
|
418
|
+
},
|
|
419
|
+
opts,
|
|
420
|
+
);
|
|
421
|
+
const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
|
|
422
|
+
admin: runSa,
|
|
423
|
+
gateway: gatewaySa,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// secretAccessor for each service's SA on exactly the secrets its env reads.
|
|
427
|
+
// Cloud Run refuses to deploy a revision whose SA can't read a referenced
|
|
428
|
+
// secret, so each service waits for its bindings (secretBindings below).
|
|
429
|
+
const secretBindings: Record<CloudRunSlug, gcp.secretmanager.SecretIamMember[]> = {
|
|
430
|
+
admin: [],
|
|
431
|
+
gateway: [],
|
|
432
|
+
};
|
|
433
|
+
for (const binding of runtimeSecretBindings()) {
|
|
434
|
+
secretBindings[binding.service].push(
|
|
435
|
+
new gcp.secretmanager.SecretIamMember(
|
|
436
|
+
`${namePrefix}-${binding.stackResource}`,
|
|
437
|
+
{
|
|
438
|
+
secretId: runtimeSecretIds[binding.secret],
|
|
439
|
+
role: "roles/secretmanager.secretAccessor",
|
|
440
|
+
member: pulumi.interpolate`serviceAccount:${serviceSa[binding.service].email}`,
|
|
441
|
+
},
|
|
442
|
+
opts,
|
|
443
|
+
),
|
|
413
444
|
);
|
|
414
445
|
}
|
|
415
446
|
|
|
@@ -455,6 +486,49 @@ new gcp.storage.BucketIAMMember(
|
|
|
455
486
|
opts,
|
|
456
487
|
);
|
|
457
488
|
|
|
489
|
+
// Cloud Run telemetry IAM (the gcp-firebase stack has had these since
|
|
490
|
+
// v0.6.6). A Cloud Run service running as a CUSTOM SA gets no telemetry
|
|
491
|
+
// roles implicitly — only the default Compute SA does:
|
|
492
|
+
// - roles/logging.logWriter: container stdout → Cloud Logging
|
|
493
|
+
// - roles/monitoring.metricWriter: CPU/memory metrics → autoscaler
|
|
494
|
+
// Project-scoped (telemetry endpoints are project-level resources).
|
|
495
|
+
new gcp.projects.IAMMember(
|
|
496
|
+
`${namePrefix}-run-log-writer`,
|
|
497
|
+
{
|
|
498
|
+
project,
|
|
499
|
+
role: "roles/logging.logWriter",
|
|
500
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
501
|
+
},
|
|
502
|
+
opts,
|
|
503
|
+
);
|
|
504
|
+
new gcp.projects.IAMMember(
|
|
505
|
+
`${namePrefix}-run-metric-writer`,
|
|
506
|
+
{
|
|
507
|
+
project,
|
|
508
|
+
role: "roles/monitoring.metricWriter",
|
|
509
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
510
|
+
},
|
|
511
|
+
opts,
|
|
512
|
+
);
|
|
513
|
+
new gcp.projects.IAMMember(
|
|
514
|
+
`${namePrefix}-gateway-log-writer`,
|
|
515
|
+
{
|
|
516
|
+
project,
|
|
517
|
+
role: "roles/logging.logWriter",
|
|
518
|
+
member: pulumi.interpolate`serviceAccount:${gatewaySa.email}`,
|
|
519
|
+
},
|
|
520
|
+
opts,
|
|
521
|
+
);
|
|
522
|
+
new gcp.projects.IAMMember(
|
|
523
|
+
`${namePrefix}-gateway-metric-writer`,
|
|
524
|
+
{
|
|
525
|
+
project,
|
|
526
|
+
role: "roles/monitoring.metricWriter",
|
|
527
|
+
member: pulumi.interpolate`serviceAccount:${gatewaySa.email}`,
|
|
528
|
+
},
|
|
529
|
+
opts,
|
|
530
|
+
);
|
|
531
|
+
|
|
458
532
|
// `static-publisher` SA used by `bunx @caelo-cms/provisioning deploy` to
|
|
459
533
|
// upload the static-generator output to the bucket. Pre-v0.2.78 this
|
|
460
534
|
// was the only path; admin Cloud Run now does the publish in-process
|
|
@@ -462,11 +536,10 @@ new gcp.storage.BucketIAMMember(
|
|
|
462
536
|
// a manual gsutil rsync from CI as a backup or for bulk re-uploads.
|
|
463
537
|
// Account-id max 30 chars; collapse "production" → "prod" so dev/staging/prod
|
|
464
538
|
// all fit within the budget.
|
|
465
|
-
const envShort = env === "production" ? "prod" : env === "staging" ? "stg" : "dev";
|
|
466
539
|
const staticPublisherSa = new gcp.serviceaccount.Account(
|
|
467
540
|
`${namePrefix}-static-publisher`,
|
|
468
541
|
{
|
|
469
|
-
accountId:
|
|
542
|
+
accountId: staticPublisherServiceAccountId(env),
|
|
470
543
|
displayName: `Caelo ${env} static publisher`,
|
|
471
544
|
},
|
|
472
545
|
opts,
|
|
@@ -518,7 +591,7 @@ function imageTag(service: string): pulumi.Output<string> {
|
|
|
518
591
|
}
|
|
519
592
|
|
|
520
593
|
interface CloudRunArgs {
|
|
521
|
-
readonly serviceName:
|
|
594
|
+
readonly serviceName: "admin" | "gateway";
|
|
522
595
|
readonly minInstances: number;
|
|
523
596
|
readonly maxInstances: number;
|
|
524
597
|
readonly memory: string;
|
|
@@ -530,9 +603,25 @@ interface CloudRunArgs {
|
|
|
530
603
|
* legitimately need longer.
|
|
531
604
|
*/
|
|
532
605
|
readonly timeout?: string;
|
|
533
|
-
|
|
606
|
+
/**
|
|
607
|
+
* The service's complete env — plain values and Secret Manager
|
|
608
|
+
* references: its env contract (src/stack-contract.ts).
|
|
609
|
+
*/
|
|
610
|
+
readonly contractEnv: ReadonlyArray<CloudRunEnvVar<pulumi.Input<string>>>;
|
|
534
611
|
}
|
|
535
612
|
|
|
613
|
+
// Inputs of the admin + gateway env contracts. The contracts are shared with
|
|
614
|
+
// `cms-provision upgrade`, which applies them to installs it rolls — add new
|
|
615
|
+
// env vars and secrets there, not here.
|
|
616
|
+
const envContractInputs = {
|
|
617
|
+
provider: "gcp",
|
|
618
|
+
projectId: project,
|
|
619
|
+
env,
|
|
620
|
+
domain,
|
|
621
|
+
region,
|
|
622
|
+
databaseUrls: { admin: dbUrls.admin, publicAdmin: dbUrls.publicAdmin, public: dbUrls.public },
|
|
623
|
+
} as const;
|
|
624
|
+
|
|
536
625
|
function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
537
626
|
return new gcp.cloudrunv2.Service(
|
|
538
627
|
`${namePrefix}-${args.serviceName}`,
|
|
@@ -553,7 +642,7 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
553
642
|
// config knob to true.
|
|
554
643
|
deletionProtection,
|
|
555
644
|
template: {
|
|
556
|
-
serviceAccount:
|
|
645
|
+
serviceAccount: serviceSa[args.serviceName].email,
|
|
557
646
|
scaling: {
|
|
558
647
|
minInstanceCount: args.minInstances,
|
|
559
648
|
maxInstanceCount: args.maxInstances,
|
|
@@ -562,24 +651,7 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
562
651
|
containers: [
|
|
563
652
|
{
|
|
564
653
|
image: imageTag(args.serviceName),
|
|
565
|
-
envs: [
|
|
566
|
-
{ name: "CAELO_PROVIDER", value: "gcp" },
|
|
567
|
-
{ name: "CAELO_ENV", value: env },
|
|
568
|
-
{ name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
|
|
569
|
-
{ name: "MEDIA_STORAGE_URL", value: pulumi.interpolate`gs://${mediaBucket.name}` },
|
|
570
|
-
// P18 — project KEK is mounted from Secret Manager so AI
|
|
571
|
-
// provider keys (and any future at-rest secrets) can be
|
|
572
|
-
// decrypted at runtime. Failing to bind would surface as
|
|
573
|
-
// "CAELO_SECRET_KEK is not set" the moment the resolver
|
|
574
|
-
// tries to decrypt — clear, loud failure.
|
|
575
|
-
{
|
|
576
|
-
name: "CAELO_SECRET_KEK",
|
|
577
|
-
valueSource: {
|
|
578
|
-
secretKeyRef: { secret: kekSecret.resource.secretId, version: "latest" },
|
|
579
|
-
},
|
|
580
|
-
},
|
|
581
|
-
...(args.extraEnv ?? []),
|
|
582
|
-
],
|
|
654
|
+
envs: [...args.contractEnv],
|
|
583
655
|
resources: { limits: { cpu: "1", memory: args.memory } },
|
|
584
656
|
},
|
|
585
657
|
],
|
|
@@ -595,22 +667,26 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
|
595
667
|
pgAdminUser,
|
|
596
668
|
cmsAdminDb,
|
|
597
669
|
cmsPublicDb,
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
|
|
670
|
+
// The env contract names the buckets as strings; keep them ordered
|
|
671
|
+
// before the services that use them.
|
|
672
|
+
mediaBucket,
|
|
673
|
+
staticBucket,
|
|
674
|
+
stagingBucket,
|
|
675
|
+
// Referenced secrets must have a version, and the service's SA
|
|
676
|
+
// must be able to read them, before Cloud Run deploys a revision.
|
|
677
|
+
pgSecret.version,
|
|
678
|
+
kekSecret.version,
|
|
679
|
+
...secretBindings[args.serviceName],
|
|
601
680
|
],
|
|
602
681
|
},
|
|
603
682
|
);
|
|
604
683
|
}
|
|
605
684
|
|
|
606
|
-
// Issue #37 — deterministic, so the admin env can carry it as a plain string.
|
|
607
|
-
const mcpServiceAccountEmail = `caelo-mcp@${project}.iam.gserviceaccount.com`;
|
|
608
|
-
|
|
609
685
|
const adminSvc = cloudRunService({
|
|
610
686
|
serviceName: "admin",
|
|
611
687
|
minInstances: adminMinInstances,
|
|
612
688
|
maxInstances: adminMaxInstances,
|
|
613
|
-
memory:
|
|
689
|
+
memory: adminMemory,
|
|
614
690
|
// 1 hour — covers the longest realistic SSE chat session (multi-tool
|
|
615
691
|
// compose-page runs that orchestrate 10+ tool calls). Cloud Run's
|
|
616
692
|
// 300s default truncated mid-stream and surfaced to operators as
|
|
@@ -618,36 +694,17 @@ const adminSvc = cloudRunService({
|
|
|
618
694
|
// in Cloud Run logs. 3600s is the platform max; no cost penalty for
|
|
619
695
|
// unused timeout (Cloud Run bills running time, not allocated time).
|
|
620
696
|
timeout: "3600s",
|
|
621
|
-
|
|
622
|
-
// + DDL + migrations. NOT public_role (write-limited).
|
|
623
|
-
// v0.2.78 — CAELO_STATIC_BUCKET / CAELO_STAGING_BUCKET drive the
|
|
624
|
-
// GCS StaticPublisher (Stage uploads to staging, Confirm-publish
|
|
625
|
-
// copies to static). CAELO_GENERATOR_CLI points at the absolute
|
|
626
|
-
// path the Dockerfile shipped the static-generator source to —
|
|
627
|
-
// the cwd-walk fallback in resolveGeneratorCli landed at
|
|
628
|
-
// /app/apps/apps/static-generator/... (doubled apps/) before
|
|
629
|
-
// v0.2.78 because the SSR bundle's import.meta.dirname doesn't
|
|
630
|
-
// sit where the walk expects.
|
|
631
|
-
extraEnv: [
|
|
632
|
-
{ name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl },
|
|
633
|
-
// #551 — the public site URL; the admin seeds site_defaults.site_base_url
|
|
634
|
-
// from it (canonical, og:url, sitemap) when it is not configured yet.
|
|
635
|
-
{ name: "CAELO_SITE_URL", value: `https://${domain}` },
|
|
636
|
-
{ name: "CAELO_STATIC_BUCKET", value: staticBucket.name },
|
|
637
|
-
{ name: "CAELO_STAGING_BUCKET", value: stagingBucket.name },
|
|
638
|
-
{ name: "CAELO_GENERATOR_CLI", value: "/app/apps/static-generator/src/cli.ts" },
|
|
639
|
-
// Issue #37 — shown in the /security/mcp `claude mcp add` command.
|
|
640
|
-
{ name: "CAELO_MCP_IAP_SERVICE_ACCOUNT", value: mcpServiceAccountEmail },
|
|
641
|
-
],
|
|
697
|
+
contractEnv: adminEnvContract(envContractInputs),
|
|
642
698
|
});
|
|
643
699
|
const gatewaySvc = cloudRunService({
|
|
644
700
|
serviceName: "gateway",
|
|
645
701
|
minInstances: gatewayMinInstances,
|
|
646
702
|
maxInstances: 100,
|
|
647
703
|
memory: "512Mi",
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
|
|
704
|
+
// public_role on cms_public for visitor writes — plus, for now, an
|
|
705
|
+
// admin_role pool (see the known gap on stack-contract.ts
|
|
706
|
+
// SERVICE_SECRET_ENV.gateway).
|
|
707
|
+
contractEnv: gatewayEnvContract(envContractInputs),
|
|
651
708
|
});
|
|
652
709
|
|
|
653
710
|
// =========================================================================
|
|
@@ -725,27 +782,15 @@ const wafPolicy = new gcp.compute.SecurityPolicy(
|
|
|
725
782
|
opts,
|
|
726
783
|
);
|
|
727
784
|
|
|
728
|
-
// Tier 1 backend — static GCS bucket via Cloud CDN
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
// (static-publisher-gcs.ts): content-hashed fonts / plugin bundles are
|
|
732
|
-
// `max-age=31536000, immutable`, pages `max-age=60` + SWR. In
|
|
733
|
-
// CACHE_ALL_STATIC mode `clientTtl` CLAMPS the max-age sent to
|
|
734
|
-
// browsers and `maxTtl` caps the edge TTL, so both must allow a year
|
|
735
|
-
// or the immutable policy is silently cut back to the old 1h. Objects
|
|
736
|
-
// that state a shorter max-age keep it; `defaultTtl` still applies only
|
|
737
|
-
// to responses without one.
|
|
785
|
+
// Tier 1 backend — static GCS bucket via Cloud CDN. The CDN policy (one-year
|
|
786
|
+
// client/edge TTL so immutable assets stay immutable, #555) is shared with
|
|
787
|
+
// `cms-provision upgrade`, which applies it to existing installs.
|
|
738
788
|
const staticBackendBucket = new gcp.compute.BackendBucket(
|
|
739
789
|
`${namePrefix}-static-backend`,
|
|
740
790
|
{
|
|
741
791
|
bucketName: staticBucket.name,
|
|
742
792
|
enableCdn: true,
|
|
743
|
-
cdnPolicy: {
|
|
744
|
-
cacheMode: "CACHE_ALL_STATIC",
|
|
745
|
-
defaultTtl: 3600,
|
|
746
|
-
maxTtl: 31536000,
|
|
747
|
-
clientTtl: 31536000,
|
|
748
|
-
},
|
|
793
|
+
cdnPolicy: { ...STATIC_CDN_POLICY },
|
|
749
794
|
},
|
|
750
795
|
opts,
|
|
751
796
|
);
|
|
@@ -856,7 +901,7 @@ const mcpServiceAccount = new gcp.serviceaccount.Account(
|
|
|
856
901
|
`${namePrefix}-mcp-sa`,
|
|
857
902
|
{
|
|
858
903
|
project,
|
|
859
|
-
accountId:
|
|
904
|
+
accountId: MCP_IAP_SERVICE_ACCOUNT_ID,
|
|
860
905
|
displayName: "Caelo MCP (IAP ingress)",
|
|
861
906
|
description:
|
|
862
907
|
"Signs the IAP credential external MCP clients use to reach the admin (issue #37).",
|
|
@@ -892,6 +937,20 @@ new gcp.iap.WebBackendServiceIamMember(
|
|
|
892
937
|
opts,
|
|
893
938
|
);
|
|
894
939
|
|
|
940
|
+
// =========================================================================
|
|
941
|
+
// Operator access — deliberately NOT here
|
|
942
|
+
// =========================================================================
|
|
943
|
+
//
|
|
944
|
+
// Who passes IAP beyond the allowlist above follows the Caelo user list. A
|
|
945
|
+
// Cloud Run job with its own service account keeps the IAP binding and the
|
|
946
|
+
// caelo-mcp token-creator binding in step (admin-core
|
|
947
|
+
// security/operator-access/sync-job.ts); the admin's run SA holds no IAM
|
|
948
|
+
// policy rights and may only start that job. The CLI owns the job, its
|
|
949
|
+
// account, its grants and its schedule (packages/provisioning/src/
|
|
950
|
+
// operator-access.ts, run by the wizard after migrations and by `upgrade`),
|
|
951
|
+
// because the job's database user can only be assigned its role after the
|
|
952
|
+
// migrations have created that role.
|
|
953
|
+
|
|
895
954
|
// URL map: routes by host header.
|
|
896
955
|
// admin.<domain> → admin backend (IAP-gated)
|
|
897
956
|
// <domain> → default static bucket; /api/* → gateway
|
|
@@ -1034,8 +1093,9 @@ const dnsRecordsRequired: pulumi.Output<DnsRecord[]> = lbIp.address.apply((ip):
|
|
|
1034
1093
|
]);
|
|
1035
1094
|
|
|
1036
1095
|
const out: CloudAdapterOutputs = {
|
|
1037
|
-
|
|
1038
|
-
|
|
1096
|
+
// Password-less — the password lives only in Secret Manager.
|
|
1097
|
+
adminDatabaseUrl: dbUrls.admin as unknown as string,
|
|
1098
|
+
publicDatabaseUrl: dbUrls.public as unknown as string,
|
|
1039
1099
|
mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
|
|
1040
1100
|
mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
|
|
1041
1101
|
bootstrapUrl:
|
|
@@ -53,6 +53,10 @@ config:
|
|
|
53
53
|
type: string
|
|
54
54
|
default: "0"
|
|
55
55
|
description: Admin Cloud Run min instances.
|
|
56
|
+
caelo-gcp-firebase:adminMemory:
|
|
57
|
+
type: string
|
|
58
|
+
default: "2Gi"
|
|
59
|
+
description: Admin Cloud Run memory. 2Gi leaves room for the Lighthouse quality audit next to editor requests; billed only while an instance runs.
|
|
56
60
|
caelo-gcp-firebase:adminMaxInstances:
|
|
57
61
|
type: string
|
|
58
62
|
default: "5"
|