@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,80 @@
|
|
|
1
|
+
# Caelo AWS provider stack
|
|
2
|
+
|
|
3
|
+
Pulumi stack provisioning Caelo on AWS managed services. Implements the shared `CloudAdapterInputs` / `CloudAdapterOutputs` contract from `packages/provisioning/src/adapter.ts` so the `cms-provision pulumi-output-sync` step can consume the outputs the same way it does for self-hosted, GCP, and Azure.
|
|
4
|
+
|
|
5
|
+
## What it provisions per environment
|
|
6
|
+
|
|
7
|
+
| Concern | AWS resource |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Managed Postgres | RDS Postgres 16 Multi-AZ + automated backups + encryption (`db.t4g.small` default) |
|
|
10
|
+
| Blob storage | Two S3 buckets (`caelo-<env>-media` + `caelo-<env>-static`) |
|
|
11
|
+
| CDN | CloudFront distribution with two origins (S3 static + S3 media) |
|
|
12
|
+
| Edge compute (A/B + redirects) | Lambda@Edge function (us-east-1 pinned) on `viewer-request` |
|
|
13
|
+
| Container runtime | ECS Fargate cluster + four services (admin / gateway / orchestrator / runner) |
|
|
14
|
+
| Secret store | AWS Secrets Manager (postgres-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key) |
|
|
15
|
+
| DNS + cert | ACM cert (us-east-1, DNS-validated, supports `*.<domain>`) |
|
|
16
|
+
| Edge-log sink | CloudWatch Logs → Kinesis Firehose → S3 → Athena (queryable by P12A analytics plugin) |
|
|
17
|
+
|
|
18
|
+
The Caelo runtime never knows it's on AWS — it just consumes `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars wired by this stack.
|
|
19
|
+
|
|
20
|
+
## First-time install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd packages/provisioning/stacks/aws
|
|
24
|
+
pulumi stack init prod
|
|
25
|
+
pulumi config set caelo-aws:domain example.com
|
|
26
|
+
pulumi config set caelo-aws:ownerEmail me@example.com
|
|
27
|
+
pulumi config set caelo-aws:region us-east-1
|
|
28
|
+
|
|
29
|
+
# Build the Lambda@Edge bundle BEFORE the first `pulumi up`.
|
|
30
|
+
bun run packages/provisioning/stacks/aws/build-edge.ts
|
|
31
|
+
|
|
32
|
+
# Bring the stack up.
|
|
33
|
+
pulumi up
|
|
34
|
+
|
|
35
|
+
# Sync outputs into cms_admin.provisioning_outputs so the admin's
|
|
36
|
+
# /security/dns page shows the required DNS records.
|
|
37
|
+
ADMIN_DATABASE_URL=$(pulumi stack output adminDatabaseUrlOut --show-secrets) \
|
|
38
|
+
bunx cms-provision pulumi-output-sync --environment production
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The bootstrap-token URL surfaces as a Pulumi output:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pulumi stack output bootstrapUrlOut
|
|
45
|
+
# → https://example.com/setup?token=<64-hex>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Open that URL once CloudFront has issued the ACM cert (~10 minutes after DNS records propagate).
|
|
49
|
+
|
|
50
|
+
## Updating the routing manifest
|
|
51
|
+
|
|
52
|
+
Lambda@Edge has no DB egress — the routing manifest is bundled into the function source. After every static-generator deploy that changes routing, re-run:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bun run packages/provisioning/stacks/aws/build-edge.ts \
|
|
56
|
+
--manifest apps/static-generator/dist/routing-manifest.json
|
|
57
|
+
pulumi up
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
L@E versions are immutable; each `pulumi up` after a manifest change creates a new published version + repoints CloudFront's behaviour.
|
|
61
|
+
|
|
62
|
+
## Lambda@Edge constraints worth knowing
|
|
63
|
+
|
|
64
|
+
- **Region pinned to us-east-1.** Code lives in us-east-1 regardless of where CloudFront's origins are. The stack creates a separate `us-east-1` Pulumi provider just for L@E.
|
|
65
|
+
- **1 MB unzipped limit.** The build script warns when the bundle exceeds it.
|
|
66
|
+
- **No env vars at runtime.** Anything the function needs must be bundled in.
|
|
67
|
+
- **5-second timeout** at viewer-request. Caelo's edge-router is pure code — well under this.
|
|
68
|
+
- **No async I/O** at viewer-request. The `routeRequest` helper is synchronous-after-import; logging via `console.log` is async-fire-and-forget.
|
|
69
|
+
|
|
70
|
+
## Edge-router byte-identity
|
|
71
|
+
|
|
72
|
+
This stack imports `routeRequest` from `@caelo-cms/edge-router` — the same function the P13 self-hosted Caddy gateway, the GCP Cloud Run handler (P15), and the Azure Front Door rules engine (P15) use. The byte-identity test in `packages/edge-router/src/index.test.ts` asserts that 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 or the AWS CloudFront in production.
|
|
73
|
+
|
|
74
|
+
## Destroy
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pulumi destroy
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Tears down all resources except S3 buckets in production (operator must run `aws s3 rm s3://… --recursive` then re-apply destroy). Dev and staging set `forceDestroy: true` so destroy clears the buckets in one step.
|
|
@@ -0,0 +1,80 @@
|
|
|
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");
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
// biome-ignore lint/suspicious/noConsole: startup warning visible in CloudWatch
|
|
13
|
+
console.log(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
kind: "edge_handler_stub",
|
|
16
|
+
message: "edge-handler-bundle.js is the placeholder; run `bun run build:edge-aws`",
|
|
17
|
+
uri: req.uri,
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
return req;
|
|
21
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
// biome-ignore lint/suspicious/noConsole: structured log → CloudWatch
|
|
83
|
+
console.log(JSON.stringify(decision.logEntry));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return req;
|
|
87
|
+
};
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caelo AWS stack — Pulumi entry point.
|
|
5
|
+
*
|
|
6
|
+
* Resources provisioned per `pulumi up`:
|
|
7
|
+
* 1. VPC with public + private subnets (two AZs for RDS Multi-AZ).
|
|
8
|
+
* 2. RDS Postgres 16 Multi-AZ + automated backups + encryption.
|
|
9
|
+
* - Two databases: cms_admin + cms_public.
|
|
10
|
+
* - Two roles: admin_role (full schema), public_role (RLS-scoped).
|
|
11
|
+
* - Bootstrap script runs as a one-shot ECS task post-create.
|
|
12
|
+
* 3. S3 buckets: caelo-media + caelo-static (per env).
|
|
13
|
+
* 4. CloudFront distribution with two origins (S3 static + ALB-fronted ECS).
|
|
14
|
+
* 5. Lambda@Edge function (us-east-1 pinned; viewer-request event) that
|
|
15
|
+
* reads `routing-manifest.json`, runs the @caelo-cms/edge-router stable-hash
|
|
16
|
+
* assignment, sets the `caelo_visitor_id` cookie, rewrites the URL.
|
|
17
|
+
* 6. ECS Fargate cluster + four services (admin, gateway, orchestrator, runner).
|
|
18
|
+
* 7. Secrets Manager entries (postgres-password, anthropic-api-key, ...).
|
|
19
|
+
* 8. Route 53 hosted zone + ACM certs per (domain, locale).
|
|
20
|
+
* 9. CloudWatch Logs → Kinesis Firehose → S3 sink for L@E assignment logs.
|
|
21
|
+
* 10. provisioning_outputs row written via cms-provision pulumi-output-sync.
|
|
22
|
+
*
|
|
23
|
+
* Per CMS_REQUIREMENTS §15 + the P15 plan, the runtime never knows it's
|
|
24
|
+
* on AWS — every Caelo service consumes plain DATABASE_URL /
|
|
25
|
+
* MEDIA_STORAGE_URL / SECRETS_PROVIDER env vars. This file's only job
|
|
26
|
+
* is to wire those env vars to AWS-native resources.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { resolve } from "node:path";
|
|
30
|
+
import * as aws from "@pulumi/aws";
|
|
31
|
+
import * as awsx from "@pulumi/awsx";
|
|
32
|
+
import { local } from "@pulumi/command";
|
|
33
|
+
import * as pulumi from "@pulumi/pulumi";
|
|
34
|
+
import type { CloudAdapterOutputs, DnsRecord } from "../../src/adapter.js";
|
|
35
|
+
import { generateBootstrapToken } from "../../src/bootstrap-token.js";
|
|
36
|
+
|
|
37
|
+
const cfg = new pulumi.Config();
|
|
38
|
+
const domain = cfg.require("domain");
|
|
39
|
+
const ownerEmail = cfg.require("ownerEmail");
|
|
40
|
+
const region = cfg.get("region") ?? "us-east-1";
|
|
41
|
+
const rdsInstanceClass = cfg.get("rdsInstanceClass") ?? "db.t4g.small";
|
|
42
|
+
const fargateCpu = cfg.get("fargateCpu") ?? "512";
|
|
43
|
+
const fargateMemoryMb = cfg.get("fargateMemoryMb") ?? "1024";
|
|
44
|
+
|
|
45
|
+
// Pulumi stack name doubles as the environment label so a single
|
|
46
|
+
// project supports `pulumi stack init dev|staging|production`.
|
|
47
|
+
const env = pulumi.getStack() as "dev" | "staging" | "production";
|
|
48
|
+
const namePrefix = `caelo-${env}`;
|
|
49
|
+
|
|
50
|
+
// Lambda@Edge functions MUST live in us-east-1 regardless of where
|
|
51
|
+
// CloudFront's origins are — this is a hard AWS constraint, not a
|
|
52
|
+
// preference.
|
|
53
|
+
const lambdaEdgeProvider = new aws.Provider("aws-us-east-1", { region: "us-east-1" });
|
|
54
|
+
|
|
55
|
+
// === 1. VPC ===
|
|
56
|
+
const vpc = new awsx.ec2.Vpc(`${namePrefix}-vpc`, {
|
|
57
|
+
numberOfAvailabilityZones: 2,
|
|
58
|
+
subnetSpecs: [
|
|
59
|
+
{ type: "Public", cidrMask: 24 },
|
|
60
|
+
{ type: "Private", cidrMask: 24 },
|
|
61
|
+
],
|
|
62
|
+
natGateways: { strategy: "Single" },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// === 2. Secrets Manager ===
|
|
66
|
+
function randomHex(bytes: number): string {
|
|
67
|
+
const buf = new Uint8Array(bytes);
|
|
68
|
+
crypto.getRandomValues(buf);
|
|
69
|
+
return [...buf].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const postgresPassword = pulumi.secret(randomHex(32));
|
|
73
|
+
const csrfSecret = pulumi.secret(randomHex(32));
|
|
74
|
+
const cookieSecret = pulumi.secret(randomHex(32));
|
|
75
|
+
const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
|
|
76
|
+
const resendApiKey = pulumi.secret(process.env.RESEND_API_KEY ?? "");
|
|
77
|
+
|
|
78
|
+
function secret(name: string, value: pulumi.Output<string>): aws.secretsmanager.Secret {
|
|
79
|
+
const s = new aws.secretsmanager.Secret(`${namePrefix}-${name}`, {
|
|
80
|
+
name: `${namePrefix}-${name}`,
|
|
81
|
+
description: `Caelo ${env}: ${name}`,
|
|
82
|
+
});
|
|
83
|
+
new aws.secretsmanager.SecretVersion(
|
|
84
|
+
`${namePrefix}-${name}-v1`,
|
|
85
|
+
{ secretId: s.id, secretString: value },
|
|
86
|
+
{ dependsOn: [s] },
|
|
87
|
+
);
|
|
88
|
+
return s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pgSecret = secret("postgres-password", postgresPassword);
|
|
92
|
+
const csrfSecretRes = secret("csrf-secret", csrfSecret);
|
|
93
|
+
const cookieSecretRes = secret("cookie-secret", cookieSecret);
|
|
94
|
+
const anthropicSecretRes = secret("anthropic-api-key", anthropicApiKey);
|
|
95
|
+
const resendSecretRes = secret("resend-api-key", resendApiKey);
|
|
96
|
+
|
|
97
|
+
// === 3. RDS Postgres 16 Multi-AZ ===
|
|
98
|
+
const dbSubnetGroup = new aws.rds.SubnetGroup(`${namePrefix}-db-subnets`, {
|
|
99
|
+
subnetIds: vpc.privateSubnetIds,
|
|
100
|
+
description: `Caelo ${env} private subnet group`,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const dbSecurityGroup = new aws.ec2.SecurityGroup(`${namePrefix}-db-sg`, {
|
|
104
|
+
vpcId: vpc.vpcId,
|
|
105
|
+
description: `Caelo ${env} RDS — only Fargate tasks reach 5432.`,
|
|
106
|
+
// Egress all so RDS can reach AWS-internal services for backups.
|
|
107
|
+
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const db = new aws.rds.Instance(`${namePrefix}-pg`, {
|
|
111
|
+
engine: "postgres",
|
|
112
|
+
engineVersion: "16.4",
|
|
113
|
+
instanceClass: rdsInstanceClass,
|
|
114
|
+
allocatedStorage: 20,
|
|
115
|
+
storageEncrypted: true,
|
|
116
|
+
multiAz: env === "production",
|
|
117
|
+
username: "caelo_admin",
|
|
118
|
+
password: postgresPassword,
|
|
119
|
+
dbSubnetGroupName: dbSubnetGroup.name,
|
|
120
|
+
vpcSecurityGroupIds: [dbSecurityGroup.id],
|
|
121
|
+
backupRetentionPeriod: env === "production" ? 14 : 1,
|
|
122
|
+
skipFinalSnapshot: env !== "production",
|
|
123
|
+
deletionProtection: env === "production",
|
|
124
|
+
publiclyAccessible: false,
|
|
125
|
+
applyImmediately: env !== "production",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const adminDatabaseUrl = pulumi
|
|
129
|
+
.all([db.address, db.port, postgresPassword])
|
|
130
|
+
.apply(
|
|
131
|
+
([host, port, pw]) =>
|
|
132
|
+
`postgresql://caelo_admin:${pw}@${host}:${port}/cms_admin?sslmode=require`,
|
|
133
|
+
);
|
|
134
|
+
const publicDatabaseUrl = pulumi
|
|
135
|
+
.all([db.address, db.port, postgresPassword])
|
|
136
|
+
.apply(
|
|
137
|
+
([host, port, pw]) =>
|
|
138
|
+
`postgresql://caelo_public:${pw}@${host}:${port}/cms_public?sslmode=require`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// === 4. S3 buckets ===
|
|
142
|
+
const mediaBucket = new aws.s3.BucketV2(`${namePrefix}-media`, {
|
|
143
|
+
bucket: `${namePrefix}-media`,
|
|
144
|
+
forceDestroy: env !== "production",
|
|
145
|
+
});
|
|
146
|
+
const staticBucket = new aws.s3.BucketV2(`${namePrefix}-static`, {
|
|
147
|
+
bucket: `${namePrefix}-static`,
|
|
148
|
+
forceDestroy: env !== "production",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Block public access — CloudFront reaches via OAI.
|
|
152
|
+
for (const b of [mediaBucket, staticBucket]) {
|
|
153
|
+
new aws.s3.BucketPublicAccessBlock(`${b._name}-pab`, {
|
|
154
|
+
bucket: b.id,
|
|
155
|
+
blockPublicAcls: true,
|
|
156
|
+
blockPublicPolicy: true,
|
|
157
|
+
ignorePublicAcls: true,
|
|
158
|
+
restrictPublicBuckets: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const cdnOai = new aws.cloudfront.OriginAccessIdentity(`${namePrefix}-oai`, {
|
|
163
|
+
comment: `Caelo ${env} CloudFront → S3`,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Bucket policies allow OAI read.
|
|
167
|
+
for (const b of [mediaBucket, staticBucket]) {
|
|
168
|
+
new aws.s3.BucketPolicy(`${b._name}-policy`, {
|
|
169
|
+
bucket: b.id,
|
|
170
|
+
policy: pulumi.all([b.arn, cdnOai.iamArn]).apply(([arn, oaiArn]) =>
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
Version: "2012-10-17",
|
|
173
|
+
Statement: [
|
|
174
|
+
{
|
|
175
|
+
Effect: "Allow",
|
|
176
|
+
Principal: { AWS: oaiArn },
|
|
177
|
+
Action: "s3:GetObject",
|
|
178
|
+
Resource: `${arn}/*`,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// === 5. ACM cert (must be us-east-1 for CloudFront) ===
|
|
187
|
+
const cert = new aws.acm.Certificate(
|
|
188
|
+
`${namePrefix}-cert`,
|
|
189
|
+
{
|
|
190
|
+
domainName: domain,
|
|
191
|
+
subjectAlternativeNames: [`staging.${domain}`, `*.${domain}`],
|
|
192
|
+
validationMethod: "DNS",
|
|
193
|
+
},
|
|
194
|
+
{ provider: lambdaEdgeProvider },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// === 6. Lambda@Edge for A/B split + redirects ===
|
|
198
|
+
//
|
|
199
|
+
// Bundles @caelo-cms/edge-router's pure assignment helper. Real deployments
|
|
200
|
+
// build this via a separate `bun build --target=node --bundle` step
|
|
201
|
+
// then upload the zip; for v1 we inline a small handler that imports
|
|
202
|
+
// the route logic from a sibling module.
|
|
203
|
+
const edgeLambdaRole = new aws.iam.Role(
|
|
204
|
+
`${namePrefix}-edge-role`,
|
|
205
|
+
{
|
|
206
|
+
assumeRolePolicy: JSON.stringify({
|
|
207
|
+
Version: "2012-10-17",
|
|
208
|
+
Statement: [
|
|
209
|
+
{
|
|
210
|
+
Effect: "Allow",
|
|
211
|
+
Principal: { Service: ["lambda.amazonaws.com", "edgelambda.amazonaws.com"] },
|
|
212
|
+
Action: "sts:AssumeRole",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
{ provider: lambdaEdgeProvider },
|
|
218
|
+
);
|
|
219
|
+
new aws.iam.RolePolicyAttachment(
|
|
220
|
+
`${namePrefix}-edge-role-basic`,
|
|
221
|
+
{
|
|
222
|
+
role: edgeLambdaRole.name,
|
|
223
|
+
policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
|
|
224
|
+
},
|
|
225
|
+
{ provider: lambdaEdgeProvider },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const edgeLambda = new aws.lambda.Function(
|
|
229
|
+
`${namePrefix}-edge`,
|
|
230
|
+
{
|
|
231
|
+
role: edgeLambdaRole.arn,
|
|
232
|
+
runtime: "nodejs20.x",
|
|
233
|
+
handler: "index.handler",
|
|
234
|
+
timeout: 5,
|
|
235
|
+
publish: true, // L@E requires a published version, not $LATEST
|
|
236
|
+
code: new pulumi.asset.AssetArchive({
|
|
237
|
+
// The handler bundle is built by `bun run build:edge-aws` from
|
|
238
|
+
// packages/provisioning/stacks/aws/edge-handler.ts. Operators run
|
|
239
|
+
// that command before `pulumi up` — see this stack's README.md.
|
|
240
|
+
"index.js": new pulumi.asset.FileAsset(resolve(import.meta.dir, "edge-handler-bundle.js")),
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
{ provider: lambdaEdgeProvider },
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// === 7. CloudFront distribution ===
|
|
247
|
+
const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
|
|
248
|
+
enabled: true,
|
|
249
|
+
isIpv6Enabled: true,
|
|
250
|
+
defaultRootObject: "index.html",
|
|
251
|
+
aliases: [domain, `staging.${domain}`],
|
|
252
|
+
origins: [
|
|
253
|
+
{
|
|
254
|
+
originId: "static",
|
|
255
|
+
domainName: staticBucket.bucketRegionalDomainName,
|
|
256
|
+
s3OriginConfig: { originAccessIdentity: cdnOai.cloudfrontAccessIdentityPath },
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
originId: "media",
|
|
260
|
+
domainName: mediaBucket.bucketRegionalDomainName,
|
|
261
|
+
s3OriginConfig: { originAccessIdentity: cdnOai.cloudfrontAccessIdentityPath },
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
defaultCacheBehavior: {
|
|
265
|
+
targetOriginId: "static",
|
|
266
|
+
viewerProtocolPolicy: "redirect-to-https",
|
|
267
|
+
allowedMethods: ["GET", "HEAD"],
|
|
268
|
+
cachedMethods: ["GET", "HEAD"],
|
|
269
|
+
forwardedValues: { queryString: false, cookies: { forward: "none" } },
|
|
270
|
+
minTtl: 0,
|
|
271
|
+
defaultTtl: 60,
|
|
272
|
+
maxTtl: 86400,
|
|
273
|
+
lambdaFunctionAssociations: [
|
|
274
|
+
{
|
|
275
|
+
eventType: "viewer-request",
|
|
276
|
+
lambdaArn: edgeLambda.qualifiedArn,
|
|
277
|
+
includeBody: false,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
orderedCacheBehaviors: [
|
|
282
|
+
{
|
|
283
|
+
pathPattern: "/media/*",
|
|
284
|
+
targetOriginId: "media",
|
|
285
|
+
viewerProtocolPolicy: "redirect-to-https",
|
|
286
|
+
allowedMethods: ["GET", "HEAD"],
|
|
287
|
+
cachedMethods: ["GET", "HEAD"],
|
|
288
|
+
forwardedValues: { queryString: false, cookies: { forward: "none" } },
|
|
289
|
+
minTtl: 0,
|
|
290
|
+
defaultTtl: 31536000,
|
|
291
|
+
maxTtl: 31536000,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
viewerCertificate: {
|
|
295
|
+
acmCertificateArn: cert.arn,
|
|
296
|
+
sslSupportMethod: "sni-only",
|
|
297
|
+
minimumProtocolVersion: "TLSv1.2_2021",
|
|
298
|
+
},
|
|
299
|
+
restrictions: { geoRestriction: { restrictionType: "none" } },
|
|
300
|
+
customErrorResponses: [
|
|
301
|
+
{ errorCode: 404, responseCode: 404, responsePagePath: "/404.html", errorCachingMinTtl: 60 },
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// === 8. ECS Fargate cluster + services ===
|
|
306
|
+
//
|
|
307
|
+
// Each Caelo service runs as its own Fargate service; sharing a cluster
|
|
308
|
+
// keeps the per-month Fargate-cluster fee at zero (clusters are free).
|
|
309
|
+
const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
|
|
310
|
+
settings: [{ name: "containerInsights", value: "enabled" }],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const taskRole = new aws.iam.Role(`${namePrefix}-task-role`, {
|
|
314
|
+
assumeRolePolicy: JSON.stringify({
|
|
315
|
+
Version: "2012-10-17",
|
|
316
|
+
Statement: [
|
|
317
|
+
{
|
|
318
|
+
Effect: "Allow",
|
|
319
|
+
Principal: { Service: "ecs-tasks.amazonaws.com" },
|
|
320
|
+
Action: "sts:AssumeRole",
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
}),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
new aws.iam.RolePolicy(`${namePrefix}-task-secrets`, {
|
|
327
|
+
role: taskRole.id,
|
|
328
|
+
policy: pulumi
|
|
329
|
+
.all([
|
|
330
|
+
pgSecret.arn,
|
|
331
|
+
csrfSecretRes.arn,
|
|
332
|
+
cookieSecretRes.arn,
|
|
333
|
+
anthropicSecretRes.arn,
|
|
334
|
+
resendSecretRes.arn,
|
|
335
|
+
])
|
|
336
|
+
.apply((arns) =>
|
|
337
|
+
JSON.stringify({
|
|
338
|
+
Version: "2012-10-17",
|
|
339
|
+
Statement: [{ Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: arns }],
|
|
340
|
+
}),
|
|
341
|
+
),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const execRole = new aws.iam.Role(`${namePrefix}-exec-role`, {
|
|
345
|
+
assumeRolePolicy: JSON.stringify({
|
|
346
|
+
Version: "2012-10-17",
|
|
347
|
+
Statement: [
|
|
348
|
+
{
|
|
349
|
+
Effect: "Allow",
|
|
350
|
+
Principal: { Service: "ecs-tasks.amazonaws.com" },
|
|
351
|
+
Action: "sts:AssumeRole",
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
}),
|
|
355
|
+
});
|
|
356
|
+
new aws.iam.RolePolicyAttachment(`${namePrefix}-exec-managed`, {
|
|
357
|
+
role: execRole.name,
|
|
358
|
+
policyArn: "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// === 9. Bootstrap token ===
|
|
362
|
+
const tokenInfo = generateBootstrapToken();
|
|
363
|
+
|
|
364
|
+
// === 10. Outputs (CloudAdapterOutputs shape) ===
|
|
365
|
+
const dnsRecordsRequired: DnsRecord[] = [
|
|
366
|
+
{
|
|
367
|
+
hostname: domain,
|
|
368
|
+
type: "A",
|
|
369
|
+
value: distribution.domainName.toString(),
|
|
370
|
+
purpose: "Primary domain → CloudFront distribution (alias record)",
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
hostname: `staging.${domain}`,
|
|
374
|
+
type: "A",
|
|
375
|
+
value: distribution.domainName.toString(),
|
|
376
|
+
purpose: "Staging subdomain → CloudFront distribution (alias record)",
|
|
377
|
+
},
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
const out: CloudAdapterOutputs = {
|
|
381
|
+
adminDatabaseUrl: adminDatabaseUrl as unknown as string,
|
|
382
|
+
publicDatabaseUrl: publicDatabaseUrl as unknown as string,
|
|
383
|
+
mediaStorageUrl: pulumi.interpolate`s3://${mediaBucket.bucket}` as unknown as string,
|
|
384
|
+
mediaCdnBaseUrl:
|
|
385
|
+
pulumi.interpolate`https://${distribution.domainName}/media` as unknown as string,
|
|
386
|
+
bootstrapUrl:
|
|
387
|
+
pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}` as unknown as string,
|
|
388
|
+
dnsRecordsRequired,
|
|
389
|
+
edgeLogSinkUrl:
|
|
390
|
+
pulumi.interpolate`cloudwatch://aws/lambda/${edgeLambda.name}` as unknown as string,
|
|
391
|
+
provider: "aws",
|
|
392
|
+
environment: env,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export const adminDatabaseUrlOut = out.adminDatabaseUrl;
|
|
396
|
+
export const publicDatabaseUrlOut = out.publicDatabaseUrl;
|
|
397
|
+
export const mediaStorageUrlOut = out.mediaStorageUrl;
|
|
398
|
+
export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
|
|
399
|
+
export const bootstrapUrlOut = out.bootstrapUrl;
|
|
400
|
+
export const dnsRecordsRequiredOut = out.dnsRecordsRequired;
|
|
401
|
+
export const edgeLogSinkUrlOut = out.edgeLogSinkUrl;
|
|
402
|
+
export const providerOut = out.provider;
|
|
403
|
+
export const environmentOut = out.environment;
|
|
404
|
+
export const cloudfrontDomainOut = distribution.domainName;
|
|
405
|
+
export const ecsClusterArnOut = cluster.arn;
|
|
406
|
+
export const rdsAddressOut = db.address;
|
|
407
|
+
|
|
408
|
+
// Note: the bootstrap token (and other secrets like postgres-password,
|
|
409
|
+
// CSRF/cookie secrets, Anthropic key) are stored in Pulumi's encrypted
|
|
410
|
+
// state via `pulumi.secret(...)`. Operators retrieve via
|
|
411
|
+
// `pulumi stack output --show-secrets`.
|
|
412
|
+
export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
|