@caelo-cms/provisioning 0.2.85 → 0.3.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.
- package/dist/cdn-copy.d.ts.map +1 -1
- package/dist/cdn-copy.js +8 -1
- package/dist/cdn-copy.js.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/gcloud.d.ts +2 -1
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +11 -2
- package/dist/gcloud.js.map +1 -1
- package/dist/install-state.d.ts +1 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +16 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +9 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -0
- package/dist/wizards/gcp-firebase.js +52 -0
- package/dist/wizards/gcp-firebase.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +27 -19
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +7 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +19 -8
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/stacks/gcp-firebase/Pulumi.yaml +90 -0
- package/stacks/gcp-firebase/index.ts +559 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caelo GCP Firebase Hosting stack (v0.3.0).
|
|
5
|
+
*
|
|
6
|
+
* Architecture (differs from `gcp/` stack):
|
|
7
|
+
* - Static site: Firebase Hosting site (no LB, no BackendBucket)
|
|
8
|
+
* - Admin: Cloud Run + custom domain mapping (admin.<domain>)
|
|
9
|
+
* + IAP-on-Cloud-Run (no LB IAP)
|
|
10
|
+
* - Gateway: Cloud Run + Firebase Hosting `rewrites` (no LB,
|
|
11
|
+
* no Cloud Armor)
|
|
12
|
+
* - Database: Cloud SQL Postgres on private VPC IP only (same
|
|
13
|
+
* as `gcp/`)
|
|
14
|
+
* - Workers: share admin Cloud Run (same as `gcp/`)
|
|
15
|
+
*
|
|
16
|
+
* The trade-off (no Cloud Armor on the gateway) was explicitly
|
|
17
|
+
* accepted by the operator. Mitigation: Firebase Edge DDoS shield,
|
|
18
|
+
* Cloud Run max-instance caps, Caelo's in-app rate-limit
|
|
19
|
+
* middleware, form CAPTCHA/PoW.
|
|
20
|
+
*
|
|
21
|
+
* Code structure mirrors `packages/provisioning/stacks/gcp/index.ts`
|
|
22
|
+
* for the shared blocks (VPC, Secrets, Cloud SQL, run SA, Cloud Run
|
|
23
|
+
* services); the divergent blocks (Firebase Hosting site, Cloud Run
|
|
24
|
+
* domain mapping, Cloud Run IAP) live at the bottom of this file.
|
|
25
|
+
* Shared code lives in `packages/provisioning/src/` (imported here).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as gcp from "@pulumi/gcp";
|
|
29
|
+
import * as pulumi from "@pulumi/pulumi";
|
|
30
|
+
import * as random from "@pulumi/random";
|
|
31
|
+
import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
|
|
32
|
+
import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
|
|
33
|
+
|
|
34
|
+
const cfg = new pulumi.Config();
|
|
35
|
+
const domain = cfg.require("domain");
|
|
36
|
+
const ownerEmail = cfg.require("ownerEmail");
|
|
37
|
+
const project = cfg.require("project");
|
|
38
|
+
const region = cfg.get("region") ?? "us-central1";
|
|
39
|
+
|
|
40
|
+
// === Operator-tunable knobs ===
|
|
41
|
+
const cloudSqlTier = cfg.get("cloudSqlTier") ?? "db-f1-micro";
|
|
42
|
+
const cloudSqlHa = cfg.getBoolean("cloudSqlHa") ?? false;
|
|
43
|
+
const cloudSqlEdition = cfg.get("cloudSqlEdition") ?? "ENTERPRISE";
|
|
44
|
+
const deletionProtection = cfg.getBoolean("deletionProtection") ?? false;
|
|
45
|
+
const adminMinInstances = Number.parseInt(cfg.get("adminMinInstances") ?? "0", 10);
|
|
46
|
+
const adminMaxInstances = Number.parseInt(cfg.get("adminMaxInstances") ?? "5", 10);
|
|
47
|
+
const gatewayMinInstances = Number.parseInt(cfg.get("gatewayMinInstances") ?? "0", 10);
|
|
48
|
+
const maxConnections = Number.parseInt(cfg.get("maxConnections") ?? "100", 10);
|
|
49
|
+
const backupRetentionDays = Number.parseInt(cfg.get("backupRetentionDays") ?? "7", 10);
|
|
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
|
+
|
|
59
|
+
const env = pulumi.getStack() as "dev" | "staging" | "production";
|
|
60
|
+
const namePrefix = `caelo-${env}`;
|
|
61
|
+
const adminDomain = `admin.${domain}`;
|
|
62
|
+
// Firebase site IDs are GLOBALLY unique across all Firebase projects.
|
|
63
|
+
// Default uses the namePrefix; operator can override if the default
|
|
64
|
+
// collides with another project's site.
|
|
65
|
+
const firebaseSiteId = cfg.get("firebaseSiteId") || `${namePrefix}-site`;
|
|
66
|
+
|
|
67
|
+
const gcpProvider = new gcp.Provider(`${namePrefix}-gcp`, { project, region });
|
|
68
|
+
const opts = { provider: gcpProvider };
|
|
69
|
+
|
|
70
|
+
// =========================================================================
|
|
71
|
+
// VPC for private Cloud SQL — Cloud Run reaches Postgres via private IP only
|
|
72
|
+
// =========================================================================
|
|
73
|
+
|
|
74
|
+
const network = new gcp.compute.Network(
|
|
75
|
+
`${namePrefix}-vpc`,
|
|
76
|
+
{ autoCreateSubnetworks: false, description: `Caelo ${env} VPC` },
|
|
77
|
+
opts,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const subnet = new gcp.compute.Subnetwork(
|
|
81
|
+
`${namePrefix}-subnet`,
|
|
82
|
+
{
|
|
83
|
+
region,
|
|
84
|
+
ipCidrRange: "10.20.0.0/20",
|
|
85
|
+
network: network.id,
|
|
86
|
+
privateIpGoogleAccess: true,
|
|
87
|
+
},
|
|
88
|
+
opts,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const privateIpAlloc = new gcp.compute.GlobalAddress(
|
|
92
|
+
`${namePrefix}-pg-private-ip`,
|
|
93
|
+
{
|
|
94
|
+
purpose: "VPC_PEERING",
|
|
95
|
+
addressType: "INTERNAL",
|
|
96
|
+
prefixLength: 16,
|
|
97
|
+
network: network.id,
|
|
98
|
+
},
|
|
99
|
+
opts,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const sqlPeering = new gcp.servicenetworking.Connection(
|
|
103
|
+
`${namePrefix}-pg-peering`,
|
|
104
|
+
{
|
|
105
|
+
network: network.id,
|
|
106
|
+
service: "servicenetworking.googleapis.com",
|
|
107
|
+
reservedPeeringRanges: [privateIpAlloc.name],
|
|
108
|
+
},
|
|
109
|
+
opts,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// =========================================================================
|
|
113
|
+
// Secret Manager — random.RandomBytes for stable values across `pulumi up`
|
|
114
|
+
// (v0.2.81 fix: pre-v0.2.81 every up rotated secrets, breaking the KEK).
|
|
115
|
+
// =========================================================================
|
|
116
|
+
|
|
117
|
+
const pgPasswordBytes = new random.RandomBytes(`${namePrefix}-pg-password-bytes`, { length: 32 });
|
|
118
|
+
const csrfSecretBytes = new random.RandomBytes(`${namePrefix}-csrf-secret-bytes`, { length: 32 });
|
|
119
|
+
const cookieSecretBytes = new random.RandomBytes(`${namePrefix}-cookie-secret-bytes`, {
|
|
120
|
+
length: 32,
|
|
121
|
+
});
|
|
122
|
+
const kekBytes = new random.RandomBytes(`${namePrefix}-secret-kek-bytes`, { length: 32 });
|
|
123
|
+
|
|
124
|
+
const postgresPassword = pulumi.secret(pgPasswordBytes.hex);
|
|
125
|
+
const csrfSecret = pulumi.secret(csrfSecretBytes.hex);
|
|
126
|
+
const cookieSecret = pulumi.secret(cookieSecretBytes.hex);
|
|
127
|
+
const caeloSecretKek = pulumi.secret(kekBytes.hex);
|
|
128
|
+
const anthropicApiKeyConfig = cfg.getSecret("anthropicApiKey");
|
|
129
|
+
const resendApiKeyConfig = cfg.getSecret("resendApiKey");
|
|
130
|
+
|
|
131
|
+
interface MadeSecret {
|
|
132
|
+
resource: gcp.secretmanager.Secret;
|
|
133
|
+
version: gcp.secretmanager.SecretVersion | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeSecret(name: string, value: pulumi.Output<string> | null): MadeSecret {
|
|
137
|
+
const resource = new gcp.secretmanager.Secret(
|
|
138
|
+
`${namePrefix}-${name}`,
|
|
139
|
+
{ secretId: `${namePrefix}-${name}`, replication: { auto: {} } },
|
|
140
|
+
opts,
|
|
141
|
+
);
|
|
142
|
+
if (value === null) return { resource, version: null };
|
|
143
|
+
const version = new gcp.secretmanager.SecretVersion(
|
|
144
|
+
`${namePrefix}-${name}-v1`,
|
|
145
|
+
{ secret: resource.name, secretData: value },
|
|
146
|
+
{ ...opts, dependsOn: [resource] },
|
|
147
|
+
);
|
|
148
|
+
return { resource, version };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pgSecret = makeSecret("postgres-password", postgresPassword);
|
|
152
|
+
const csrfSecretRes = makeSecret("csrf-secret", csrfSecret);
|
|
153
|
+
const cookieSecretRes = makeSecret("cookie-secret", cookieSecret);
|
|
154
|
+
const kekSecret = makeSecret("secret-kek", caeloSecretKek);
|
|
155
|
+
const anthropicSecretRes = makeSecret("anthropic-api-key", anthropicApiKeyConfig ?? null);
|
|
156
|
+
const resendSecretRes = makeSecret("resend-api-key", resendApiKeyConfig ?? null);
|
|
157
|
+
|
|
158
|
+
// =========================================================================
|
|
159
|
+
// Tier 4 — Cloud SQL Postgres (private IP only; HA configurable)
|
|
160
|
+
// =========================================================================
|
|
161
|
+
|
|
162
|
+
const sqlInstance = new gcp.sql.DatabaseInstance(
|
|
163
|
+
`${namePrefix}-pg`,
|
|
164
|
+
{
|
|
165
|
+
databaseVersion: "POSTGRES_16",
|
|
166
|
+
region,
|
|
167
|
+
settings: {
|
|
168
|
+
tier: cloudSqlTier,
|
|
169
|
+
edition: cloudSqlEdition,
|
|
170
|
+
availabilityType: cloudSqlHa ? "REGIONAL" : "ZONAL",
|
|
171
|
+
diskSize: 20,
|
|
172
|
+
diskType: "PD_SSD",
|
|
173
|
+
diskAutoresize: true,
|
|
174
|
+
backupConfiguration: {
|
|
175
|
+
enabled: true,
|
|
176
|
+
startTime: "03:00",
|
|
177
|
+
backupRetentionSettings: { retainedBackups: backupRetentionDays },
|
|
178
|
+
pointInTimeRecoveryEnabled: true,
|
|
179
|
+
transactionLogRetentionDays: 7,
|
|
180
|
+
},
|
|
181
|
+
ipConfiguration: {
|
|
182
|
+
ipv4Enabled: false,
|
|
183
|
+
privateNetwork: network.id,
|
|
184
|
+
enablePrivatePathForGoogleCloudServices: true,
|
|
185
|
+
},
|
|
186
|
+
databaseFlags: [{ name: "max_connections", value: maxConnections.toString() }],
|
|
187
|
+
},
|
|
188
|
+
deletionProtection,
|
|
189
|
+
},
|
|
190
|
+
{ ...opts, dependsOn: [sqlPeering] },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const cmsAdminDb = new gcp.sql.Database(
|
|
194
|
+
`${namePrefix}-cms-admin`,
|
|
195
|
+
{ instance: sqlInstance.name, name: "cms_admin" },
|
|
196
|
+
opts,
|
|
197
|
+
);
|
|
198
|
+
const cmsPublicDb = new gcp.sql.Database(
|
|
199
|
+
`${namePrefix}-cms-public`,
|
|
200
|
+
{ instance: sqlInstance.name, name: "cms_public" },
|
|
201
|
+
opts,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const pgAdminUser = new gcp.sql.User(
|
|
205
|
+
`${namePrefix}-admin-user`,
|
|
206
|
+
{ instance: sqlInstance.name, name: "admin_role", password: postgresPassword },
|
|
207
|
+
opts,
|
|
208
|
+
);
|
|
209
|
+
new gcp.sql.User(
|
|
210
|
+
`${namePrefix}-public-user`,
|
|
211
|
+
{ instance: sqlInstance.name, name: "public_role", password: postgresPassword },
|
|
212
|
+
opts,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const adminDatabaseUrl = pulumi.interpolate`postgres://admin_role:${postgresPassword}@${sqlInstance.privateIpAddress}:5432/cms_admin?sslmode=require`;
|
|
216
|
+
const publicAdminDatabaseUrl = pulumi.interpolate`postgres://admin_role:${postgresPassword}@${sqlInstance.privateIpAddress}:5432/cms_public?sslmode=require`;
|
|
217
|
+
const publicDatabaseUrl = pulumi.interpolate`postgres://public_role:${postgresPassword}@${sqlInstance.privateIpAddress}:5432/cms_public?sslmode=require`;
|
|
218
|
+
|
|
219
|
+
// =========================================================================
|
|
220
|
+
// Media bucket — private, signed-URL access only (same as `gcp` stack)
|
|
221
|
+
// =========================================================================
|
|
222
|
+
|
|
223
|
+
const mediaBucket = new gcp.storage.Bucket(
|
|
224
|
+
`${namePrefix}-media`,
|
|
225
|
+
{
|
|
226
|
+
name: `${project}-${namePrefix}-media`,
|
|
227
|
+
location: region.toUpperCase(),
|
|
228
|
+
uniformBucketLevelAccess: true,
|
|
229
|
+
forceDestroy: env !== "production",
|
|
230
|
+
versioning: { enabled: env === "production" },
|
|
231
|
+
},
|
|
232
|
+
opts,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// =========================================================================
|
|
236
|
+
// Cloud Run service account + IAM roles
|
|
237
|
+
// =========================================================================
|
|
238
|
+
|
|
239
|
+
const runSa = new gcp.serviceaccount.Account(
|
|
240
|
+
`${namePrefix}-run-sa`,
|
|
241
|
+
{
|
|
242
|
+
accountId: `${namePrefix}-run-sa`,
|
|
243
|
+
displayName: `Caelo ${env} Cloud Run service account`,
|
|
244
|
+
},
|
|
245
|
+
opts,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
for (const made of [
|
|
249
|
+
{ name: "postgres-password", made: pgSecret },
|
|
250
|
+
{ name: "csrf-secret", made: csrfSecretRes },
|
|
251
|
+
{ name: "cookie-secret", made: cookieSecretRes },
|
|
252
|
+
{ name: "secret-kek", made: kekSecret },
|
|
253
|
+
{ name: "anthropic-api-key", made: anthropicSecretRes },
|
|
254
|
+
{ name: "resend-api-key", made: resendSecretRes },
|
|
255
|
+
]) {
|
|
256
|
+
new gcp.secretmanager.SecretIamMember(
|
|
257
|
+
`${namePrefix}-${made.name}-binding`,
|
|
258
|
+
{
|
|
259
|
+
secretId: made.made.resource.secretId,
|
|
260
|
+
role: "roles/secretmanager.secretAccessor",
|
|
261
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
262
|
+
},
|
|
263
|
+
{ ...opts, dependsOn: [made.made.resource] },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
new gcp.storage.BucketIAMMember(
|
|
268
|
+
`${namePrefix}-media-rw`,
|
|
269
|
+
{
|
|
270
|
+
bucket: mediaBucket.name,
|
|
271
|
+
role: "roles/storage.objectAdmin",
|
|
272
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
273
|
+
},
|
|
274
|
+
opts,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// v0.3.0 — Firebase Hosting admin role for the static-publisher path.
|
|
278
|
+
// The admin Cloud Run service calls the Firebase Hosting REST API to
|
|
279
|
+
// create versions, populateFiles, finalize, and create channels.
|
|
280
|
+
new gcp.projects.IAMMember(
|
|
281
|
+
`${namePrefix}-run-firebase-hosting`,
|
|
282
|
+
{
|
|
283
|
+
project,
|
|
284
|
+
role: "roles/firebasehosting.admin",
|
|
285
|
+
member: pulumi.interpolate`serviceAccount:${runSa.email}`,
|
|
286
|
+
},
|
|
287
|
+
opts,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// =========================================================================
|
|
291
|
+
// Tier 1 — Firebase Hosting site
|
|
292
|
+
// =========================================================================
|
|
293
|
+
|
|
294
|
+
const firebaseSite = new gcp.firebase.HostingSite(
|
|
295
|
+
`${namePrefix}-firebase-site`,
|
|
296
|
+
{
|
|
297
|
+
project,
|
|
298
|
+
siteId: firebaseSiteId,
|
|
299
|
+
},
|
|
300
|
+
opts,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Custom domain on the Firebase site for the apex. The operator wires
|
|
304
|
+
// DNS by following the records printed in the stack outputs.
|
|
305
|
+
const firebaseCustomDomain = new gcp.firebase.HostingCustomDomain(
|
|
306
|
+
`${namePrefix}-firebase-apex`,
|
|
307
|
+
{
|
|
308
|
+
project,
|
|
309
|
+
siteId: firebaseSite.siteId,
|
|
310
|
+
customDomain: domain,
|
|
311
|
+
waitDnsVerification: false,
|
|
312
|
+
},
|
|
313
|
+
{ ...opts, dependsOn: [firebaseSite] },
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// =========================================================================
|
|
317
|
+
// Cloud Run services — admin + gateway
|
|
318
|
+
// =========================================================================
|
|
319
|
+
|
|
320
|
+
interface CloudRunArgs {
|
|
321
|
+
readonly serviceName: string;
|
|
322
|
+
readonly minInstances: number;
|
|
323
|
+
readonly maxInstances: number;
|
|
324
|
+
readonly memory: string;
|
|
325
|
+
readonly timeout: string;
|
|
326
|
+
readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
|
|
327
|
+
/** v0.3.1 — admin: ALL (so IAP gates internet traffic). gateway:
|
|
328
|
+
* INTERNAL_LOAD_BALANCER (locked down; only Firebase Hosting
|
|
329
|
+
* rewrites + run.invoker-authorised callers can reach it). */
|
|
330
|
+
readonly ingress: "INGRESS_TRAFFIC_ALL" | "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER";
|
|
331
|
+
/** v0.3.1 — set true on admin so Cloud Run IAP gates traffic. */
|
|
332
|
+
readonly iapEnabled?: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
|
|
336
|
+
return new gcp.cloudrunv2.Service(
|
|
337
|
+
`${namePrefix}-${args.serviceName}`,
|
|
338
|
+
{
|
|
339
|
+
location: region,
|
|
340
|
+
ingress: args.ingress,
|
|
341
|
+
// v0.3.1 — Cloud Run native IAP. With this on, every request
|
|
342
|
+
// is gated by IAP before the container sees it. Allowlisted
|
|
343
|
+
// users get through via the IAM bindings further below;
|
|
344
|
+
// others get a 403 from IAP's login challenge.
|
|
345
|
+
iapEnabled: args.iapEnabled ?? false,
|
|
346
|
+
deletionProtection,
|
|
347
|
+
template: {
|
|
348
|
+
serviceAccount: runSa.email,
|
|
349
|
+
scaling: {
|
|
350
|
+
minInstanceCount: args.minInstances,
|
|
351
|
+
maxInstanceCount: args.maxInstances,
|
|
352
|
+
},
|
|
353
|
+
timeout: args.timeout,
|
|
354
|
+
containers: [
|
|
355
|
+
{
|
|
356
|
+
image: `europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/${args.serviceName}:main`,
|
|
357
|
+
envs: [
|
|
358
|
+
{ name: "CAELO_PROVIDER", value: "gcp-firebase" },
|
|
359
|
+
{ name: "CAELO_ENV", value: env },
|
|
360
|
+
{ name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
|
|
361
|
+
{ name: "MEDIA_STORAGE_URL", value: pulumi.interpolate`gs://${mediaBucket.name}` },
|
|
362
|
+
{
|
|
363
|
+
name: "CAELO_SECRET_KEK",
|
|
364
|
+
valueSource: {
|
|
365
|
+
secretKeyRef: { secret: kekSecret.resource.secretId, version: "latest" },
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
...(args.extraEnv ?? []),
|
|
369
|
+
],
|
|
370
|
+
resources: { limits: { cpu: "1", memory: args.memory } },
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
vpcAccess: {
|
|
374
|
+
networkInterfaces: [{ network: network.name, subnetwork: subnet.name }],
|
|
375
|
+
egress: "PRIVATE_RANGES_ONLY",
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
...opts,
|
|
381
|
+
dependsOn: [
|
|
382
|
+
pgAdminUser,
|
|
383
|
+
cmsAdminDb,
|
|
384
|
+
cmsPublicDb,
|
|
385
|
+
...(pgSecret.version ? [pgSecret.version] : []),
|
|
386
|
+
...(kekSecret.version ? [kekSecret.version] : []),
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Gateway provisioned first so we can pass its name/region into the
|
|
393
|
+
// admin's env (the Firebase publisher needs them to wire rewrites).
|
|
394
|
+
const gatewaySvc = cloudRunService({
|
|
395
|
+
serviceName: "gateway",
|
|
396
|
+
minInstances: gatewayMinInstances,
|
|
397
|
+
maxInstances: 100,
|
|
398
|
+
memory: "512Mi",
|
|
399
|
+
timeout: "60s",
|
|
400
|
+
// v0.3.1 — gateway is reachable ONLY via Firebase Hosting
|
|
401
|
+
// rewrites or any caller holding roles/run.invoker on the
|
|
402
|
+
// service. Anonymous internet traffic gets a Cloud Run 403.
|
|
403
|
+
ingress: "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER",
|
|
404
|
+
extraEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const adminSvc = cloudRunService({
|
|
408
|
+
serviceName: "admin",
|
|
409
|
+
minInstances: adminMinInstances,
|
|
410
|
+
maxInstances: adminMaxInstances,
|
|
411
|
+
memory: "1Gi",
|
|
412
|
+
timeout: "3600s",
|
|
413
|
+
// v0.3.1 — admin is internet-reachable but gated by Cloud Run
|
|
414
|
+
// native IAP. iapEnabled flips the IAP integration on the service.
|
|
415
|
+
ingress: "INGRESS_TRAFFIC_ALL",
|
|
416
|
+
iapEnabled: true,
|
|
417
|
+
extraEnv: [
|
|
418
|
+
{ name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl },
|
|
419
|
+
{ name: "CAELO_FIREBASE_SITE", value: firebaseSite.siteId },
|
|
420
|
+
{ name: "CAELO_GENERATOR_CLI", value: "/app/apps/static-generator/src/cli.ts" },
|
|
421
|
+
// v0.3.1 — Firebase publisher needs the gateway service name +
|
|
422
|
+
// region to declare the /api/** rewrite when creating each
|
|
423
|
+
// version.
|
|
424
|
+
{ name: "CAELO_GATEWAY_SERVICE", value: gatewaySvc.name },
|
|
425
|
+
{ name: "CAELO_GATEWAY_REGION", value: region },
|
|
426
|
+
],
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// =========================================================================
|
|
430
|
+
// Gateway — Firebase Hosting service identity gets run.invoker
|
|
431
|
+
// =========================================================================
|
|
432
|
+
|
|
433
|
+
// v0.3.1 — Firebase Hosting uses a Google-managed service identity
|
|
434
|
+
// to proxy traffic via `rewrites` to Cloud Run. Grant it
|
|
435
|
+
// `roles/run.invoker` on the gateway so /api/** requests succeed.
|
|
436
|
+
// The SA name follows the standard `service-<project-number>@
|
|
437
|
+
// gcp-sa-firebasehosting.iam.gserviceaccount.com` shape. We fetch
|
|
438
|
+
// the project number via `gcp.organizations.getProject` rather than
|
|
439
|
+
// hardcoding it.
|
|
440
|
+
const projectInfo = gcp.organizations.getProjectOutput({ projectId: project }, opts);
|
|
441
|
+
const firebaseHostingSa = pulumi.interpolate`serviceAccount:service-${projectInfo.number}@gcp-sa-firebasehosting.iam.gserviceaccount.com`;
|
|
442
|
+
|
|
443
|
+
new gcp.cloudrun.IamMember(
|
|
444
|
+
`${namePrefix}-gateway-firebase-invoker`,
|
|
445
|
+
{
|
|
446
|
+
location: region,
|
|
447
|
+
project,
|
|
448
|
+
service: gatewaySvc.name,
|
|
449
|
+
role: "roles/run.invoker",
|
|
450
|
+
member: firebaseHostingSa,
|
|
451
|
+
},
|
|
452
|
+
opts,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// =========================================================================
|
|
456
|
+
// Admin custom domain — Cloud Run domain mapping
|
|
457
|
+
// =========================================================================
|
|
458
|
+
|
|
459
|
+
const adminDomainMapping = new gcp.cloudrun.DomainMapping(
|
|
460
|
+
`${namePrefix}-admin-domain`,
|
|
461
|
+
{
|
|
462
|
+
location: region,
|
|
463
|
+
name: adminDomain,
|
|
464
|
+
metadata: { namespace: project },
|
|
465
|
+
spec: {
|
|
466
|
+
routeName: adminSvc.name,
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{ ...opts, dependsOn: [adminSvc] },
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// =========================================================================
|
|
473
|
+
// IAP on the admin Cloud Run service (no LB)
|
|
474
|
+
// =========================================================================
|
|
475
|
+
|
|
476
|
+
// Cloud Run native IAP: per-allowlist-entry IAM binding on the admin
|
|
477
|
+
// service grants `roles/iap.httpsResourceAccessor`. The actual "IAP
|
|
478
|
+
// enabled" flag on Cloud Run is set via `iapEnabled` (gen2) which
|
|
479
|
+
// gates every request through IAP before the container sees it.
|
|
480
|
+
// Allowlisted users get to the container; everyone else gets 403.
|
|
481
|
+
//
|
|
482
|
+
// NOTE — Cloud Run native IAP support is GA but the Pulumi resource
|
|
483
|
+
// surface for the `iapEnabled` flag depends on the @pulumi/gcp version
|
|
484
|
+
// shipped. If your provider build is older than v8.x, run
|
|
485
|
+
// `gcloud beta run services update <admin-service> --iap` as a
|
|
486
|
+
// post-up step. The stack provisions the IAM bindings either way.
|
|
487
|
+
for (const principal of iapAllowlist) {
|
|
488
|
+
new gcp.cloudrun.IamMember(
|
|
489
|
+
`${namePrefix}-admin-iap-${principal.replace(/[^a-z0-9]/gi, "-").slice(0, 40)}`,
|
|
490
|
+
{
|
|
491
|
+
location: region,
|
|
492
|
+
project,
|
|
493
|
+
service: adminSvc.name,
|
|
494
|
+
role: "roles/iap.httpsResourceAccessor",
|
|
495
|
+
member: principal,
|
|
496
|
+
},
|
|
497
|
+
opts,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// =========================================================================
|
|
502
|
+
// Outputs — DNS records the operator wires manually
|
|
503
|
+
// =========================================================================
|
|
504
|
+
|
|
505
|
+
const dnsRecords: pulumi.Output<DnsRecord[]> = pulumi
|
|
506
|
+
.all([adminDomainMapping.statuses, firebaseCustomDomain.requiredDnsUpdates])
|
|
507
|
+
.apply(([adminStatuses, firebaseUpdates]) => {
|
|
508
|
+
const out: DnsRecord[] = [];
|
|
509
|
+
|
|
510
|
+
// Admin subdomain — Cloud Run domain mapping prints CNAMEs/As.
|
|
511
|
+
const adminTargets =
|
|
512
|
+
adminStatuses
|
|
513
|
+
?.flatMap((s) => s.resourceRecords ?? [])
|
|
514
|
+
.map((r) => ({ type: r.type ?? "CNAME", value: r.rrdata ?? "" })) ?? [];
|
|
515
|
+
for (const t of adminTargets) {
|
|
516
|
+
out.push({ host: adminDomain, type: t.type as DnsRecord["type"], value: t.value });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Apex — Firebase Hosting custom-domain DNS instructions.
|
|
520
|
+
// firebaseCustomDomain.requiredDnsUpdates is an array of update
|
|
521
|
+
// groups; each group has a list of `desired` records the operator
|
|
522
|
+
// creates and `discovered` records currently in DNS. We surface the
|
|
523
|
+
// `desired` set so the operator knows what to put in the registrar.
|
|
524
|
+
const apex = firebaseUpdates?.[0];
|
|
525
|
+
if (apex) {
|
|
526
|
+
for (const desired of apex.desireds ?? []) {
|
|
527
|
+
for (const v of desired.records ?? []) {
|
|
528
|
+
out.push({
|
|
529
|
+
host: domain,
|
|
530
|
+
type: (v.type ?? "A") as DnsRecord["type"],
|
|
531
|
+
value: v.rdata ?? "",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const bootstrapToken = generateBootstrapToken();
|
|
540
|
+
|
|
541
|
+
const outputs: CloudAdapterOutputs = {
|
|
542
|
+
adminUrl: pulumi.interpolate`https://${adminDomain}`,
|
|
543
|
+
gatewayUrl: gatewaySvc.uri,
|
|
544
|
+
staticUrl: `https://${domain}`,
|
|
545
|
+
adminDatabaseUrl,
|
|
546
|
+
publicDatabaseUrl,
|
|
547
|
+
bootstrapToken,
|
|
548
|
+
dnsRecords,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
export const adminUrl = outputs.adminUrl;
|
|
552
|
+
export const gatewayUrl = outputs.gatewayUrl;
|
|
553
|
+
export const staticUrl = outputs.staticUrl;
|
|
554
|
+
export const adminDatabaseUrlOut = outputs.adminDatabaseUrl;
|
|
555
|
+
export const publicDatabaseUrlOut = outputs.publicDatabaseUrl;
|
|
556
|
+
export const bootstrapTokenOut = outputs.bootstrapToken;
|
|
557
|
+
export const dnsRecordsOut = outputs.dnsRecords;
|
|
558
|
+
export const firebaseSiteIdOut = firebaseSite.siteId;
|
|
559
|
+
export const firebaseSiteName = firebaseSite.name;
|