@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.
- package/caddy/Caddyfile.production +18 -0
- package/caddy/Caddyfile.staging +21 -0
- package/package.json +27 -0
- package/src/adapter.ts +103 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +93 -0
- package/src/cdn-copy.ts +84 -0
- package/src/cli.ts +674 -0
- package/src/compose.ts +123 -0
- package/src/index.test.ts +246 -0
- package/src/index.ts +52 -0
- package/src/redirects-emit.ts +166 -0
- package/stacks/aws/Pulumi.yaml +39 -0
- package/stacks/aws/README.md +80 -0
- package/stacks/aws/build-edge.ts +80 -0
- package/stacks/aws/edge-handler-bundle.js +21 -0
- package/stacks/aws/edge-handler.ts +87 -0
- package/stacks/aws/index.ts +412 -0
- package/stacks/azure/Pulumi.yaml +37 -0
- package/stacks/azure/README.md +69 -0
- package/stacks/azure/edge-handler.ts +88 -0
- package/stacks/azure/index.ts +309 -0
- package/stacks/gcp/Pulumi.yaml +36 -0
- package/stacks/gcp/README.md +78 -0
- package/stacks/gcp/edge-handler.ts +106 -0
- package/stacks/gcp/index.ts +483 -0
- package/stacks/self-hosted/Pulumi.yaml +27 -0
- package/stacks/self-hosted/README.md +43 -0
- package/stacks/self-hosted/index.ts +117 -0
- package/tsconfig.json +16 -0
|
@@ -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`.
|