@caelo-cms/provisioning 0.1.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +92 -7
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +10 -9
- package/dist/compose.js.map +1 -1
- package/dist/dns/cloudflare.d.ts +9 -0
- package/dist/dns/cloudflare.d.ts.map +1 -0
- package/dist/dns/cloudflare.js +160 -0
- package/dist/dns/cloudflare.js.map +1 -0
- package/dist/dns/index.d.ts +12 -0
- package/dist/dns/index.d.ts.map +1 -0
- package/dist/dns/index.js +42 -0
- package/dist/dns/index.js.map +1 -0
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -0
- package/dist/dns/manual.js +96 -0
- package/dist/dns/manual.js.map +1 -0
- package/dist/dns/types.d.ts +23 -0
- package/dist/dns/types.d.ts.map +1 -0
- package/dist/dns/types.js +3 -0
- package/dist/dns/types.js.map +1 -0
- package/dist/gcloud.d.ts +42 -0
- package/dist/gcloud.d.ts.map +1 -0
- package/dist/gcloud.js +187 -0
- package/dist/gcloud.js.map +1 -0
- package/dist/install-state.d.ts +54 -0
- package/dist/install-state.d.ts.map +1 -0
- package/dist/install-state.js +118 -0
- package/dist/install-state.js.map +1 -0
- package/dist/lifecycle.d.ts +19 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +588 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/migration-runner.d.ts +15 -0
- package/dist/migration-runner.d.ts.map +1 -0
- package/dist/migration-runner.js +174 -0
- package/dist/migration-runner.js.map +1 -0
- package/dist/redirects-emit.d.ts.map +1 -1
- package/dist/redirects-emit.js +4 -1
- package/dist/redirects-emit.js.map +1 -1
- package/dist/wizard.d.ts +35 -0
- package/dist/wizard.d.ts.map +1 -0
- package/dist/wizard.js +160 -0
- package/dist/wizard.js.map +1 -0
- package/dist/wizards/gcp-cost.d.ts +27 -0
- package/dist/wizards/gcp-cost.d.ts.map +1 -0
- package/dist/wizards/gcp-cost.js +77 -0
- package/dist/wizards/gcp-cost.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +37 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -0
- package/dist/wizards/gcp-pulumi.js +100 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -0
- package/dist/wizards/gcp.d.ts +9 -0
- package/dist/wizards/gcp.d.ts.map +1 -0
- package/dist/wizards/gcp.js +895 -0
- package/dist/wizards/gcp.js.map +1 -0
- package/package.json +13 -2
- package/stacks/aws/index.ts +6 -7
- package/stacks/azure/index.ts +11 -11
- package/stacks/gcp/Pulumi.production.yaml +16 -0
- package/stacks/gcp/Pulumi.yaml +52 -6
- package/stacks/gcp/index.ts +569 -188
- package/stacks/self-hosted/index.ts +3 -3
- package/static/welcome.html +155 -0
package/stacks/gcp/index.ts
CHANGED
|
@@ -1,66 +1,75 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Caelo GCP stack —
|
|
4
|
+
* Caelo GCP stack — three-tier deployment per CLAUDE.md §11.B.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 "../../
|
|
40
|
-
import { generateBootstrapToken } from "../../
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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 =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
150
|
-
availabilityType:
|
|
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:
|
|
193
|
+
pointInTimeRecoveryEnabled: cloudSqlHa,
|
|
157
194
|
startTime: "03:00",
|
|
158
195
|
backupRetentionSettings: {
|
|
159
196
|
retentionUnit: "COUNT",
|
|
160
|
-
retainedBackups:
|
|
197
|
+
retainedBackups: backupRetentionDays,
|
|
161
198
|
},
|
|
162
199
|
},
|
|
163
200
|
ipConfiguration: {
|
|
164
201
|
ipv4Enabled: false,
|
|
165
202
|
privateNetwork: network.id,
|
|
166
203
|
},
|
|
167
|
-
deletionProtectionEnabled:
|
|
204
|
+
deletionProtectionEnabled: deletionProtection,
|
|
168
205
|
},
|
|
169
|
-
deletionProtection
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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://
|
|
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://
|
|
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
|
|
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
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
"
|
|
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}-${
|
|
331
|
+
`${namePrefix}-${made.name}-binding`,
|
|
280
332
|
{
|
|
281
|
-
secretId
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
315
|
-
maxInstanceCount:
|
|
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: "
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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({
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
{
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
//
|
|
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}-
|
|
649
|
+
`${namePrefix}-iap-invoke-admin`,
|
|
399
650
|
{
|
|
400
651
|
location: region,
|
|
401
|
-
name:
|
|
652
|
+
name: adminSvc.name,
|
|
402
653
|
role: "roles/run.invoker",
|
|
403
|
-
member:
|
|
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
|
-
//
|
|
415
|
-
filter: pulumi.interpolate`resource.type="cloud_run_revision" AND resource.labels.service_name="${
|
|
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
|
-
//
|
|
810
|
+
// =========================================================================
|
|
811
|
+
// Bootstrap token + outputs
|
|
812
|
+
// =========================================================================
|
|
813
|
+
|
|
433
814
|
const tokenInfo = generateBootstrapToken();
|
|
434
815
|
|
|
435
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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:
|
|
824
|
+
hostname: adminDomain,
|
|
449
825
|
type: "A",
|
|
450
|
-
value:
|
|
451
|
-
purpose: "
|
|
826
|
+
value: ip,
|
|
827
|
+
purpose: "Admin app → Cloud 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://${
|
|
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
|
|
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: {} };
|