@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.
Files changed (82) 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 +65 -6
  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 +67 -0
  12. package/dist/deployed-release.d.ts.map +1 -0
  13. package/dist/deployed-release.js +144 -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.map +1 -1
  31. package/dist/lifecycle.js +314 -46
  32. package/dist/lifecycle.js.map +1 -1
  33. package/dist/mcp-iap.d.ts +5 -26
  34. package/dist/mcp-iap.d.ts.map +1 -1
  35. package/dist/mcp-iap.js +6 -18
  36. package/dist/mcp-iap.js.map +1 -1
  37. package/dist/migration-runner.d.ts +25 -0
  38. package/dist/migration-runner.d.ts.map +1 -1
  39. package/dist/migration-runner.js +60 -19
  40. package/dist/migration-runner.js.map +1 -1
  41. package/dist/operator-access.d.ts +83 -0
  42. package/dist/operator-access.d.ts.map +1 -0
  43. package/dist/operator-access.js +562 -0
  44. package/dist/operator-access.js.map +1 -0
  45. package/dist/runtime-secrets.d.ts +128 -0
  46. package/dist/runtime-secrets.d.ts.map +1 -0
  47. package/dist/runtime-secrets.js +444 -0
  48. package/dist/runtime-secrets.js.map +1 -0
  49. package/dist/stack-contract.d.ts +231 -0
  50. package/dist/stack-contract.d.ts.map +1 -0
  51. package/dist/stack-contract.js +455 -0
  52. package/dist/stack-contract.js.map +1 -0
  53. package/dist/stack-converge.d.ts +183 -0
  54. package/dist/stack-converge.d.ts.map +1 -0
  55. package/dist/stack-converge.js +513 -0
  56. package/dist/stack-converge.js.map +1 -0
  57. package/dist/wizard.d.ts.map +1 -1
  58. package/dist/wizard.js +8 -1
  59. package/dist/wizard.js.map +1 -1
  60. package/dist/wizards/gcp-cost.d.ts +6 -10
  61. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  62. package/dist/wizards/gcp-cost.js +34 -2
  63. package/dist/wizards/gcp-cost.js.map +1 -1
  64. package/dist/wizards/gcp-pulumi.d.ts +32 -1
  65. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  66. package/dist/wizards/gcp-pulumi.js +60 -5
  67. package/dist/wizards/gcp-pulumi.js.map +1 -1
  68. package/dist/wizards/gcp.d.ts.map +1 -1
  69. package/dist/wizards/gcp.js +79 -16
  70. package/dist/wizards/gcp.js.map +1 -1
  71. package/package.json +3 -3
  72. package/stacks/aws/Pulumi.yaml +4 -0
  73. package/stacks/aws/index.ts +11 -0
  74. package/stacks/azure/Pulumi.yaml +4 -0
  75. package/stacks/azure/index.ts +10 -1
  76. package/stacks/gcp/Pulumi.yaml +4 -3
  77. package/stacks/gcp/README.md +13 -2
  78. package/stacks/gcp/index.ts +200 -140
  79. package/stacks/gcp-firebase/Pulumi.yaml +4 -0
  80. package/stacks/gcp-firebase/index.ts +165 -75
  81. package/stacks/self-hosted/Pulumi.yaml +4 -0
  82. package/stacks/self-hosted/index.ts +3 -0
@@ -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; runtime mounts via secret_environment_variables
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
- // 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");
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 | null;
215
+ version: gcp.secretmanager.SecretVersion;
192
216
  }
193
217
 
194
- function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecret {
218
+ function makeSecret(name: string, value: pulumi.Output<string>): MadeSecret {
195
219
  const resource = new gcp.secretmanager.Secret(
196
220
  `${namePrefix}-${name}`,
197
- { secretId: `${namePrefix}-${name}`, replication: secretReplication },
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
- // 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);
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: [{ name: "max_connections", value: maxConnections.toString() }],
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
- // 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`);
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: `${project}-${namePrefix}-media`,
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: `${project}-${namePrefix}-static`,
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: `${project}-${namePrefix}-staging`,
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: `${namePrefix}-run-sa`,
402
+ accountId: runServiceAccountId(env),
388
403
  displayName: `Caelo ${env} Cloud Run service account`,
389
404
  },
390
405
  opts,
391
406
  );
392
407
 
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] },
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: `caelo-${envShort}-publisher`,
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: string;
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
- readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
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: runSa.email,
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
- ...(pgSecret.version ? [pgSecret.version] : []),
599
- // KEK must exist before Cloud Run can mount it via secretKeyRef.
600
- ...(kekSecret.version ? [kekSecret.version] : []),
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: "1Gi",
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
- // 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
- ],
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
- // 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 }],
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
- // 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.
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: "caelo-mcp",
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
- adminDatabaseUrl: adminDatabaseUrl as unknown as string,
1038
- publicDatabaseUrl: publicDatabaseUrl as unknown as string,
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"