@caelo-cms/provisioning 0.10.40 → 0.10.42
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 +5 -7
- package/caddy/Caddyfile.staging +4 -4
- package/dist/adapter.d.ts +3 -3
- package/dist/admin-domain.d.ts +8 -0
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +2 -2
- package/dist/admin-domain.js.map +1 -1
- package/dist/azure-front-door.d.ts +29 -0
- package/dist/azure-front-door.d.ts.map +1 -0
- package/dist/azure-front-door.js +32 -0
- package/dist/azure-front-door.js.map +1 -0
- package/dist/cli.js +69 -20
- package/dist/cli.js.map +1 -1
- package/dist/default-firewall.d.ts +12 -9
- package/dist/default-firewall.d.ts.map +1 -1
- package/dist/default-firewall.js +140 -28
- package/dist/default-firewall.js.map +1 -1
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +3 -0
- package/dist/gcloud.js.map +1 -1
- package/dist/internal-token.d.ts +9 -0
- package/dist/internal-token.d.ts.map +1 -0
- package/dist/internal-token.js +23 -0
- package/dist/internal-token.js.map +1 -0
- package/dist/lifecycle.js +2 -2
- package/dist/lifecycle.js.map +1 -1
- package/dist/stack-contract.d.ts +34 -13
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +45 -15
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +3 -3
- package/dist/stack-converge.js +7 -7
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +79 -0
- package/dist/staging-admin.d.ts.map +1 -0
- package/dist/staging-admin.js +223 -0
- package/dist/staging-admin.js.map +1 -0
- package/dist/wizards/gcp.js +15 -13
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +3 -4
- package/stacks/aws/Pulumi.yaml +3 -3
- package/stacks/aws/README.md +4 -28
- package/stacks/aws/index.ts +53 -71
- package/stacks/azure/Pulumi.yaml +3 -3
- package/stacks/azure/README.md +7 -11
- package/stacks/azure/index.ts +132 -42
- package/stacks/gcp/Pulumi.yaml +3 -3
- package/stacks/gcp/README.md +3 -18
- package/stacks/aws/build-edge.ts +0 -80
- package/stacks/aws/edge-handler-bundle.js +0 -20
- package/stacks/aws/edge-handler.ts +0 -86
- package/stacks/azure/edge-handler.ts +0 -87
- package/stacks/gcp/edge-handler.ts +0 -105
package/stacks/azure/index.ts
CHANGED
|
@@ -8,18 +8,16 @@
|
|
|
8
8
|
* 2. Azure Database for PostgreSQL flexible server, zone-redundant in
|
|
9
9
|
* production. Two databases (cms_admin + cms_public) created via
|
|
10
10
|
* post-create Container Apps job (bootstrap.sh).
|
|
11
|
-
* 3. Storage account
|
|
12
|
-
*
|
|
13
|
-
* 4. Azure Front Door Standard
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* emitted via emitRedirectsAzureFrontDoor).
|
|
18
|
-
* 5. Azure Container Apps (admin/gateway/orchestrator/runner + edge-router),
|
|
11
|
+
* 3. Storage account with a private `media` container and static-website
|
|
12
|
+
* hosting (the `$web` container the static generator publishes to).
|
|
13
|
+
* 4. Azure Front Door Standard serving the static website on the primary
|
|
14
|
+
* domain with a managed certificate — files served as files, no compute
|
|
15
|
+
* on the request path (CLAUDE.md §11.B Tier 1).
|
|
16
|
+
* 5. Azure Container Apps (admin/gateway/orchestrator/runner),
|
|
19
17
|
* mounted with Key Vault secrets via managed identity.
|
|
20
18
|
* 6. Key Vault entries (postgres-password, csrf-secret, cookie-secret,
|
|
21
19
|
* anthropic-api-key, resend-api-key).
|
|
22
|
-
* 7. Log Analytics workspace receives
|
|
20
|
+
* 7. Log Analytics workspace receives Container Apps logs;
|
|
23
21
|
* P12A analytics plugin's Azure adapter queries via monitor-query.
|
|
24
22
|
* 8. Azure DNS zone for the primary domain (operator may opt out via
|
|
25
23
|
* config + manage at their existing registrar).
|
|
@@ -39,6 +37,7 @@
|
|
|
39
37
|
import * as azure from "@pulumi/azure-native";
|
|
40
38
|
import * as pulumi from "@pulumi/pulumi";
|
|
41
39
|
import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
|
|
40
|
+
import { azureFrontDoorDnsRecords } from "../../dist/azure-front-door.js";
|
|
42
41
|
import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
|
|
43
42
|
import { ADMIN_MEMORY_DEFAULT, azureContainerAppResources } from "../../dist/stack-contract.js";
|
|
44
43
|
|
|
@@ -190,13 +189,124 @@ const _mediaContainer = new azure.storage.BlobContainer(`${namePrefix}-media`, {
|
|
|
190
189
|
publicAccess: "None",
|
|
191
190
|
});
|
|
192
191
|
|
|
193
|
-
|
|
192
|
+
// The container the static generator publishes to. Managed explicitly (not
|
|
193
|
+
// left to static-website hosting, which would create it implicitly) so a
|
|
194
|
+
// stack created before Front Door keeps owning it — changing the resource
|
|
195
|
+
// type would make Pulumi delete the container and every published page.
|
|
196
|
+
const staticContainer = new azure.storage.BlobContainer(`${namePrefix}-static`, {
|
|
194
197
|
resourceGroupName: rg.name,
|
|
195
198
|
accountName: storage.name,
|
|
196
199
|
containerName: "$web",
|
|
197
200
|
publicAccess: "Blob",
|
|
198
201
|
});
|
|
199
202
|
|
|
203
|
+
// Static-website hosting serves `$web` on the account's web endpoint, which
|
|
204
|
+
// resolves `/path/` to `/path/index.html` — the URL shape the static
|
|
205
|
+
// generator emits. Front Door fronts that endpoint.
|
|
206
|
+
const staticWebsite = new azure.storage.StorageAccountStaticWebsite(
|
|
207
|
+
`${namePrefix}-static-website`,
|
|
208
|
+
{
|
|
209
|
+
resourceGroupName: rg.name,
|
|
210
|
+
accountName: storage.name,
|
|
211
|
+
indexDocument: "index.html",
|
|
212
|
+
error404Document: "404.html",
|
|
213
|
+
},
|
|
214
|
+
{ dependsOn: [staticContainer] },
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// === 4b. Front Door (static site on the primary domain) ===
|
|
218
|
+
const frontDoor = new azure.cdn.Profile(`${namePrefix}-fd`, {
|
|
219
|
+
resourceGroupName: rg.name,
|
|
220
|
+
profileName: `${namePrefix}-fd`,
|
|
221
|
+
location: "Global",
|
|
222
|
+
sku: { name: "Standard_AzureFrontDoor" },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const frontDoorEndpoint = new azure.cdn.AFDEndpoint(`${namePrefix}-fd-endpoint`, {
|
|
226
|
+
resourceGroupName: rg.name,
|
|
227
|
+
profileName: frontDoor.name,
|
|
228
|
+
endpointName: `${namePrefix}-site`,
|
|
229
|
+
location: "Global",
|
|
230
|
+
enabledState: "Enabled",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const staticOriginGroup = new azure.cdn.AFDOriginGroup(`${namePrefix}-fd-static`, {
|
|
234
|
+
resourceGroupName: rg.name,
|
|
235
|
+
profileName: frontDoor.name,
|
|
236
|
+
originGroupName: "static",
|
|
237
|
+
loadBalancingSettings: { sampleSize: 4, successfulSamplesRequired: 3 },
|
|
238
|
+
healthProbeSettings: {
|
|
239
|
+
probePath: "/",
|
|
240
|
+
probeProtocol: "Https",
|
|
241
|
+
probeRequestType: "HEAD",
|
|
242
|
+
probeIntervalInSeconds: 100,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// `primaryEndpoints.web` is `https://<account>.z<n>.web.core.windows.net/`;
|
|
247
|
+
// Front Door wants the bare host.
|
|
248
|
+
const staticWebHost = storage.primaryEndpoints.apply((e) => new URL(e.web).host);
|
|
249
|
+
const staticOrigin = new azure.cdn.AFDOrigin(
|
|
250
|
+
`${namePrefix}-fd-static-origin`,
|
|
251
|
+
{
|
|
252
|
+
resourceGroupName: rg.name,
|
|
253
|
+
profileName: frontDoor.name,
|
|
254
|
+
originGroupName: staticOriginGroup.name,
|
|
255
|
+
originName: "static-website",
|
|
256
|
+
hostName: staticWebHost,
|
|
257
|
+
originHostHeader: staticWebHost,
|
|
258
|
+
httpsPort: 443,
|
|
259
|
+
enabledState: "Enabled",
|
|
260
|
+
},
|
|
261
|
+
{ dependsOn: [staticWebsite] },
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const siteDomain = new azure.cdn.AFDCustomDomain(`${namePrefix}-fd-domain`, {
|
|
265
|
+
resourceGroupName: rg.name,
|
|
266
|
+
profileName: frontDoor.name,
|
|
267
|
+
customDomainName: domain.replace(/\./g, "-"),
|
|
268
|
+
hostName: domain,
|
|
269
|
+
tlsSettings: { certificateType: "ManagedCertificate", minimumTlsVersion: "TLS12" },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
new azure.cdn.Route(
|
|
273
|
+
`${namePrefix}-fd-route`,
|
|
274
|
+
{
|
|
275
|
+
resourceGroupName: rg.name,
|
|
276
|
+
profileName: frontDoor.name,
|
|
277
|
+
endpointName: frontDoorEndpoint.name,
|
|
278
|
+
routeName: "static",
|
|
279
|
+
originGroup: { id: staticOriginGroup.id },
|
|
280
|
+
customDomains: [{ id: siteDomain.id }],
|
|
281
|
+
patternsToMatch: ["/*"],
|
|
282
|
+
// Without a cache configuration Front Door does not cache the route at
|
|
283
|
+
// all; static files are edge-cached per their Cache-Control (§11.B
|
|
284
|
+
// Tier 1). The generated site has no query-string-dependent content.
|
|
285
|
+
cacheConfiguration: {
|
|
286
|
+
queryStringCachingBehavior: "IgnoreQueryString",
|
|
287
|
+
compressionSettings: {
|
|
288
|
+
isCompressionEnabled: true,
|
|
289
|
+
contentTypesToCompress: [
|
|
290
|
+
"text/html",
|
|
291
|
+
"text/css",
|
|
292
|
+
"text/plain",
|
|
293
|
+
"text/xml",
|
|
294
|
+
"application/javascript",
|
|
295
|
+
"application/json",
|
|
296
|
+
"application/xml",
|
|
297
|
+
"image/svg+xml",
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
supportedProtocols: ["Http", "Https"],
|
|
302
|
+
forwardingProtocol: "HttpsOnly",
|
|
303
|
+
httpsRedirect: "Enabled",
|
|
304
|
+
linkToDefaultDomain: "Enabled",
|
|
305
|
+
},
|
|
306
|
+
// A route needs an origin in its group before Front Door accepts it.
|
|
307
|
+
{ dependsOn: [staticOrigin] },
|
|
308
|
+
);
|
|
309
|
+
|
|
200
310
|
// === 5. Log Analytics workspace ===
|
|
201
311
|
const logWorkspace = new azure.operationalinsights.Workspace(`${namePrefix}-logs`, {
|
|
202
312
|
resourceGroupName: rg.name,
|
|
@@ -251,7 +361,7 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
|
|
|
251
361
|
configuration: {
|
|
252
362
|
activeRevisionsMode: "Single",
|
|
253
363
|
ingress: {
|
|
254
|
-
external:
|
|
364
|
+
external: false,
|
|
255
365
|
targetPort: 8080,
|
|
256
366
|
transport: "Auto",
|
|
257
367
|
},
|
|
@@ -306,37 +416,19 @@ const _orchestratorApp = containerApp({
|
|
|
306
416
|
databaseEnv: adminDatabaseEnv,
|
|
307
417
|
});
|
|
308
418
|
const _runnerApp = containerApp({ serviceName: "runner", databaseEnv: adminDatabaseEnv });
|
|
309
|
-
const edgeRouterApp = containerApp({
|
|
310
|
-
serviceName: "edge-router",
|
|
311
|
-
// Serves the static site and the A/B routing manifest; no database.
|
|
312
|
-
databaseEnv: [],
|
|
313
|
-
extraEnv: [
|
|
314
|
-
{
|
|
315
|
-
name: "STATIC_BUCKET_URL",
|
|
316
|
-
value: pulumi.interpolate`https://${storage.name}.blob.core.windows.net/$web`,
|
|
317
|
-
},
|
|
318
|
-
{ name: "MANIFEST_OBJECT", value: "ab-routing.json" },
|
|
319
|
-
],
|
|
320
|
-
});
|
|
321
|
-
|
|
322
419
|
// === 7. Bootstrap token ===
|
|
323
420
|
const tokenInfo = generateBootstrapToken();
|
|
324
421
|
|
|
325
422
|
// === 8. CloudAdapterOutputs ===
|
|
326
|
-
const dnsRecordsRequired
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
) as unknown as string,
|
|
336
|
-
purpose:
|
|
337
|
-
"Primary domain → edge-router Container App (P15.4 review-pass moves this behind Front Door)",
|
|
338
|
-
},
|
|
339
|
-
];
|
|
423
|
+
const dnsRecordsRequired = pulumi
|
|
424
|
+
.all([frontDoorEndpoint.hostName, siteDomain.validationProperties])
|
|
425
|
+
.apply(([endpointHostName, validation]) =>
|
|
426
|
+
azureFrontDoorDnsRecords({
|
|
427
|
+
domain,
|
|
428
|
+
endpointHostName,
|
|
429
|
+
validationToken: validation.validationToken,
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
340
432
|
|
|
341
433
|
const out: CloudAdapterOutputs = {
|
|
342
434
|
adminDatabaseUrl: adminDatabaseUrl as unknown as string,
|
|
@@ -347,7 +439,7 @@ const out: CloudAdapterOutputs = {
|
|
|
347
439
|
mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
|
|
348
440
|
bootstrapUrl:
|
|
349
441
|
pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
|
|
350
|
-
dnsRecordsRequired,
|
|
442
|
+
dnsRecordsRequired: dnsRecordsRequired as unknown as DnsRecord[],
|
|
351
443
|
edgeLogSinkUrl: pulumi.interpolate`loganalytics://${logWorkspace.name}` as unknown as string,
|
|
352
444
|
provider: "azure",
|
|
353
445
|
environment: env,
|
|
@@ -366,7 +458,5 @@ export const environmentOut = out.environment;
|
|
|
366
458
|
export const pgServerFqdnOut = pgServer.fullyQualifiedDomainName;
|
|
367
459
|
export const storageAccountNameOut = storage.name;
|
|
368
460
|
export const adminContainerAppFqdnOut = adminApp.configuration.apply((c) => c?.ingress?.fqdn);
|
|
369
|
-
export const
|
|
370
|
-
(c) => c?.ingress?.fqdn,
|
|
371
|
-
);
|
|
461
|
+
export const frontDoorEndpointHostNameOut = frontDoorEndpoint.hostName;
|
|
372
462
|
export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
|
package/stacks/gcp/Pulumi.yaml
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
#
|
|
3
3
|
# Caelo GCP provider stack. Provisions Cloud SQL HA + GCS (media + static
|
|
4
|
-
# output) + Cloud CDN + Cloud Run (admin/gateway/orchestrator/runner +
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# output) + Cloud CDN + Cloud Run (admin/gateway/orchestrator/runner) +
|
|
5
|
+
# Secret Manager + Cloud Logging → BigQuery sink for gateway request
|
|
6
|
+
# logs. Implements the shared CloudAdapterInputs /
|
|
7
7
|
# CloudAdapterOutputs contract from packages/provisioning/src/adapter.ts.
|
|
8
8
|
name: caelo-gcp
|
|
9
9
|
runtime:
|
package/stacks/gcp/README.md
CHANGED
|
@@ -8,8 +8,7 @@ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| Managed Postgres | Cloud SQL Postgres 16 (REGIONAL HA in production, ZONAL elsewhere) + automated backups + PITR + private IP only |
|
|
10
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
|
|
12
|
-
| Edge compute (A/B + redirects) | Cloud Run service `<env>-edge-router` running `edge-handler.ts` with `@caelo-cms/edge-router` |
|
|
11
|
+
| CDN | Global HTTPS load balancer + Cloud CDN backend bucket: `<domain>` serves the static bucket as files, `/api/*` goes to the gateway, `admin.<domain>` to the IAP-gated admin |
|
|
13
12
|
| Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
|
|
14
13
|
| Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret, public-role-password and gateway-role-password (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. The gateway's service account reads only the two gateway role passwords (#613); `gateway_role` itself is created by migration 0248 and gets its password from the CLI after migrations. |
|
|
15
14
|
| Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
|
|
@@ -36,7 +35,7 @@ pulumi config set caelo-gcp:region europe-west1
|
|
|
36
35
|
# Build + push images. Operator does this once + on every release.
|
|
37
36
|
gcloud auth configure-docker us-central1-docker.pkg.dev
|
|
38
37
|
gcloud builds submit ... --tag us-central1-docker.pkg.dev/$PROJECT/caelo/admin:latest
|
|
39
|
-
# Repeat for gateway, orchestrator, runner
|
|
38
|
+
# Repeat for gateway, orchestrator, runner.
|
|
40
39
|
|
|
41
40
|
# The stack references two secrets it does not create (the CLI wizard and
|
|
42
41
|
# `upgrade` create them; a direct `pulumi up` needs them first). Values go
|
|
@@ -65,28 +64,14 @@ pulumi stack output bootstrapUrlOut
|
|
|
65
64
|
# → https://example.com/setup?token=<64-hex>
|
|
66
65
|
```
|
|
67
66
|
|
|
68
|
-
Open after the
|
|
69
|
-
|
|
70
|
-
## Edge-router behaviour
|
|
71
|
-
|
|
72
|
-
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:
|
|
73
|
-
|
|
74
|
-
- **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.
|
|
75
|
-
- **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.
|
|
76
|
-
- **Sets the `caelo_visitor_id` cookie directly** in the response (Cloud Run handlers control headers; L@E's viewer-request cannot).
|
|
77
|
-
- **Cloud Logging → BigQuery**, not CloudWatch → Athena. Same `{kind: "ab_assignment", ...}` JSON shape.
|
|
67
|
+
Open after the domain's and the admin's DNS A records point at the load balancer IP and the managed certificate is active.
|
|
78
68
|
|
|
79
69
|
## v1 limitations (P15 review-pass items)
|
|
80
70
|
|
|
81
|
-
- **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.
|
|
82
71
|
- **Container image build pipeline** — operators push via `gcloud builds submit` for v1. A unified `cms-provision build-images --provider gcp` lands later.
|
|
83
72
|
- **Per-locale managed SSL certs** — operator creates via `gcloud compute ssl-certificates create` per locale subdomain for v1. Auto-provisioning lands alongside the LB.
|
|
84
73
|
- **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.
|
|
85
74
|
|
|
86
|
-
## Edge-router byte-identity
|
|
87
|
-
|
|
88
|
-
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.
|
|
89
|
-
|
|
90
75
|
## Destroy
|
|
91
76
|
|
|
92
77
|
```bash
|
package/stacks/aws/build-edge.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Bundle Caelo's Lambda@Edge handler + the latest routing-manifest.json
|
|
6
|
-
* into a single CommonJS file Pulumi uploads as the L@E function source.
|
|
7
|
-
*
|
|
8
|
-
* Run BEFORE every `pulumi up` against the AWS stack:
|
|
9
|
-
* bun run packages/provisioning/stacks/aws/build-edge.ts \
|
|
10
|
-
* --manifest /path/to/routing-manifest.json
|
|
11
|
-
*
|
|
12
|
-
* The manifest path defaults to the static-output bucket's local
|
|
13
|
-
* mirror (apps/static-generator/dist/routing-manifest.json) if the
|
|
14
|
-
* --manifest flag is omitted.
|
|
15
|
-
*
|
|
16
|
-
* Why bundle the manifest IN: Lambda@Edge has no IAM role for RDS,
|
|
17
|
-
* no network egress to non-AWS endpoints, no SSM access. The function
|
|
18
|
-
* is pure code + bundled config. A new manifest = a new bundle = a
|
|
19
|
-
* new Pulumi `update` of the Lambda function (versioned by L@E).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
-
import { resolve } from "node:path";
|
|
24
|
-
|
|
25
|
-
function arg(name: string): string | undefined {
|
|
26
|
-
const idx = process.argv.indexOf(`--${name}`);
|
|
27
|
-
if (idx === -1) return undefined;
|
|
28
|
-
return process.argv[idx + 1];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// P15 hot-fix #1 — bundle the edge-router-shaped `ab-routing.json`,
|
|
32
|
-
// not the deploy manifest `routing-manifest.json`. The two live
|
|
33
|
-
// alongside each other in the static-output bucket; only the AB one
|
|
34
|
-
// matches @caelo-cms/edge-router's RoutingManifest shape.
|
|
35
|
-
const manifestPath =
|
|
36
|
-
arg("manifest") ??
|
|
37
|
-
resolve(import.meta.dir, "../../../..", "apps/static-generator/dist/ab-routing.json");
|
|
38
|
-
|
|
39
|
-
const manifest = existsSync(manifestPath)
|
|
40
|
-
? JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
41
|
-
: { manifestVersion: "0", experiments: [] };
|
|
42
|
-
|
|
43
|
-
console.log(`Manifest: ${manifestPath}`);
|
|
44
|
-
console.log(`Version: ${manifest.manifestVersion}`);
|
|
45
|
-
console.log(`Experiments: ${manifest.experiments.length}`);
|
|
46
|
-
|
|
47
|
-
const entry = resolve(import.meta.dir, "edge-handler.ts");
|
|
48
|
-
const out = resolve(import.meta.dir, "edge-handler-bundle.js");
|
|
49
|
-
|
|
50
|
-
// Bun's bundler: target=node, format=cjs (Lambda@Edge wants CJS),
|
|
51
|
-
// inline the manifest via `--define`, externalize nothing (pure JS).
|
|
52
|
-
const proc = Bun.spawn(
|
|
53
|
-
[
|
|
54
|
-
"bun",
|
|
55
|
-
"build",
|
|
56
|
-
entry,
|
|
57
|
-
"--target=node",
|
|
58
|
-
"--format=cjs",
|
|
59
|
-
"--outfile",
|
|
60
|
-
out,
|
|
61
|
-
"--define",
|
|
62
|
-
`__INLINE_MANIFEST__=${JSON.stringify(manifest)}`,
|
|
63
|
-
],
|
|
64
|
-
{ stdout: "inherit", stderr: "inherit" },
|
|
65
|
-
);
|
|
66
|
-
await proc.exited;
|
|
67
|
-
if (proc.exitCode !== 0) {
|
|
68
|
-
console.error("bun build failed");
|
|
69
|
-
process.exit(proc.exitCode ?? 1);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const sizeKb = Math.round(readFileSync(out).byteLength / 1024);
|
|
73
|
-
console.log(`Wrote ${out} (${sizeKb} KB)`);
|
|
74
|
-
if (sizeKb > 1024) {
|
|
75
|
-
console.warn(
|
|
76
|
-
`Lambda@Edge has a 1 MB unzipped limit; bundle is ${sizeKb} KB. Strip dependencies before next deploy.`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
writeFileSync(`${out}.manifest-version.txt`, String(manifest.manifestVersion));
|
|
80
|
-
console.log("Next: cd packages/provisioning/stacks/aws && pulumi up");
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
-
//
|
|
3
|
-
// PLACEHOLDER. Replaced at deploy time by `bun run build:edge-aws`
|
|
4
|
-
// which bundles edge-handler.ts + the latest routing-manifest.json
|
|
5
|
-
// into a single Lambda@Edge deployment artifact.
|
|
6
|
-
//
|
|
7
|
-
// Until the build step runs, this stub returns the request unchanged
|
|
8
|
-
// so a fresh `pulumi up` doesn't emit a broken Lambda. Operator MUST
|
|
9
|
-
// run the build step before the first real deploy.
|
|
10
|
-
exports.handler = async (event) => {
|
|
11
|
-
const req = event.Records[0].cf.request;
|
|
12
|
-
console.log(
|
|
13
|
-
JSON.stringify({
|
|
14
|
-
kind: "edge_handler_stub",
|
|
15
|
-
message: "edge-handler-bundle.js is the placeholder; run `bun run build:edge-aws`",
|
|
16
|
-
uri: req.uri,
|
|
17
|
-
}),
|
|
18
|
-
);
|
|
19
|
-
return req;
|
|
20
|
-
};
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Caelo AWS Lambda@Edge handler — viewer-request event.
|
|
5
|
-
*
|
|
6
|
-
* Imports the SAME `routeRequest` from `@caelo-cms/edge-router` that the
|
|
7
|
-
* P13 self-hosted Caddy gateway uses, so the assignment hash is
|
|
8
|
-
* byte-identical across all four runtimes (P13 Caddy + GCP + AWS + Azure).
|
|
9
|
-
* The byte-identity test in packages/edge-router/src/index.test.ts
|
|
10
|
-
* locks the contract.
|
|
11
|
-
*
|
|
12
|
-
* Bundled by `bun run build:edge-aws` (see this stack's README) into
|
|
13
|
-
* `edge-handler-bundle.js` which Pulumi uploads as the Lambda code.
|
|
14
|
-
* The routing manifest is embedded into the bundle at build time —
|
|
15
|
-
* Lambda@Edge has no DB access (no IAM, no network egress to RDS), so
|
|
16
|
-
* a fresh bundle is built + deployed on every routing-manifest bump.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { RoutingManifest } from "@caelo-cms/edge-router";
|
|
20
|
-
import { routeRequest } from "@caelo-cms/edge-router";
|
|
21
|
-
|
|
22
|
-
// Bundled at build time. The build script reads the latest
|
|
23
|
-
// routing-manifest.json from the static-output bucket and inlines it.
|
|
24
|
-
// Empty manifest = pass-through (no experiments active).
|
|
25
|
-
declare const __INLINE_MANIFEST__: RoutingManifest;
|
|
26
|
-
|
|
27
|
-
const MANIFEST: RoutingManifest = __INLINE_MANIFEST__;
|
|
28
|
-
const COOKIE_NAME = "caelo_visitor_id";
|
|
29
|
-
|
|
30
|
-
interface CloudFrontHeaderEntry {
|
|
31
|
-
readonly key: string;
|
|
32
|
-
readonly value: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface CloudFrontRequest {
|
|
36
|
-
uri: string;
|
|
37
|
-
querystring: string;
|
|
38
|
-
headers: Record<string, ReadonlyArray<CloudFrontHeaderEntry>>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface CloudFrontEvent {
|
|
42
|
-
Records: Array<{ cf: { request: CloudFrontRequest } }>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function readCookie(req: CloudFrontRequest, name: string): string | null {
|
|
46
|
-
const raw = req.headers.cookie?.[0]?.value ?? "";
|
|
47
|
-
for (const pair of raw.split(/;\s*/)) {
|
|
48
|
-
const eq = pair.indexOf("=");
|
|
49
|
-
if (eq < 0) continue;
|
|
50
|
-
if (pair.slice(0, eq) === name) return decodeURIComponent(pair.slice(eq + 1));
|
|
51
|
-
}
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export const handler = async (event: CloudFrontEvent): Promise<CloudFrontRequest> => {
|
|
56
|
-
const req = event.Records[0]?.cf.request;
|
|
57
|
-
if (!req) throw new Error("edge-handler: malformed event");
|
|
58
|
-
|
|
59
|
-
const visitorIdCookie = readCookie(req, COOKIE_NAME);
|
|
60
|
-
const decision = routeRequest(MANIFEST, {
|
|
61
|
-
pathname: req.uri,
|
|
62
|
-
visitorIdCookie,
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// Path rewrite — Lambda@Edge mutates request.uri in-place.
|
|
66
|
-
req.uri = decision.rewritePathname;
|
|
67
|
-
|
|
68
|
-
// Set the cookie for the response. L@E viewer-request can't set
|
|
69
|
-
// response headers directly; we have to rely on a complementary
|
|
70
|
-
// viewer-response function, OR (simpler) include the cookie in the
|
|
71
|
-
// forwarded request so the origin (S3 / ECS) can echo it. v1 takes
|
|
72
|
-
// the simpler path: bake the visitor id into a custom header that
|
|
73
|
-
// the static origin's Set-Cookie config picks up. CloudFront's
|
|
74
|
-
// "function" tier supports response-header rewrites without a
|
|
75
|
-
// second Lambda — that's the operator's setup step.
|
|
76
|
-
req.headers["x-caelo-visitor-id"] = [{ key: "X-Caelo-Visitor-Id", value: decision.setVisitorId }];
|
|
77
|
-
|
|
78
|
-
// Emit assignment log via console.log; CloudWatch picks it up,
|
|
79
|
-
// Kinesis Firehose ships to S3, Athena queries it, P12A analytics
|
|
80
|
-
// plugin's AWS adapter normalises into ab_assignment_aggregates.
|
|
81
|
-
if (decision.logEntry) {
|
|
82
|
-
console.log(JSON.stringify(decision.logEntry));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return req;
|
|
86
|
-
};
|
|
@@ -1,87 +0,0 @@
|
|
|
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
|
-
console.log(JSON.stringify(decision.logEntry));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const target = new URL(url.toString());
|
|
79
|
-
target.pathname = decision.rewritePathname;
|
|
80
|
-
const headers = new Headers({
|
|
81
|
-
Location: target.toString(),
|
|
82
|
-
"Cache-Control": "no-store, private",
|
|
83
|
-
"Set-Cookie": `${COOKIE_NAME}=${encodeURIComponent(decision.setVisitorId)}; Path=/; Max-Age=31536000; SameSite=Lax; Secure; HttpOnly`,
|
|
84
|
-
});
|
|
85
|
-
return new Response(null, { status: 307, headers });
|
|
86
|
-
},
|
|
87
|
-
});
|