@caelo-cms/provisioning 0.1.0 → 0.2.1

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 (96) hide show
  1. package/dist/adapter.d.ts +95 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +3 -0
  4. package/dist/adapter.js.map +1 -0
  5. package/dist/bootstrap-token.d.ts +11 -0
  6. package/dist/bootstrap-token.d.ts.map +1 -0
  7. package/dist/bootstrap-token.js +9 -0
  8. package/dist/bootstrap-token.js.map +1 -0
  9. package/dist/caddy.d.ts +34 -0
  10. package/dist/caddy.d.ts.map +1 -0
  11. package/dist/caddy.js +53 -0
  12. package/dist/caddy.js.map +1 -0
  13. package/{src/cdn-copy.ts → dist/cdn-copy.d.ts} +11 -42
  14. package/dist/cdn-copy.d.ts.map +1 -0
  15. package/dist/cdn-copy.js +48 -0
  16. package/dist/cdn-copy.js.map +1 -0
  17. package/dist/cli.d.ts +3 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +670 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/compose.d.ts +27 -0
  22. package/dist/compose.d.ts.map +1 -0
  23. package/{src/compose.ts → dist/compose.js} +15 -35
  24. package/dist/compose.js.map +1 -0
  25. package/dist/dns/cloudflare.d.ts +9 -0
  26. package/dist/dns/cloudflare.d.ts.map +1 -0
  27. package/dist/dns/cloudflare.js +160 -0
  28. package/dist/dns/cloudflare.js.map +1 -0
  29. package/dist/dns/index.d.ts +12 -0
  30. package/dist/dns/index.d.ts.map +1 -0
  31. package/dist/dns/index.js +42 -0
  32. package/dist/dns/index.js.map +1 -0
  33. package/dist/dns/manual.d.ts +5 -0
  34. package/dist/dns/manual.d.ts.map +1 -0
  35. package/dist/dns/manual.js +96 -0
  36. package/dist/dns/manual.js.map +1 -0
  37. package/dist/dns/types.d.ts +23 -0
  38. package/dist/dns/types.d.ts.map +1 -0
  39. package/dist/dns/types.js +3 -0
  40. package/dist/dns/types.js.map +1 -0
  41. package/dist/gcloud.d.ts +42 -0
  42. package/dist/gcloud.d.ts.map +1 -0
  43. package/dist/gcloud.js +187 -0
  44. package/dist/gcloud.js.map +1 -0
  45. package/dist/index.d.ts +22 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +7 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/install-state.d.ts +54 -0
  50. package/dist/install-state.d.ts.map +1 -0
  51. package/dist/install-state.js +118 -0
  52. package/dist/install-state.js.map +1 -0
  53. package/dist/lifecycle.d.ts +19 -0
  54. package/dist/lifecycle.d.ts.map +1 -0
  55. package/dist/lifecycle.js +589 -0
  56. package/dist/lifecycle.js.map +1 -0
  57. package/dist/migration-runner.d.ts +15 -0
  58. package/dist/migration-runner.d.ts.map +1 -0
  59. package/dist/migration-runner.js +174 -0
  60. package/dist/migration-runner.js.map +1 -0
  61. package/dist/redirects-emit.d.ts +65 -0
  62. package/dist/redirects-emit.d.ts.map +1 -0
  63. package/dist/redirects-emit.js +92 -0
  64. package/dist/redirects-emit.js.map +1 -0
  65. package/dist/wizard.d.ts +35 -0
  66. package/dist/wizard.d.ts.map +1 -0
  67. package/dist/wizard.js +160 -0
  68. package/dist/wizard.js.map +1 -0
  69. package/dist/wizards/gcp-cost.d.ts +27 -0
  70. package/dist/wizards/gcp-cost.d.ts.map +1 -0
  71. package/dist/wizards/gcp-cost.js +77 -0
  72. package/dist/wizards/gcp-cost.js.map +1 -0
  73. package/dist/wizards/gcp-pulumi.d.ts +37 -0
  74. package/dist/wizards/gcp-pulumi.d.ts.map +1 -0
  75. package/dist/wizards/gcp-pulumi.js +100 -0
  76. package/dist/wizards/gcp-pulumi.js.map +1 -0
  77. package/dist/wizards/gcp.d.ts +9 -0
  78. package/dist/wizards/gcp.d.ts.map +1 -0
  79. package/dist/wizards/gcp.js +895 -0
  80. package/dist/wizards/gcp.js.map +1 -0
  81. package/package.json +34 -7
  82. package/stacks/aws/index.ts +6 -7
  83. package/stacks/azure/index.ts +11 -11
  84. package/stacks/gcp/Pulumi.production.yaml +16 -0
  85. package/stacks/gcp/Pulumi.yaml +52 -6
  86. package/stacks/gcp/index.ts +569 -188
  87. package/stacks/self-hosted/index.ts +3 -3
  88. package/static/welcome.html +155 -0
  89. package/src/adapter.ts +0 -103
  90. package/src/bootstrap-token.ts +0 -20
  91. package/src/caddy.ts +0 -93
  92. package/src/cli.ts +0 -674
  93. package/src/index.test.ts +0 -246
  94. package/src/index.ts +0 -52
  95. package/src/redirects-emit.ts +0 -166
  96. package/tsconfig.json +0 -16
@@ -1,66 +1,75 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * Caelo GCP stack — Pulumi entry point.
4
+ * Caelo GCP stack — three-tier deployment per CLAUDE.md §11.B.
5
5
  *
6
- * Resources provisioned per `pulumi up`:
7
- * 1. Cloud SQL Postgres 16 HA + automated backups + point-in-time recovery,
8
- * attached to a VPC via private IP.
9
- * - Two databases (cms_admin + cms_public) + two roles via post-create
10
- * run of packages/migrations/src/bootstrap.sh as a Cloud Run job.
11
- * 2. GCS buckets: caelo-<env>-media + caelo-<env>-static. Uniform bucket-
12
- * level access; storage.objectViewer granted to the Cloud CDN service
13
- * account.
14
- * 3. Cloud CDN backend bucket (static output) + backend service (Cloud
15
- * Run admin + gateway). URL map routes /api/* → gateway, /admin/* →
16
- * admin Cloud Run, default → static output bucket.
17
- * 4. Cloud Run services for admin, gateway, orchestrator, runner. Image
18
- * tags read from Pulumi config; pushed by operator via `gcloud builds
19
- * submit` for v1 (CI integration is P15 review-pass).
20
- * 5. Edge-router Cloud Run service that wraps @caelo-cms/edge-router's
21
- * routeRequest. Receives Cloud CDN's "passthrough" requests (URL
22
- * map matches /* → edge-router → static origin), assigns variants,
23
- * sets the caelo_visitor_id cookie, returns the appropriate static
24
- * path. Same hash → same variant as the AWS L@E + self-hosted Caddy.
25
- * 6. Secret Manager entries (postgres-password, csrf-secret, cookie-
26
- * secret, anthropic-api-key, resend-api-key); Cloud Run reads via
27
- * `secret_environment_variables` mounts.
28
- * 7. Cloud Logging sink → BigQuery dataset `caelo_edge_logs`. P12A
29
- * analytics plugin's GCP adapter queries via `bigquery.jobs.query`.
30
- * 8. Google-managed SSL certs per (domain, locale).
6
+ * Tier 1 Static site: GCS + Cloud CDN + Managed SSL via single LB
7
+ * Tier 2 Admin app: Cloud Run + Identity-Aware Proxy (allowlist)
8
+ * Tier 3 API gateway: Cloud Run + Cloud Armor (path-prefix on the LB)
9
+ * Tier 4 Database: Cloud SQL Postgres on private VPC IP only
10
+ * Tier 5 Workers: share the admin's Cloud Run process
31
11
  *
32
- * The Caelo runtime never knows it's on GCP every service consumes
33
- * plain DATABASE_URL / MEDIA_STORAGE_URL / SECRETS_PROVIDER env vars.
34
- * This file's only job is to wire those env vars to GCP-native resources.
12
+ * The same single LB serves both the static site (default route) and the
13
+ * gateway (/api/* prefix). The admin sits on its own Cloud Run domain
14
+ * mapping (admin.<domain>) so IAP can gate it independently IAP can't
15
+ * be attached selectively to one path of a multi-backend LB.
16
+ *
17
+ * Defaults: minimal-cost (~$30/mo). Operators bump knobs to scale up.
35
18
  */
36
19
 
37
20
  import * as gcp from "@pulumi/gcp";
38
21
  import * as pulumi from "@pulumi/pulumi";
39
- import type { CloudAdapterOutputs, DnsRecord } from "../../src/adapter.js";
40
- import { generateBootstrapToken } from "../../src/bootstrap-token.js";
22
+ import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
23
+ import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
41
24
 
42
25
  const cfg = new pulumi.Config();
43
26
  const domain = cfg.require("domain");
44
27
  const ownerEmail = cfg.require("ownerEmail");
45
28
  const project = cfg.require("project");
46
29
  const region = cfg.get("region") ?? "us-central1";
47
- const cloudSqlTier = cfg.get("cloudSqlTier") ?? "db-custom-1-3840";
48
- const cloudRunMinInstances = Number.parseInt(cfg.get("cloudRunMinInstances") ?? "0", 10);
49
30
 
50
- // Pulumi stack name is the environment label.
31
+ // === Operator-tunable knobs (defaults: minimal viable; scale-up later) ===
32
+ const cloudSqlTier = cfg.get("cloudSqlTier") ?? "db-f1-micro";
33
+ const cloudSqlHa = cfg.getBoolean("cloudSqlHa") ?? false;
34
+ // ENTERPRISE supports legacy shared-core tiers (db-f1-micro etc., ~$10/mo).
35
+ // ENTERPRISE_PLUS requires per-tier-N machines (~$50+/mo). For docs-shaped
36
+ // installs ENTERPRISE is the right call; production-traffic operators can
37
+ // flip to PLUS for the IOPS + sub-second failover.
38
+ const cloudSqlEdition = cfg.get("cloudSqlEdition") ?? "ENTERPRISE";
39
+ // CLAUDE.md §11.C: defaults are minimal-cost + easy-to-tear-down.
40
+ // Operators flip deletionProtection ON for production deploys; the
41
+ // opt-in shape is safer than the opt-out (a fresh-install user who
42
+ // wants to `caelo-cms destroy` shouldn't hit a wall).
43
+ const deletionProtection = cfg.getBoolean("deletionProtection") ?? false;
44
+ const adminMinInstances = Number.parseInt(cfg.get("adminMinInstances") ?? "0", 10);
45
+ const gatewayMinInstances = Number.parseInt(cfg.get("gatewayMinInstances") ?? "0", 10);
46
+ const wafAdaptiveProtection = cfg.getBoolean("wafAdaptiveProtection") ?? false;
47
+ const backupRetentionDays = Number.parseInt(cfg.get("backupRetentionDays") ?? "7", 10);
48
+ // IAP allowlist: comma-list of "user:<email>" or "group:<group@domain>".
49
+ // Empty config string falls back to just the ownerEmail.
50
+ const iapAllowlistRaw = cfg.get("iapAllowlist");
51
+ const iapAllowlist =
52
+ iapAllowlistRaw && iapAllowlistRaw.trim().length > 0
53
+ ? iapAllowlistRaw
54
+ .split(",")
55
+ .map((s) => s.trim())
56
+ .filter(Boolean)
57
+ : [`user:${ownerEmail}`];
58
+
51
59
  const env = pulumi.getStack() as "dev" | "staging" | "production";
52
60
  const namePrefix = `caelo-${env}`;
61
+ const adminDomain = `admin.${domain}`;
53
62
 
54
63
  const gcpProvider = new gcp.Provider(`${namePrefix}-gcp`, { project, region });
55
64
  const opts = { provider: gcpProvider };
56
65
 
57
- // === 1. VPC for private Cloud SQL ===
66
+ // =========================================================================
67
+ // VPC for private Cloud SQL — Cloud Run reaches Postgres via private IP only
68
+ // =========================================================================
69
+
58
70
  const network = new gcp.compute.Network(
59
71
  `${namePrefix}-vpc`,
60
- {
61
- autoCreateSubnetworks: false,
62
- description: `Caelo ${env} VPC`,
63
- },
72
+ { autoCreateSubnetworks: false, description: `Caelo ${env} VPC` },
64
73
  opts,
65
74
  );
66
75
 
@@ -75,9 +84,6 @@ const subnet = new gcp.compute.Subnetwork(
75
84
  opts,
76
85
  );
77
86
 
78
- // Allocate a private services range so Cloud SQL can attach via VPC
79
- // peering — this is the supported path for "managed Postgres on a
80
- // private IP". Without this Cloud SQL forces a public endpoint.
81
87
  const privateIpAlloc = new gcp.compute.GlobalAddress(
82
88
  `${namePrefix}-pg-private-ip`,
83
89
  {
@@ -99,7 +105,10 @@ const sqlPeering = new gcp.servicenetworking.Connection(
99
105
  opts,
100
106
  );
101
107
 
102
- // === 2. Secret Manager ===
108
+ // =========================================================================
109
+ // Secret Manager — never env-var literal; runtime mounts via secret_environment_variables
110
+ // =========================================================================
111
+
103
112
  function randomHex(bytes: number): string {
104
113
  const buf = new Uint8Array(bytes);
105
114
  crypto.getRandomValues(buf);
@@ -109,21 +118,38 @@ function randomHex(bytes: number): string {
109
118
  const postgresPassword = pulumi.secret(randomHex(32));
110
119
  const csrfSecret = pulumi.secret(randomHex(32));
111
120
  const cookieSecret = pulumi.secret(randomHex(32));
112
- const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
113
- const resendApiKey = pulumi.secret(process.env.RESEND_API_KEY ?? "");
121
+ // P18 project KEK (32 bytes hex) encrypts AI provider API keys + future
122
+ // at-rest secrets in cms_admin. Auto-generated per stack so the operator
123
+ // never types it; Cloud Run binds it as CAELO_SECRET_KEK env via
124
+ // `valueSource.secretKeyRef` below. Rotating it requires a re-encrypt
125
+ // CLI (deferred — the api_key_kek_fp column lets a future tool find rows
126
+ // under the old KEK and refuse to silently return garbage).
127
+ const caeloSecretKek = pulumi.secret(randomHex(32));
128
+ // anthropicApiKey is now OPTIONAL — operators configure the key via
129
+ // /security/ai (encrypted into ai_providers) after first login. Setting
130
+ // it via Pulumi config is supported as a backwards-compat path for
131
+ // installs that already wired it before P18; the Secret resource still
132
+ // exists in either case so a future v1 can be added without redeploying.
133
+ const anthropicApiKeyConfig = cfg.getSecret("anthropicApiKey");
134
+ // resendApiKey is OPTIONAL — empty config means "skip the SecretVersion"
135
+ // so GCP doesn't reject `payload: ""`. The Secret resource itself still
136
+ // exists; operators add a v1 later via `gcloud secrets versions add`.
137
+ const resendApiKeyConfig = cfg.getSecret("resendApiKey");
138
+
139
+ interface MadeSecret {
140
+ resource: gcp.secretmanager.Secret;
141
+ version: gcp.secretmanager.SecretVersion | null;
142
+ }
114
143
 
115
- function secret(
116
- name: string,
117
- value: pulumi.Output<string>,
118
- ): { resource: gcp.secretmanager.Secret; version: gcp.secretmanager.SecretVersion } {
144
+ function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecret {
119
145
  const resource = new gcp.secretmanager.Secret(
120
146
  `${namePrefix}-${name}`,
121
- {
122
- secretId: `${namePrefix}-${name}`,
123
- replication: { auto: {} },
124
- },
147
+ { secretId: `${namePrefix}-${name}`, replication: { auto: {} } },
125
148
  opts,
126
149
  );
150
+ if (value === null) {
151
+ return { resource, version: null };
152
+ }
127
153
  const version = new gcp.secretmanager.SecretVersion(
128
154
  `${namePrefix}-${name}-v1`,
129
155
  { secret: resource.name, secretData: value },
@@ -132,13 +158,24 @@ function secret(
132
158
  return { resource, version };
133
159
  }
134
160
 
135
- const pgSecret = secret("postgres-password", postgresPassword);
136
- secret("csrf-secret", csrfSecret);
137
- secret("cookie-secret", cookieSecret);
138
- secret("anthropic-api-key", anthropicApiKey);
139
- secret("resend-api-key", resendApiKey);
161
+ const pgSecret = makeSecret("postgres-password", postgresPassword);
162
+ const csrfSecretRes = makeSecret("csrf-secret", csrfSecret);
163
+ const cookieSecretRes = makeSecret("cookie-secret", cookieSecret);
164
+ const kekSecret = makeSecret("secret-kek", caeloSecretKek);
165
+ // Anthropic key is optional now — operators configure providers via
166
+ // /security/ai after first login. Skip the v1 when no key is in config;
167
+ // the Secret resource still exists for backwards-compat env-var path.
168
+ const anthropicSecretRes = makeSecret("anthropic-api-key", anthropicApiKeyConfig ?? null);
169
+ // Resend: skip the v1 when no key is configured (cfg.getSecret returns
170
+ // undefined when the key is unset in Pulumi.yaml + not overridden in the
171
+ // stack config). The Secret resource still exists; operators add a v1
172
+ // later via `gcloud secrets versions add` or `pulumi config set --secret`.
173
+ const resendSecretRes = makeSecret("resend-api-key", resendApiKeyConfig ?? null);
174
+
175
+ // =========================================================================
176
+ // Tier 4 — Cloud SQL Postgres (private IP only; HA + retention configurable)
177
+ // =========================================================================
140
178
 
141
- // === 3. Cloud SQL Postgres 16 HA ===
142
179
  const sqlInstance = new gcp.sql.DatabaseInstance(
143
180
  `${namePrefix}-pg`,
144
181
  {
@@ -146,38 +183,45 @@ const sqlInstance = new gcp.sql.DatabaseInstance(
146
183
  region,
147
184
  settings: {
148
185
  tier: cloudSqlTier,
149
- // Regional = HA (synchronous replica in another zone).
150
- availabilityType: env === "production" ? "REGIONAL" : "ZONAL",
186
+ edition: cloudSqlEdition,
187
+ availabilityType: cloudSqlHa ? "REGIONAL" : "ZONAL",
151
188
  diskSize: 20,
152
189
  diskType: "PD_SSD",
153
190
  diskAutoresize: true,
154
191
  backupConfiguration: {
155
192
  enabled: true,
156
- pointInTimeRecoveryEnabled: env === "production",
193
+ pointInTimeRecoveryEnabled: cloudSqlHa,
157
194
  startTime: "03:00",
158
195
  backupRetentionSettings: {
159
196
  retentionUnit: "COUNT",
160
- retainedBackups: env === "production" ? 14 : 1,
197
+ retainedBackups: backupRetentionDays,
161
198
  },
162
199
  },
163
200
  ipConfiguration: {
164
201
  ipv4Enabled: false,
165
202
  privateNetwork: network.id,
166
203
  },
167
- deletionProtectionEnabled: env === "production",
204
+ deletionProtectionEnabled: deletionProtection,
168
205
  },
169
- deletionProtection: env === "production",
206
+ deletionProtection,
170
207
  },
171
208
  { ...opts, dependsOn: [sqlPeering] },
172
209
  );
173
210
 
211
+ // Caelo's DatabaseAdapter.verifyRoles() asserts that the connecting
212
+ // user IS named admin_role or public_role per CMS_REQUIREMENTS §12.3
213
+ // (matches the bootstrap.sh self-hosted setup). Cloud SQL uses
214
+ // CREATE USER which is just CREATE ROLE WITH LOGIN, so the role names
215
+ // passed here become the auth user names — no rename needed.
174
216
  const pgAdminUser = new gcp.sql.User(
175
217
  `${namePrefix}-pg-admin`,
176
- {
177
- instance: sqlInstance.name,
178
- name: "caelo_admin",
179
- password: postgresPassword,
180
- },
218
+ { instance: sqlInstance.name, name: "admin_role", password: postgresPassword },
219
+ opts,
220
+ );
221
+
222
+ const pgPublicUser = new gcp.sql.User(
223
+ `${namePrefix}-pg-public`,
224
+ { instance: sqlInstance.name, name: "public_role", password: postgresPassword },
181
225
  opts,
182
226
  );
183
227
 
@@ -192,14 +236,30 @@ const cmsPublicDb = new gcp.sql.Database(
192
236
  opts,
193
237
  );
194
238
 
239
+ // admin_role on cms_admin — admin app's primary DB.
195
240
  const adminDatabaseUrl = pulumi
196
241
  .all([sqlInstance.privateIpAddress, postgresPassword])
197
- .apply(([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_admin?sslmode=require`);
242
+ .apply(([host, pw]) => `postgresql://admin_role:${pw}@${host}:5432/cms_admin?sslmode=require`);
243
+ // admin_role on cms_public — admin's second pool: cross-DB reads of
244
+ // plugin data + DDL for plugin schemas + migration runs. Wired to the
245
+ // admin (and migration job) via PUBLIC_ADMIN_DATABASE_URL.
246
+ const publicAdminDatabaseUrl = pulumi
247
+ .all([sqlInstance.privateIpAddress, postgresPassword])
248
+ .apply(([host, pw]) => `postgresql://admin_role:${pw}@${host}:5432/cms_public?sslmode=require`);
249
+ // public_role on cms_public — write-limited gateway role. Wired to the
250
+ // gateway via PUBLIC_DATABASE_URL.
198
251
  const publicDatabaseUrl = pulumi
199
252
  .all([sqlInstance.privateIpAddress, postgresPassword])
200
- .apply(([host, pw]) => `postgresql://caelo_public:${pw}@${host}:5432/cms_public?sslmode=require`);
253
+ .apply(([host, pw]) => `postgresql://public_role:${pw}@${host}:5432/cms_public?sslmode=require`);
254
+
255
+ // Reference pgPublicUser so Pulumi knows it's part of the dependency
256
+ // graph (Cloud Run env-var deps don't see this directly).
257
+ void pgPublicUser;
258
+
259
+ // =========================================================================
260
+ // GCS buckets — static (Tier 1 origin, public-read) + media (private, signed-URL only)
261
+ // =========================================================================
201
262
 
202
- // === 4. GCS buckets ===
203
263
  const mediaBucket = new gcp.storage.Bucket(
204
264
  `${namePrefix}-media`,
205
265
  {
@@ -220,11 +280,19 @@ const staticBucket = new gcp.storage.Bucket(
220
280
  uniformBucketLevelAccess: true,
221
281
  forceDestroy: env !== "production",
222
282
  website: { mainPageSuffix: "index.html", notFoundPage: "404.html" },
283
+ cors: [
284
+ {
285
+ origins: [`https://${domain}`],
286
+ methods: ["GET", "HEAD"],
287
+ responseHeaders: ["Content-Type"],
288
+ maxAgeSeconds: 3600,
289
+ },
290
+ ],
223
291
  },
224
292
  opts,
225
293
  );
226
294
 
227
- // Public read on the static-output bucket so Cloud CDN can fetch.
295
+ // Public read on the static bucket so Cloud CDN can fetch.
228
296
  new gcp.storage.BucketIAMMember(
229
297
  `${namePrefix}-static-public-read`,
230
298
  {
@@ -235,28 +303,9 @@ new gcp.storage.BucketIAMMember(
235
303
  opts,
236
304
  );
237
305
 
238
- // === 5. BigQuery dataset for A/B assignment logs ===
239
- const edgeLogDataset = new gcp.bigquery.Dataset(
240
- `${namePrefix}-edge-logs-ds`,
241
- {
242
- datasetId: `${namePrefix.replace(/-/g, "_")}_edge_logs`,
243
- location: region.toUpperCase(),
244
- description: `Caelo ${env} A/B assignment log sink (P12A analytics plugin).`,
245
- },
246
- opts,
247
- );
248
-
249
- // === 6. Cloud Run services ===
250
- //
251
- // v1 expects images pushed via `gcloud builds submit` to Artifact Registry
252
- // at `<region>-docker.pkg.dev/<project>/caelo/<service>:<sha>`. Operators
253
- // set the image tag via Pulumi config — the stack just wires the right
254
- // env vars + secret mounts.
255
- function imageTag(service: string): string {
256
- return (
257
- cfg.get(`image-${service}`) ?? `${region}-docker.pkg.dev/${project}/caelo/${service}:latest`
258
- );
259
- }
306
+ // =========================================================================
307
+ // Cloud Run service account — single SA used by admin + gateway
308
+ // =========================================================================
260
309
 
261
310
  const runSa = new gcp.serviceaccount.Account(
262
311
  `${namePrefix}-run-sa`,
@@ -267,26 +316,30 @@ const runSa = new gcp.serviceaccount.Account(
267
316
  opts,
268
317
  );
269
318
 
270
- // Grant the run SA secret access for each Caelo secret.
271
- for (const sn of [
272
- "postgres-password",
273
- "csrf-secret",
274
- "cookie-secret",
275
- "anthropic-api-key",
276
- "resend-api-key",
319
+ // Pass the Secret RESOURCE (not a hardcoded id) so Pulumi infers the
320
+ // dependency edge otherwise the IamMember create races the Secret
321
+ // create on first apply and 404s. Same `dependsOn` for belt-and-braces.
322
+ for (const made of [
323
+ { name: "postgres-password", made: pgSecret },
324
+ { name: "csrf-secret", made: csrfSecretRes },
325
+ { name: "cookie-secret", made: cookieSecretRes },
326
+ { name: "secret-kek", made: kekSecret },
327
+ { name: "anthropic-api-key", made: anthropicSecretRes },
328
+ { name: "resend-api-key", made: resendSecretRes },
277
329
  ]) {
278
330
  new gcp.secretmanager.SecretIamMember(
279
- `${namePrefix}-${sn}-binding`,
331
+ `${namePrefix}-${made.name}-binding`,
280
332
  {
281
- secretId: `${namePrefix}-${sn}`,
333
+ // .secretId from the resource is an Output<string> — Pulumi
334
+ // tracks the read-after-create edge automatically.
335
+ secretId: made.made.resource.secretId,
282
336
  role: "roles/secretmanager.secretAccessor",
283
337
  member: pulumi.interpolate`serviceAccount:${runSa.email}`,
284
338
  },
285
- opts,
339
+ { ...opts, dependsOn: [made.made.resource] },
286
340
  );
287
341
  }
288
342
 
289
- // Bucket access for Cloud Run.
290
343
  new gcp.storage.BucketIAMMember(
291
344
  `${namePrefix}-media-rw`,
292
345
  {
@@ -297,22 +350,98 @@ new gcp.storage.BucketIAMMember(
297
350
  opts,
298
351
  );
299
352
 
300
- interface CloudRunServiceArgs {
353
+ // `static-publisher` SA used by `bunx @caelo-cms/provisioning deploy` to
354
+ // upload the static-generator output to the bucket. Separate from runSa so
355
+ // admin Cloud Run can never accidentally write to the public-read bucket.
356
+ // Account-id max 30 chars; collapse "production" → "prod" so dev/staging/prod
357
+ // all fit within the budget.
358
+ const envShort = env === "production" ? "prod" : env === "staging" ? "stg" : "dev";
359
+ const staticPublisherSa = new gcp.serviceaccount.Account(
360
+ `${namePrefix}-static-publisher`,
361
+ {
362
+ accountId: `caelo-${envShort}-publisher`,
363
+ displayName: `Caelo ${env} static publisher`,
364
+ },
365
+ opts,
366
+ );
367
+ new gcp.storage.BucketIAMMember(
368
+ `${namePrefix}-static-publisher-rw`,
369
+ {
370
+ bucket: staticBucket.name,
371
+ role: "roles/storage.objectAdmin",
372
+ member: pulumi.interpolate`serviceAccount:${staticPublisherSa.email}`,
373
+ },
374
+ opts,
375
+ );
376
+
377
+ // =========================================================================
378
+ // Tier 2 + Tier 3 — Cloud Run services (admin + gateway only; workers share admin)
379
+ // =========================================================================
380
+
381
+ // §11.C: pre-built signed images on a public registry are the contract.
382
+ // The Caelo team publishes admin + gateway images to a public-pull AR
383
+ // repo at `europe-west1-docker.pkg.dev/<CAELO_PUBLIC_REGISTRY_PROJECT>
384
+ // /caelo-cms-images/<service>:<tag>`. Cloud Run reads it directly with
385
+ // no operator-side IAM binding (the repo's allUsers role grants
386
+ // anonymous pull). No per-install image copy, no operator-owned AR
387
+ // repo, no wizard step. Same shape per provider — AWS reads
388
+ // `public.ecr.aws/caelo-cms/...`, Azure reads the team-controlled ACR.
389
+ //
390
+ // Default points at the Caelo-team-managed registry. Operators who want
391
+ // to pull from their own copy override per-stack via
392
+ // `pulumi config set caelo-gcp:image-<service> <full-tag>` or
393
+ // `pulumi config set caelo-gcp:public-registry-project <other-project>`.
394
+ const publicRegistryProject = cfg.get("public-registry-project") ?? "caelo-website";
395
+ const publicRegistryRegion = cfg.get("public-registry-region") ?? "europe-west1";
396
+ const publicRegistryRepo = cfg.get("public-registry-repo") ?? "caelo-cms-images";
397
+ const imageTagSource = cfg.get("image-tag") ?? "main";
398
+
399
+ function imageTag(service: string): pulumi.Output<string> {
400
+ // Per-service digest pin (`image-digest-<service>`): operator can pin
401
+ // an exact sha256 digest. The wizard resolves this at provision time
402
+ // (resolves :main → digest via `gcloud artifacts docker images list`)
403
+ // so each pulumi up rolls Cloud Run to the freshest published image
404
+ // even though the `:main` tag is mutable. Without this Cloud Run
405
+ // would silently keep running an old image after a release.
406
+ const override = cfg.get(`image-${service}`);
407
+ if (override) return pulumi.output(override);
408
+ const digest = cfg.get(`image-digest-${service}`);
409
+ const base = `${publicRegistryRegion}-docker.pkg.dev/${publicRegistryProject}/${publicRegistryRepo}/${service}`;
410
+ return pulumi.output(digest ? `${base}@${digest}` : `${base}:${imageTagSource}`);
411
+ }
412
+
413
+ interface CloudRunArgs {
301
414
  readonly serviceName: string;
415
+ readonly minInstances: number;
416
+ readonly maxInstances: number;
417
+ readonly memory: string;
302
418
  readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
303
419
  }
304
420
 
305
- function cloudRunService(args: CloudRunServiceArgs): gcp.cloudrunv2.Service {
421
+ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
306
422
  return new gcp.cloudrunv2.Service(
307
423
  `${namePrefix}-${args.serviceName}`,
308
424
  {
309
425
  location: region,
310
- ingress: "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER",
426
+ // INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER means only the LB can reach
427
+ // the gateway directly. Admin uses Cloud Run domain mapping +
428
+ // IAP (set below) so its URL must be public-resolvable but
429
+ // IAP gates every request.
430
+ ingress:
431
+ args.serviceName === "admin"
432
+ ? "INGRESS_TRAFFIC_ALL"
433
+ : "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER",
434
+ // Cloud Run defaults deletionProtection=true, which blocks
435
+ // `pulumi destroy` until the field is flipped + a separate up
436
+ // applies the change. We default to false (operator-friendly
437
+ // teardown); production deploys flip the `deletionProtection`
438
+ // config knob to true.
439
+ deletionProtection,
311
440
  template: {
312
441
  serviceAccount: runSa.email,
313
442
  scaling: {
314
- minInstanceCount: cloudRunMinInstances,
315
- maxInstanceCount: 10,
443
+ minInstanceCount: args.minInstances,
444
+ maxInstanceCount: args.maxInstances,
316
445
  },
317
446
  containers: [
318
447
  {
@@ -320,105 +449,354 @@ function cloudRunService(args: CloudRunServiceArgs): gcp.cloudrunv2.Service {
320
449
  envs: [
321
450
  { name: "CAELO_PROVIDER", value: "gcp" },
322
451
  { name: "CAELO_ENV", value: env },
452
+ { name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
453
+ { name: "MEDIA_STORAGE_URL", value: pulumi.interpolate`gs://${mediaBucket.name}` },
454
+ // P18 — project KEK is mounted from Secret Manager so AI
455
+ // provider keys (and any future at-rest secrets) can be
456
+ // decrypted at runtime. Failing to bind would surface as
457
+ // "CAELO_SECRET_KEK is not set" the moment the resolver
458
+ // tries to decrypt — clear, loud failure.
323
459
  {
324
- name: "ADMIN_DATABASE_URL",
325
- value: adminDatabaseUrl,
326
- },
327
- {
328
- name: "PUBLIC_ADMIN_DATABASE_URL",
329
- value: publicDatabaseUrl,
460
+ name: "CAELO_SECRET_KEK",
461
+ valueSource: {
462
+ secretKeyRef: { secret: kekSecret.resource.secretId, version: "latest" },
463
+ },
330
464
  },
331
- { name: "MEDIA_STORAGE_URL", value: pulumi.interpolate`gs://${mediaBucket.name}` },
332
465
  ...(args.extraEnv ?? []),
333
466
  ],
334
- resources: {
335
- limits: {
336
- cpu: "1",
337
- memory: "1Gi",
338
- },
339
- },
467
+ resources: { limits: { cpu: "1", memory: args.memory } },
340
468
  },
341
469
  ],
342
470
  vpcAccess: {
343
- // Direct VPC egress so Cloud Run reaches Cloud SQL via private IP.
344
- networkInterfaces: [
345
- {
346
- network: network.name,
347
- subnetwork: subnet.name,
348
- },
349
- ],
471
+ networkInterfaces: [{ network: network.name, subnetwork: subnet.name }],
350
472
  egress: "PRIVATE_RANGES_ONLY",
351
473
  },
352
474
  },
353
475
  },
354
- { ...opts, dependsOn: [pgAdminUser, cmsAdminDb, cmsPublicDb] },
476
+ {
477
+ ...opts,
478
+ dependsOn: [
479
+ pgAdminUser,
480
+ cmsAdminDb,
481
+ cmsPublicDb,
482
+ ...(pgSecret.version ? [pgSecret.version] : []),
483
+ // KEK must exist before Cloud Run can mount it via secretKeyRef.
484
+ ...(kekSecret.version ? [kekSecret.version] : []),
485
+ ],
486
+ },
355
487
  );
356
488
  }
357
489
 
358
- const adminSvc = cloudRunService({ serviceName: "admin" });
359
- const gatewaySvc = cloudRunService({ serviceName: "gateway" });
360
- const orchestratorSvc = cloudRunService({ serviceName: "orchestrator" });
361
- const runnerSvc = cloudRunService({ serviceName: "runner" });
362
-
363
- // Edge-router Cloud Run service. Ingresses public traffic (ALL), runs
364
- // @caelo-cms/edge-router, returns a redirect to the variant's static path.
365
- // Cloud CDN is configured to NOT cache edge-router responses (it's a
366
- // per-visitor-cookie-keyed routing decision); only the static-bucket
367
- // response is cached.
368
- const edgeRouterSvc = new gcp.cloudrunv2.Service(
369
- `${namePrefix}-edge-router`,
490
+ const adminSvc = cloudRunService({
491
+ serviceName: "admin",
492
+ minInstances: adminMinInstances,
493
+ maxInstances: 10,
494
+ memory: "1Gi",
495
+ // Admin's second pool: admin_role on cms_public for cross-DB reads
496
+ // + DDL + migrations. NOT public_role (write-limited).
497
+ extraEnv: [{ name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl }],
498
+ });
499
+ const gatewaySvc = cloudRunService({
500
+ serviceName: "gateway",
501
+ minInstances: gatewayMinInstances,
502
+ maxInstances: 100,
503
+ memory: "512Mi",
504
+ // Gateway's only DB pool: public_role on cms_public, write-limited
505
+ // per CLAUDE.md (gateway must NEVER hold admin_role creds).
506
+ extraEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
507
+ });
508
+
509
+ // =========================================================================
510
+ // Tier 1 + Tier 2 + Tier 3 LB — single HTTPS LB serves three backends
511
+ // =========================================================================
512
+ //
513
+ // One global IP, one URL map, one managed cert covering caelo-cms.com +
514
+ // admin.caelo-cms.com. Three backends routed by Host header / path:
515
+ // - host=caelo-cms.com, default → static bucket (Cloud CDN)
516
+ // - host=caelo-cms.com, path=/api/* → gateway Cloud Run (Cloud Armor)
517
+ // - host=admin.caelo-cms.com, default → admin Cloud Run (IAP-gated)
518
+ //
519
+ // CLAUDE.md §11.B Tier 2: admin behind cloud-native identity proxy. IAP
520
+ // attaches to the admin BackendService directly (iap.enabled=true on the
521
+ // BackendService spec) — no DomainMapping (avoids GCP's Search Console
522
+ // verification gate that bites every operator), no post-up
523
+ // `gcloud run services update --iap` step. Operator just adds an A
524
+ // record for admin.<domain> pointing at the LB IP and the IAP allowlist
525
+ // gates access.
526
+
527
+ const lbIp = new gcp.compute.GlobalAddress(
528
+ `${namePrefix}-lb-ip`,
529
+ { addressType: "EXTERNAL", description: "Caelo public LB IP" },
530
+ opts,
531
+ );
532
+
533
+ // Tier 3 — Cloud Armor security policy (rate limit + OWASP basic rules)
534
+ const wafPolicy = new gcp.compute.SecurityPolicy(
535
+ `${namePrefix}-waf`,
370
536
  {
371
- location: region,
372
- ingress: "INGRESS_TRAFFIC_ALL",
373
- template: {
374
- serviceAccount: runSa.email,
375
- scaling: { minInstanceCount: cloudRunMinInstances, maxInstanceCount: 100 },
376
- containers: [
377
- {
378
- image: imageTag("edge-router"),
379
- envs: [
380
- { name: "CAELO_PROVIDER", value: "gcp" },
381
- { name: "CAELO_ENV", value: env },
382
- { name: "STATIC_BUCKET", value: staticBucket.name },
383
- // P15 hot-fix #1 — `ab-routing.json` carries the
384
- // edge-router-shaped manifest; `routing-manifest.json` is
385
- // the deploy-provenance file (different schema).
386
- { name: "MANIFEST_OBJECT", value: "ab-routing.json" },
387
- ],
388
- resources: { limits: { cpu: "1", memory: "512Mi" } },
537
+ description: `Caelo ${env} Cloud Armor — rate limit + OWASP`,
538
+ rules: [
539
+ // Rate limit per source IP — 100 req / min sliding window.
540
+ {
541
+ action: "rate_based_ban",
542
+ priority: 1000,
543
+ match: {
544
+ versionedExpr: "SRC_IPS_V1",
545
+ config: { srcIpRanges: ["*"] },
389
546
  },
390
- ],
547
+ rateLimitOptions: {
548
+ rateLimitThreshold: { count: 100, intervalSec: 60 },
549
+ conformAction: "allow",
550
+ exceedAction: "deny(429)",
551
+ enforceOnKey: "IP",
552
+ banDurationSec: 600,
553
+ },
554
+ },
555
+ // OWASP rule pack (preconfigured WAF) — free tier covers the basics.
556
+ {
557
+ action: "deny(403)",
558
+ priority: 2000,
559
+ match: {
560
+ expr: { expression: "evaluatePreconfiguredWaf('sqli-v33-stable')" },
561
+ },
562
+ },
563
+ {
564
+ action: "deny(403)",
565
+ priority: 2001,
566
+ match: {
567
+ expr: { expression: "evaluatePreconfiguredWaf('xss-v33-stable')" },
568
+ },
569
+ },
570
+ // Default allow.
571
+ {
572
+ action: "allow",
573
+ priority: 2147483647,
574
+ match: { versionedExpr: "SRC_IPS_V1", config: { srcIpRanges: ["*"] } },
575
+ },
576
+ ],
577
+ ...(wafAdaptiveProtection
578
+ ? { adaptiveProtectionConfig: { layer7DdosDefenseConfig: { enable: true } } }
579
+ : {}),
580
+ },
581
+ opts,
582
+ );
583
+
584
+ // Tier 1 backend — static GCS bucket via Cloud CDN
585
+ const staticBackendBucket = new gcp.compute.BackendBucket(
586
+ `${namePrefix}-static-backend`,
587
+ {
588
+ bucketName: staticBucket.name,
589
+ enableCdn: true,
590
+ cdnPolicy: {
591
+ cacheMode: "CACHE_ALL_STATIC",
592
+ defaultTtl: 3600,
593
+ maxTtl: 86400,
594
+ clientTtl: 3600,
391
595
  },
392
596
  },
393
597
  opts,
394
598
  );
395
599
 
396
- // Public invocation for the edge-router.
600
+ // Tier 3 backend gateway Cloud Run via serverless NEG
601
+ const gatewayNeg = new gcp.compute.RegionNetworkEndpointGroup(
602
+ `${namePrefix}-gateway-neg`,
603
+ {
604
+ region,
605
+ networkEndpointType: "SERVERLESS",
606
+ cloudRun: { service: gatewaySvc.name },
607
+ },
608
+ opts,
609
+ );
610
+
611
+ const gatewayBackendService = new gcp.compute.BackendService(
612
+ `${namePrefix}-gateway-backend`,
613
+ {
614
+ protocol: "HTTPS",
615
+ backends: [{ group: gatewayNeg.id }],
616
+ loadBalancingScheme: "EXTERNAL_MANAGED",
617
+ securityPolicy: wafPolicy.id,
618
+ },
619
+ opts,
620
+ );
621
+
622
+ // Tier 2 backend — admin Cloud Run via serverless NEG, IAP-gated.
623
+ const adminNeg = new gcp.compute.RegionNetworkEndpointGroup(
624
+ `${namePrefix}-admin-neg`,
625
+ {
626
+ region,
627
+ networkEndpointType: "SERVERLESS",
628
+ cloudRun: { service: adminSvc.name },
629
+ },
630
+ opts,
631
+ );
632
+
633
+ // Google-managed IAP requires a GCP-managed service identity in the
634
+ // project. Pulumi's iap.enabled=true on a BackendService doesn't
635
+ // trigger this auto-provision (only the gcloud CLI does). Without it,
636
+ // IAP gets through OAuth but then errors with "The IAP service account
637
+ // is not provisioned" when forwarding to Cloud Run. ServiceIdentity
638
+ // creates `service-<project-number>@gcp-sa-iap.iam.gserviceaccount.com`
639
+ // idempotently.
640
+ const iapServiceIdentity = new gcp.projects.ServiceIdentity(
641
+ `${namePrefix}-iap-sa`,
642
+ { project, service: "iap.googleapis.com" },
643
+ opts,
644
+ );
645
+
646
+ // IAP forwards authenticated requests to Cloud Run via this SA, which
647
+ // needs run.invoker on the admin service.
397
648
  new gcp.cloudrunv2.ServiceIamMember(
398
- `${namePrefix}-edge-router-public`,
649
+ `${namePrefix}-iap-invoke-admin`,
399
650
  {
400
651
  location: region,
401
- name: edgeRouterSvc.name,
652
+ name: adminSvc.name,
402
653
  role: "roles/run.invoker",
403
- member: "allUsers",
654
+ member: pulumi.interpolate`serviceAccount:${iapServiceIdentity.email}`,
655
+ },
656
+ opts,
657
+ );
658
+
659
+ // Google-managed IAP: enabled=true with no OAuth client/secret means
660
+ // IAP auto-provisions and manages the OAuth credentials internally.
661
+ // The classic gcp.iap.Brand + Client resources are deprecated (the IAP
662
+ // OAuth Admin API shuts down March 19, 2026); Google-managed IAP is
663
+ // the documented replacement and Just Works for new projects.
664
+ const adminBackendService = new gcp.compute.BackendService(
665
+ `${namePrefix}-admin-backend`,
666
+ {
667
+ protocol: "HTTPS",
668
+ backends: [{ group: adminNeg.id }],
669
+ loadBalancingScheme: "EXTERNAL_MANAGED",
670
+ iap: { enabled: true },
671
+ },
672
+ { ...opts, dependsOn: [iapServiceIdentity] },
673
+ );
674
+
675
+ // IAP allowlist — bind iap.httpsResourceAccessor on the admin
676
+ // BackendService directly (resource-scoped, narrower than project-wide).
677
+ for (const principal of iapAllowlist) {
678
+ new gcp.iap.WebBackendServiceIamMember(
679
+ `${namePrefix}-admin-iap-allow-${principal.replace(/[^a-z0-9]/gi, "-").toLowerCase()}`,
680
+ {
681
+ project,
682
+ webBackendService: adminBackendService.name,
683
+ role: "roles/iap.httpsResourceAccessor",
684
+ member: principal,
685
+ },
686
+ opts,
687
+ );
688
+ }
689
+
690
+ // URL map: routes by host header.
691
+ // admin.<domain> → admin backend (IAP-gated)
692
+ // <domain> → default static bucket; /api/* → gateway
693
+ const urlMap = new gcp.compute.URLMap(
694
+ `${namePrefix}-url-map`,
695
+ {
696
+ defaultService: staticBackendBucket.id,
697
+ hostRules: [
698
+ { hosts: [domain], pathMatcher: "public" },
699
+ { hosts: [adminDomain], pathMatcher: "admin" },
700
+ ],
701
+ pathMatchers: [
702
+ {
703
+ name: "public",
704
+ defaultService: staticBackendBucket.id,
705
+ pathRules: [{ paths: ["/api/*"], service: gatewayBackendService.id }],
706
+ },
707
+ {
708
+ name: "admin",
709
+ defaultService: adminBackendService.id,
710
+ },
711
+ ],
712
+ },
713
+ opts,
714
+ );
715
+
716
+ // Managed TLS cert covering both hostnames. Single cert resource;
717
+ // the certificate's SAN list grows by one entry vs the prior version.
718
+ const managedCert = new gcp.compute.ManagedSslCertificate(
719
+ `${namePrefix}-cert`,
720
+ { managed: { domains: [domain, adminDomain] } },
721
+ opts,
722
+ );
723
+
724
+ const httpsProxy = new gcp.compute.TargetHttpsProxy(
725
+ `${namePrefix}-https-proxy`,
726
+ { urlMap: urlMap.id, sslCertificates: [managedCert.id] },
727
+ opts,
728
+ );
729
+
730
+ new gcp.compute.GlobalForwardingRule(
731
+ `${namePrefix}-https-fwd`,
732
+ {
733
+ target: httpsProxy.id,
734
+ portRange: "443",
735
+ ipAddress: lbIp.address,
736
+ loadBalancingScheme: "EXTERNAL_MANAGED",
737
+ },
738
+ opts,
739
+ );
740
+
741
+ // HTTP → HTTPS redirect: a tiny URLMap with no backend, just a 301
742
+ // redirect rule. Same LB IP, port 80, returns to the request's host
743
+ // over HTTPS preserving the path. Without this, http:// requests get
744
+ // ERR_EMPTY_RESPONSE.
745
+ const httpRedirectMap = new gcp.compute.URLMap(
746
+ `${namePrefix}-http-redirect`,
747
+ {
748
+ defaultUrlRedirect: {
749
+ httpsRedirect: true,
750
+ redirectResponseCode: "MOVED_PERMANENTLY_DEFAULT",
751
+ stripQuery: false,
752
+ },
753
+ },
754
+ opts,
755
+ );
756
+
757
+ const httpProxy = new gcp.compute.TargetHttpProxy(
758
+ `${namePrefix}-http-proxy`,
759
+ { urlMap: httpRedirectMap.id },
760
+ opts,
761
+ );
762
+
763
+ new gcp.compute.GlobalForwardingRule(
764
+ `${namePrefix}-http-fwd`,
765
+ {
766
+ target: httpProxy.id,
767
+ portRange: "80",
768
+ ipAddress: lbIp.address,
769
+ loadBalancingScheme: "EXTERNAL_MANAGED",
770
+ },
771
+ opts,
772
+ );
773
+
774
+ // =========================================================================
775
+ // Cloud Logging sink → BigQuery for the analytics plugin
776
+ // =========================================================================
777
+
778
+ const edgeLogDataset = new gcp.bigquery.Dataset(
779
+ `${namePrefix}-edge-logs-ds`,
780
+ {
781
+ datasetId: `${namePrefix.replace(/-/g, "_")}_edge_logs`,
782
+ location: region.toUpperCase(),
783
+ description: `Caelo ${env} edge log sink (P12A analytics plugin).`,
404
784
  },
405
785
  opts,
406
786
  );
407
787
 
408
- // === 7. Cloud Logging sink → BigQuery ===
409
788
  const loggingSink = new gcp.logging.ProjectSink(
410
789
  `${namePrefix}-edge-log-sink`,
411
790
  {
412
791
  name: `${namePrefix}-edge-log-sink`,
413
792
  destination: pulumi.interpolate`bigquery.googleapis.com/projects/${project}/datasets/${edgeLogDataset.datasetId}`,
414
- // Only ab_assignment lines from the edge-router service.
415
- filter: pulumi.interpolate`resource.type="cloud_run_revision" AND resource.labels.service_name="${edgeRouterSvc.name}" AND jsonPayload.kind="ab_assignment"`,
793
+ // Capture all gateway request logs + admin warnings/errors.
794
+ filter: pulumi.interpolate`(resource.type="cloud_run_revision" AND resource.labels.service_name="${gatewaySvc.name}") OR (resource.type="cloud_run_revision" AND resource.labels.service_name="${adminSvc.name}" AND severity>=WARNING)`,
416
795
  uniqueWriterIdentity: true,
417
796
  },
418
797
  opts,
419
798
  );
420
799
 
421
- // Grant the sink's writer identity BigQuery dataEditor on the dataset.
422
800
  new gcp.bigquery.DatasetIamMember(
423
801
  `${namePrefix}-edge-log-sink-bq-perms`,
424
802
  {
@@ -429,28 +807,26 @@ new gcp.bigquery.DatasetIamMember(
429
807
  opts,
430
808
  );
431
809
 
432
- // === 8. Bootstrap token ===
810
+ // =========================================================================
811
+ // Bootstrap token + outputs
812
+ // =========================================================================
813
+
433
814
  const tokenInfo = generateBootstrapToken();
434
815
 
435
- // === 9. CloudAdapterOutputs ===
436
- const dnsRecordsRequired: DnsRecord[] = [
816
+ const dnsRecordsRequired: pulumi.Output<DnsRecord[]> = lbIp.address.apply((ip) => [
437
817
  {
438
818
  hostname: domain,
439
819
  type: "A",
440
- // The actual IP comes from the load balancer (not provisioned in
441
- // PR 3 operator wires it manually for v1, P15 review-pass adds
442
- // gcp.compute.GlobalForwardingRule). Surface the placeholder so
443
- // the DNS UI tells the operator what record TYPE is needed.
444
- value: "<gcp-load-balancer-ip>",
445
- purpose: "Primary domain → GCP HTTPS load balancer (operator must wire after `pulumi up`)",
820
+ value: ip,
821
+ purpose: "Public docs site static GCS via Cloud CDN (Tier 1 LB backend)",
446
822
  },
447
823
  {
448
- hostname: `staging.${domain}`,
824
+ hostname: adminDomain,
449
825
  type: "A",
450
- value: "<gcp-load-balancer-ip>",
451
- purpose: "Staging subdomainGCP HTTPS load balancer",
826
+ value: ip,
827
+ purpose: "Admin appCloud Run via LB (Tier 2, IAP-gated by allowlist)",
452
828
  },
453
- ];
829
+ ]);
454
830
 
455
831
  const out: CloudAdapterOutputs = {
456
832
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
@@ -458,8 +834,8 @@ const out: CloudAdapterOutputs = {
458
834
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
459
835
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
460
836
  bootstrapUrl:
461
- pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
462
- dnsRecordsRequired,
837
+ pulumi.interpolate`https://${adminDomain}/setup?token=${tokenInfo.token}` as unknown as string,
838
+ dnsRecordsRequired: dnsRecordsRequired as unknown as DnsRecord[],
463
839
  edgeLogSinkUrl:
464
840
  pulumi.interpolate`bigquery://${project}/${edgeLogDataset.datasetId}` as unknown as string,
465
841
  provider: "gcp",
@@ -479,5 +855,10 @@ export const cloudSqlConnectionNameOut = sqlInstance.connectionName;
479
855
  export const cloudSqlPrivateIpOut = sqlInstance.privateIpAddress;
480
856
  export const adminCloudRunUrlOut = adminSvc.uri;
481
857
  export const gatewayCloudRunUrlOut = gatewaySvc.uri;
482
- export const edgeRouterCloudRunUrlOut = edgeRouterSvc.uri;
858
+ export const lbIpOut = lbIp.address;
859
+ export const adminDomainOut = adminDomain;
483
860
  export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
861
+ export const staticPublisherSaEmailOut = staticPublisherSa.email;
862
+ export const staticBucketNameOut = staticBucket.name;
863
+ // P15 self-hosted CDN-copy adapter ABI — kept for cross-stack compat.
864
+ export const selfHostedCdnCopy = { pin: {}, unpin: {} };