@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.
@@ -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;