otavia 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/bun.lock +589 -0
- package/package.json +35 -0
- package/src/cli.ts +153 -0
- package/src/commands/__tests__/aws-auth.test.ts +32 -0
- package/src/commands/__tests__/cell.test.ts +44 -0
- package/src/commands/__tests__/dev.test.ts +49 -0
- package/src/commands/__tests__/init.test.ts +47 -0
- package/src/commands/__tests__/setup.test.ts +263 -0
- package/src/commands/aws-auth.ts +32 -0
- package/src/commands/aws.ts +59 -0
- package/src/commands/cell.ts +33 -0
- package/src/commands/clean.ts +32 -0
- package/src/commands/deploy.ts +508 -0
- package/src/commands/dev/__tests__/fixtures/gateway-cell/cell.yaml +8 -0
- package/src/commands/dev/__tests__/gateway-backend-routes.test.ts +13 -0
- package/src/commands/dev/__tests__/gateway-forward-url.test.ts +20 -0
- package/src/commands/dev/__tests__/gateway-sso-base-url.test.ts +93 -0
- package/src/commands/dev/__tests__/tunnel.test.ts +93 -0
- package/src/commands/dev/__tests__/vite-dev-proxy-rules.test.ts +220 -0
- package/src/commands/dev/__tests__/well-known.test.ts +88 -0
- package/src/commands/dev/forward-url.ts +7 -0
- package/src/commands/dev/gateway.ts +421 -0
- package/src/commands/dev/main-frontend-runtime/main-entry.ts +35 -0
- package/src/commands/dev/main-frontend-runtime/vite-config.ts +210 -0
- package/src/commands/dev/mount-selection.ts +9 -0
- package/src/commands/dev/tunnel.ts +176 -0
- package/src/commands/dev/vite-dev.ts +382 -0
- package/src/commands/dev/well-known.ts +76 -0
- package/src/commands/dev.ts +107 -0
- package/src/commands/init.ts +69 -0
- package/src/commands/lint.ts +49 -0
- package/src/commands/setup.ts +887 -0
- package/src/commands/test.ts +331 -0
- package/src/commands/typecheck.ts +36 -0
- package/src/config/__tests__/load-cell-yaml.test.ts +248 -0
- package/src/config/__tests__/load-otavia-yaml.test.ts +492 -0
- package/src/config/__tests__/ports.test.ts +48 -0
- package/src/config/__tests__/resolve-cell-dir.test.ts +60 -0
- package/src/config/__tests__/resolve-params.test.ts +137 -0
- package/src/config/__tests__/resource-names.test.ts +62 -0
- package/src/config/cell-yaml-schema.ts +115 -0
- package/src/config/load-cell-yaml.ts +87 -0
- package/src/config/load-otavia-yaml.ts +256 -0
- package/src/config/otavia-yaml-schema.ts +49 -0
- package/src/config/ports.ts +57 -0
- package/src/config/resolve-cell-dir.ts +55 -0
- package/src/config/resolve-params.ts +160 -0
- package/src/config/resource-names.ts +60 -0
- package/src/deploy/__tests__/template.test.ts +137 -0
- package/src/deploy/api-gateway.ts +96 -0
- package/src/deploy/cloudflare-dns.ts +261 -0
- package/src/deploy/cloudfront.ts +228 -0
- package/src/deploy/dynamodb.ts +68 -0
- package/src/deploy/lambda.ts +121 -0
- package/src/deploy/s3.ts +57 -0
- package/src/deploy/template.ts +264 -0
- package/src/deploy/types.ts +16 -0
- package/src/local/docker.ts +175 -0
- package/src/local/dynamodb-local.ts +124 -0
- package/src/local/minio-local.ts +44 -0
- package/src/utils/env.test.ts +74 -0
- package/src/utils/env.ts +79 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare DNS + ACM certificate automation for deploy.
|
|
3
|
+
* Creates ACM cert, adds DNS validation record via Cloudflare API,
|
|
4
|
+
* waits for validation, then creates CNAME to CloudFront.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type CloudflareDnsRecord = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
content: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type AcmValidationRecord = {
|
|
15
|
+
Name: string;
|
|
16
|
+
Type: string;
|
|
17
|
+
Value: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type AwsRunner = (
|
|
21
|
+
args: string[],
|
|
22
|
+
env: Record<string, string | undefined>,
|
|
23
|
+
opts?: { pipeStderr?: boolean }
|
|
24
|
+
) => Promise<{ exitCode: number; stdout: string }>;
|
|
25
|
+
|
|
26
|
+
export type CloudflareDnsOptions = {
|
|
27
|
+
domainHost: string;
|
|
28
|
+
zoneId: string;
|
|
29
|
+
cloudflareToken: string;
|
|
30
|
+
awsEnv: Record<string, string | undefined>;
|
|
31
|
+
awsCli: AwsRunner;
|
|
32
|
+
region?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function cfApi(
|
|
36
|
+
path: string,
|
|
37
|
+
token: string,
|
|
38
|
+
options?: { method?: string; body?: unknown }
|
|
39
|
+
): Promise<unknown> {
|
|
40
|
+
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
41
|
+
method: options?.method ?? "GET",
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${token}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
47
|
+
});
|
|
48
|
+
const data = (await res.json()) as { success: boolean; result: unknown; errors?: unknown[] };
|
|
49
|
+
if (!data.success) {
|
|
50
|
+
throw new Error(`Cloudflare API error: ${JSON.stringify(data.errors)}`);
|
|
51
|
+
}
|
|
52
|
+
return data.result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Request ACM certificate and return ARN. */
|
|
56
|
+
async function requestAcmCertificate(
|
|
57
|
+
domainHost: string,
|
|
58
|
+
awsCli: AwsRunner,
|
|
59
|
+
awsEnv: Record<string, string | undefined>,
|
|
60
|
+
region: string,
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
const { exitCode, stdout } = await awsCli(
|
|
63
|
+
[
|
|
64
|
+
"acm", "request-certificate",
|
|
65
|
+
"--domain-name", domainHost,
|
|
66
|
+
"--validation-method", "DNS",
|
|
67
|
+
"--region", region,
|
|
68
|
+
"--output", "json",
|
|
69
|
+
],
|
|
70
|
+
awsEnv,
|
|
71
|
+
{ pipeStderr: true },
|
|
72
|
+
);
|
|
73
|
+
if (exitCode !== 0) throw new Error("Failed to request ACM certificate");
|
|
74
|
+
const data = JSON.parse(stdout) as { CertificateArn: string };
|
|
75
|
+
return data.CertificateArn;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get DNS validation record from ACM certificate. Retries until available. */
|
|
79
|
+
async function getAcmValidationRecord(
|
|
80
|
+
certArn: string,
|
|
81
|
+
awsCli: AwsRunner,
|
|
82
|
+
awsEnv: Record<string, string | undefined>,
|
|
83
|
+
region: string,
|
|
84
|
+
): Promise<AcmValidationRecord> {
|
|
85
|
+
for (let i = 0; i < 30; i++) {
|
|
86
|
+
const { exitCode, stdout } = await awsCli(
|
|
87
|
+
[
|
|
88
|
+
"acm", "describe-certificate",
|
|
89
|
+
"--certificate-arn", certArn,
|
|
90
|
+
"--region", region,
|
|
91
|
+
"--query", "Certificate.DomainValidationOptions[0].ResourceRecord",
|
|
92
|
+
"--output", "json",
|
|
93
|
+
],
|
|
94
|
+
awsEnv,
|
|
95
|
+
{ pipeStderr: true },
|
|
96
|
+
);
|
|
97
|
+
if (exitCode === 0) {
|
|
98
|
+
const record = JSON.parse(stdout) as AcmValidationRecord | null;
|
|
99
|
+
if (record?.Name && record?.Value) return record;
|
|
100
|
+
}
|
|
101
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
102
|
+
}
|
|
103
|
+
throw new Error("Timed out waiting for ACM validation record");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Create or update a DNS record in Cloudflare. */
|
|
107
|
+
async function upsertCloudflareRecord(
|
|
108
|
+
zoneId: string,
|
|
109
|
+
token: string,
|
|
110
|
+
name: string,
|
|
111
|
+
type: string,
|
|
112
|
+
content: string,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const cleanName = name.replace(/\.$/, "");
|
|
115
|
+
const cleanContent = content.replace(/\.$/, "");
|
|
116
|
+
|
|
117
|
+
// Search for existing record — use search param for partial match since
|
|
118
|
+
// Cloudflare's exact name filter can miss records with zone suffix appended
|
|
119
|
+
const allRecords = (await cfApi(
|
|
120
|
+
`/zones/${zoneId}/dns_records?type=${type}&per_page=100`,
|
|
121
|
+
token,
|
|
122
|
+
)) as CloudflareDnsRecord[];
|
|
123
|
+
const existing = allRecords.filter(
|
|
124
|
+
(r) => r.name === cleanName || r.name.startsWith(cleanName.split(".")[0]),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (existing.length > 0) {
|
|
128
|
+
// Update existing
|
|
129
|
+
await cfApi(`/zones/${zoneId}/dns_records/${existing[0].id}`, token, {
|
|
130
|
+
method: "PUT",
|
|
131
|
+
body: { type, name: cleanName, content: cleanContent, ttl: 1, proxied: false },
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
// Create new
|
|
135
|
+
await cfApi(`/zones/${zoneId}/dns_records`, token, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
body: { type, name: cleanName, content: cleanContent, ttl: 1, proxied: false },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Wait for ACM certificate to be issued. */
|
|
143
|
+
async function waitForAcmValidation(
|
|
144
|
+
certArn: string,
|
|
145
|
+
awsCli: AwsRunner,
|
|
146
|
+
awsEnv: Record<string, string | undefined>,
|
|
147
|
+
region: string,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
console.log(" Waiting for ACM certificate validation...");
|
|
150
|
+
for (let i = 0; i < 60; i++) {
|
|
151
|
+
const { exitCode, stdout } = await awsCli(
|
|
152
|
+
[
|
|
153
|
+
"acm", "describe-certificate",
|
|
154
|
+
"--certificate-arn", certArn,
|
|
155
|
+
"--region", region,
|
|
156
|
+
"--query", "Certificate.Status",
|
|
157
|
+
"--output", "text",
|
|
158
|
+
],
|
|
159
|
+
awsEnv,
|
|
160
|
+
{ pipeStderr: true },
|
|
161
|
+
);
|
|
162
|
+
if (exitCode === 0) {
|
|
163
|
+
const status = stdout.trim();
|
|
164
|
+
if (status === "ISSUED") {
|
|
165
|
+
console.log(" Certificate issued.");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (status === "FAILED") {
|
|
169
|
+
throw new Error("ACM certificate validation failed");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (i % 5 === 0 && i > 0) console.log(` Still waiting... (${i * 5}s)`);
|
|
173
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
174
|
+
}
|
|
175
|
+
throw new Error("Timed out waiting for ACM certificate validation (5 minutes)");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Check if an ISSUED ACM certificate already exists for the domain. */
|
|
179
|
+
async function findExistingCertificate(
|
|
180
|
+
domainHost: string,
|
|
181
|
+
awsCli: AwsRunner,
|
|
182
|
+
awsEnv: Record<string, string | undefined>,
|
|
183
|
+
region: string,
|
|
184
|
+
): Promise<string | null> {
|
|
185
|
+
const { exitCode, stdout } = await awsCli(
|
|
186
|
+
[
|
|
187
|
+
"acm", "list-certificates",
|
|
188
|
+
"--region", region,
|
|
189
|
+
"--query", `CertificateSummaryList[?DomainName=='${domainHost}' && Status=='ISSUED'].CertificateArn | [0]`,
|
|
190
|
+
"--output", "text",
|
|
191
|
+
],
|
|
192
|
+
awsEnv,
|
|
193
|
+
{ pipeStderr: true },
|
|
194
|
+
);
|
|
195
|
+
if (exitCode !== 0) return null;
|
|
196
|
+
const arn = stdout.trim();
|
|
197
|
+
return arn && arn !== "None" ? arn : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Ensure ACM certificate exists for domain with Cloudflare DNS validation.
|
|
202
|
+
* Returns certificate ARN.
|
|
203
|
+
*/
|
|
204
|
+
export async function ensureAcmCertificateWithCloudflare(
|
|
205
|
+
opts: CloudflareDnsOptions,
|
|
206
|
+
): Promise<string> {
|
|
207
|
+
const region = opts.region ?? "us-east-1";
|
|
208
|
+
|
|
209
|
+
// Check for existing valid certificate
|
|
210
|
+
const existing = await findExistingCertificate(opts.domainHost, opts.awsCli, opts.awsEnv, region);
|
|
211
|
+
if (existing) {
|
|
212
|
+
console.log(` Using existing ACM certificate: ${existing}`);
|
|
213
|
+
return existing;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Request new certificate
|
|
217
|
+
console.log(` Requesting ACM certificate for ${opts.domainHost}...`);
|
|
218
|
+
const certArn = await requestAcmCertificate(opts.domainHost, opts.awsCli, opts.awsEnv, region);
|
|
219
|
+
console.log(` Certificate ARN: ${certArn}`);
|
|
220
|
+
|
|
221
|
+
// Get validation record
|
|
222
|
+
const validationRecord = await getAcmValidationRecord(certArn, opts.awsCli, opts.awsEnv, region);
|
|
223
|
+
console.log(` Adding DNS validation record to Cloudflare...`);
|
|
224
|
+
|
|
225
|
+
// Create validation record in Cloudflare
|
|
226
|
+
await upsertCloudflareRecord(
|
|
227
|
+
opts.zoneId,
|
|
228
|
+
opts.cloudflareToken,
|
|
229
|
+
validationRecord.Name,
|
|
230
|
+
validationRecord.Type,
|
|
231
|
+
validationRecord.Value,
|
|
232
|
+
);
|
|
233
|
+
console.log(` DNS record created: ${validationRecord.Name}`);
|
|
234
|
+
|
|
235
|
+
// Wait for validation
|
|
236
|
+
await waitForAcmValidation(certArn, opts.awsCli, opts.awsEnv, region);
|
|
237
|
+
|
|
238
|
+
return certArn;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Create CNAME record pointing domain to CloudFront distribution.
|
|
243
|
+
*/
|
|
244
|
+
export async function createCloudFrontDnsRecord(
|
|
245
|
+
opts: {
|
|
246
|
+
domainHost: string;
|
|
247
|
+
cloudFrontDomain: string;
|
|
248
|
+
zoneId: string;
|
|
249
|
+
cloudflareToken: string;
|
|
250
|
+
},
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
console.log(` Creating DNS record: ${opts.domainHost} → ${opts.cloudFrontDomain}`);
|
|
253
|
+
await upsertCloudflareRecord(
|
|
254
|
+
opts.zoneId,
|
|
255
|
+
opts.cloudflareToken,
|
|
256
|
+
opts.domainHost,
|
|
257
|
+
"CNAME",
|
|
258
|
+
opts.cloudFrontDomain,
|
|
259
|
+
);
|
|
260
|
+
console.log(` DNS record created.`);
|
|
261
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate CloudFront distribution: single domain, path behaviors per cell.
|
|
3
|
+
* Reference: cell-cli cloudfront.ts generateCloudFrontPlatform.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CACHING_DISABLED_POLICY = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
|
|
7
|
+
const CACHING_OPTIMIZED_POLICY = "658327ea-f89d-4fab-a63d-7e88639e58f6";
|
|
8
|
+
|
|
9
|
+
export interface GenerateCloudFrontOptions {
|
|
10
|
+
domainHost: string;
|
|
11
|
+
defaultOriginId: string;
|
|
12
|
+
/** Root path ("/") should redirect to /<mount>/ when set. */
|
|
13
|
+
defaultCellMount?: string;
|
|
14
|
+
/** S3 frontend bucket ref - used for default origin */
|
|
15
|
+
frontendBucketRef?: string;
|
|
16
|
+
/** Path behaviors: pathPattern (e.g. /sso/*) -> originId. API origins use originId as Api Ref. */
|
|
17
|
+
pathBehaviors: Array<{ pathPattern: string; originId: string; isApi?: boolean }>;
|
|
18
|
+
hostedZoneId?: string;
|
|
19
|
+
certificateArn?: string;
|
|
20
|
+
stackName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
import type { CfnFragment } from "./types.js";
|
|
24
|
+
|
|
25
|
+
export function generateCloudFrontDistribution(options: GenerateCloudFrontOptions): CfnFragment {
|
|
26
|
+
const {
|
|
27
|
+
domainHost,
|
|
28
|
+
defaultOriginId,
|
|
29
|
+
defaultCellMount,
|
|
30
|
+
frontendBucketRef = "FrontendBucket",
|
|
31
|
+
pathBehaviors,
|
|
32
|
+
hostedZoneId,
|
|
33
|
+
certificateArn,
|
|
34
|
+
stackName,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
const resources: Record<string, unknown> = {};
|
|
38
|
+
const conditions: Record<string, unknown> = {};
|
|
39
|
+
const rootRedirectPath = defaultCellMount ? `/${defaultCellMount}/` : "/";
|
|
40
|
+
const rootIndexPath = defaultCellMount ? `/${defaultCellMount}/index.html` : "/index.html";
|
|
41
|
+
|
|
42
|
+
const autoCert = !certificateArn && !!domainHost && !!hostedZoneId;
|
|
43
|
+
let certificateRef: unknown;
|
|
44
|
+
if (autoCert) {
|
|
45
|
+
resources.AcmCertificate = {
|
|
46
|
+
Type: "AWS::CertificateManager::Certificate",
|
|
47
|
+
Properties: {
|
|
48
|
+
DomainName: domainHost,
|
|
49
|
+
ValidationMethod: "DNS",
|
|
50
|
+
DomainValidationOptions: [{ DomainName: domainHost, HostedZoneId: hostedZoneId }],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
certificateRef = { Ref: "AcmCertificate" };
|
|
54
|
+
} else if (certificateArn) {
|
|
55
|
+
certificateRef = certificateArn;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const useCustomDomain = !!domainHost && (autoCert || !!certificateArn);
|
|
59
|
+
conditions.UseCustomDomain = {
|
|
60
|
+
"Fn::Not": [{ "Fn::Equals": [useCustomDomain ? domainHost : "", ""] }],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
resources.FrontendOAC = {
|
|
64
|
+
Type: "AWS::CloudFront::OriginAccessControl",
|
|
65
|
+
Properties: {
|
|
66
|
+
OriginAccessControlConfig: {
|
|
67
|
+
Name: `${stackName}-frontend-oac`,
|
|
68
|
+
OriginAccessControlOriginType: "s3",
|
|
69
|
+
SigningBehavior: "always",
|
|
70
|
+
SigningProtocol: "sigv4",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
resources.SpaRewriteFunction = {
|
|
76
|
+
Type: "AWS::CloudFront::Function",
|
|
77
|
+
Properties: {
|
|
78
|
+
Name: `${stackName}-spa-rewrite`,
|
|
79
|
+
AutoPublish: true,
|
|
80
|
+
FunctionCode: [
|
|
81
|
+
"function handler(event) {",
|
|
82
|
+
" var uri = event.request.uri;",
|
|
83
|
+
` var rootRedirectPath = ${JSON.stringify(rootRedirectPath)};`,
|
|
84
|
+
` var rootIndexPath = ${JSON.stringify(rootIndexPath)};`,
|
|
85
|
+
" if (uri === '/') {",
|
|
86
|
+
" return {",
|
|
87
|
+
" statusCode: 302,",
|
|
88
|
+
" statusDescription: 'Found',",
|
|
89
|
+
" headers: {",
|
|
90
|
+
" location: { value: rootRedirectPath }",
|
|
91
|
+
" }",
|
|
92
|
+
" };",
|
|
93
|
+
" } else if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
|
94
|
+
" var parts = uri.split('/').filter(Boolean);",
|
|
95
|
+
" if (parts.length > 0) {",
|
|
96
|
+
" event.request.uri = '/' + parts[0] + '/index.html';",
|
|
97
|
+
" } else {",
|
|
98
|
+
" event.request.uri = rootIndexPath;",
|
|
99
|
+
" }",
|
|
100
|
+
" }",
|
|
101
|
+
" return event.request;",
|
|
102
|
+
"}",
|
|
103
|
+
].join("\n"),
|
|
104
|
+
FunctionConfig: {
|
|
105
|
+
Comment: "SPA fallback",
|
|
106
|
+
Runtime: "cloudfront-js-2.0",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const origins: unknown[] = [
|
|
112
|
+
{
|
|
113
|
+
Id: defaultOriginId,
|
|
114
|
+
DomainName: { "Fn::GetAtt": [frontendBucketRef, "RegionalDomainName"] },
|
|
115
|
+
OriginAccessControlId: { "Fn::GetAtt": ["FrontendOAC", "Id"] },
|
|
116
|
+
S3OriginConfig: { OriginAccessIdentity: "" },
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const seenApiOrigins = new Set<string>();
|
|
121
|
+
for (const b of pathBehaviors) {
|
|
122
|
+
if (b.isApi && !seenApiOrigins.has(b.originId)) {
|
|
123
|
+
seenApiOrigins.add(b.originId);
|
|
124
|
+
origins.push({
|
|
125
|
+
Id: b.originId,
|
|
126
|
+
DomainName: {
|
|
127
|
+
"Fn::Sub": `\${${b.originId}}.execute-api.\${AWS::Region}.amazonaws.com`,
|
|
128
|
+
},
|
|
129
|
+
CustomOriginConfig: {
|
|
130
|
+
HTTPSPort: 443,
|
|
131
|
+
OriginProtocolPolicy: "https-only",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const cacheBehaviors = pathBehaviors.map((b) => ({
|
|
138
|
+
PathPattern: b.pathPattern,
|
|
139
|
+
TargetOriginId: b.originId,
|
|
140
|
+
ViewerProtocolPolicy: "https-only" as const,
|
|
141
|
+
AllowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
|
|
142
|
+
Compress: true,
|
|
143
|
+
CachePolicyId: CACHING_DISABLED_POLICY,
|
|
144
|
+
OriginRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac",
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
resources.FrontendCloudFront = {
|
|
148
|
+
Type: "AWS::CloudFront::Distribution",
|
|
149
|
+
Properties: {
|
|
150
|
+
DistributionConfig: {
|
|
151
|
+
Enabled: true,
|
|
152
|
+
DefaultRootObject: "index.html",
|
|
153
|
+
Origins: origins,
|
|
154
|
+
DefaultCacheBehavior: {
|
|
155
|
+
TargetOriginId: defaultOriginId,
|
|
156
|
+
ViewerProtocolPolicy: "redirect-to-https",
|
|
157
|
+
AllowedMethods: ["GET", "HEAD", "OPTIONS"],
|
|
158
|
+
Compress: true,
|
|
159
|
+
CachePolicyId: CACHING_OPTIMIZED_POLICY,
|
|
160
|
+
FunctionAssociations: [
|
|
161
|
+
{
|
|
162
|
+
EventType: "viewer-request",
|
|
163
|
+
FunctionARN: { "Fn::GetAtt": ["SpaRewriteFunction", "FunctionARN"] },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
CacheBehaviors: cacheBehaviors,
|
|
168
|
+
Aliases: {
|
|
169
|
+
"Fn::If": ["UseCustomDomain", [domainHost], { Ref: "AWS::NoValue" }],
|
|
170
|
+
},
|
|
171
|
+
ViewerCertificate: {
|
|
172
|
+
"Fn::If": [
|
|
173
|
+
"UseCustomDomain",
|
|
174
|
+
{
|
|
175
|
+
AcmCertificateArn: certificateRef,
|
|
176
|
+
SslSupportMethod: "sni-only",
|
|
177
|
+
MinimumProtocolVersion: "TLSv1.2_2021",
|
|
178
|
+
},
|
|
179
|
+
{ CloudFrontDefaultCertificate: true },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
resources.FrontendBucketPolicy = {
|
|
187
|
+
Type: "AWS::S3::BucketPolicy",
|
|
188
|
+
DependsOn: "FrontendCloudFront",
|
|
189
|
+
Properties: {
|
|
190
|
+
Bucket: { Ref: frontendBucketRef },
|
|
191
|
+
PolicyDocument: {
|
|
192
|
+
Version: "2012-10-17",
|
|
193
|
+
Statement: [
|
|
194
|
+
{
|
|
195
|
+
Sid: "AllowCloudFrontOAC",
|
|
196
|
+
Effect: "Allow",
|
|
197
|
+
Principal: { Service: "cloudfront.amazonaws.com" },
|
|
198
|
+
Action: "s3:GetObject",
|
|
199
|
+
Resource: { "Fn::Sub": `\${${frontendBucketRef}.Arn}/*` },
|
|
200
|
+
Condition: {
|
|
201
|
+
StringEquals: {
|
|
202
|
+
"AWS:SourceArn": {
|
|
203
|
+
"Fn::Sub":
|
|
204
|
+
"arn:aws:cloudfront::${AWS::AccountId}:distribution/${FrontendCloudFront}",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
Resources: resources,
|
|
216
|
+
Outputs: {
|
|
217
|
+
FrontendUrl: {
|
|
218
|
+
Description: "CloudFront URL",
|
|
219
|
+
Value: { "Fn::Sub": "https://${FrontendCloudFront.DomainName}" },
|
|
220
|
+
},
|
|
221
|
+
FrontendDistributionId: {
|
|
222
|
+
Description: "CloudFront distribution ID",
|
|
223
|
+
Value: { Ref: "FrontendCloudFront" },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
Conditions: conditions,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { TableConfig } from "../config/cell-yaml-schema.js";
|
|
2
|
+
import type { CfnFragment } from "./types.js";
|
|
3
|
+
import { toPascalCase } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a single DynamoDB table fragment.
|
|
7
|
+
* Uses KeySchema, AttributeDefinitions, BillingMode PAY_PER_REQUEST, GSI if present.
|
|
8
|
+
*/
|
|
9
|
+
export function generateDynamoDBTable(
|
|
10
|
+
tableName: string,
|
|
11
|
+
tableKey: string,
|
|
12
|
+
config: TableConfig
|
|
13
|
+
): CfnFragment {
|
|
14
|
+
const logicalId = `${toPascalCase(tableKey)}Table`;
|
|
15
|
+
const keys = Object.entries(config.keys);
|
|
16
|
+
|
|
17
|
+
const attrMap = new Map<string, string>();
|
|
18
|
+
for (const [name, type] of keys) {
|
|
19
|
+
attrMap.set(name, type);
|
|
20
|
+
}
|
|
21
|
+
if (config.gsi) {
|
|
22
|
+
for (const gsi of Object.values(config.gsi)) {
|
|
23
|
+
for (const [name, type] of Object.entries(gsi.keys)) {
|
|
24
|
+
attrMap.set(name, type);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const properties: Record<string, unknown> = {
|
|
30
|
+
TableName: tableName,
|
|
31
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
32
|
+
AttributeDefinitions: [...attrMap.entries()].map(([name, type]) => ({
|
|
33
|
+
AttributeName: name,
|
|
34
|
+
AttributeType: type,
|
|
35
|
+
})),
|
|
36
|
+
KeySchema: keys.map(([name], i) => ({
|
|
37
|
+
AttributeName: name,
|
|
38
|
+
KeyType: i === 0 ? "HASH" : "RANGE",
|
|
39
|
+
})),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (config.gsi) {
|
|
43
|
+
properties.GlobalSecondaryIndexes = Object.entries(config.gsi).map(
|
|
44
|
+
([indexName, gsi]) => ({
|
|
45
|
+
IndexName: indexName,
|
|
46
|
+
KeySchema: Object.entries(gsi.keys).map(([name], i) => ({
|
|
47
|
+
AttributeName: name,
|
|
48
|
+
KeyType: i === 0 ? "HASH" : "RANGE",
|
|
49
|
+
})),
|
|
50
|
+
Projection: { ProjectionType: gsi.projection },
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
Resources: {
|
|
57
|
+
[logicalId]: {
|
|
58
|
+
Type: "AWS::DynamoDB::Table",
|
|
59
|
+
Properties: properties,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
Outputs: {
|
|
63
|
+
[`${logicalId}Arn`]: {
|
|
64
|
+
Value: { "Fn::GetAtt": [logicalId, "Arn"] },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { CfnFragment } from "./types.js";
|
|
2
|
+
import { toPascalCase } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface LambdaFragmentProps {
|
|
5
|
+
handlerPath: string;
|
|
6
|
+
runtime: string;
|
|
7
|
+
timeout: number;
|
|
8
|
+
memory: number;
|
|
9
|
+
envVars: Record<string, string>;
|
|
10
|
+
/** Logical IDs of DynamoDB table resources (e.g. SsoThreadsTable) for IAM */
|
|
11
|
+
tableLogicalIds?: string[];
|
|
12
|
+
/** Logical IDs of S3 bucket resources for IAM */
|
|
13
|
+
bucketLogicalIds?: string[];
|
|
14
|
+
/** For Secrets Manager refs: key -> secret name (cell/secretName) */
|
|
15
|
+
secretRefs?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate Lambda function + IAM role for one backend entry.
|
|
20
|
+
* Env vars from resolved params; IAM policy for DynamoDB and S3 if provided.
|
|
21
|
+
*/
|
|
22
|
+
export function generateLambdaFragment(
|
|
23
|
+
entryKey: string,
|
|
24
|
+
logicalIdPrefix: string,
|
|
25
|
+
props: LambdaFragmentProps
|
|
26
|
+
): CfnFragment {
|
|
27
|
+
const pascalEntry = toPascalCase(entryKey);
|
|
28
|
+
const functionLogicalId = `${logicalIdPrefix}${pascalEntry}Function`;
|
|
29
|
+
const roleLogicalId = `${logicalIdPrefix}${pascalEntry}LambdaRole`;
|
|
30
|
+
|
|
31
|
+
const envVariables: Record<string, string> = { ...props.envVars };
|
|
32
|
+
if (props.secretRefs) {
|
|
33
|
+
for (const [key, secretName] of Object.entries(props.secretRefs)) {
|
|
34
|
+
envVariables[key] = `{{resolve:secretsmanager:${secretName}}}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const policyStatements: unknown[] = [
|
|
39
|
+
{
|
|
40
|
+
Effect: "Allow",
|
|
41
|
+
Action: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
|
|
42
|
+
Resource: "*",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
if (props.tableLogicalIds && props.tableLogicalIds.length > 0) {
|
|
47
|
+
const tableResources: unknown[] = [];
|
|
48
|
+
for (const id of props.tableLogicalIds) {
|
|
49
|
+
tableResources.push({ "Fn::GetAtt": [id, "Arn"] });
|
|
50
|
+
tableResources.push({ "Fn::Sub": `\${${id}.Arn}/index/*` });
|
|
51
|
+
}
|
|
52
|
+
policyStatements.push({
|
|
53
|
+
Effect: "Allow",
|
|
54
|
+
Action: [
|
|
55
|
+
"dynamodb:GetItem",
|
|
56
|
+
"dynamodb:PutItem",
|
|
57
|
+
"dynamodb:UpdateItem",
|
|
58
|
+
"dynamodb:DeleteItem",
|
|
59
|
+
"dynamodb:Query",
|
|
60
|
+
"dynamodb:BatchGetItem",
|
|
61
|
+
"dynamodb:BatchWriteItem",
|
|
62
|
+
"dynamodb:Scan",
|
|
63
|
+
],
|
|
64
|
+
Resource: tableResources,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (props.bucketLogicalIds && props.bucketLogicalIds.length > 0) {
|
|
69
|
+
const bucketResources: unknown[] = [];
|
|
70
|
+
for (const id of props.bucketLogicalIds) {
|
|
71
|
+
bucketResources.push({ "Fn::GetAtt": [id, "Arn"] });
|
|
72
|
+
bucketResources.push({ "Fn::Sub": `\${${id}.Arn}/*` });
|
|
73
|
+
}
|
|
74
|
+
policyStatements.push({
|
|
75
|
+
Effect: "Allow",
|
|
76
|
+
Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
|
|
77
|
+
Resource: bucketResources,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const resources: Record<string, unknown> = {
|
|
82
|
+
[roleLogicalId]: {
|
|
83
|
+
Type: "AWS::IAM::Role",
|
|
84
|
+
Properties: {
|
|
85
|
+
AssumeRolePolicyDocument: {
|
|
86
|
+
Version: "2012-10-17",
|
|
87
|
+
Statement: [
|
|
88
|
+
{
|
|
89
|
+
Effect: "Allow",
|
|
90
|
+
Principal: { Service: "lambda.amazonaws.com" },
|
|
91
|
+
Action: "sts:AssumeRole",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
Policies: [
|
|
96
|
+
{
|
|
97
|
+
PolicyName: "LambdaPolicy",
|
|
98
|
+
PolicyDocument: {
|
|
99
|
+
Version: "2012-10-17",
|
|
100
|
+
Statement: policyStatements,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
[functionLogicalId]: {
|
|
107
|
+
Type: "AWS::Lambda::Function",
|
|
108
|
+
Properties: {
|
|
109
|
+
Runtime: props.runtime,
|
|
110
|
+
Handler: "index.handler",
|
|
111
|
+
Code: { S3Bucket: "PLACEHOLDER", S3Key: props.handlerPath },
|
|
112
|
+
Timeout: props.timeout,
|
|
113
|
+
MemorySize: props.memory,
|
|
114
|
+
Role: { "Fn::GetAtt": [roleLogicalId, "Arn"] },
|
|
115
|
+
Environment: { Variables: envVariables },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return { Resources: resources };
|
|
121
|
+
}
|