@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,37 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ #
3
+ # Caelo Azure provider stack. Provisions Azure DB for PostgreSQL flexible
4
+ # server (zone-redundant) + Storage Account (Blob) + Front Door + Container
5
+ # Apps (admin/gateway/orchestrator/runner + edge-router) + Key Vault +
6
+ # Log Analytics → A/B assignment log sink. Implements the shared
7
+ # CloudAdapterInputs / CloudAdapterOutputs contract from
8
+ # packages/provisioning/src/adapter.ts.
9
+ name: caelo-azure
10
+ runtime:
11
+ name: nodejs
12
+ options:
13
+ typescript: true
14
+ packagemanager: bun
15
+ description: Caelo Azure stack — Azure DB flexible + Blob + Front Door + Container Apps + Key Vault.
16
+ config:
17
+ caelo-azure:domain:
18
+ type: string
19
+ description: Primary domain (e.g. example.com).
20
+ caelo-azure:ownerEmail:
21
+ type: string
22
+ description: Operator email for Pulumi notifications.
23
+ caelo-azure:subscription:
24
+ type: string
25
+ description: Azure subscription id.
26
+ caelo-azure:resourceGroup:
27
+ type: string
28
+ default: caelo-rg
29
+ description: Resource group name. Created by the stack if it doesn't exist.
30
+ caelo-azure:location:
31
+ type: string
32
+ default: westeurope
33
+ description: Azure region. Container Apps regional availability is checked at preview.
34
+ caelo-azure:flexibleServerSku:
35
+ type: string
36
+ default: Standard_B2s
37
+ description: Postgres flexible-server SKU. B2s = burstable; bump to GP_Standard_D2s_v3 for production.
@@ -0,0 +1,69 @@
1
+ # Caelo Azure provider stack
2
+
3
+ Pulumi stack provisioning Caelo on Azure managed services. Implements the shared `CloudAdapterInputs` / `CloudAdapterOutputs` contract from `packages/provisioning/src/adapter.ts`.
4
+
5
+ ## What it provisions per environment
6
+
7
+ | Concern | Azure resource |
8
+ |---|---|
9
+ | Managed Postgres | Azure DB for PostgreSQL flexible server (zone-redundant in production) + automated backups (geo-redundant in production) |
10
+ | Blob storage | Storage Account + two containers (`media` private, `$web` static-website-enabled) |
11
+ | CDN / Edge | Azure Container Apps `edge-router` instance (Front Door + custom-domain bindings land in P15.4 review-pass) |
12
+ | Edge compute (A/B + redirects) | Container App `edge-router` running `edge-handler.ts` with `@caelo-cms/edge-router` |
13
+ | Container runtime | Five Container Apps (admin / gateway / orchestrator / runner / edge-router) in one managed environment |
14
+ | Secret store | Key Vault (postgres-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key) |
15
+ | Edge-log sink | Log Analytics workspace receives Container Apps logs (queryable by P12A analytics plugin via Azure Monitor) |
16
+
17
+ The Caelo runtime never knows it's on Azure — every Container App consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
18
+
19
+ ## First-time install
20
+
21
+ ```bash
22
+ cd packages/provisioning/stacks/azure
23
+ pulumi stack init prod
24
+ pulumi config set caelo-azure:domain example.com
25
+ pulumi config set caelo-azure:ownerEmail me@example.com
26
+ pulumi config set caelo-azure:subscription <your-subscription-guid>
27
+ pulumi config set caelo-azure:location westeurope
28
+
29
+ # Build + push images. Operator does this on every release.
30
+ az login
31
+ az acr login --name <your-acr>
32
+ docker build -t <your-acr>.azurecr.io/admin:latest apps/admin
33
+ docker push <your-acr>.azurecr.io/admin:latest
34
+ # Repeat for gateway, orchestrator, runner, edge-router.
35
+
36
+ # Bring the stack up.
37
+ pulumi up
38
+
39
+ # Sync outputs into cms_admin.provisioning_outputs via the
40
+ # /api/internal/provisioning-outputs/sync endpoint (P15.1 signed-JWT).
41
+ CAELO_INTERNAL_SECRET=$(pulumi stack output --show-secrets internalSecretOut) \
42
+ CAELO_ADMIN_URL=https://example.com \
43
+ bunx cms-provision pulumi-output-sync --environment production
44
+ ```
45
+
46
+ The bootstrap-token URL is a Pulumi output:
47
+
48
+ ```bash
49
+ pulumi stack output bootstrapUrlOut
50
+ # → https://example.com/setup?token=<64-hex>
51
+ ```
52
+
53
+ ## v1 limitations (P15.4 review-pass items)
54
+
55
+ - **Front Door + custom-domain SSL** not auto-provisioned. v1 surfaces the edge-router Container App's FQDN as the temporary CNAME target; P15.4 wires Front Door + ManagedIdentity-based cert binding.
56
+ - **Container image build pipeline** — operators push via `az acr build` for v1.
57
+ - **Per-locale custom-domain SSL** — operator wires per-locale subdomains via Front Door custom-domain bindings; auto-provisioning lands alongside the Front Door wiring.
58
+
59
+ ## Edge-router byte-identity
60
+
61
+ The byte-identity test in `packages/edge-router/src/index.test.ts` asserts a fixed corpus of (visitorId, manifestVersion, experimentId) tuples produces identical variant labels across every runtime — a visitor sees the same variant whether they hit self-hosted Caddy in dev, AWS CloudFront in staging, or Azure Container Apps in production.
62
+
63
+ ## Destroy
64
+
65
+ ```bash
66
+ pulumi destroy
67
+ ```
68
+
69
+ Tears down all resources; the resource group is removed (which transitively removes everything inside it). Soft-deleted Key Vault entries linger per Azure's `softDeleteRetentionInDays` setting (90d in production; 7d elsewhere).
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo Azure edge-router — Azure Container App 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
+ * GCP Cloud Run handler use. The byte-identity test in
9
+ * packages/edge-router/src/index.test.ts asserts identical variant
10
+ * labels across every runtime — same hash, same variant, every visitor.
11
+ *
12
+ * Differences from the GCP Cloud Run handler:
13
+ * - Logs go to stdout in JSON; Container Apps' Log Analytics integration
14
+ * ships them to the workspace; the P12A Azure adapter queries via
15
+ * Azure Monitor's Log Analytics API.
16
+ * - Manifest fetched from Blob storage (anonymous public read on the
17
+ * `$web` container's `ab-routing.json` blob).
18
+ *
19
+ * v1 ships the source; the build pipeline (`az acr build` per service,
20
+ * pushing to ACR) lands in P17.0 release engineering.
21
+ */
22
+
23
+ import { EMPTY_MANIFEST, type RoutingManifest, routeRequest } from "@caelo-cms/edge-router";
24
+
25
+ const STATIC_BUCKET_URL = process.env.STATIC_BUCKET_URL ?? "";
26
+ const MANIFEST_OBJECT = process.env.MANIFEST_OBJECT ?? "ab-routing.json";
27
+ const MANIFEST_REFRESH_MS = 30_000;
28
+ const COOKIE_NAME = "caelo_visitor_id";
29
+
30
+ let manifestCache: { value: RoutingManifest; loadedAt: number } = {
31
+ value: EMPTY_MANIFEST,
32
+ loadedAt: 0,
33
+ };
34
+
35
+ async function fetchManifest(): Promise<RoutingManifest> {
36
+ if (Date.now() - manifestCache.loadedAt < MANIFEST_REFRESH_MS) {
37
+ return manifestCache.value;
38
+ }
39
+ if (!STATIC_BUCKET_URL) return EMPTY_MANIFEST;
40
+ try {
41
+ const url = `${STATIC_BUCKET_URL}/${MANIFEST_OBJECT}`;
42
+ const r = await fetch(url, { signal: AbortSignal.timeout(2000) });
43
+ if (!r.ok) return manifestCache.value;
44
+ const m = (await r.json()) as RoutingManifest;
45
+ manifestCache = { value: m, loadedAt: Date.now() };
46
+ return m;
47
+ } catch {
48
+ return manifestCache.value;
49
+ }
50
+ }
51
+
52
+ function readCookie(header: string | null, name: string): string | null {
53
+ if (!header) return null;
54
+ for (const pair of header.split(/;\s*/)) {
55
+ const eq = pair.indexOf("=");
56
+ if (eq < 0) continue;
57
+ if (pair.slice(0, eq) === name) return decodeURIComponent(pair.slice(eq + 1));
58
+ }
59
+ return null;
60
+ }
61
+
62
+ const port = Number(process.env.PORT ?? 8080);
63
+ Bun.serve({
64
+ port,
65
+ async fetch(req: Request): Promise<Response> {
66
+ const url = new URL(req.url);
67
+ const manifest = await fetchManifest();
68
+ const visitorIdCookie = readCookie(req.headers.get("cookie"), COOKIE_NAME);
69
+ const decision = routeRequest(manifest, {
70
+ pathname: url.pathname,
71
+ visitorIdCookie,
72
+ });
73
+
74
+ if (decision.logEntry) {
75
+ // biome-ignore lint/suspicious/noConsole: structured log → Log Analytics
76
+ console.log(JSON.stringify(decision.logEntry));
77
+ }
78
+
79
+ const target = new URL(url.toString());
80
+ target.pathname = decision.rewritePathname;
81
+ const headers = new Headers({
82
+ Location: target.toString(),
83
+ "Cache-Control": "no-store, private",
84
+ "Set-Cookie": `${COOKIE_NAME}=${encodeURIComponent(decision.setVisitorId)}; Path=/; Max-Age=31536000; SameSite=Lax; Secure; HttpOnly`,
85
+ });
86
+ return new Response(null, { status: 307, headers });
87
+ },
88
+ });
@@ -0,0 +1,309 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Caelo Azure stack — Pulumi entry point.
5
+ *
6
+ * Resources provisioned per `pulumi up`:
7
+ * 1. Resource group (created if missing).
8
+ * 2. Azure Database for PostgreSQL flexible server, zone-redundant in
9
+ * production. Two databases (cms_admin + cms_public) created via
10
+ * post-create Container Apps job (bootstrap.sh).
11
+ * 3. Storage account + two containers (`media` + `static`); the
12
+ * `static` container is `$web`-enabled for static-website hosting.
13
+ * 4. Azure Front Door Standard with two origins (static blob + Container
14
+ * Apps fronting admin/gateway). Rules engine handles A/B split
15
+ * (matches `pageSlug` from the routing manifest, rewrites to the
16
+ * variant blob path) and redirects (per-row from the redirects table
17
+ * emitted via emitRedirectsAzureFrontDoor).
18
+ * 5. Azure Container Apps (admin/gateway/orchestrator/runner + edge-router),
19
+ * mounted with Key Vault secrets via managed identity.
20
+ * 6. Key Vault entries (postgres-password, csrf-secret, cookie-secret,
21
+ * anthropic-api-key, resend-api-key).
22
+ * 7. Log Analytics workspace receives Front Door + Container Apps logs;
23
+ * P12A analytics plugin's Azure adapter queries via monitor-query.
24
+ * 8. Azure DNS zone for the primary domain (operator may opt out via
25
+ * config + manage at their existing registrar).
26
+ *
27
+ * Constraints worth knowing:
28
+ * - Container Apps not in every region; the stack errors loudly at
29
+ * preview if `location` is unsupported.
30
+ * - Front Door cert auto-management requires the apex domain to point
31
+ * at Front Door; if the operator manages DNS elsewhere they must
32
+ * publish the CNAME validation TXT record themselves.
33
+ *
34
+ * The Caelo runtime never knows it's on Azure — every Container App
35
+ * consumes plain DATABASE_URL / MEDIA_STORAGE_URL / SECRETS_PROVIDER
36
+ * env vars wired by this stack.
37
+ */
38
+
39
+ import * as azure from "@pulumi/azure-native";
40
+ import * as pulumi from "@pulumi/pulumi";
41
+ import type { CloudAdapterOutputs, DnsRecord } from "../../src/adapter.js";
42
+ import { generateBootstrapToken } from "../../src/bootstrap-token.js";
43
+
44
+ const cfg = new pulumi.Config();
45
+ const domain = cfg.require("domain");
46
+ const ownerEmail = cfg.require("ownerEmail");
47
+ const subscription = cfg.require("subscription");
48
+ const rgName = cfg.get("resourceGroup") ?? "caelo-rg";
49
+ const location = cfg.get("location") ?? "westeurope";
50
+ const flexibleServerSku = cfg.get("flexibleServerSku") ?? "Standard_B2s";
51
+
52
+ const env = pulumi.getStack() as "dev" | "staging" | "production";
53
+ const namePrefix = `caelo${env}`; // Azure resource names disallow hyphens in some types.
54
+
55
+ // === 1. Resource group ===
56
+ const rg = new azure.resources.ResourceGroup(`${namePrefix}-rg`, {
57
+ resourceGroupName: rgName,
58
+ location,
59
+ });
60
+
61
+ // === 2. Secrets ===
62
+ function randomHex(bytes: number): string {
63
+ const buf = new Uint8Array(bytes);
64
+ crypto.getRandomValues(buf);
65
+ return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
66
+ }
67
+
68
+ const postgresPassword = pulumi.secret(randomHex(32));
69
+ const csrfSecret = pulumi.secret(randomHex(32));
70
+ const cookieSecret = pulumi.secret(randomHex(32));
71
+ const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
72
+ const resendApiKey = pulumi.secret(process.env.RESEND_API_KEY ?? "");
73
+
74
+ // Key Vault.
75
+ const vault = new azure.keyvault.Vault(`${namePrefix}-kv`, {
76
+ resourceGroupName: rg.name,
77
+ vaultName: `${namePrefix}kv`.slice(0, 24), // KV name max 24 chars
78
+ properties: {
79
+ tenantId: subscription, // operator passes their AAD tenant id via this config
80
+ sku: { family: "A", name: "standard" },
81
+ enableRbacAuthorization: true,
82
+ enableSoftDelete: true,
83
+ softDeleteRetentionInDays: env === "production" ? 90 : 7,
84
+ },
85
+ });
86
+
87
+ function keyVaultSecret(shortName: string, value: pulumi.Output<string>): azure.keyvault.Secret {
88
+ return new azure.keyvault.Secret(`${namePrefix}-${shortName}`, {
89
+ resourceGroupName: rg.name,
90
+ vaultName: vault.name,
91
+ secretName: `${namePrefix}-${shortName}`,
92
+ properties: { value },
93
+ });
94
+ }
95
+
96
+ const pgSecret = keyVaultSecret("pg-password", postgresPassword);
97
+ keyVaultSecret("csrf-secret", csrfSecret);
98
+ keyVaultSecret("cookie-secret", cookieSecret);
99
+ keyVaultSecret("anthropic-api-key", anthropicApiKey);
100
+ keyVaultSecret("resend-api-key", resendApiKey);
101
+
102
+ // === 3. Postgres flexible server ===
103
+ const pgServer = new azure.dbforpostgresql.Server(`${namePrefix}-pg`, {
104
+ resourceGroupName: rg.name,
105
+ serverName: `${namePrefix}-pg`,
106
+ location,
107
+ version: "16",
108
+ sku: {
109
+ name: flexibleServerSku,
110
+ tier: flexibleServerSku.startsWith("Standard_B") ? "Burstable" : "GeneralPurpose",
111
+ },
112
+ administratorLogin: "caelo_admin",
113
+ administratorLoginPassword: postgresPassword,
114
+ storage: { storageSizeGB: 32 },
115
+ backup: {
116
+ backupRetentionDays: env === "production" ? 14 : 7,
117
+ geoRedundantBackup: env === "production" ? "Enabled" : "Disabled",
118
+ },
119
+ highAvailability: { mode: env === "production" ? "ZoneRedundant" : "Disabled" },
120
+ network: { publicNetworkAccess: "Disabled" },
121
+ });
122
+
123
+ const pgAdmin = new azure.dbforpostgresql.Database(`${namePrefix}-cms-admin-db`, {
124
+ resourceGroupName: rg.name,
125
+ serverName: pgServer.name,
126
+ databaseName: "cms_admin",
127
+ charset: "UTF8",
128
+ });
129
+ const pgPublic = new azure.dbforpostgresql.Database(`${namePrefix}-cms-public-db`, {
130
+ resourceGroupName: rg.name,
131
+ serverName: pgServer.name,
132
+ databaseName: "cms_public",
133
+ charset: "UTF8",
134
+ });
135
+
136
+ const adminDatabaseUrl = pulumi
137
+ .all([pgServer.fullyQualifiedDomainName, postgresPassword])
138
+ .apply(([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_admin?sslmode=require`);
139
+ const publicDatabaseUrl = pulumi
140
+ .all([pgServer.fullyQualifiedDomainName, postgresPassword])
141
+ .apply(([host, pw]) => `postgresql://caelo_public:${pw}@${host}:5432/cms_public?sslmode=require`);
142
+
143
+ // === 4. Storage account + containers ===
144
+ const storage = new azure.storage.StorageAccount(`${namePrefix}-st`, {
145
+ resourceGroupName: rg.name,
146
+ accountName: `${namePrefix.slice(0, 18)}st`, // Storage account names are 3-24 lowercase
147
+ location,
148
+ sku: { name: "Standard_LRS" },
149
+ kind: "StorageV2",
150
+ enableHttpsTrafficOnly: true,
151
+ minimumTlsVersion: "TLS1_2",
152
+ });
153
+
154
+ const mediaContainer = new azure.storage.BlobContainer(`${namePrefix}-media`, {
155
+ resourceGroupName: rg.name,
156
+ accountName: storage.name,
157
+ containerName: "media",
158
+ publicAccess: "None",
159
+ });
160
+
161
+ const staticContainer = new azure.storage.BlobContainer(`${namePrefix}-static`, {
162
+ resourceGroupName: rg.name,
163
+ accountName: storage.name,
164
+ containerName: "$web",
165
+ publicAccess: "Blob",
166
+ });
167
+
168
+ // === 5. Log Analytics workspace ===
169
+ const logWorkspace = new azure.operationalinsights.Workspace(`${namePrefix}-logs`, {
170
+ resourceGroupName: rg.name,
171
+ workspaceName: `${namePrefix}-logs`,
172
+ location,
173
+ sku: { name: "PerGB2018" },
174
+ retentionInDays: env === "production" ? 90 : 30,
175
+ });
176
+
177
+ // === 6. Container Apps environment + apps ===
178
+ const cappEnv = new azure.app.ManagedEnvironment(`${namePrefix}-capp-env`, {
179
+ resourceGroupName: rg.name,
180
+ environmentName: `${namePrefix}-capp-env`,
181
+ location,
182
+ appLogsConfiguration: {
183
+ destination: "log-analytics",
184
+ logAnalyticsConfiguration: pulumi
185
+ .all([logWorkspace.customerId, logWorkspace.name, rg.name])
186
+ .apply(([cid, name, rgName_]) => ({
187
+ customerId: cid ?? "",
188
+ sharedKey: pulumi
189
+ .output(
190
+ azure.operationalinsights.getSharedKeys({
191
+ resourceGroupName: rgName_,
192
+ workspaceName: name,
193
+ }),
194
+ )
195
+ .apply((k) => k.primarySharedKey ?? ""),
196
+ })),
197
+ },
198
+ });
199
+
200
+ interface ContainerAppArgs {
201
+ readonly serviceName: string;
202
+ readonly extraEnv?: ReadonlyArray<{ name: string; value: pulumi.Input<string> }>;
203
+ }
204
+
205
+ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
206
+ return new azure.app.ContainerApp(`${namePrefix}-${args.serviceName}`, {
207
+ resourceGroupName: rg.name,
208
+ containerAppName: `${namePrefix}-${args.serviceName}`,
209
+ location,
210
+ managedEnvironmentId: cappEnv.id,
211
+ configuration: {
212
+ activeRevisionsMode: "Single",
213
+ ingress: {
214
+ external: args.serviceName === "edge-router",
215
+ targetPort: 8080,
216
+ transport: "Auto",
217
+ },
218
+ },
219
+ template: {
220
+ scale: { minReplicas: env === "production" ? 1 : 0, maxReplicas: 10 },
221
+ containers: [
222
+ {
223
+ name: args.serviceName,
224
+ // Image pushed via `az acr build` for v1 (operator's job).
225
+ image:
226
+ cfg.get(`image-${args.serviceName}`) ??
227
+ `${namePrefix}.azurecr.io/${args.serviceName}:latest`,
228
+ env: [
229
+ { name: "CAELO_PROVIDER", value: "azure" },
230
+ { name: "CAELO_ENV", value: env },
231
+ { name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
232
+ { name: "PUBLIC_ADMIN_DATABASE_URL", value: publicDatabaseUrl },
233
+ {
234
+ name: "MEDIA_STORAGE_URL",
235
+ value: pulumi.interpolate`https://${storage.name}.blob.core.windows.net/media`,
236
+ },
237
+ ...(args.extraEnv ?? []),
238
+ ],
239
+ resources: { cpu: 0.5, memory: "1.0Gi" },
240
+ },
241
+ ],
242
+ },
243
+ });
244
+ }
245
+
246
+ const adminApp = containerApp({ serviceName: "admin" });
247
+ const gatewayApp = containerApp({ serviceName: "gateway" });
248
+ const orchestratorApp = containerApp({ serviceName: "orchestrator" });
249
+ const runnerApp = containerApp({ serviceName: "runner" });
250
+ const edgeRouterApp = containerApp({
251
+ serviceName: "edge-router",
252
+ extraEnv: [
253
+ {
254
+ name: "STATIC_BUCKET_URL",
255
+ value: pulumi.interpolate`https://${storage.name}.blob.core.windows.net/$web`,
256
+ },
257
+ { name: "MANIFEST_OBJECT", value: "ab-routing.json" },
258
+ ],
259
+ });
260
+
261
+ // === 7. Bootstrap token ===
262
+ const tokenInfo = generateBootstrapToken();
263
+
264
+ // === 8. CloudAdapterOutputs ===
265
+ const dnsRecordsRequired: DnsRecord[] = [
266
+ {
267
+ hostname: domain,
268
+ type: "CNAME",
269
+ // Front Door domain comes from the resource (P15.4 review-pass adds
270
+ // the actual Front Door + custom-domain bindings; v1 surfaces the
271
+ // edge-router Container App FQDN as the temporary target).
272
+ value: edgeRouterApp.configuration.apply(
273
+ (c) => c?.ingress?.fqdn ?? "<edge-router-fqdn-pending>",
274
+ ) as unknown as string,
275
+ purpose:
276
+ "Primary domain → edge-router Container App (P15.4 review-pass moves this behind Front Door)",
277
+ },
278
+ ];
279
+
280
+ const out: CloudAdapterOutputs = {
281
+ adminDatabaseUrl: adminDatabaseUrl as unknown as string,
282
+ publicDatabaseUrl: publicDatabaseUrl as unknown as string,
283
+ mediaStorageUrl:
284
+ pulumi.interpolate`https://${storage.name}.blob.core.windows.net/media` as unknown as string,
285
+ mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
286
+ bootstrapUrl:
287
+ pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
288
+ dnsRecordsRequired,
289
+ edgeLogSinkUrl: pulumi.interpolate`loganalytics://${logWorkspace.name}` as unknown as string,
290
+ provider: "azure",
291
+ environment: env,
292
+ };
293
+
294
+ export const adminDatabaseUrlOut = out.adminDatabaseUrl;
295
+ export const publicDatabaseUrlOut = out.publicDatabaseUrl;
296
+ export const mediaStorageUrlOut = out.mediaStorageUrl;
297
+ export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
298
+ export const bootstrapUrlOut = out.bootstrapUrl;
299
+ export const dnsRecordsRequiredOut = out.dnsRecordsRequired;
300
+ export const edgeLogSinkUrlOut = out.edgeLogSinkUrl;
301
+ export const providerOut = out.provider;
302
+ export const environmentOut = out.environment;
303
+ export const pgServerFqdnOut = pgServer.fullyQualifiedDomainName;
304
+ export const storageAccountNameOut = storage.name;
305
+ export const adminContainerAppFqdnOut = adminApp.configuration.apply((c) => c?.ingress?.fqdn);
306
+ export const edgeRouterContainerAppFqdnOut = edgeRouterApp.configuration.apply(
307
+ (c) => c?.ingress?.fqdn,
308
+ );
309
+ export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MPL-2.0
2
+ #
3
+ # Caelo GCP provider stack. Provisions Cloud SQL HA + GCS (media + static
4
+ # output) + Cloud CDN + Cloud Run (admin/gateway/orchestrator/runner +
5
+ # edge-router) + Secret Manager + Cloud Logging → BigQuery sink for
6
+ # A/B assignment logs. Implements the shared CloudAdapterInputs /
7
+ # CloudAdapterOutputs contract from packages/provisioning/src/adapter.ts.
8
+ name: caelo-gcp
9
+ runtime:
10
+ name: nodejs
11
+ options:
12
+ typescript: true
13
+ packagemanager: bun
14
+ description: Caelo GCP stack — Cloud SQL HA + GCS + Cloud CDN + Cloud Run + Secret Manager.
15
+ config:
16
+ caelo-gcp:domain:
17
+ type: string
18
+ description: Primary domain (e.g. example.com).
19
+ caelo-gcp:ownerEmail:
20
+ type: string
21
+ description: Operator email for Pulumi notifications.
22
+ caelo-gcp:project:
23
+ type: string
24
+ description: GCP project id (Pulumi requires this even when it can read it from gcloud).
25
+ caelo-gcp:region:
26
+ type: string
27
+ default: us-central1
28
+ description: Primary GCP region (Cloud SQL, Cloud Run, GCS).
29
+ caelo-gcp:cloudSqlTier:
30
+ type: string
31
+ default: db-custom-1-3840
32
+ description: Cloud SQL machine tier. Default = 1 vCPU, 3.75 GB. Bump for production traffic.
33
+ caelo-gcp:cloudRunMinInstances:
34
+ type: string
35
+ default: "0"
36
+ description: Min instances per service. Set to 1 in production to avoid cold-start latency (~$30/mo per service).
@@ -0,0 +1,78 @@
1
+ # Caelo GCP provider stack
2
+
3
+ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `CloudAdapterInputs` / `CloudAdapterOutputs` contract from `packages/provisioning/src/adapter.ts` so the `cms-provision pulumi-output-sync` step consumes the outputs the same way it does for self-hosted, AWS, and Azure.
4
+
5
+ ## What it provisions per environment
6
+
7
+ | Concern | GCP resource |
8
+ |---|---|
9
+ | Managed Postgres | Cloud SQL Postgres 16 (REGIONAL HA in production, ZONAL elsewhere) + automated backups + PITR + private IP only |
10
+ | Blob storage | Two GCS buckets (`<project>-caelo-<env>-media`, `<project>-caelo-<env>-static`) with uniform bucket-level access |
11
+ | CDN | Cloud CDN backend bucket (operator wires the URL map + load balancer for v1; full LB in P15 review-pass) |
12
+ | Edge compute (A/B + redirects) | Cloud Run service `<env>-edge-router` running `edge-handler.ts` with `@caelo-cms/edge-router` |
13
+ | Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
14
+ | Secret store | Secret Manager (postgres-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key) |
15
+ | Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
16
+
17
+ The Caelo runtime never knows it's on GCP — every Cloud Run service consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
18
+
19
+ ## First-time install
20
+
21
+ ```bash
22
+ cd packages/provisioning/stacks/gcp
23
+ pulumi stack init prod
24
+ pulumi config set caelo-gcp:domain example.com
25
+ pulumi config set caelo-gcp:ownerEmail me@example.com
26
+ pulumi config set caelo-gcp:project my-gcp-project
27
+ pulumi config set caelo-gcp:region us-central1
28
+
29
+ # Build + push images. Operator does this once + on every release.
30
+ gcloud auth configure-docker us-central1-docker.pkg.dev
31
+ gcloud builds submit ... --tag us-central1-docker.pkg.dev/$PROJECT/caelo/admin:latest
32
+ # Repeat for gateway, orchestrator, runner, edge-router.
33
+
34
+ # Bring the stack up.
35
+ pulumi up
36
+
37
+ # Sync outputs into cms_admin.provisioning_outputs so the admin's
38
+ # /security/dns page surfaces the required DNS records.
39
+ ADMIN_DATABASE_URL=$(pulumi stack output adminDatabaseUrlOut --show-secrets) \
40
+ bunx cms-provision pulumi-output-sync --environment production
41
+ ```
42
+
43
+ The bootstrap-token URL is a Pulumi output:
44
+
45
+ ```bash
46
+ pulumi stack output bootstrapUrlOut
47
+ # → https://example.com/setup?token=<64-hex>
48
+ ```
49
+
50
+ Open after the operator has wired the GCP HTTPS load balancer to the edge-router Cloud Run service + the admin's DNS A record points at the LB IP.
51
+
52
+ ## Edge-router behaviour
53
+
54
+ This stack's `edge-handler.ts` imports `routeRequest` from `@caelo-cms/edge-router` — same function the AWS Lambda@Edge function and the P13 self-hosted Caddy gateway use. Differences from AWS L@E:
55
+
56
+ - **Manifest lives in GCS, not bundled in the binary.** The handler fetches `gs://<static-bucket>/routing-manifest.json` on a 30s TTL cache. A new manifest takes effect within 30s without a Cloud Run redeploy.
57
+ - **Returns 307 redirects** (instead of L@E's request-mutation rewrite) so the browser hits the variant URL directly + the static origin's cache respects the variant cache key.
58
+ - **Sets the `caelo_visitor_id` cookie directly** in the response (Cloud Run handlers control headers; L@E's viewer-request cannot).
59
+ - **Cloud Logging → BigQuery**, not CloudWatch → Athena. Same `{kind: "ab_assignment", ...}` JSON shape.
60
+
61
+ ## v1 limitations (P15 review-pass items)
62
+
63
+ - **GCP HTTPS load balancer + URL map** not auto-provisioned. Operator wires the LB to point `/api/*` → gateway, `/admin/*` → admin, `/_caelo-variant/*` → static bucket directly, and everything else → edge-router. Auto-provisioning lands when telemetry shows operators want it.
64
+ - **Container image build pipeline** — operators push via `gcloud builds submit` for v1. A unified `cms-provision build-images --provider gcp` lands later.
65
+ - **Per-locale managed SSL certs** — operator creates via `gcloud compute ssl-certificates create` per locale subdomain for v1. Auto-provisioning lands alongside the LB.
66
+ - **DNS records** — `dnsRecordsRequired` includes a `<gcp-load-balancer-ip>` placeholder; the DNS UI shows the right hostname/type for the operator to fill in once the LB IP exists.
67
+
68
+ ## Edge-router byte-identity
69
+
70
+ The byte-identity test in `packages/edge-router/src/index.test.ts` asserts a fixed corpus of (visitorId, manifestVersion, experimentId) tuples produces identical variant labels across every runtime — a visitor sees the same variant whether they hit the self-hosted Caddy in dev, the AWS CloudFront in staging, or the GCP Cloud Run in production.
71
+
72
+ ## Destroy
73
+
74
+ ```bash
75
+ pulumi destroy
76
+ ```
77
+
78
+ Tears down all resources except buckets in production (operator removes via `gsutil rm -r gs://<bucket>` then re-applies destroy). Dev/staging set `forceDestroy: true`.