@caelo-cms/provisioning 0.10.29 → 0.10.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/admin-domain.d.ts +54 -0
  2. package/dist/admin-domain.d.ts.map +1 -0
  3. package/dist/admin-domain.js +122 -0
  4. package/dist/admin-domain.js.map +1 -0
  5. package/dist/cli.js +71 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/compose.d.ts +7 -9
  8. package/dist/compose.d.ts.map +1 -1
  9. package/dist/compose.js +17 -1
  10. package/dist/compose.js.map +1 -1
  11. package/dist/deployed-release.d.ts +84 -0
  12. package/dist/deployed-release.d.ts.map +1 -0
  13. package/dist/deployed-release.js +182 -0
  14. package/dist/deployed-release.js.map +1 -0
  15. package/dist/gcloud-retry.d.ts +16 -0
  16. package/dist/gcloud-retry.d.ts.map +1 -0
  17. package/dist/gcloud-retry.js +17 -0
  18. package/dist/gcloud-retry.js.map +1 -0
  19. package/dist/gcloud.d.ts.map +1 -1
  20. package/dist/gcloud.js +2 -0
  21. package/dist/gcloud.js.map +1 -1
  22. package/dist/gcp-names.d.ts +58 -0
  23. package/dist/gcp-names.d.ts.map +1 -0
  24. package/dist/gcp-names.js +88 -0
  25. package/dist/gcp-names.js.map +1 -0
  26. package/dist/install-state.d.ts +31 -0
  27. package/dist/install-state.d.ts.map +1 -1
  28. package/dist/install-state.js +38 -0
  29. package/dist/install-state.js.map +1 -1
  30. package/dist/lifecycle.d.ts +5 -0
  31. package/dist/lifecycle.d.ts.map +1 -1
  32. package/dist/lifecycle.js +340 -50
  33. package/dist/lifecycle.js.map +1 -1
  34. package/dist/mcp-iap.d.ts +5 -26
  35. package/dist/mcp-iap.d.ts.map +1 -1
  36. package/dist/mcp-iap.js +6 -18
  37. package/dist/mcp-iap.js.map +1 -1
  38. package/dist/migration-runner.d.ts +25 -0
  39. package/dist/migration-runner.d.ts.map +1 -1
  40. package/dist/migration-runner.js +60 -19
  41. package/dist/migration-runner.js.map +1 -1
  42. package/dist/operator-access.d.ts +83 -0
  43. package/dist/operator-access.d.ts.map +1 -0
  44. package/dist/operator-access.js +562 -0
  45. package/dist/operator-access.js.map +1 -0
  46. package/dist/region-data.d.ts +30 -0
  47. package/dist/region-data.d.ts.map +1 -0
  48. package/dist/region-data.js +406 -0
  49. package/dist/region-data.js.map +1 -0
  50. package/dist/regions.d.ts +130 -0
  51. package/dist/regions.d.ts.map +1 -0
  52. package/dist/regions.js +247 -0
  53. package/dist/regions.js.map +1 -0
  54. package/dist/runtime-secrets.d.ts +128 -0
  55. package/dist/runtime-secrets.d.ts.map +1 -0
  56. package/dist/runtime-secrets.js +444 -0
  57. package/dist/runtime-secrets.js.map +1 -0
  58. package/dist/stack-contract.d.ts +231 -0
  59. package/dist/stack-contract.d.ts.map +1 -0
  60. package/dist/stack-contract.js +455 -0
  61. package/dist/stack-contract.js.map +1 -0
  62. package/dist/stack-converge.d.ts +183 -0
  63. package/dist/stack-converge.d.ts.map +1 -0
  64. package/dist/stack-converge.js +513 -0
  65. package/dist/stack-converge.js.map +1 -0
  66. package/dist/wizard.d.ts +7 -0
  67. package/dist/wizard.d.ts.map +1 -1
  68. package/dist/wizard.js +11 -1
  69. package/dist/wizard.js.map +1 -1
  70. package/dist/wizards/gcp-cost.d.ts +10 -10
  71. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  72. package/dist/wizards/gcp-cost.js +40 -3
  73. package/dist/wizards/gcp-cost.js.map +1 -1
  74. package/dist/wizards/gcp-firebase.d.ts +2 -0
  75. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  76. package/dist/wizards/gcp-firebase.js +1 -0
  77. package/dist/wizards/gcp-firebase.js.map +1 -1
  78. package/dist/wizards/gcp-pulumi.d.ts +51 -1
  79. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  80. package/dist/wizards/gcp-pulumi.js +89 -6
  81. package/dist/wizards/gcp-pulumi.js.map +1 -1
  82. package/dist/wizards/gcp.d.ts +2 -0
  83. package/dist/wizards/gcp.d.ts.map +1 -1
  84. package/dist/wizards/gcp.js +171 -23
  85. package/dist/wizards/gcp.js.map +1 -1
  86. package/package.json +3 -3
  87. package/stacks/aws/Pulumi.yaml +5 -2
  88. package/stacks/aws/README.md +3 -1
  89. package/stacks/aws/index.ts +21 -1
  90. package/stacks/azure/Pulumi.yaml +6 -3
  91. package/stacks/azure/README.md +1 -1
  92. package/stacks/azure/index.ts +22 -3
  93. package/stacks/gcp/Pulumi.yaml +5 -5
  94. package/stacks/gcp/README.md +15 -3
  95. package/stacks/gcp/index.ts +204 -141
  96. package/stacks/gcp-firebase/Pulumi.yaml +5 -2
  97. package/stacks/gcp-firebase/index.ts +169 -76
  98. package/stacks/self-hosted/Pulumi.yaml +4 -0
  99. package/stacks/self-hosted/index.ts +3 -0
@@ -22,12 +22,34 @@ 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");
28
47
  const ownerEmail = cfg.require("ownerEmail");
29
48
  const project = cfg.require("project");
30
- const region = cfg.get("region") ?? "us-central1";
49
+ // #607 — chosen by the operator in the wizard and fixed after install; no
50
+ // default, so a stack run without it fails instead of landing in a region
51
+ // nobody picked.
52
+ const region = cfg.require("region");
31
53
 
32
54
  // Where Secret Manager keeps the secret payloads. Fixed at create time —
33
55
  // changing it replaces every secret — so the wizard pins it per stack:
@@ -57,6 +79,9 @@ const cloudSqlEdition = cfg.get("cloudSqlEdition") ?? "ENTERPRISE";
57
79
  // wants to `caelo-cms destroy` shouldn't hit a wall).
58
80
  const deletionProtection = cfg.getBoolean("deletionProtection") ?? false;
59
81
  const adminMinInstances = Number.parseInt(cfg.get("adminMinInstances") ?? "0", 10);
82
+ // #553 — the admin also runs the Lighthouse quality audit; same knob and
83
+ // default on every adapter (stack-contract.ts ADMIN_MEMORY_DEFAULT).
84
+ const adminMemory = cfg.get("adminMemory") ?? ADMIN_MEMORY_DEFAULT;
60
85
  // adminMaxInstances default is intentionally low. Each admin process holds
61
86
  // TWO Bun SQL pools (admin_role on cms_admin + admin_role on cms_public),
62
87
  // each with default pool-max=10 → up to ~20 connections per instance under
@@ -140,7 +165,7 @@ const sqlPeering = new gcp.servicenetworking.Connection(
140
165
  );
141
166
 
142
167
  // =========================================================================
143
- // Secret Manager — never env-var literal; runtime mounts via secret_environment_variables
168
+ // Secret Manager — never env-var literal; Cloud Run reads each via secretKeyRef
144
169
  // =========================================================================
145
170
 
146
171
  // v0.2.81 — secrets MUST be generated by a Pulumi resource that
@@ -160,15 +185,9 @@ const sqlPeering = new gcp.servicenetworking.Connection(
160
185
  // one — don't pass `opts` (which carries gcpProvider). Pulumi
161
186
  // auto-loads the random provider on first reference.
162
187
  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
188
  const kekBytes = new random.RandomBytes(`${namePrefix}-secret-kek-bytes`, { length: 32 });
168
189
 
169
190
  const postgresPassword = pulumi.secret(pgPasswordBytes.hex);
170
- const csrfSecret = pulumi.secret(csrfSecretBytes.hex);
171
- const cookieSecret = pulumi.secret(cookieSecretBytes.hex);
172
191
  // P18 — project KEK (32 bytes hex) encrypts AI provider API keys
173
192
  // + future at-rest secrets in cms_admin. Stable across `pulumi up`
174
193
  // (see comment above). Cloud Run binds it as CAELO_SECRET_KEK env
@@ -181,25 +200,30 @@ const caeloSecretKek = pulumi.secret(kekBytes.hex);
181
200
  // Secret + SecretVersion, but the Cloud Run service never read it
182
201
  // (no env-var mount existed). Dead code path removed.
183
202
  //
184
- // resendApiKey is OPTIONAL — empty config means "skip the SecretVersion"
185
- // so GCP doesn't reject `payload: ""`. The Secret resource itself still
186
- // exists; operators add a v1 later via `gcloud secrets versions add`.
187
- const resendApiKeyConfig = cfg.getSecret("resendApiKey");
203
+ // csrf-secret, cookie-secret and resend-api-key were dropped for the same
204
+ // reason: CSRF secrets are per session (in cms_admin), the gateway's cookie
205
+ // secret lives in site_settings, and the Resend key in the email config —
206
+ // no service ever read the three Secret Manager secrets. `pulumi up` deletes
207
+ // them; `cms-provision upgrade` leaves them in place (nothing reads them).
208
+ //
209
+ // internal-secret (CAELO_INTERNAL_SECRET) and tool-approval-secret
210
+ // (CAELO_TOOL_APPROVAL_SECRET) are generated by the CLI, not here
211
+ // (src/runtime-secrets.ts, stack-contract.ts CLI_GENERATED_SECRETS): the
212
+ // wizard creates them before `pulumi up`, `upgrade` on installs that predate
213
+ // them. This program only references them by id, so a plain `pulumi up`
214
+ // needs them to exist (run the wizard or `cms-provision upgrade` once).
188
215
 
189
216
  interface MadeSecret {
190
217
  resource: gcp.secretmanager.Secret;
191
- version: gcp.secretmanager.SecretVersion | null;
218
+ version: gcp.secretmanager.SecretVersion;
192
219
  }
193
220
 
194
- function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecret {
221
+ function makeSecret(name: string, value: pulumi.Output<string>): MadeSecret {
195
222
  const resource = new gcp.secretmanager.Secret(
196
223
  `${namePrefix}-${name}`,
197
- { secretId: `${namePrefix}-${name}`, replication: secretReplication },
224
+ { secretId: gcpSecretId(env, name), replication: secretReplication },
198
225
  opts,
199
226
  );
200
- if (value === null) {
201
- return { resource, version: null };
202
- }
203
227
  const version = new gcp.secretmanager.SecretVersion(
204
228
  `${namePrefix}-${name}-v1`,
205
229
  { secret: resource.name, secretData: value },
@@ -209,17 +233,17 @@ function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecr
209
233
  }
210
234
 
211
235
  const pgSecret = makeSecret("postgres-password", postgresPassword);
212
- const csrfSecretRes = makeSecret("csrf-secret", csrfSecret);
213
- const cookieSecretRes = makeSecret("cookie-secret", cookieSecret);
214
236
  const kekSecret = makeSecret("secret-kek", caeloSecretKek);
215
- // v0.3.2 — anthropic-api-key Secret dropped. See the
216
- // caeloSecretKek comment above for the runtime-path rationale.
217
- //
218
- // Resend: skip the v1 when no key is configured (cfg.getSecret returns
219
- // undefined when the key is unset in Pulumi.yaml + not overridden in the
220
- // stack config). The Secret resource still exists; operators add a v1
221
- // later via `gcloud secrets versions add` or `pulumi config set --secret`.
222
- const resendSecretRes = makeSecret("resend-api-key", resendApiKeyConfig ?? null);
237
+
238
+ // The id each runtime secret is bound by: the Pulumi resource where this
239
+ // program owns the secret (so bindings wait for it), the CLI-generated id
240
+ // otherwise.
241
+ const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
242
+ "postgres-password": pgSecret.resource.secretId,
243
+ "secret-kek": kekSecret.resource.secretId,
244
+ "internal-secret": gcpSecretId(env, "internal-secret"),
245
+ "tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
246
+ };
223
247
 
224
248
  // =========================================================================
225
249
  // Tier 4 — Cloud SQL Postgres (private IP only; HA + retention configurable)
@@ -242,7 +266,12 @@ const sqlInstance = new gcp.sql.DatabaseInstance(
242
266
  // pool-max=10, two pools per admin process). Override at
243
267
  // instance level so the cap matches the Cloud Run scale-out
244
268
  // headroom defined by adminMaxInstances + gatewayMaxInstances.
245
- databaseFlags: [{ name: "max_connections", value: maxConnections.toString() }],
269
+ databaseFlags: [
270
+ { name: "max_connections", value: maxConnections.toString() },
271
+ // The operator-access sync job logs in as its service account (Cloud
272
+ // SQL IAM database authentication, no password). No restart needed.
273
+ { name: "cloudsql.iam_authentication", value: "on" },
274
+ ],
246
275
  backupConfiguration: {
247
276
  enabled: true,
248
277
  pointInTimeRecoveryEnabled: cloudSqlHa,
@@ -291,21 +320,10 @@ const cmsPublicDb = new gcp.sql.Database(
291
320
  opts,
292
321
  );
293
322
 
294
- // admin_role on cms_admin — admin app's primary DB.
295
- const adminDatabaseUrl = pulumi
296
- .all([sqlInstance.privateIpAddress, postgresPassword])
297
- .apply(([host, pw]) => `postgresql://admin_role:${pw}@${host}:5432/cms_admin?sslmode=require`);
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`);
323
+ // Password-less connection URLs (stack-contract.ts databaseUrls); the
324
+ // services get the password as a Secret Manager reference
325
+ // (SERVICE_SECRET_ENV), so no revision ever carries it.
326
+ const dbUrls = sqlInstance.privateIpAddress.apply(databaseUrls);
309
327
 
310
328
  // Reference pgPublicUser so Pulumi knows it's part of the dependency
311
329
  // graph (Cloud Run env-var deps don't see this directly).
@@ -318,7 +336,7 @@ void pgPublicUser;
318
336
  const mediaBucket = new gcp.storage.Bucket(
319
337
  `${namePrefix}-media`,
320
338
  {
321
- name: `${project}-${namePrefix}-media`,
339
+ name: gcpBucketName(project, env, "media"),
322
340
  location: region.toUpperCase(),
323
341
  uniformBucketLevelAccess: true,
324
342
  forceDestroy: env !== "production",
@@ -330,7 +348,7 @@ const mediaBucket = new gcp.storage.Bucket(
330
348
  const staticBucket = new gcp.storage.Bucket(
331
349
  `${namePrefix}-static`,
332
350
  {
333
- name: `${project}-${namePrefix}-static`,
351
+ name: gcpBucketName(project, env, "static"),
334
352
  location: region.toUpperCase(),
335
353
  uniformBucketLevelAccess: true,
336
354
  forceDestroy: env !== "production",
@@ -366,7 +384,7 @@ new gcp.storage.BucketIAMMember(
366
384
  const stagingBucket = new gcp.storage.Bucket(
367
385
  `${namePrefix}-staging`,
368
386
  {
369
- name: `${project}-${namePrefix}-staging`,
387
+ name: gcpBucketName(project, env, "staging"),
370
388
  location: region.toUpperCase(),
371
389
  uniformBucketLevelAccess: true,
372
390
  forceDestroy: env !== "production",
@@ -384,32 +402,48 @@ const stagingBucket = new gcp.storage.Bucket(
384
402
  const runSa = new gcp.serviceaccount.Account(
385
403
  `${namePrefix}-run-sa`,
386
404
  {
387
- accountId: `${namePrefix}-run-sa`,
405
+ accountId: runServiceAccountId(env),
388
406
  displayName: `Caelo ${env} Cloud Run service account`,
389
407
  },
390
408
  opts,
391
409
  );
392
410
 
393
- // Pass the Secret RESOURCE (not a hardcoded id) so Pulumi infers the
394
- // dependency edge — otherwise the IamMember create races the Secret
395
- // create on first apply and 404s. Same `dependsOn` for belt-and-braces.
396
- for (const made of [
397
- { name: "postgres-password", made: pgSecret },
398
- { name: "csrf-secret", made: csrfSecretRes },
399
- { name: "cookie-secret", made: cookieSecretRes },
400
- { name: "secret-kek", made: kekSecret },
401
- { name: "resend-api-key", made: resendSecretRes },
402
- ]) {
403
- new gcp.secretmanager.SecretIamMember(
404
- `${namePrefix}-${made.name}-binding`,
405
- {
406
- // .secretId from the resource is an Output<string> — Pulumi
407
- // tracks the read-after-create edge automatically.
408
- secretId: made.made.resource.secretId,
409
- role: "roles/secretmanager.secretAccessor",
410
- member: pulumi.interpolate`serviceAccount:${runSa.email}`,
411
- },
412
- { ...opts, dependsOn: [made.made.resource] },
411
+ // The gateway runs as its own SA so it can read only the secrets it needs
412
+ // (stack-contract.ts SERVICE_SECRET_ENV) — never the KEK or the admin-only
413
+ // secrets. `cms-provision upgrade` creates it on older installs, hence
414
+ // createIgnoreAlreadyExists.
415
+ const gatewaySa = new gcp.serviceaccount.Account(
416
+ `${namePrefix}-gateway-sa`,
417
+ {
418
+ accountId: gatewayServiceAccountId(env),
419
+ displayName: `Caelo ${env} gateway`,
420
+ createIgnoreAlreadyExists: true,
421
+ },
422
+ opts,
423
+ );
424
+ const serviceSa: Record<CloudRunSlug, gcp.serviceaccount.Account> = {
425
+ admin: runSa,
426
+ gateway: gatewaySa,
427
+ };
428
+
429
+ // secretAccessor for each service's SA on exactly the secrets its env reads.
430
+ // Cloud Run refuses to deploy a revision whose SA can't read a referenced
431
+ // secret, so each service waits for its bindings (secretBindings below).
432
+ const secretBindings: Record<CloudRunSlug, gcp.secretmanager.SecretIamMember[]> = {
433
+ admin: [],
434
+ gateway: [],
435
+ };
436
+ for (const binding of runtimeSecretBindings()) {
437
+ secretBindings[binding.service].push(
438
+ new gcp.secretmanager.SecretIamMember(
439
+ `${namePrefix}-${binding.stackResource}`,
440
+ {
441
+ secretId: runtimeSecretIds[binding.secret],
442
+ role: "roles/secretmanager.secretAccessor",
443
+ member: pulumi.interpolate`serviceAccount:${serviceSa[binding.service].email}`,
444
+ },
445
+ opts,
446
+ ),
413
447
  );
414
448
  }
415
449
 
@@ -455,6 +489,49 @@ new gcp.storage.BucketIAMMember(
455
489
  opts,
456
490
  );
457
491
 
492
+ // Cloud Run telemetry IAM (the gcp-firebase stack has had these since
493
+ // v0.6.6). A Cloud Run service running as a CUSTOM SA gets no telemetry
494
+ // roles implicitly — only the default Compute SA does:
495
+ // - roles/logging.logWriter: container stdout → Cloud Logging
496
+ // - roles/monitoring.metricWriter: CPU/memory metrics → autoscaler
497
+ // Project-scoped (telemetry endpoints are project-level resources).
498
+ new gcp.projects.IAMMember(
499
+ `${namePrefix}-run-log-writer`,
500
+ {
501
+ project,
502
+ role: "roles/logging.logWriter",
503
+ member: pulumi.interpolate`serviceAccount:${runSa.email}`,
504
+ },
505
+ opts,
506
+ );
507
+ new gcp.projects.IAMMember(
508
+ `${namePrefix}-run-metric-writer`,
509
+ {
510
+ project,
511
+ role: "roles/monitoring.metricWriter",
512
+ member: pulumi.interpolate`serviceAccount:${runSa.email}`,
513
+ },
514
+ opts,
515
+ );
516
+ new gcp.projects.IAMMember(
517
+ `${namePrefix}-gateway-log-writer`,
518
+ {
519
+ project,
520
+ role: "roles/logging.logWriter",
521
+ member: pulumi.interpolate`serviceAccount:${gatewaySa.email}`,
522
+ },
523
+ opts,
524
+ );
525
+ new gcp.projects.IAMMember(
526
+ `${namePrefix}-gateway-metric-writer`,
527
+ {
528
+ project,
529
+ role: "roles/monitoring.metricWriter",
530
+ member: pulumi.interpolate`serviceAccount:${gatewaySa.email}`,
531
+ },
532
+ opts,
533
+ );
534
+
458
535
  // `static-publisher` SA used by `bunx @caelo-cms/provisioning deploy` to
459
536
  // upload the static-generator output to the bucket. Pre-v0.2.78 this
460
537
  // was the only path; admin Cloud Run now does the publish in-process
@@ -462,11 +539,10 @@ new gcp.storage.BucketIAMMember(
462
539
  // a manual gsutil rsync from CI as a backup or for bulk re-uploads.
463
540
  // Account-id max 30 chars; collapse "production" → "prod" so dev/staging/prod
464
541
  // all fit within the budget.
465
- const envShort = env === "production" ? "prod" : env === "staging" ? "stg" : "dev";
466
542
  const staticPublisherSa = new gcp.serviceaccount.Account(
467
543
  `${namePrefix}-static-publisher`,
468
544
  {
469
- accountId: `caelo-${envShort}-publisher`,
545
+ accountId: staticPublisherServiceAccountId(env),
470
546
  displayName: `Caelo ${env} static publisher`,
471
547
  },
472
548
  opts,
@@ -518,7 +594,7 @@ function imageTag(service: string): pulumi.Output<string> {
518
594
  }
519
595
 
520
596
  interface CloudRunArgs {
521
- readonly serviceName: string;
597
+ readonly serviceName: "admin" | "gateway";
522
598
  readonly minInstances: number;
523
599
  readonly maxInstances: number;
524
600
  readonly memory: string;
@@ -530,9 +606,25 @@ interface CloudRunArgs {
530
606
  * legitimately need longer.
531
607
  */
532
608
  readonly timeout?: string;
533
- readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
609
+ /**
610
+ * The service's complete env — plain values and Secret Manager
611
+ * references: its env contract (src/stack-contract.ts).
612
+ */
613
+ readonly contractEnv: ReadonlyArray<CloudRunEnvVar<pulumi.Input<string>>>;
534
614
  }
535
615
 
616
+ // Inputs of the admin + gateway env contracts. The contracts are shared with
617
+ // `cms-provision upgrade`, which applies them to installs it rolls — add new
618
+ // env vars and secrets there, not here.
619
+ const envContractInputs = {
620
+ provider: "gcp",
621
+ projectId: project,
622
+ env,
623
+ domain,
624
+ region,
625
+ databaseUrls: { admin: dbUrls.admin, publicAdmin: dbUrls.publicAdmin, public: dbUrls.public },
626
+ } as const;
627
+
536
628
  function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
537
629
  return new gcp.cloudrunv2.Service(
538
630
  `${namePrefix}-${args.serviceName}`,
@@ -553,7 +645,7 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
553
645
  // config knob to true.
554
646
  deletionProtection,
555
647
  template: {
556
- serviceAccount: runSa.email,
648
+ serviceAccount: serviceSa[args.serviceName].email,
557
649
  scaling: {
558
650
  minInstanceCount: args.minInstances,
559
651
  maxInstanceCount: args.maxInstances,
@@ -562,24 +654,7 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
562
654
  containers: [
563
655
  {
564
656
  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
- ],
657
+ envs: [...args.contractEnv],
583
658
  resources: { limits: { cpu: "1", memory: args.memory } },
584
659
  },
585
660
  ],
@@ -595,22 +670,26 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
595
670
  pgAdminUser,
596
671
  cmsAdminDb,
597
672
  cmsPublicDb,
598
- ...(pgSecret.version ? [pgSecret.version] : []),
599
- // KEK must exist before Cloud Run can mount it via secretKeyRef.
600
- ...(kekSecret.version ? [kekSecret.version] : []),
673
+ // The env contract names the buckets as strings; keep them ordered
674
+ // before the services that use them.
675
+ mediaBucket,
676
+ staticBucket,
677
+ stagingBucket,
678
+ // Referenced secrets must have a version, and the service's SA
679
+ // must be able to read them, before Cloud Run deploys a revision.
680
+ pgSecret.version,
681
+ kekSecret.version,
682
+ ...secretBindings[args.serviceName],
601
683
  ],
602
684
  },
603
685
  );
604
686
  }
605
687
 
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
688
  const adminSvc = cloudRunService({
610
689
  serviceName: "admin",
611
690
  minInstances: adminMinInstances,
612
691
  maxInstances: adminMaxInstances,
613
- memory: "1Gi",
692
+ memory: adminMemory,
614
693
  // 1 hour — covers the longest realistic SSE chat session (multi-tool
615
694
  // compose-page runs that orchestrate 10+ tool calls). Cloud Run's
616
695
  // 300s default truncated mid-stream and surfaced to operators as
@@ -618,36 +697,17 @@ const adminSvc = cloudRunService({
618
697
  // in Cloud Run logs. 3600s is the platform max; no cost penalty for
619
698
  // unused timeout (Cloud Run bills running time, not allocated time).
620
699
  timeout: "3600s",
621
- // Admin's second pool: admin_role on cms_public for cross-DB reads
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
- ],
700
+ contractEnv: adminEnvContract(envContractInputs),
642
701
  });
643
702
  const gatewaySvc = cloudRunService({
644
703
  serviceName: "gateway",
645
704
  minInstances: gatewayMinInstances,
646
705
  maxInstances: 100,
647
706
  memory: "512Mi",
648
- // Gateway's only DB pool: public_role on cms_public, write-limited
649
- // per CLAUDE.md (gateway must NEVER hold admin_role creds).
650
- extraEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
707
+ // public_role on cms_public for visitor writes — plus, for now, an
708
+ // admin_role pool (see the known gap on stack-contract.ts
709
+ // SERVICE_SECRET_ENV.gateway).
710
+ contractEnv: gatewayEnvContract(envContractInputs),
651
711
  });
652
712
 
653
713
  // =========================================================================
@@ -725,27 +785,15 @@ const wafPolicy = new gcp.compute.SecurityPolicy(
725
785
  opts,
726
786
  );
727
787
 
728
- // Tier 1 backend — static GCS bucket via Cloud CDN
729
- //
730
- // The GCS publisher sets an explicit Cache-Control on every object
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.
788
+ // Tier 1 backend — static GCS bucket via Cloud CDN. The CDN policy (one-year
789
+ // client/edge TTL so immutable assets stay immutable, #555) is shared with
790
+ // `cms-provision upgrade`, which applies it to existing installs.
738
791
  const staticBackendBucket = new gcp.compute.BackendBucket(
739
792
  `${namePrefix}-static-backend`,
740
793
  {
741
794
  bucketName: staticBucket.name,
742
795
  enableCdn: true,
743
- cdnPolicy: {
744
- cacheMode: "CACHE_ALL_STATIC",
745
- defaultTtl: 3600,
746
- maxTtl: 31536000,
747
- clientTtl: 31536000,
748
- },
796
+ cdnPolicy: { ...STATIC_CDN_POLICY },
749
797
  },
750
798
  opts,
751
799
  );
@@ -856,7 +904,7 @@ const mcpServiceAccount = new gcp.serviceaccount.Account(
856
904
  `${namePrefix}-mcp-sa`,
857
905
  {
858
906
  project,
859
- accountId: "caelo-mcp",
907
+ accountId: MCP_IAP_SERVICE_ACCOUNT_ID,
860
908
  displayName: "Caelo MCP (IAP ingress)",
861
909
  description:
862
910
  "Signs the IAP credential external MCP clients use to reach the admin (issue #37).",
@@ -892,6 +940,20 @@ new gcp.iap.WebBackendServiceIamMember(
892
940
  opts,
893
941
  );
894
942
 
943
+ // =========================================================================
944
+ // Operator access — deliberately NOT here
945
+ // =========================================================================
946
+ //
947
+ // Who passes IAP beyond the allowlist above follows the Caelo user list. A
948
+ // Cloud Run job with its own service account keeps the IAP binding and the
949
+ // caelo-mcp token-creator binding in step (admin-core
950
+ // security/operator-access/sync-job.ts); the admin's run SA holds no IAM
951
+ // policy rights and may only start that job. The CLI owns the job, its
952
+ // account, its grants and its schedule (packages/provisioning/src/
953
+ // operator-access.ts, run by the wizard after migrations and by `upgrade`),
954
+ // because the job's database user can only be assigned its role after the
955
+ // migrations have created that role.
956
+
895
957
  // URL map: routes by host header.
896
958
  // admin.<domain> → admin backend (IAP-gated)
897
959
  // <domain> → default static bucket; /api/* → gateway
@@ -1034,8 +1096,9 @@ const dnsRecordsRequired: pulumi.Output<DnsRecord[]> = lbIp.address.apply((ip):
1034
1096
  ]);
1035
1097
 
1036
1098
  const out: CloudAdapterOutputs = {
1037
- adminDatabaseUrl: adminDatabaseUrl as unknown as string,
1038
- publicDatabaseUrl: publicDatabaseUrl as unknown as string,
1099
+ // Password-less — the password lives only in Secret Manager.
1100
+ adminDatabaseUrl: dbUrls.admin as unknown as string,
1101
+ publicDatabaseUrl: dbUrls.public as unknown as string,
1039
1102
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
1040
1103
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
1041
1104
  bootstrapUrl:
@@ -35,8 +35,7 @@ config:
35
35
  # The 7 cross-provider knobs (subset of gcp's 8 — no Cloud Armor adaptive protection here)
36
36
  caelo-gcp-firebase:region:
37
37
  type: string
38
- default: us-central1
39
- description: Primary GCP region (Cloud SQL, Cloud Run).
38
+ description: Primary GCP region (Cloud SQL, Cloud Run). Required, no default; chosen in the wizard and fixed after install (#607).
40
39
  caelo-gcp-firebase:cloudSqlTier:
41
40
  type: string
42
41
  default: db-f1-micro
@@ -53,6 +52,10 @@ config:
53
52
  type: string
54
53
  default: "0"
55
54
  description: Admin Cloud Run min instances.
55
+ caelo-gcp-firebase:adminMemory:
56
+ type: string
57
+ default: "2Gi"
58
+ description: Admin Cloud Run memory. 2Gi leaves room for the Lighthouse quality audit next to editor requests; billed only while an instance runs.
56
59
  caelo-gcp-firebase:adminMaxInstances:
57
60
  type: string
58
61
  default: "5"