@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
package/src/compose.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P14 — production docker-compose.yml generator.
|
|
5
|
+
*
|
|
6
|
+
* Emits the production stack: Postgres + pgBackRest sidecar + Caddy +
|
|
7
|
+
* MinIO + four Caelo services (admin, gateway, orchestrator, runner).
|
|
8
|
+
* Idempotent: same input → byte-identical output. The CLI's `up`
|
|
9
|
+
* sub-command writes this to `<repo>/.caelo/docker-compose.yml` and
|
|
10
|
+
* runs `docker compose up -d`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ComposeSpec {
|
|
14
|
+
readonly domain: string;
|
|
15
|
+
readonly postgresPassword: string; // generated by cms-provision; persisted in .caelo/secrets
|
|
16
|
+
readonly minioRootUser: string;
|
|
17
|
+
readonly minioRootPassword: string;
|
|
18
|
+
readonly anthropicApiKey?: string;
|
|
19
|
+
readonly resendApiKey?: string;
|
|
20
|
+
readonly diskSize: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateDockerCompose(spec: ComposeSpec): string {
|
|
24
|
+
const env = (k: string, v: string | undefined): string => (v ? ` ${k}: "${escape(v)}"` : "");
|
|
25
|
+
return `# SPDX-License-Identifier: MPL-2.0
|
|
26
|
+
# Generated by cms-provision — do not edit; re-run \`bunx cms-provision up\`.
|
|
27
|
+
# Domain: ${spec.domain}
|
|
28
|
+
|
|
29
|
+
services:
|
|
30
|
+
postgres:
|
|
31
|
+
image: postgres:16-alpine
|
|
32
|
+
container_name: caelo-postgres
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
environment:
|
|
35
|
+
POSTGRES_PASSWORD: "${escape(spec.postgresPassword)}"
|
|
36
|
+
POSTGRES_USER: caelo
|
|
37
|
+
POSTGRES_DB: caelo
|
|
38
|
+
volumes:
|
|
39
|
+
- caelo-pg:/var/lib/postgresql/data
|
|
40
|
+
- ./bootstrap.sh:/docker-entrypoint-initdb.d/00_bootstrap.sh:ro
|
|
41
|
+
healthcheck:
|
|
42
|
+
test: ["CMD-SHELL", "pg_isready -U caelo"]
|
|
43
|
+
interval: 10s
|
|
44
|
+
timeout: 5s
|
|
45
|
+
retries: 5
|
|
46
|
+
|
|
47
|
+
pgbackrest:
|
|
48
|
+
image: pgbackrest/pgbackrest:latest
|
|
49
|
+
container_name: caelo-pgbackrest
|
|
50
|
+
restart: unless-stopped
|
|
51
|
+
depends_on: [postgres]
|
|
52
|
+
volumes:
|
|
53
|
+
- caelo-pg-backups:/var/lib/pgbackrest
|
|
54
|
+
- ./pgbackrest.conf:/etc/pgbackrest.conf:ro
|
|
55
|
+
|
|
56
|
+
minio:
|
|
57
|
+
image: minio/minio:latest
|
|
58
|
+
container_name: caelo-minio
|
|
59
|
+
restart: unless-stopped
|
|
60
|
+
command: server /data --console-address ":9001"
|
|
61
|
+
environment:
|
|
62
|
+
MINIO_ROOT_USER: "${escape(spec.minioRootUser)}"
|
|
63
|
+
MINIO_ROOT_PASSWORD: "${escape(spec.minioRootPassword)}"
|
|
64
|
+
volumes:
|
|
65
|
+
- caelo-minio:/data
|
|
66
|
+
|
|
67
|
+
caddy:
|
|
68
|
+
image: caddy:2-alpine
|
|
69
|
+
container_name: caelo-caddy
|
|
70
|
+
restart: unless-stopped
|
|
71
|
+
ports: ["80:80", "443:443"]
|
|
72
|
+
volumes:
|
|
73
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
74
|
+
- caelo-caddy-data:/data
|
|
75
|
+
- caelo-caddy-config:/config
|
|
76
|
+
- ./output:/srv/caelo:ro
|
|
77
|
+
|
|
78
|
+
caelo-admin:
|
|
79
|
+
image: oven/bun:1.3
|
|
80
|
+
container_name: caelo-admin
|
|
81
|
+
restart: unless-stopped
|
|
82
|
+
depends_on:
|
|
83
|
+
postgres: { condition: service_healthy }
|
|
84
|
+
working_dir: /app
|
|
85
|
+
volumes:
|
|
86
|
+
- ../..:/app
|
|
87
|
+
command: bun run --filter @caelo-cms/admin start
|
|
88
|
+
environment:
|
|
89
|
+
ADMIN_DATABASE_URL: "postgres://caelo:${escape(spec.postgresPassword)}@postgres:5432/cms_admin"
|
|
90
|
+
PUBLIC_ADMIN_DATABASE_URL: "postgres://caelo:${escape(spec.postgresPassword)}@postgres:5432/cms_public"
|
|
91
|
+
NODE_ENV: production
|
|
92
|
+
${env("ANTHROPIC_API_KEY", spec.anthropicApiKey)}
|
|
93
|
+
${env("RESEND_API_KEY", spec.resendApiKey)}
|
|
94
|
+
|
|
95
|
+
caelo-gateway:
|
|
96
|
+
image: oven/bun:1.3
|
|
97
|
+
container_name: caelo-gateway
|
|
98
|
+
restart: unless-stopped
|
|
99
|
+
depends_on:
|
|
100
|
+
postgres: { condition: service_healthy }
|
|
101
|
+
working_dir: /app
|
|
102
|
+
volumes:
|
|
103
|
+
- ../..:/app
|
|
104
|
+
command: bun run apps/api-gateway/src/server.ts
|
|
105
|
+
environment:
|
|
106
|
+
ADMIN_DATABASE_URL: "postgres://caelo:${escape(spec.postgresPassword)}@postgres:5432/cms_admin"
|
|
107
|
+
PUBLIC_DATABASE_URL: "postgres://caelo:${escape(spec.postgresPassword)}@postgres:5432/cms_public"
|
|
108
|
+
GATEWAY_PORT: "8090"
|
|
109
|
+
NODE_ENV: production
|
|
110
|
+
|
|
111
|
+
volumes:
|
|
112
|
+
caelo-pg:
|
|
113
|
+
caelo-pg-backups:
|
|
114
|
+
caelo-minio:
|
|
115
|
+
caelo-caddy-data:
|
|
116
|
+
caelo-caddy-config:
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function escape(s: string): string {
|
|
121
|
+
// Conservative escape for double-quoted YAML strings.
|
|
122
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
123
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { generateBootstrapToken } from "./bootstrap-token.js";
|
|
5
|
+
import { generateCaddyfile } from "./caddy.js";
|
|
6
|
+
import { loadCdnCopyAdapter, selfHostedCdnCopy } from "./cdn-copy.js";
|
|
7
|
+
import { generateDockerCompose } from "./compose.js";
|
|
8
|
+
import {
|
|
9
|
+
emitRedirectsAzureFrontDoor,
|
|
10
|
+
emitRedirectsCloudFront,
|
|
11
|
+
emitRedirectsCloudflare,
|
|
12
|
+
type RedirectRow,
|
|
13
|
+
} from "./redirects-emit.js";
|
|
14
|
+
|
|
15
|
+
describe("Caddyfile generator", () => {
|
|
16
|
+
it("emits a vhost per domain + email block", () => {
|
|
17
|
+
const out = generateCaddyfile({
|
|
18
|
+
ownerEmail: "owner@example.com",
|
|
19
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
20
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
21
|
+
adminPort: 5173,
|
|
22
|
+
gatewayPort: 8090,
|
|
23
|
+
domains: [
|
|
24
|
+
{ hostname: "example.com", kind: "public", env: "production" },
|
|
25
|
+
{ hostname: "staging.example.com", kind: "public", env: "staging" },
|
|
26
|
+
{ hostname: "admin.example.com", kind: "admin", env: "production" },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
expect(out).toContain("email owner@example.com");
|
|
30
|
+
expect(out).toContain("example.com {");
|
|
31
|
+
expect(out).toContain("staging.example.com {");
|
|
32
|
+
expect(out).toContain("admin.example.com {");
|
|
33
|
+
// Staging gets noindex.
|
|
34
|
+
const stagingBlock = out.match(/staging\.example\.com \{[^]*?\n\}/)?.[0] ?? "";
|
|
35
|
+
expect(stagingBlock).toContain('X-Robots-Tag "noindex"');
|
|
36
|
+
// Production does NOT get noindex.
|
|
37
|
+
const productionBlock = out.match(/(?<!staging\.|admin\.)example\.com \{[^]*?\n\}/)?.[0] ?? "";
|
|
38
|
+
expect(productionBlock).not.toContain('X-Robots-Tag "noindex"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to a localhost dev block when no domains configured", () => {
|
|
42
|
+
const out = generateCaddyfile({
|
|
43
|
+
ownerEmail: "x@y.z",
|
|
44
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
45
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
46
|
+
adminPort: 5173,
|
|
47
|
+
gatewayPort: 8090,
|
|
48
|
+
domains: [],
|
|
49
|
+
});
|
|
50
|
+
expect(out).toContain(":8081 {");
|
|
51
|
+
expect(out).toContain("reverse_proxy localhost:5173");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("scopes locale-public vhosts to the per-locale dist subdir", () => {
|
|
55
|
+
const out = generateCaddyfile({
|
|
56
|
+
ownerEmail: "x@y.z",
|
|
57
|
+
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
58
|
+
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
59
|
+
adminPort: 5173,
|
|
60
|
+
gatewayPort: 8090,
|
|
61
|
+
domains: [
|
|
62
|
+
{ hostname: "de.example.com", kind: "locale-public", localeCode: "de", env: "production" },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
expect(out).toContain("root * /srv/caelo/output/production/current/de");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("docker-compose generator", () => {
|
|
70
|
+
it("emits postgres + caddy + caelo services with the supplied secrets", () => {
|
|
71
|
+
const out = generateDockerCompose({
|
|
72
|
+
domain: "example.com",
|
|
73
|
+
postgresPassword: "supersecret",
|
|
74
|
+
minioRootUser: "caelo",
|
|
75
|
+
minioRootPassword: "miniopass",
|
|
76
|
+
anthropicApiKey: "sk-ant-…",
|
|
77
|
+
diskSize: "20Gi",
|
|
78
|
+
});
|
|
79
|
+
expect(out).toContain("caelo-postgres");
|
|
80
|
+
expect(out).toContain("caelo-caddy");
|
|
81
|
+
expect(out).toContain("caelo-admin");
|
|
82
|
+
expect(out).toContain("caelo-gateway");
|
|
83
|
+
expect(out).toContain("supersecret");
|
|
84
|
+
expect(out).toContain("miniopass");
|
|
85
|
+
expect(out).toContain("ANTHROPIC_API_KEY");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("escapes secrets containing quotes safely", () => {
|
|
89
|
+
const out = generateDockerCompose({
|
|
90
|
+
domain: "example.com",
|
|
91
|
+
postgresPassword: 'pa"ss',
|
|
92
|
+
minioRootUser: "caelo",
|
|
93
|
+
minioRootPassword: "x",
|
|
94
|
+
diskSize: "1Gi",
|
|
95
|
+
});
|
|
96
|
+
expect(out).toContain('pa\\"ss');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("bootstrap token generator", () => {
|
|
101
|
+
it("emits 64 hex chars + ~24h expiry", () => {
|
|
102
|
+
const t = generateBootstrapToken();
|
|
103
|
+
expect(t.token).toMatch(/^[0-9a-f]{64}$/);
|
|
104
|
+
const ttlHours = (Date.parse(t.expiresAt) - Date.now()) / 3600_000;
|
|
105
|
+
expect(ttlHours).toBeGreaterThan(23);
|
|
106
|
+
expect(ttlHours).toBeLessThan(25);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("each call returns a unique token", () => {
|
|
110
|
+
const a = generateBootstrapToken().token;
|
|
111
|
+
const b = generateBootstrapToken().token;
|
|
112
|
+
expect(a).not.toBe(b);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("redirects-emit", () => {
|
|
117
|
+
const rows: RedirectRow[] = [
|
|
118
|
+
{ fromPath: "/old", toPath: "/new", statusCode: 301 },
|
|
119
|
+
{ fromPath: "/blog/2024", toPath: "/blog", statusCode: 302 },
|
|
120
|
+
{ fromPath: "/legacy", toPath: "/modern", statusCode: 308 },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
describe("emitRedirectsCloudflare", () => {
|
|
124
|
+
it("emits one line per row in `<from> <to> <status>` format", () => {
|
|
125
|
+
const out = emitRedirectsCloudflare(rows);
|
|
126
|
+
expect(out).toContain("/old /new 301\n");
|
|
127
|
+
expect(out).toContain("/blog/2024 /blog 302\n");
|
|
128
|
+
expect(out).toContain("/legacy /modern 308\n");
|
|
129
|
+
expect(out.startsWith("# Generated by @caelo-cms/provisioning")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("empty rows → header only, no trailing newline noise", () => {
|
|
133
|
+
const out = emitRedirectsCloudflare([]);
|
|
134
|
+
expect(out).toBe("# Generated by @caelo-cms/provisioning — do not edit by hand.\n");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("is byte-identical for the same input (deterministic)", () => {
|
|
138
|
+
expect(emitRedirectsCloudflare(rows)).toBe(emitRedirectsCloudflare([...rows]));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("emitRedirectsCloudFront", () => {
|
|
143
|
+
it("returns parseable JSON config + a Lambda source that consumes it", () => {
|
|
144
|
+
const out = emitRedirectsCloudFront(rows);
|
|
145
|
+
const parsed = JSON.parse(out.jsonConfig) as {
|
|
146
|
+
redirects: Array<{ from: string; to: string; status: number }>;
|
|
147
|
+
};
|
|
148
|
+
expect(parsed.redirects).toHaveLength(3);
|
|
149
|
+
expect(parsed.redirects[0]).toEqual({ from: "/old", to: "/new", status: 301 });
|
|
150
|
+
expect(out.lambdaSource).toContain("exports.handler");
|
|
151
|
+
expect(out.lambdaSource).toContain('require("./redirects.json")');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("preserves status codes verbatim (301/302/307/308)", () => {
|
|
155
|
+
const allStatuses: RedirectRow[] = [
|
|
156
|
+
{ fromPath: "/a", toPath: "/x", statusCode: 301 },
|
|
157
|
+
{ fromPath: "/b", toPath: "/x", statusCode: 302 },
|
|
158
|
+
{ fromPath: "/c", toPath: "/x", statusCode: 307 },
|
|
159
|
+
{ fromPath: "/d", toPath: "/x", statusCode: 308 },
|
|
160
|
+
];
|
|
161
|
+
const out = emitRedirectsCloudFront(allStatuses);
|
|
162
|
+
const parsed = JSON.parse(out.jsonConfig) as {
|
|
163
|
+
redirects: Array<{ status: number }>;
|
|
164
|
+
};
|
|
165
|
+
expect(parsed.redirects.map((r) => r.status)).toEqual([301, 302, 307, 308]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("emitRedirectsAzureFrontDoor", () => {
|
|
170
|
+
it("emits one rule per row with monotonically-increasing order", () => {
|
|
171
|
+
const out = emitRedirectsAzureFrontDoor(rows);
|
|
172
|
+
expect(out).toHaveLength(3);
|
|
173
|
+
expect(out[0]?.name).toBe("redirect-1");
|
|
174
|
+
expect(out[0]?.order).toBe(1);
|
|
175
|
+
expect(out[2]?.order).toBe(3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("maps status codes to Front Door redirectType vocabulary", () => {
|
|
179
|
+
const out = emitRedirectsAzureFrontDoor([
|
|
180
|
+
{ fromPath: "/a", toPath: "/x", statusCode: 301 },
|
|
181
|
+
{ fromPath: "/b", toPath: "/x", statusCode: 302 },
|
|
182
|
+
{ fromPath: "/c", toPath: "/x", statusCode: 307 },
|
|
183
|
+
{ fromPath: "/d", toPath: "/x", statusCode: 308 },
|
|
184
|
+
]);
|
|
185
|
+
expect(out.map((r) => r.actions[0]?.parameters.redirectType)).toEqual([
|
|
186
|
+
"Moved",
|
|
187
|
+
"Found",
|
|
188
|
+
"TemporaryRedirect",
|
|
189
|
+
"PermanentRedirect",
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("RequestUri condition matches the from path exactly", () => {
|
|
194
|
+
const out = emitRedirectsAzureFrontDoor(rows);
|
|
195
|
+
expect(out[1]?.conditions[0]?.parameters.matchValues).toEqual(["/blog/2024"]);
|
|
196
|
+
expect(out[1]?.actions[0]?.parameters.destinationPath).toBe("/blog");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("path-validation guard (review-pass #9)", () => {
|
|
201
|
+
it("Cloudflare emitter rejects fromPath with whitespace", () => {
|
|
202
|
+
expect(() =>
|
|
203
|
+
emitRedirectsCloudflare([{ fromPath: "/old path", toPath: "/new", statusCode: 301 }]),
|
|
204
|
+
).toThrow(/contains whitespace/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("CloudFront emitter rejects toPath with newline", () => {
|
|
208
|
+
expect(() =>
|
|
209
|
+
emitRedirectsCloudFront([{ fromPath: "/old", toPath: "/new\nlies", statusCode: 301 }]),
|
|
210
|
+
).toThrow(/contains whitespace/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("Azure emitter rejects tab in path", () => {
|
|
214
|
+
expect(() =>
|
|
215
|
+
emitRedirectsAzureFrontDoor([{ fromPath: "/old\there", toPath: "/x", statusCode: 301 }]),
|
|
216
|
+
).toThrow(/contains whitespace/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("hyphens, dots, slashes, unicode in path segments are accepted", () => {
|
|
220
|
+
// Regression guard for the original buggy regex `[\s -]` which
|
|
221
|
+
// rejected hyphens.
|
|
222
|
+
expect(() =>
|
|
223
|
+
emitRedirectsCloudflare([
|
|
224
|
+
{ fromPath: "/blog/spring-launch", toPath: "/blog/sommer-2026", statusCode: 301 },
|
|
225
|
+
]),
|
|
226
|
+
).not.toThrow();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("cdn-copy adapter loader", () => {
|
|
232
|
+
it("returns the no-op self-hosted adapter when provider is unset/empty/self-hosted", async () => {
|
|
233
|
+
expect(await loadCdnCopyAdapter(undefined)).toBe(selfHostedCdnCopy);
|
|
234
|
+
expect(await loadCdnCopyAdapter("")).toBe(selfHostedCdnCopy);
|
|
235
|
+
expect(await loadCdnCopyAdapter("self-hosted")).toBe(selfHostedCdnCopy);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("self-hosted adapter pin returns a /media/<key> URL", async () => {
|
|
239
|
+
const url = await selfHostedCdnCopy.pin("photos/2026/april/sunset.webp");
|
|
240
|
+
expect(url).toBe("/media/photos/2026/april/sunset.webp");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("rejects unknown provider names loudly (no silent fallback per CLAUDE.md §2)", async () => {
|
|
244
|
+
await expect(loadCdnCopyAdapter("digitalocean")).rejects.toThrow(/unknown CAELO_PROVIDER/);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @caelo-cms/provisioning — P14.
|
|
5
|
+
*
|
|
6
|
+
* Pulumi-driven self-hosted stack + cms-provision CLI helpers.
|
|
7
|
+
*
|
|
8
|
+
* Public surface:
|
|
9
|
+
* - generateCaddyfile(spec) → string
|
|
10
|
+
* - generateDockerCompose(spec) → string
|
|
11
|
+
* - generateBootstrapToken() → { token, expiresAt }
|
|
12
|
+
*
|
|
13
|
+
* The CLI (cli.ts) wires these into init / up / regenerate-caddy /
|
|
14
|
+
* backup / restore / status sub-commands. The Pulumi stack files
|
|
15
|
+
* (stacks/self-hosted/*) are imported by the CLI's `up` path and
|
|
16
|
+
* declare the actual Docker resources.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type {
|
|
20
|
+
CloudAdapterInputs,
|
|
21
|
+
CloudAdapterOutputs,
|
|
22
|
+
DnsRecord,
|
|
23
|
+
Environment,
|
|
24
|
+
LocaleConfig,
|
|
25
|
+
LocaleStrategy,
|
|
26
|
+
ProvisioningOutputsJson,
|
|
27
|
+
SupportedProvider,
|
|
28
|
+
} from "./adapter.js";
|
|
29
|
+
export {
|
|
30
|
+
type BootstrapToken,
|
|
31
|
+
generateBootstrapToken,
|
|
32
|
+
} from "./bootstrap-token.js";
|
|
33
|
+
export {
|
|
34
|
+
type CaddyDomainSpec,
|
|
35
|
+
type CaddyfileSpec,
|
|
36
|
+
generateCaddyfile,
|
|
37
|
+
} from "./caddy.js";
|
|
38
|
+
export {
|
|
39
|
+
type CdnCopyAdapter,
|
|
40
|
+
loadCdnCopyAdapter,
|
|
41
|
+
selfHostedCdnCopy,
|
|
42
|
+
} from "./cdn-copy.js";
|
|
43
|
+
export { type ComposeSpec, generateDockerCompose } from "./compose.js";
|
|
44
|
+
export {
|
|
45
|
+
type CloudFrontRedirectArtifact,
|
|
46
|
+
emitRedirectsAzureFrontDoor,
|
|
47
|
+
emitRedirectsCloudFront,
|
|
48
|
+
emitRedirectsCloudflare,
|
|
49
|
+
type FrontDoorRule,
|
|
50
|
+
type RedirectRow,
|
|
51
|
+
type RedirectStatusCode,
|
|
52
|
+
} from "./redirects-emit.js";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P15 — per-provider redirect file generators.
|
|
5
|
+
*
|
|
6
|
+
* P14's self-hosted Caddy consumes a `_redirects.caddy` file. Cloud
|
|
7
|
+
* providers want different formats: Cloudflare Pages reads `_redirects`
|
|
8
|
+
* (also a fine fit for any platform that consumes that format),
|
|
9
|
+
* CloudFront wants a JSON config a Lambda@Edge function reads at
|
|
10
|
+
* startup, and Azure Front Door wants a rules-engine RuleEngineRule[].
|
|
11
|
+
*
|
|
12
|
+
* The deploy step calls the right emitter based on `process.env.CAELO_PROVIDER`
|
|
13
|
+
* and uploads the result to the right place. Each emitter is pure +
|
|
14
|
+
* idempotent — same input → same byte output.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type RedirectStatusCode = 301 | 302 | 307 | 308;
|
|
18
|
+
|
|
19
|
+
export interface RedirectRow {
|
|
20
|
+
readonly fromPath: string;
|
|
21
|
+
readonly toPath: string;
|
|
22
|
+
readonly statusCode: RedirectStatusCode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reject whitespace + control chars in either path before any emitter
|
|
27
|
+
* touches them. The Cloudflare `_redirects` format is space-delimited;
|
|
28
|
+
* a `fromPath` containing a space ambiguates the line (CF parses
|
|
29
|
+
* `/old foo /new 301` as from=`/old`, to=`foo`, status=`/new`, then
|
|
30
|
+
* fails on `301`). Caelo's redirects table normally rejects these
|
|
31
|
+
* upstream but the emitter shouldn't trust that — pure functions
|
|
32
|
+
* defend their own contract.
|
|
33
|
+
*/
|
|
34
|
+
function assertPathClean(label: "fromPath" | "toPath", path: string): void {
|
|
35
|
+
if (/[\s\x00-\x1f]/.test(path)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`redirects-emit: ${label}=${JSON.stringify(path)} contains whitespace or control chars; reject upstream`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertRowsClean(rows: ReadonlyArray<RedirectRow>): void {
|
|
43
|
+
for (const r of rows) {
|
|
44
|
+
assertPathClean("fromPath", r.fromPath);
|
|
45
|
+
assertPathClean("toPath", r.toPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cloudflare Pages `_redirects` format (also consumed by Netlify, Vercel,
|
|
51
|
+
* Render, etc.). One redirect per line: `<from> <to> <status>`. Wildcards
|
|
52
|
+
* use `:splat`; we only support exact-path redirects in v1 (matches what
|
|
53
|
+
* Caelo's redirects table allows).
|
|
54
|
+
*/
|
|
55
|
+
export function emitRedirectsCloudflare(rows: ReadonlyArray<RedirectRow>): string {
|
|
56
|
+
assertRowsClean(rows);
|
|
57
|
+
const header = "# Generated by @caelo-cms/provisioning — do not edit by hand.\n";
|
|
58
|
+
const lines = rows.map((r) => `${r.fromPath} ${r.toPath} ${r.statusCode}`);
|
|
59
|
+
return header + lines.join("\n") + (rows.length > 0 ? "\n" : "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* CloudFront via Lambda@Edge. Returns a JSON config the L@E function
|
|
64
|
+
* reads at startup + a tiny Lambda source that consumes it. The L@E
|
|
65
|
+
* function is deployed to `us-east-1` (CloudFront constraint); the
|
|
66
|
+
* config blob is bundled into the Lambda deployment artifact.
|
|
67
|
+
*
|
|
68
|
+
* Returning both halves lets the AWS stack ship a single asset.
|
|
69
|
+
*/
|
|
70
|
+
export interface CloudFrontRedirectArtifact {
|
|
71
|
+
readonly jsonConfig: string;
|
|
72
|
+
readonly lambdaSource: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function emitRedirectsCloudFront(
|
|
76
|
+
rows: ReadonlyArray<RedirectRow>,
|
|
77
|
+
): CloudFrontRedirectArtifact {
|
|
78
|
+
assertRowsClean(rows);
|
|
79
|
+
const jsonConfig = JSON.stringify(
|
|
80
|
+
{ redirects: rows.map((r) => ({ from: r.fromPath, to: r.toPath, status: r.statusCode })) },
|
|
81
|
+
null,
|
|
82
|
+
2,
|
|
83
|
+
);
|
|
84
|
+
// Tiny Lambda@Edge handler. v1 is exact-path match (matches Caelo's
|
|
85
|
+
// redirects table). Wildcard support lands when the table grows it.
|
|
86
|
+
const lambdaSource = `// SPDX-License-Identifier: MPL-2.0
|
|
87
|
+
// Generated by @caelo-cms/provisioning. Bundled with redirects.json.
|
|
88
|
+
const REDIRECTS = require("./redirects.json").redirects;
|
|
89
|
+
const TABLE = new Map(REDIRECTS.map((r) => [r.from, r]));
|
|
90
|
+
exports.handler = (event, _ctx, callback) => {
|
|
91
|
+
const req = event.Records[0].cf.request;
|
|
92
|
+
const m = TABLE.get(req.uri);
|
|
93
|
+
if (!m) return callback(null, req);
|
|
94
|
+
callback(null, {
|
|
95
|
+
status: String(m.status),
|
|
96
|
+
statusDescription: "Redirect",
|
|
97
|
+
headers: { location: [{ key: "Location", value: m.to }] },
|
|
98
|
+
});
|
|
99
|
+
};`;
|
|
100
|
+
return { jsonConfig, lambdaSource };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Azure Front Door rules engine. Returns a Pulumi-compatible
|
|
105
|
+
* RuleEngineRule[]-shaped array (untyped here so callers don't need to
|
|
106
|
+
* import @pulumi/azure-native). Each rule fires on a path-equals match
|
|
107
|
+
* + executes a 301/302/etc. response.
|
|
108
|
+
*/
|
|
109
|
+
export interface FrontDoorRule {
|
|
110
|
+
readonly name: string;
|
|
111
|
+
readonly order: number;
|
|
112
|
+
readonly conditions: ReadonlyArray<{
|
|
113
|
+
readonly name: "RequestUri";
|
|
114
|
+
readonly parameters: {
|
|
115
|
+
readonly operator: "Equal";
|
|
116
|
+
readonly matchValues: ReadonlyArray<string>;
|
|
117
|
+
};
|
|
118
|
+
}>;
|
|
119
|
+
readonly actions: ReadonlyArray<{
|
|
120
|
+
readonly name: "UrlRedirect";
|
|
121
|
+
readonly parameters: {
|
|
122
|
+
readonly redirectType: "Moved" | "Found" | "TemporaryRedirect" | "PermanentRedirect";
|
|
123
|
+
readonly destinationPath: string;
|
|
124
|
+
};
|
|
125
|
+
}>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function emitRedirectsAzureFrontDoor(
|
|
129
|
+
rows: ReadonlyArray<RedirectRow>,
|
|
130
|
+
): ReadonlyArray<FrontDoorRule> {
|
|
131
|
+
assertRowsClean(rows);
|
|
132
|
+
return rows.map((r, i) => ({
|
|
133
|
+
name: `redirect-${i + 1}`,
|
|
134
|
+
order: i + 1,
|
|
135
|
+
conditions: [
|
|
136
|
+
{
|
|
137
|
+
name: "RequestUri" as const,
|
|
138
|
+
parameters: { operator: "Equal" as const, matchValues: [r.fromPath] },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
actions: [
|
|
142
|
+
{
|
|
143
|
+
name: "UrlRedirect" as const,
|
|
144
|
+
parameters: {
|
|
145
|
+
redirectType: statusToFrontDoor(r.statusCode),
|
|
146
|
+
destinationPath: r.toPath,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function statusToFrontDoor(
|
|
154
|
+
status: RedirectStatusCode,
|
|
155
|
+
): FrontDoorRule["actions"][0]["parameters"]["redirectType"] {
|
|
156
|
+
switch (status) {
|
|
157
|
+
case 301:
|
|
158
|
+
return "Moved";
|
|
159
|
+
case 302:
|
|
160
|
+
return "Found";
|
|
161
|
+
case 307:
|
|
162
|
+
return "TemporaryRedirect";
|
|
163
|
+
case 308:
|
|
164
|
+
return "PermanentRedirect";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
#
|
|
3
|
+
# Caelo AWS provider stack. Provisions RDS Postgres + S3 (media + static
|
|
4
|
+
# output) + CloudFront + Lambda@Edge (A/B split + redirects) + ECS
|
|
5
|
+
# Fargate (admin / gateway / orchestrator / runner) + Secrets Manager.
|
|
6
|
+
# Implements the shared CloudAdapterInputs/CloudAdapterOutputs contract
|
|
7
|
+
# from packages/provisioning/src/adapter.ts so the cms-provision CLI's
|
|
8
|
+
# pulumi-output-sync subcommand can consume the outputs uniformly with
|
|
9
|
+
# the GCP + Azure stacks.
|
|
10
|
+
name: caelo-aws
|
|
11
|
+
runtime:
|
|
12
|
+
name: nodejs
|
|
13
|
+
options:
|
|
14
|
+
typescript: true
|
|
15
|
+
packagemanager: bun
|
|
16
|
+
description: Caelo AWS stack — RDS Multi-AZ + S3 + CloudFront + Lambda@Edge + ECS Fargate.
|
|
17
|
+
config:
|
|
18
|
+
caelo-aws:domain:
|
|
19
|
+
type: string
|
|
20
|
+
description: Primary domain (e.g. example.com). Admin + production public both bind here.
|
|
21
|
+
caelo-aws:ownerEmail:
|
|
22
|
+
type: string
|
|
23
|
+
description: Operator email used for ACM cert validation + Pulumi notifications.
|
|
24
|
+
caelo-aws:region:
|
|
25
|
+
type: string
|
|
26
|
+
default: us-east-1
|
|
27
|
+
description: Primary AWS region (RDS, S3, ECS). Lambda@Edge always pinned to us-east-1 regardless.
|
|
28
|
+
caelo-aws:rdsInstanceClass:
|
|
29
|
+
type: string
|
|
30
|
+
default: db.t4g.small
|
|
31
|
+
description: RDS instance class. db.t4g.small is the cheapest Multi-AZ-capable Graviton class.
|
|
32
|
+
caelo-aws:fargateCpu:
|
|
33
|
+
type: string
|
|
34
|
+
default: "512"
|
|
35
|
+
description: vCPU units per Fargate task (512 = 0.5 vCPU). Bump for production.
|
|
36
|
+
caelo-aws:fargateMemoryMb:
|
|
37
|
+
type: string
|
|
38
|
+
default: "1024"
|
|
39
|
+
description: Memory MB per Fargate task. Bump alongside CPU.
|