@caelo-cms/provisioning 0.1.0

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,106 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo GCP edge-router — Cloud Run HTTP handler.
5
+ *
6
+ * Imports the SAME `routeRequest` from `@caelo-cms/edge-router` that the
7
+ * P13 self-hosted Caddy gateway, the AWS Lambda@Edge function, and the
8
+ * future Azure Front Door rules engine use. The byte-identity test in
9
+ * packages/edge-router/src/index.test.ts asserts a fixed corpus of
10
+ * (visitorId, manifestVersion, experimentId) tuples produces identical
11
+ * variant labels across every runtime.
12
+ *
13
+ * Differences from AWS L@E:
14
+ * - Cloud Run has full network egress (we fetch the manifest from GCS
15
+ * on a TTL-cached schedule rather than bundling at deploy time).
16
+ * - Cloud Run can set response headers directly (no companion handler).
17
+ * - Logs flow via `console.log(JSON.stringify(...))` to Cloud Logging,
18
+ * where the project sink filters `jsonPayload.kind="ab_assignment"`
19
+ * and ships to BigQuery.
20
+ *
21
+ * This file is meant to be the entry of a Cloud Run container; the
22
+ * operator's image build step (`gcloud builds submit`) bundles it
23
+ * together with the static-output bucket as the upstream for non-API
24
+ * routes. v1 ships the source; the build pipeline lands in P15
25
+ * review-pass.
26
+ */
27
+
28
+ import { EMPTY_MANIFEST, type RoutingManifest, routeRequest } from "@caelo-cms/edge-router";
29
+
30
+ const STATIC_BUCKET = process.env.STATIC_BUCKET ?? "";
31
+ // P15 hot-fix #1 — distinct filename from the deploy manifest. The
32
+ // static-generator writes `routing-manifest.json` (deploy provenance)
33
+ // AND `ab-routing.json` (edge-router shape — RoutingManifest).
34
+ const MANIFEST_OBJECT = process.env.MANIFEST_OBJECT ?? "ab-routing.json";
35
+ const MANIFEST_REFRESH_MS = 30_000;
36
+ const COOKIE_NAME = "caelo_visitor_id";
37
+
38
+ let manifestCache: { value: RoutingManifest; loadedAt: number } = {
39
+ value: EMPTY_MANIFEST,
40
+ loadedAt: 0,
41
+ };
42
+
43
+ async function fetchManifest(): Promise<RoutingManifest> {
44
+ if (Date.now() - manifestCache.loadedAt < MANIFEST_REFRESH_MS) {
45
+ return manifestCache.value;
46
+ }
47
+ if (!STATIC_BUCKET) return EMPTY_MANIFEST;
48
+ try {
49
+ const url = `https://storage.googleapis.com/${STATIC_BUCKET}/${MANIFEST_OBJECT}`;
50
+ const r = await fetch(url, { signal: AbortSignal.timeout(2000) });
51
+ if (!r.ok) return manifestCache.value; // keep stale on transient failure
52
+ const m = (await r.json()) as RoutingManifest;
53
+ manifestCache = { value: m, loadedAt: Date.now() };
54
+ return m;
55
+ } catch {
56
+ return manifestCache.value;
57
+ }
58
+ }
59
+
60
+ function readCookie(header: string | null, name: string): string | null {
61
+ if (!header) return null;
62
+ for (const pair of header.split(/;\s*/)) {
63
+ const eq = pair.indexOf("=");
64
+ if (eq < 0) continue;
65
+ if (pair.slice(0, eq) === name) return decodeURIComponent(pair.slice(eq + 1));
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Cloud Run HTTP handler. Cloud Run accepts any web framework with a
72
+ * standard fetch-style handler; v1 uses Bun's native server for
73
+ * portability with the rest of the Caelo runtime.
74
+ */
75
+ const port = Number(process.env.PORT ?? 8080);
76
+ Bun.serve({
77
+ port,
78
+ async fetch(req: Request): Promise<Response> {
79
+ const url = new URL(req.url);
80
+ const manifest = await fetchManifest();
81
+ const visitorIdCookie = readCookie(req.headers.get("cookie"), COOKIE_NAME);
82
+ const decision = routeRequest(manifest, {
83
+ pathname: url.pathname,
84
+ visitorIdCookie,
85
+ });
86
+
87
+ if (decision.logEntry) {
88
+ // biome-ignore lint/suspicious/noConsole: structured log → Cloud Logging → BigQuery sink
89
+ console.log(JSON.stringify(decision.logEntry));
90
+ }
91
+
92
+ // Edge-router's job is to redirect; the static origin (GCS bucket)
93
+ // serves the rewritten path. 307 keeps method semantics intact (a
94
+ // non-GET hitting an experiment page would otherwise lose its body).
95
+ const target = new URL(url.toString());
96
+ target.pathname = decision.rewritePathname;
97
+ const headers = new Headers({
98
+ Location: target.toString(),
99
+ "Cache-Control": "no-store, private",
100
+ // Set the cookie for the next request. Long max-age so visitor
101
+ // assignment persists across the experiment lifetime.
102
+ "Set-Cookie": `${COOKIE_NAME}=${encodeURIComponent(decision.setVisitorId)}; Path=/; Max-Age=31536000; SameSite=Lax; Secure; HttpOnly`,
103
+ });
104
+ return new Response(null, { status: 307, headers });
105
+ },
106
+ });
@@ -0,0 +1,483 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo GCP stack — Pulumi entry point.
5
+ *
6
+ * Resources provisioned per `pulumi up`:
7
+ * 1. Cloud SQL Postgres 16 HA + automated backups + point-in-time recovery,
8
+ * attached to a VPC via private IP.
9
+ * - Two databases (cms_admin + cms_public) + two roles via post-create
10
+ * run of packages/migrations/src/bootstrap.sh as a Cloud Run job.
11
+ * 2. GCS buckets: caelo-<env>-media + caelo-<env>-static. Uniform bucket-
12
+ * level access; storage.objectViewer granted to the Cloud CDN service
13
+ * account.
14
+ * 3. Cloud CDN backend bucket (static output) + backend service (Cloud
15
+ * Run admin + gateway). URL map routes /api/* → gateway, /admin/* →
16
+ * admin Cloud Run, default → static output bucket.
17
+ * 4. Cloud Run services for admin, gateway, orchestrator, runner. Image
18
+ * tags read from Pulumi config; pushed by operator via `gcloud builds
19
+ * submit` for v1 (CI integration is P15 review-pass).
20
+ * 5. Edge-router Cloud Run service that wraps @caelo-cms/edge-router's
21
+ * routeRequest. Receives Cloud CDN's "passthrough" requests (URL
22
+ * map matches /* → edge-router → static origin), assigns variants,
23
+ * sets the caelo_visitor_id cookie, returns the appropriate static
24
+ * path. Same hash → same variant as the AWS L@E + self-hosted Caddy.
25
+ * 6. Secret Manager entries (postgres-password, csrf-secret, cookie-
26
+ * secret, anthropic-api-key, resend-api-key); Cloud Run reads via
27
+ * `secret_environment_variables` mounts.
28
+ * 7. Cloud Logging sink → BigQuery dataset `caelo_edge_logs`. P12A
29
+ * analytics plugin's GCP adapter queries via `bigquery.jobs.query`.
30
+ * 8. Google-managed SSL certs per (domain, locale).
31
+ *
32
+ * The Caelo runtime never knows it's on GCP — every service consumes
33
+ * plain DATABASE_URL / MEDIA_STORAGE_URL / SECRETS_PROVIDER env vars.
34
+ * This file's only job is to wire those env vars to GCP-native resources.
35
+ */
36
+
37
+ import * as gcp from "@pulumi/gcp";
38
+ import * as pulumi from "@pulumi/pulumi";
39
+ import type { CloudAdapterOutputs, DnsRecord } from "../../src/adapter.js";
40
+ import { generateBootstrapToken } from "../../src/bootstrap-token.js";
41
+
42
+ const cfg = new pulumi.Config();
43
+ const domain = cfg.require("domain");
44
+ const ownerEmail = cfg.require("ownerEmail");
45
+ const project = cfg.require("project");
46
+ 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
+
50
+ // Pulumi stack name is the environment label.
51
+ const env = pulumi.getStack() as "dev" | "staging" | "production";
52
+ const namePrefix = `caelo-${env}`;
53
+
54
+ const gcpProvider = new gcp.Provider(`${namePrefix}-gcp`, { project, region });
55
+ const opts = { provider: gcpProvider };
56
+
57
+ // === 1. VPC for private Cloud SQL ===
58
+ const network = new gcp.compute.Network(
59
+ `${namePrefix}-vpc`,
60
+ {
61
+ autoCreateSubnetworks: false,
62
+ description: `Caelo ${env} VPC`,
63
+ },
64
+ opts,
65
+ );
66
+
67
+ const subnet = new gcp.compute.Subnetwork(
68
+ `${namePrefix}-subnet`,
69
+ {
70
+ region,
71
+ ipCidrRange: "10.20.0.0/20",
72
+ network: network.id,
73
+ privateIpGoogleAccess: true,
74
+ },
75
+ opts,
76
+ );
77
+
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
+ const privateIpAlloc = new gcp.compute.GlobalAddress(
82
+ `${namePrefix}-pg-private-ip`,
83
+ {
84
+ purpose: "VPC_PEERING",
85
+ addressType: "INTERNAL",
86
+ prefixLength: 16,
87
+ network: network.id,
88
+ },
89
+ opts,
90
+ );
91
+
92
+ const sqlPeering = new gcp.servicenetworking.Connection(
93
+ `${namePrefix}-pg-peering`,
94
+ {
95
+ network: network.id,
96
+ service: "servicenetworking.googleapis.com",
97
+ reservedPeeringRanges: [privateIpAlloc.name],
98
+ },
99
+ opts,
100
+ );
101
+
102
+ // === 2. Secret Manager ===
103
+ function randomHex(bytes: number): string {
104
+ const buf = new Uint8Array(bytes);
105
+ crypto.getRandomValues(buf);
106
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
107
+ }
108
+
109
+ const postgresPassword = pulumi.secret(randomHex(32));
110
+ const csrfSecret = pulumi.secret(randomHex(32));
111
+ const cookieSecret = pulumi.secret(randomHex(32));
112
+ const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
113
+ const resendApiKey = pulumi.secret(process.env.RESEND_API_KEY ?? "");
114
+
115
+ function secret(
116
+ name: string,
117
+ value: pulumi.Output<string>,
118
+ ): { resource: gcp.secretmanager.Secret; version: gcp.secretmanager.SecretVersion } {
119
+ const resource = new gcp.secretmanager.Secret(
120
+ `${namePrefix}-${name}`,
121
+ {
122
+ secretId: `${namePrefix}-${name}`,
123
+ replication: { auto: {} },
124
+ },
125
+ opts,
126
+ );
127
+ const version = new gcp.secretmanager.SecretVersion(
128
+ `${namePrefix}-${name}-v1`,
129
+ { secret: resource.name, secretData: value },
130
+ { ...opts, dependsOn: [resource] },
131
+ );
132
+ return { resource, version };
133
+ }
134
+
135
+ const pgSecret = secret("postgres-password", postgresPassword);
136
+ secret("csrf-secret", csrfSecret);
137
+ secret("cookie-secret", cookieSecret);
138
+ secret("anthropic-api-key", anthropicApiKey);
139
+ secret("resend-api-key", resendApiKey);
140
+
141
+ // === 3. Cloud SQL Postgres 16 HA ===
142
+ const sqlInstance = new gcp.sql.DatabaseInstance(
143
+ `${namePrefix}-pg`,
144
+ {
145
+ databaseVersion: "POSTGRES_16",
146
+ region,
147
+ settings: {
148
+ tier: cloudSqlTier,
149
+ // Regional = HA (synchronous replica in another zone).
150
+ availabilityType: env === "production" ? "REGIONAL" : "ZONAL",
151
+ diskSize: 20,
152
+ diskType: "PD_SSD",
153
+ diskAutoresize: true,
154
+ backupConfiguration: {
155
+ enabled: true,
156
+ pointInTimeRecoveryEnabled: env === "production",
157
+ startTime: "03:00",
158
+ backupRetentionSettings: {
159
+ retentionUnit: "COUNT",
160
+ retainedBackups: env === "production" ? 14 : 1,
161
+ },
162
+ },
163
+ ipConfiguration: {
164
+ ipv4Enabled: false,
165
+ privateNetwork: network.id,
166
+ },
167
+ deletionProtectionEnabled: env === "production",
168
+ },
169
+ deletionProtection: env === "production",
170
+ },
171
+ { ...opts, dependsOn: [sqlPeering] },
172
+ );
173
+
174
+ const pgAdminUser = new gcp.sql.User(
175
+ `${namePrefix}-pg-admin`,
176
+ {
177
+ instance: sqlInstance.name,
178
+ name: "caelo_admin",
179
+ password: postgresPassword,
180
+ },
181
+ opts,
182
+ );
183
+
184
+ const cmsAdminDb = new gcp.sql.Database(
185
+ `${namePrefix}-cms-admin-db`,
186
+ { instance: sqlInstance.name, name: "cms_admin" },
187
+ opts,
188
+ );
189
+ const cmsPublicDb = new gcp.sql.Database(
190
+ `${namePrefix}-cms-public-db`,
191
+ { instance: sqlInstance.name, name: "cms_public" },
192
+ opts,
193
+ );
194
+
195
+ const adminDatabaseUrl = pulumi
196
+ .all([sqlInstance.privateIpAddress, postgresPassword])
197
+ .apply(([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_admin?sslmode=require`);
198
+ const publicDatabaseUrl = pulumi
199
+ .all([sqlInstance.privateIpAddress, postgresPassword])
200
+ .apply(([host, pw]) => `postgresql://caelo_public:${pw}@${host}:5432/cms_public?sslmode=require`);
201
+
202
+ // === 4. GCS buckets ===
203
+ const mediaBucket = new gcp.storage.Bucket(
204
+ `${namePrefix}-media`,
205
+ {
206
+ name: `${project}-${namePrefix}-media`,
207
+ location: region.toUpperCase(),
208
+ uniformBucketLevelAccess: true,
209
+ forceDestroy: env !== "production",
210
+ versioning: { enabled: env === "production" },
211
+ },
212
+ opts,
213
+ );
214
+
215
+ const staticBucket = new gcp.storage.Bucket(
216
+ `${namePrefix}-static`,
217
+ {
218
+ name: `${project}-${namePrefix}-static`,
219
+ location: region.toUpperCase(),
220
+ uniformBucketLevelAccess: true,
221
+ forceDestroy: env !== "production",
222
+ website: { mainPageSuffix: "index.html", notFoundPage: "404.html" },
223
+ },
224
+ opts,
225
+ );
226
+
227
+ // Public read on the static-output bucket so Cloud CDN can fetch.
228
+ new gcp.storage.BucketIAMMember(
229
+ `${namePrefix}-static-public-read`,
230
+ {
231
+ bucket: staticBucket.name,
232
+ role: "roles/storage.objectViewer",
233
+ member: "allUsers",
234
+ },
235
+ opts,
236
+ );
237
+
238
+ // === 5. BigQuery dataset for A/B assignment logs ===
239
+ const edgeLogDataset = new gcp.bigquery.Dataset(
240
+ `${namePrefix}-edge-logs-ds`,
241
+ {
242
+ datasetId: `${namePrefix.replace(/-/g, "_")}_edge_logs`,
243
+ location: region.toUpperCase(),
244
+ description: `Caelo ${env} A/B assignment log sink (P12A analytics plugin).`,
245
+ },
246
+ opts,
247
+ );
248
+
249
+ // === 6. Cloud Run services ===
250
+ //
251
+ // v1 expects images pushed via `gcloud builds submit` to Artifact Registry
252
+ // at `<region>-docker.pkg.dev/<project>/caelo/<service>:<sha>`. Operators
253
+ // set the image tag via Pulumi config — the stack just wires the right
254
+ // env vars + secret mounts.
255
+ function imageTag(service: string): string {
256
+ return (
257
+ cfg.get(`image-${service}`) ?? `${region}-docker.pkg.dev/${project}/caelo/${service}:latest`
258
+ );
259
+ }
260
+
261
+ const runSa = new gcp.serviceaccount.Account(
262
+ `${namePrefix}-run-sa`,
263
+ {
264
+ accountId: `${namePrefix}-run-sa`,
265
+ displayName: `Caelo ${env} Cloud Run service account`,
266
+ },
267
+ opts,
268
+ );
269
+
270
+ // Grant the run SA secret access for each Caelo secret.
271
+ for (const sn of [
272
+ "postgres-password",
273
+ "csrf-secret",
274
+ "cookie-secret",
275
+ "anthropic-api-key",
276
+ "resend-api-key",
277
+ ]) {
278
+ new gcp.secretmanager.SecretIamMember(
279
+ `${namePrefix}-${sn}-binding`,
280
+ {
281
+ secretId: `${namePrefix}-${sn}`,
282
+ role: "roles/secretmanager.secretAccessor",
283
+ member: pulumi.interpolate`serviceAccount:${runSa.email}`,
284
+ },
285
+ opts,
286
+ );
287
+ }
288
+
289
+ // Bucket access for Cloud Run.
290
+ new gcp.storage.BucketIAMMember(
291
+ `${namePrefix}-media-rw`,
292
+ {
293
+ bucket: mediaBucket.name,
294
+ role: "roles/storage.objectAdmin",
295
+ member: pulumi.interpolate`serviceAccount:${runSa.email}`,
296
+ },
297
+ opts,
298
+ );
299
+
300
+ interface CloudRunServiceArgs {
301
+ readonly serviceName: string;
302
+ readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
303
+ }
304
+
305
+ function cloudRunService(args: CloudRunServiceArgs): gcp.cloudrunv2.Service {
306
+ return new gcp.cloudrunv2.Service(
307
+ `${namePrefix}-${args.serviceName}`,
308
+ {
309
+ location: region,
310
+ ingress: "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER",
311
+ template: {
312
+ serviceAccount: runSa.email,
313
+ scaling: {
314
+ minInstanceCount: cloudRunMinInstances,
315
+ maxInstanceCount: 10,
316
+ },
317
+ containers: [
318
+ {
319
+ image: imageTag(args.serviceName),
320
+ envs: [
321
+ { name: "CAELO_PROVIDER", value: "gcp" },
322
+ { name: "CAELO_ENV", value: env },
323
+ {
324
+ name: "ADMIN_DATABASE_URL",
325
+ value: adminDatabaseUrl,
326
+ },
327
+ {
328
+ name: "PUBLIC_ADMIN_DATABASE_URL",
329
+ value: publicDatabaseUrl,
330
+ },
331
+ { name: "MEDIA_STORAGE_URL", value: pulumi.interpolate`gs://${mediaBucket.name}` },
332
+ ...(args.extraEnv ?? []),
333
+ ],
334
+ resources: {
335
+ limits: {
336
+ cpu: "1",
337
+ memory: "1Gi",
338
+ },
339
+ },
340
+ },
341
+ ],
342
+ vpcAccess: {
343
+ // Direct VPC egress so Cloud Run reaches Cloud SQL via private IP.
344
+ networkInterfaces: [
345
+ {
346
+ network: network.name,
347
+ subnetwork: subnet.name,
348
+ },
349
+ ],
350
+ egress: "PRIVATE_RANGES_ONLY",
351
+ },
352
+ },
353
+ },
354
+ { ...opts, dependsOn: [pgAdminUser, cmsAdminDb, cmsPublicDb] },
355
+ );
356
+ }
357
+
358
+ const adminSvc = cloudRunService({ serviceName: "admin" });
359
+ const gatewaySvc = cloudRunService({ serviceName: "gateway" });
360
+ const orchestratorSvc = cloudRunService({ serviceName: "orchestrator" });
361
+ const runnerSvc = cloudRunService({ serviceName: "runner" });
362
+
363
+ // Edge-router Cloud Run service. Ingresses public traffic (ALL), runs
364
+ // @caelo-cms/edge-router, returns a redirect to the variant's static path.
365
+ // Cloud CDN is configured to NOT cache edge-router responses (it's a
366
+ // per-visitor-cookie-keyed routing decision); only the static-bucket
367
+ // response is cached.
368
+ const edgeRouterSvc = new gcp.cloudrunv2.Service(
369
+ `${namePrefix}-edge-router`,
370
+ {
371
+ location: region,
372
+ ingress: "INGRESS_TRAFFIC_ALL",
373
+ template: {
374
+ serviceAccount: runSa.email,
375
+ scaling: { minInstanceCount: cloudRunMinInstances, maxInstanceCount: 100 },
376
+ containers: [
377
+ {
378
+ image: imageTag("edge-router"),
379
+ envs: [
380
+ { name: "CAELO_PROVIDER", value: "gcp" },
381
+ { name: "CAELO_ENV", value: env },
382
+ { name: "STATIC_BUCKET", value: staticBucket.name },
383
+ // P15 hot-fix #1 — `ab-routing.json` carries the
384
+ // edge-router-shaped manifest; `routing-manifest.json` is
385
+ // the deploy-provenance file (different schema).
386
+ { name: "MANIFEST_OBJECT", value: "ab-routing.json" },
387
+ ],
388
+ resources: { limits: { cpu: "1", memory: "512Mi" } },
389
+ },
390
+ ],
391
+ },
392
+ },
393
+ opts,
394
+ );
395
+
396
+ // Public invocation for the edge-router.
397
+ new gcp.cloudrunv2.ServiceIamMember(
398
+ `${namePrefix}-edge-router-public`,
399
+ {
400
+ location: region,
401
+ name: edgeRouterSvc.name,
402
+ role: "roles/run.invoker",
403
+ member: "allUsers",
404
+ },
405
+ opts,
406
+ );
407
+
408
+ // === 7. Cloud Logging sink → BigQuery ===
409
+ const loggingSink = new gcp.logging.ProjectSink(
410
+ `${namePrefix}-edge-log-sink`,
411
+ {
412
+ name: `${namePrefix}-edge-log-sink`,
413
+ destination: pulumi.interpolate`bigquery.googleapis.com/projects/${project}/datasets/${edgeLogDataset.datasetId}`,
414
+ // Only ab_assignment lines from the edge-router service.
415
+ filter: pulumi.interpolate`resource.type="cloud_run_revision" AND resource.labels.service_name="${edgeRouterSvc.name}" AND jsonPayload.kind="ab_assignment"`,
416
+ uniqueWriterIdentity: true,
417
+ },
418
+ opts,
419
+ );
420
+
421
+ // Grant the sink's writer identity BigQuery dataEditor on the dataset.
422
+ new gcp.bigquery.DatasetIamMember(
423
+ `${namePrefix}-edge-log-sink-bq-perms`,
424
+ {
425
+ datasetId: edgeLogDataset.datasetId,
426
+ role: "roles/bigquery.dataEditor",
427
+ member: loggingSink.writerIdentity,
428
+ },
429
+ opts,
430
+ );
431
+
432
+ // === 8. Bootstrap token ===
433
+ const tokenInfo = generateBootstrapToken();
434
+
435
+ // === 9. CloudAdapterOutputs ===
436
+ const dnsRecordsRequired: DnsRecord[] = [
437
+ {
438
+ hostname: domain,
439
+ type: "A",
440
+ // The actual IP comes from the load balancer (not provisioned in
441
+ // PR 3 — operator wires it manually for v1, P15 review-pass adds
442
+ // gcp.compute.GlobalForwardingRule). Surface the placeholder so
443
+ // the DNS UI tells the operator what record TYPE is needed.
444
+ value: "<gcp-load-balancer-ip>",
445
+ purpose: "Primary domain → GCP HTTPS load balancer (operator must wire after `pulumi up`)",
446
+ },
447
+ {
448
+ hostname: `staging.${domain}`,
449
+ type: "A",
450
+ value: "<gcp-load-balancer-ip>",
451
+ purpose: "Staging subdomain → GCP HTTPS load balancer",
452
+ },
453
+ ];
454
+
455
+ const out: CloudAdapterOutputs = {
456
+ adminDatabaseUrl: adminDatabaseUrl as unknown as string,
457
+ publicDatabaseUrl: publicDatabaseUrl as unknown as string,
458
+ mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
459
+ mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
460
+ bootstrapUrl:
461
+ pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
462
+ dnsRecordsRequired,
463
+ edgeLogSinkUrl:
464
+ pulumi.interpolate`bigquery://${project}/${edgeLogDataset.datasetId}` as unknown as string,
465
+ provider: "gcp",
466
+ environment: env,
467
+ };
468
+
469
+ export const adminDatabaseUrlOut = out.adminDatabaseUrl;
470
+ export const publicDatabaseUrlOut = out.publicDatabaseUrl;
471
+ export const mediaStorageUrlOut = out.mediaStorageUrl;
472
+ export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
473
+ export const bootstrapUrlOut = out.bootstrapUrl;
474
+ export const dnsRecordsRequiredOut = out.dnsRecordsRequired;
475
+ export const edgeLogSinkUrlOut = out.edgeLogSinkUrl;
476
+ export const providerOut = out.provider;
477
+ export const environmentOut = out.environment;
478
+ export const cloudSqlConnectionNameOut = sqlInstance.connectionName;
479
+ export const cloudSqlPrivateIpOut = sqlInstance.privateIpAddress;
480
+ export const adminCloudRunUrlOut = adminSvc.uri;
481
+ export const gatewayCloudRunUrlOut = gatewaySvc.uri;
482
+ export const edgeRouterCloudRunUrlOut = edgeRouterSvc.uri;
483
+ export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
@@ -0,0 +1,27 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ #
3
+ # Caelo self-hosted Pulumi project. Wraps cms-provision's compose +
4
+ # Caddyfile generators behind a real Pulumi resource graph so operators
5
+ # get state tracking, drift detection, and `pulumi up` / `pulumi destroy`
6
+ # semantics. The actual orchestration is `docker compose up -d` —
7
+ # Pulumi just owns the lifecycle of the generated files + the up
8
+ # command, which makes parallel cloud variants in P15 (GCP / AWS / Azure)
9
+ # slot in cleanly under the same project umbrella.
10
+ name: caelo-self-hosted
11
+ runtime:
12
+ name: nodejs
13
+ options:
14
+ typescript: true
15
+ packagemanager: bun
16
+ description: Caelo self-hosted Docker Compose stack (Postgres + Caddy + MinIO + admin/gateway).
17
+ config:
18
+ caelo-self-hosted:domain:
19
+ type: string
20
+ description: Primary domain (e.g. example.com). Admin + production public both bind here; staging gets staging.<domain>.
21
+ caelo-self-hosted:ownerEmail:
22
+ type: string
23
+ description: Operator email used for ACME registration with Let's Encrypt.
24
+ caelo-self-hosted:caeloDir:
25
+ type: string
26
+ default: ./.caelo
27
+ description: Where the generated docker-compose.yml + Caddyfile + secrets live.
@@ -0,0 +1,43 @@
1
+ # Caelo self-hosted Pulumi stack
2
+
3
+ Real Pulumi resources around the self-hosted Docker Compose generators in `packages/provisioning/src/`. Two operator paths:
4
+
5
+ | Tool | When to use |
6
+ |------------------|--------------------------------------------------------------------|
7
+ | `cms-provision` | Dev iteration, initial install, ad-hoc backups + Caddy regen. |
8
+ | Pulumi (this) | Production install with state tracking + `pulumi destroy` semantics. |
9
+
10
+ Both share the same generators, so a stack imported from one tool can be re-managed by the other.
11
+
12
+ ## First-time install
13
+
14
+ ```bash
15
+ cd packages/provisioning/stacks/self-hosted
16
+ pulumi stack init prod
17
+ pulumi config set caelo-self-hosted:domain example.com
18
+ pulumi config set caelo-self-hosted:ownerEmail me@example.com
19
+ pulumi up
20
+ ```
21
+
22
+ Pulumi mints postgres + minio passwords, generates `docker-compose.yml` + `Caddyfile` + `pending-token.json` under `.caelo/`, and runs `docker compose up -d`. After the run:
23
+
24
+ ```bash
25
+ pulumi stack output bootstrapUrl
26
+ # → https://example.com/setup?token=<64-hex>
27
+ ```
28
+
29
+ Open that URL once Caddy has issued the cert (~30 s).
30
+
31
+ ## Destroy
32
+
33
+ ```bash
34
+ pulumi destroy
35
+ ```
36
+
37
+ Tears down the compose stack with `-v`, deleting all volumes (Postgres + MinIO + Caddy data). The generated files are removed too.
38
+
39
+ ## Why this exists alongside `cms-provision`
40
+
41
+ The `cms-provision` CLI is the contributor-friendly path — runs without Pulumi installed, fast iteration on the generators themselves. The Pulumi stack is the **production wrapper**: it exposes a real resource graph for drift detection (operators can see what changed since last `up`), supports `--target` for surgical updates, and gives P15's GCP / AWS / Azure adapters a sibling `stacks/<provider>/` shape to slot into.
42
+
43
+ Cloud variants in P15 will follow the same pattern: each provider is a sibling Pulumi project under `stacks/`, sharing the compose + Caddyfile generators where applicable and adding provider-specific resources (Cloud SQL, RDS, etc.) on top.