-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy pathwebapp.ts
More file actions
186 lines (172 loc) · 7.34 KB
/
webapp.ts
File metadata and controls
186 lines (172 loc) · 7.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
import { IgnoreMode, Duration, CfnOutput, Stack, RemovalPolicy } from 'aws-cdk-lib';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
import { DockerImageFunction, DockerImageCode, Architecture } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import { readFileSync } from 'fs';
import { CloudFrontLambdaFunctionUrlService } from './cf-lambda-furl-service/service';
import { IHostedZone } from 'aws-cdk-lib/aws-route53';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Database } from './database';
import { EdgeFunction } from './cf-lambda-furl-service/edge-function';
import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
import { Auth } from './auth/';
import { ContainerImageBuild } from 'deploy-time-build';
import { join } from 'path';
import { EventBus } from './event-bus/';
import { AsyncJob } from './async-job';
import { Trigger } from 'aws-cdk-lib/triggers';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
export interface WebappProps {
database: Database;
signPayloadHandler: EdgeFunction;
accessLogBucket: Bucket;
auth: Auth;
eventBus: EventBus;
asyncJob: AsyncJob;
/**
* Route 53 hosted zone for custom domain.
*
* @default No custom domain. The webapp will use CloudFront's default domain (e.g., d1234567890.cloudfront.net).
*/
hostedZone?: IHostedZone;
/**
* ACM certificate for custom domain (must be in us-east-1 for CloudFront).
*
* @default No custom domain.
*/
certificate?: ICertificate;
/**
* Subdomain name for the webapp. If not specified, the root domain will be used.
*
* @default Use root domain
*/
subDomain?: string;
}
export class Webapp extends Construct {
public readonly baseUrl: string;
constructor(scope: Construct, id: string, props: WebappProps) {
super(scope, id);
const { database, hostedZone, auth, subDomain, eventBus, asyncJob } = props;
// Use ContainerImageBuild to inject deploy-time values in the build environment
const image = new ContainerImageBuild(this, 'Build', {
directory: join('..', 'webapp'),
platform: Platform.LINUX_ARM64,
ignoreMode: IgnoreMode.DOCKER,
exclude: readFileSync(join('..', 'webapp', '.dockerignore'))
.toString()
.split('\n'),
tagPrefix: 'webapp-starter-',
buildArgs: {
ALLOWED_ORIGIN_HOST: hostedZone ? `*.${hostedZone.zoneName}` : '*.cloudfront.net',
SKIP_TS_BUILD: 'true',
NEXT_PUBLIC_EVENT_HTTP_ENDPOINT: eventBus.httpEndpoint,
NEXT_PUBLIC_AWS_REGION: Stack.of(this).region,
},
});
const handler = new DockerImageFunction(this, 'Handler', {
code: image.toLambdaDockerImageCode(),
timeout: Duration.minutes(3),
environment: {
...database.getLambdaEnvironment('main'),
COGNITO_DOMAIN: auth.domainName,
USER_POOL_ID: auth.userPool.userPoolId,
USER_POOL_CLIENT_ID: auth.client.userPoolClientId,
ASYNC_JOB_HANDLER_ARN: asyncJob.handler.functionArn,
},
vpc: database.cluster.vpc,
memorySize: 1024,
architecture: Architecture.ARM_64,
logGroup: new LogGroup(this, 'HandlerLogs', {
retention: RetentionDays.ONE_WEEK,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
handler.connections.allowToDefaultPort(database);
asyncJob.handler.grantInvoke(handler);
const service = new CloudFrontLambdaFunctionUrlService(this, 'Resource', {
subDomain,
handler,
serviceName: 'Webapp',
hostedZone,
certificate: props.certificate,
accessLogBucket: props.accessLogBucket,
signPayloadHandler: props.signPayloadHandler,
});
this.baseUrl = service.url;
if (hostedZone) {
auth.addAllowedCallbackUrls(
`http://localhost:3010/api/auth/sign-in-callback`,
`http://localhost:3010/api/auth/sign-out-callback`,
);
auth.addAllowedCallbackUrls(
`${this.baseUrl}/api/auth/sign-in-callback`,
`${this.baseUrl}/api/auth/sign-out-callback`,
);
handler.addEnvironment('AMPLIFY_APP_ORIGIN', service.url);
} else {
auth.updateAllowedCallbackUrls(
[`${this.baseUrl}/api/auth/sign-in-callback`, `http://localhost:3010/api/auth/sign-in-callback`],
[`${this.baseUrl}/api/auth/sign-out-callback`, `http://localhost:3010/api/auth/sign-out-callback`],
);
const originSourceParameter = new StringParameter(this, 'OriginSourceParameter', {
stringValue: 'dummy',
});
originSourceParameter.grantRead(handler);
handler.addEnvironment('AMPLIFY_APP_ORIGIN_SOURCE_PARAMETER', originSourceParameter.parameterName);
// We need to pass AMPLIFY_APP_ORIGIN environment variable for callback URL,
// but we cannot know CloudFront domain before deploying Lambda function.
// To avoid the circular dependency, we fetch the domain name on runtime.
new AwsCustomResource(this, 'UpdateAmplifyOriginSourceParameter', {
onUpdate: {
service: 'ssm',
action: 'putParameter',
parameters: {
Name: originSourceParameter.parameterName,
Value: service.url,
Overwrite: true,
},
physicalResourceId: PhysicalResourceId.of(originSourceParameter.parameterName),
},
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: [originSourceParameter.parameterArn],
}),
});
}
const migrationRunner = new DockerImageFunction(this, 'MigrationRunner', {
code: DockerImageCode.fromImageAsset(join('..', 'webapp'), {
platform: Platform.LINUX_ARM64,
cmd: ['migration-runner.handler'],
file: 'job.Dockerfile',
}),
architecture: Architecture.ARM_64,
timeout: Duration.minutes(5),
environment: {
...database.getLambdaEnvironment('main'),
},
vpc: database.cluster.vpc,
memorySize: 256,
logGroup: new LogGroup(this, 'MigrationRunnerLogs', {
retention: RetentionDays.ONE_WEEK,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
migrationRunner.connections.allowToDefaultPort(database);
// Run database migration during CDK deployment
// The Trigger construct automatically invokes the migration runner with default payload (command: 'deploy')
// To manually run migrations with different commands (e.g., 'force'), use the AWS CLI command shown in the CDK output below
const trigger = new Trigger(this, 'MigrationTrigger', {
handler: migrationRunner,
});
// make sure migration is executed after the database cluster is available.
trigger.node.addDependency(database.cluster);
// Output migration-related information for manual invocation
// Available commands: "deploy" (default), "force" (with --accept-data-loss)
// Example: aws lambda invoke --function-name <FUNCTION_NAME> --payload '{"command":"force"}' --cli-binary-format raw-in-base64-out /dev/stdout
new CfnOutput(Stack.of(this), 'MigrationFunctionName', { value: migrationRunner.functionName });
new CfnOutput(Stack.of(this), 'MigrationCommand', {
value: `aws lambda invoke --function-name ${migrationRunner.functionName} --payload '{"command":"deploy"}' --cli-binary-format raw-in-base64-out /dev/stdout`,
});
}
}