@bitblit/ratchet-epsilon-deployment 6.0.145-alpha → 6.0.147-alpha

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/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@bitblit/ratchet-epsilon-deployment",
3
- "version": "6.0.145-alpha",
3
+ "version": "6.0.147-alpha",
4
4
  "description": "Epsilon CDK extensions to simplify deployment",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "files": [
8
+ "src/**",
8
9
  "lib/**",
9
10
  "bin/**"
10
11
  ],
@@ -50,14 +51,14 @@
50
51
  },
51
52
  "license": "Apache-2.0",
52
53
  "dependencies": {
53
- "@bitblit/ratchet-aws": "6.0.145-alpha",
54
- "@bitblit/ratchet-common": "6.0.145-alpha",
55
- "@bitblit/ratchet-epsilon-common": "6.0.145-alpha",
54
+ "@bitblit/ratchet-aws": "6.0.147-alpha",
55
+ "@bitblit/ratchet-common": "6.0.147-alpha",
56
+ "@bitblit/ratchet-epsilon-common": "6.0.147-alpha",
56
57
  "aws-cdk-lib": "2.221.1",
57
58
  "constructs": "10.4.2"
58
59
  },
59
60
  "peerDependencies": {
60
- "@bitblit/ratchet-common": "6.0.145-alpha",
61
+ "@bitblit/ratchet-common": "6.0.147-alpha",
61
62
  "aws-cdk-lib": "^2.221.1",
62
63
  "constructs": "^10.4.2"
63
64
  }
@@ -0,0 +1,19 @@
1
+ import { BuildInformation } from '@bitblit/ratchet-common/build/build-information';
2
+
3
+ export class RatchetEpsilonDeploymentInfo {
4
+ // Empty constructor prevents instantiation
5
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
6
+ private constructor() {}
7
+
8
+ public static buildInformation(): BuildInformation {
9
+ const val: BuildInformation = {
10
+ version: 'LOCAL-SNAPSHOT',
11
+ hash: 'LOCAL-HASH',
12
+ branch: 'LOCAL-BRANCH',
13
+ tag: 'LOCAL-TAG',
14
+ timeBuiltISO: 'LOCAL-TIME-ISO',
15
+ notes: 'LOCAL-NOTES',
16
+ };
17
+ return val;
18
+ }
19
+ }
@@ -0,0 +1,7 @@
1
+ import { IBucket } from 'aws-cdk-lib/aws-s3';
2
+ import { BehaviorOptions } from 'aws-cdk-lib/aws-cloudfront';
3
+
4
+ export interface BucketAndBehaviorOptions {
5
+ bucket: IBucket;
6
+ behaviorOptions: BehaviorOptions;
7
+ }
@@ -0,0 +1,5 @@
1
+ export enum EpsilonApiStackFeature {
2
+ WebLambda = 'WebLambda',
3
+ BackgroundLambda = 'BackgroundLambda',
4
+ AwsBatchHandler = 'AwsBatchHandler',
5
+ }
@@ -0,0 +1,32 @@
1
+ import { StackProps } from 'aws-cdk-lib';
2
+ import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
3
+ import { EpsilonApiStackFeature } from './epsilon-api-stack-feature.js';
4
+ import { SubnetAttributes } from 'aws-cdk-lib/aws-ec2';
5
+ import { EpsilonSimpleLambdaCloudfrontDistributionProps } from './epsilon-simple-lambda-cloudfront-distribution-props';
6
+
7
+ export interface EpsilonApiStackProps extends StackProps {
8
+ batchInstancesEc2KeyPairName?: string;
9
+ additionalPolicyStatements: PolicyStatement[];
10
+
11
+ disabledFeatures?: EpsilonApiStackFeature[];
12
+
13
+ dockerFileFolder: string;
14
+ dockerFileName: string;
15
+
16
+ lambdaSecurityGroupIds: string[];
17
+ vpcSubnetAttributes: SubnetAttributes[];
18
+ vpcId: string;
19
+
20
+ extraEnvironmentalVars?: Record<string, string>;
21
+ webLambdaPingMinutes?: number;
22
+
23
+ webMemorySizeMb?: number;
24
+ backgroundMemorySizeMb?: number;
25
+
26
+ webTimeoutSeconds?: number;
27
+ backgroundTimeoutSeconds?: number;
28
+
29
+ replaceBatchComputeEnvironment?: boolean;
30
+
31
+ autoCloudfrontDistribution?: EpsilonSimpleLambdaCloudfrontDistributionProps;
32
+ }
@@ -0,0 +1,284 @@
1
+ import { Duration, Lazy, Size, Stack } from 'aws-cdk-lib';
2
+ import { Construct } from 'constructs';
3
+ import { DockerImageCode, DockerImageFunction, FunctionUrl, FunctionUrlAuthType, HttpMethod } from 'aws-cdk-lib/aws-lambda';
4
+ import { ManagedPolicy, PolicyDocument, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
5
+ import { Topic } from 'aws-cdk-lib/aws-sns';
6
+ import { Queue } from 'aws-cdk-lib/aws-sqs';
7
+ import { LambdaSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
8
+
9
+ import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
10
+ import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
11
+ import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets';
12
+ import { StringRatchet } from '@bitblit/ratchet-common/lang/string-ratchet';
13
+ import { EpsilonStackUtil } from './epsilon-stack-util.js';
14
+ import { EpsilonApiStackProps } from './epsilon-api-stack-props.js';
15
+ import { RatchetEpsilonDeploymentInfo } from '../../build/ratchet-epsilon-deployment-info.js';
16
+ import {
17
+ EcsFargateContainerDefinition,
18
+ EcsFargateContainerDefinitionProps,
19
+ EcsJobDefinition,
20
+ EcsJobDefinitionProps,
21
+ FargateComputeEnvironment,
22
+ FargateComputeEnvironmentProps,
23
+ JobQueue,
24
+ JobQueueProps,
25
+ } from 'aws-cdk-lib/aws-batch';
26
+ import { SecurityGroup, Subnet, SubnetSelection, Vpc } from 'aws-cdk-lib/aws-ec2';
27
+
28
+ import { ContainerImage } from 'aws-cdk-lib/aws-ecs';
29
+ import { EpsilonApiStackFeature } from './epsilon-api-stack-feature.js';
30
+ import { EpsilonSimpleLambdaCloudfrontDistributionProps } from './epsilon-simple-lambda-cloudfront-distribution-props';
31
+ import { EpsilonSimpleLambdaCloudfrontDistribution } from './epsilon-simple-lambda-cloudfront-distribution';
32
+ import { EpsilonRoute53Handling } from './epsilon-route-53-handling';
33
+ import { HostedZone, RecordSet, RecordType } from 'aws-cdk-lib/aws-route53';
34
+ import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets';
35
+
36
+ export class EpsilonApiStack extends Stack {
37
+ private webHandler: DockerImageFunction;
38
+ private backgroundHandler: DockerImageFunction;
39
+
40
+ public webFunctionUrl: FunctionUrl;
41
+ public apiDomain: string;
42
+
43
+ constructor(scope: Construct, id: string, props?: EpsilonApiStackProps) {
44
+ super(scope, id, props);
45
+
46
+ const disabledFeatures: EpsilonApiStackFeature[] = props?.disabledFeatures || [];
47
+
48
+ // Build the docker image first
49
+ const dockerImageAsset: DockerImageAsset = new DockerImageAsset(this, id + 'DockerImage', {
50
+ directory: props.dockerFileFolder,
51
+ file: props.dockerFileName,
52
+ });
53
+ const dockerImageCode: DockerImageCode = DockerImageCode.fromImageAsset(props.dockerFileFolder, { file: props.dockerFileName });
54
+
55
+ const notificationTopic: Topic = new Topic(this, id + 'WorkNotificationTopic');
56
+ const workQueue: Queue = new Queue(this, id + 'WorkQueue', {
57
+ fifo: true,
58
+ retentionPeriod: Duration.hours(8),
59
+ visibilityTimeout: Duration.minutes(5),
60
+ contentBasedDeduplication: true,
61
+ ...props,
62
+ });
63
+
64
+ const interApiGenericEventTopic: Topic = new Topic(this, id + 'InterApiTopic');
65
+
66
+ const epsilonEnv: Record<string, string> = {
67
+ EPSILON_AWS_REGION: StringRatchet.safeString(Stack.of(this).region),
68
+ EPSILON_AWS_AVAILABILITY_ZONES: StringRatchet.safeString(JSON.stringify(Stack.of(this).availabilityZones)),
69
+ EPSILON_BACKGROUND_SQS_QUEUE_URL: StringRatchet.safeString(workQueue.queueUrl),
70
+ EPSILON_BACKGROUND_SNS_TOPIC_ARN: StringRatchet.safeString(notificationTopic.topicArn),
71
+ EPSILON_INTER_API_EVENT_TOPIC_ARN: StringRatchet.safeString(interApiGenericEventTopic.topicArn),
72
+ EPSILON_LIB_BUILD_HASH: StringRatchet.safeString(RatchetEpsilonDeploymentInfo.buildInformation().hash),
73
+ EPSILON_LIB_BUILD_TIME: StringRatchet.safeString(RatchetEpsilonDeploymentInfo.buildInformation().timeBuiltISO),
74
+ EPSILON_LIB_BUILD_BRANCH_OR_TAG: StringRatchet.safeString(
75
+ RatchetEpsilonDeploymentInfo.buildInformation().branch || RatchetEpsilonDeploymentInfo.buildInformation().tag,
76
+ ),
77
+ EPSILON_LIB_BUILD_VERSION: StringRatchet.safeString(RatchetEpsilonDeploymentInfo.buildInformation().version),
78
+ };
79
+ const env: Record<string, string> = Object.assign({}, props.extraEnvironmentalVars || {}, epsilonEnv);
80
+
81
+ if (!disabledFeatures.includes(EpsilonApiStackFeature.AwsBatchHandler)) {
82
+ // Then build the Batch compute stuff...
83
+ // This is the role that ECS uses to pull containers, secrets, etc
84
+ const executionRole = new Role(this, id + 'BatchExecutionRole', {
85
+ assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'), //'ec2.amazonaws.com'),
86
+ managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')],
87
+ inlinePolicies: {
88
+ root: new PolicyDocument({
89
+ statements: EpsilonStackUtil.ECS_POLICY_STATEMENTS,
90
+ }),
91
+ },
92
+ });
93
+
94
+ // This is the role used by the container to actually do business logic (your code uses this role)
95
+ const jobRole = new Role(this, id + 'BatchJobRole', {
96
+ assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
97
+ managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')],
98
+ inlinePolicies: {
99
+ root: new PolicyDocument({
100
+ statements: EpsilonStackUtil.createDefaultPolicyStatementList(props, workQueue, notificationTopic, interApiGenericEventTopic),
101
+ }),
102
+ },
103
+ });
104
+
105
+ const subnetSelection: SubnetSelection = {
106
+ subnets: props.vpcSubnetAttributes.map((subnetAttr, index) => Subnet.fromSubnetAttributes(this, `VpcSubnet${index}`, subnetAttr)),
107
+ };
108
+
109
+ // Created AWSServiceBatchRole
110
+ // https://docs.aws.amazon.com/batch/latest/userguide/service_IAM_role.html
111
+ const compEnvProps: FargateComputeEnvironmentProps = {
112
+ vpc: Vpc.fromLookup(this, `Vpc`, { vpcId: props.vpcId }),
113
+ computeEnvironmentName: id + 'ComputeEnv',
114
+ enabled: true,
115
+ maxvCpus: 16,
116
+ replaceComputeEnvironment: props.replaceBatchComputeEnvironment ?? false,
117
+ securityGroups: props.lambdaSecurityGroupIds.map((sgId, index) =>
118
+ SecurityGroup.fromSecurityGroupId(this, `SecurityGroup${index}`, `sg-${sgId}`),
119
+ ),
120
+ serviceRole: Role.fromRoleArn(
121
+ this,
122
+ `${id}ServiceRole`,
123
+ 'arn:aws:iam::' + props.env.account + ':role/aws-service-role/batch.amazonaws.com/AWSServiceRoleForBatch',
124
+ ),
125
+ //Role.fromRoleArn(this, `${id}ServiceRole`, 'arn:aws:iam::' + props.env.account + ':role/AWSBatchServiceRole'),
126
+ spot: false,
127
+ terminateOnUpdate: false,
128
+ updateTimeout: Duration.hours(4),
129
+ updateToLatestImageVersion: true,
130
+ vpcSubnets: subnetSelection,
131
+ //vpcSubnets: subnetSelection,
132
+ };
133
+
134
+ const compEnv: FargateComputeEnvironment = new FargateComputeEnvironment(this, id + 'ComputeEnv', compEnvProps);
135
+
136
+ const batchJobQueueProps: JobQueueProps = {
137
+ computeEnvironments: [{ order: 1, computeEnvironment: compEnv }],
138
+ enabled: true,
139
+ jobQueueName: id + 'BatchJobQueue',
140
+ priority: 10,
141
+ schedulingPolicy: undefined, // Implement later?
142
+ };
143
+
144
+ const batchJobQueue = new JobQueue(this, id + 'BatchJobQueue', batchJobQueueProps);
145
+
146
+ const batchEnvVars: Record<string, any> = EpsilonStackUtil.toEnvironmentVariables([
147
+ env,
148
+ props.extraEnvironmentalVars || {},
149
+ {
150
+ EPSILON_RUNNING_IN_AWS_BATCH: true,
151
+ },
152
+ ]);
153
+
154
+ const containerDef: EcsFargateContainerDefinitionProps = {
155
+ cpu: 4,
156
+ image: ContainerImage.fromRegistry(dockerImageAsset.imageUri),
157
+ memory: Size.mebibytes(8192),
158
+ assignPublicIp: true, // Need this to talk to ECS to get the container
159
+ command: ['Ref::taskName', 'Ref::taskDataBase64', 'Ref::traceId', 'Ref::traceDepth', 'Ref::taskMetaDataBase64'], // Bootstrap to the Lambda handler
160
+ environment: batchEnvVars,
161
+ executionRole: executionRole,
162
+ //fargatePlatformVersion: undefined,
163
+ jobRole: jobRole, //Role.fromRoleArn(this, `${id}JobRole`, jobRole.roleArn),
164
+ //linuxParameters: undefined,
165
+ readonlyRootFilesystem: false,
166
+ //secrets: undefined,
167
+ //user: undefined,
168
+ volumes: [],
169
+ };
170
+
171
+ const fargateContainerDefinitionDef = new EcsFargateContainerDefinition(this, `${id}FargateContainerDefinition`, containerDef);
172
+
173
+ const jobProps: EcsJobDefinitionProps = {
174
+ jobDefinitionName: id + 'JobDefinition',
175
+ retryAttempts: 3,
176
+ retryStrategies: undefined,
177
+ schedulingPriority: undefined,
178
+ timeout: undefined,
179
+ container: fargateContainerDefinitionDef,
180
+ };
181
+
182
+ const jobDef: EcsJobDefinition = new EcsJobDefinition(this, id + 'JobDefinition', jobProps);
183
+
184
+ // Add AWS batch vars to the environment
185
+ env['EPSILON_AWS_BATCH_JOB_DEFINITION_ARN'] = jobDef.jobDefinitionArn; // .ref;
186
+ env['EPSILON_AWS_BATCH_JOB_QUEUE_ARN'] = batchJobQueue.jobQueueArn; // .ref;
187
+ }
188
+
189
+ // This is needed for both background and web lambdas
190
+ const lambdaRole = new Role(this, 'customRole', {
191
+ roleName: id + 'LambdaCustomRole',
192
+ assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
193
+ managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')],
194
+ inlinePolicies: {
195
+ root: new PolicyDocument({
196
+ statements: EpsilonStackUtil.createDefaultPolicyStatementList(props, workQueue, notificationTopic, interApiGenericEventTopic),
197
+ }),
198
+ },
199
+ });
200
+
201
+ if (!disabledFeatures.includes(EpsilonApiStackFeature.WebLambda)) {
202
+ this.webHandler = new DockerImageFunction(this, id + 'Web', {
203
+ //reservedConcurrentExecutions: 1,
204
+ retryAttempts: 2,
205
+ //allowAllOutbound: true, // Needs a VPC
206
+ memorySize: props.webMemorySizeMb || 128,
207
+ ephemeralStorageSize: Size.mebibytes(512),
208
+ timeout: Duration.seconds(props.webTimeoutSeconds || 20),
209
+ code: dockerImageCode,
210
+ role: lambdaRole,
211
+ environment: env,
212
+ });
213
+
214
+ if (props?.webLambdaPingMinutes && props.webLambdaPingMinutes > 0) {
215
+ // Wire up the cron handler
216
+ const rule = new Rule(this, id + 'WebKeepaliveRule', {
217
+ schedule: Schedule.rate(Duration.minutes(Math.ceil(props.webLambdaPingMinutes))),
218
+ });
219
+ rule.addTarget(new LambdaFunction(this.webHandler));
220
+ }
221
+
222
+ this.webFunctionUrl = this.webHandler.addFunctionUrl({
223
+ authType: FunctionUrlAuthType.NONE,
224
+ cors: {
225
+ allowedOrigins: ['*'],
226
+ allowedHeaders: ['Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key'],
227
+ allowedMethods: [HttpMethod.ALL],
228
+ allowCredentials: true,
229
+ },
230
+ });
231
+
232
+ this.apiDomain = Lazy.uncachedString({
233
+ produce: (context) => {
234
+ const resolved = context.resolve(this.webFunctionUrl.url);
235
+ return { 'Fn::Select': [2, { 'Fn::Split': ['/', resolved] }] } as any;
236
+ },
237
+ });
238
+
239
+ if (props.autoCloudfrontDistribution) {
240
+ const distroPropsCopy: EpsilonSimpleLambdaCloudfrontDistributionProps = Object.assign({}, props.autoCloudfrontDistribution);
241
+ distroPropsCopy.lambdaFunctionDomain = this.webFunctionUrl;
242
+ const dist = new EpsilonSimpleLambdaCloudfrontDistribution(this, id + 'DirectApiCloudfrontDistro', distroPropsCopy);
243
+ // Have to be able to skip this since SOME people don't do DNS in Route53
244
+ if (props?.autoCloudfrontDistribution.route53Handling === EpsilonRoute53Handling.Update) {
245
+ if (props?.autoCloudfrontDistribution.domainNames?.length) {
246
+ for (const dn of props.autoCloudfrontDistribution.domainNames) {
247
+ const _domain: RecordSet = new RecordSet(this, id + 'DomainName-' + dn, {
248
+ recordType: RecordType.A,
249
+ recordName: dn,
250
+ target: {
251
+ aliasTarget: new CloudFrontTarget(dist),
252
+ },
253
+ zone: HostedZone.fromLookup(this, id + 'HostZone-' + dn, { domainName: EpsilonStackUtil.extractApexDomain(dn) }),
254
+ });
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ if (!disabledFeatures.includes(EpsilonApiStackFeature.BackgroundLambda)) {
262
+ this.backgroundHandler = new DockerImageFunction(this, id + 'Background', {
263
+ //reservedConcurrentExecutions: 1,
264
+ retryAttempts: 2,
265
+ // allowAllOutbound: true,
266
+ memorySize: props.backgroundMemorySizeMb || 3000,
267
+ ephemeralStorageSize: Size.mebibytes(512),
268
+ timeout: Duration.seconds(props.backgroundTimeoutSeconds || 900),
269
+ code: dockerImageCode,
270
+ role: lambdaRole,
271
+ environment: env,
272
+ });
273
+
274
+ notificationTopic.addSubscription(new LambdaSubscription(this.backgroundHandler));
275
+ interApiGenericEventTopic.addSubscription(new LambdaSubscription(this.backgroundHandler));
276
+
277
+ // Wire up the cron handler
278
+ const rule = new Rule(this, id + 'CronRule', {
279
+ schedule: Schedule.rate(Duration.minutes(1)),
280
+ });
281
+ rule.addTarget(new LambdaFunction(this.backgroundHandler));
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,9 @@
1
+ import { FunctionUrl } from 'aws-cdk-lib/aws-lambda';
2
+ import { Construct } from 'constructs';
3
+ import { IResponseHeadersPolicy } from 'aws-cdk-lib/aws-cloudfront';
4
+
5
+ export interface EpsilonLambdaToCloudfrontPathMapping {
6
+ lambdaFunctionUrl: FunctionUrl;
7
+ pathPattern: string;
8
+ responseHeadersPolicyCreator?: (scope: Construct, id: string) => IResponseHeadersPolicy;
9
+ }
@@ -0,0 +1,7 @@
1
+ // NOTE: This is a psuedo-enum to fix some issues with Typescript enums. See: https://exploringjs.com/tackling-ts/ch_enum-alternatives.html for details
2
+
3
+ export const EpsilonRoute53Handling = {
4
+ Update: 'Update',
5
+ DoNotUpdate: 'DoNotUpdate',
6
+ };
7
+ export type EpsilonRoute53Handling = (typeof EpsilonRoute53Handling)[keyof typeof EpsilonRoute53Handling];
@@ -0,0 +1,25 @@
1
+ import { StackProps } from 'aws-cdk-lib';
2
+ import {
3
+ AllowedMethods,
4
+ ICachePolicy,
5
+ IResponseHeadersPolicy,
6
+ PriceClass,
7
+ SSLMethod,
8
+ ViewerProtocolPolicy,
9
+ } from 'aws-cdk-lib/aws-cloudfront';
10
+ import { FunctionUrl } from 'aws-cdk-lib/aws-lambda';
11
+ import { EpsilonRoute53Handling } from './epsilon-route-53-handling';
12
+ import { Construct } from 'constructs';
13
+
14
+ export interface EpsilonSimpleLambdaCloudfrontDistributionProps extends StackProps {
15
+ lambdaFunctionDomain: FunctionUrl;
16
+ httpsCertArn: string;
17
+ domainNames: string[];
18
+ protocolPolicy?: ViewerProtocolPolicy;
19
+ cachePolicy?: ICachePolicy;
20
+ priceClass?: PriceClass;
21
+ sslMethod?: SSLMethod;
22
+ route53Handling?: EpsilonRoute53Handling;
23
+ allowedMethods?: AllowedMethods;
24
+ responseHeadersPolicyCreator?: (scope: Construct, id: string) => IResponseHeadersPolicy;
25
+ }
@@ -0,0 +1,48 @@
1
+ import { Construct } from 'constructs';
2
+ import {
3
+ AllowedMethods,
4
+ BehaviorOptions,
5
+ CachePolicy,
6
+ Distribution,
7
+ DistributionProps,
8
+ IResponseHeadersPolicy,
9
+ OriginRequestPolicy,
10
+ PriceClass,
11
+ ResponseHeadersPolicy,
12
+ SSLMethod,
13
+ ViewerProtocolPolicy,
14
+ } from 'aws-cdk-lib/aws-cloudfront';
15
+ import { FunctionUrlOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
16
+ import { EpsilonSimpleLambdaCloudfrontDistributionProps } from './epsilon-simple-lambda-cloudfront-distribution-props.js';
17
+ import { Certificate, ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
18
+
19
+ export class EpsilonSimpleLambdaCloudfrontDistribution extends Distribution {
20
+ constructor(scope: Construct, id: string, props?: EpsilonSimpleLambdaCloudfrontDistributionProps) {
21
+ let policy: IResponseHeadersPolicy = ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT_AND_SECURITY_HEADERS;
22
+ if (props.responseHeadersPolicyCreator) {
23
+ policy = props.responseHeadersPolicyCreator(scope, id);
24
+ }
25
+
26
+ const behavior: BehaviorOptions = {
27
+ origin: new FunctionUrlOrigin(props.lambdaFunctionDomain),
28
+ compress: true,
29
+ viewerProtocolPolicy: props.protocolPolicy ?? ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
30
+ cachePolicy: props.cachePolicy ?? CachePolicy.CACHING_DISABLED,
31
+ allowedMethods: props.allowedMethods ?? AllowedMethods.ALLOW_ALL,
32
+ originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
33
+ responseHeadersPolicy: policy,
34
+ };
35
+
36
+ const httpsCertificate: ICertificate = Certificate.fromCertificateArn(scope, id + 'HttpsCert', props.httpsCertArn);
37
+
38
+ const distributionProps: DistributionProps = {
39
+ defaultBehavior: behavior,
40
+ priceClass: props.priceClass ?? PriceClass.PRICE_CLASS_ALL,
41
+ certificate: httpsCertificate,
42
+ domainNames: props.domainNames,
43
+ sslSupportMethod: props.sslMethod ?? SSLMethod.SNI,
44
+ };
45
+
46
+ super(scope, id + 'CloudfrontDistro', distributionProps);
47
+ }
48
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { EpsilonStackUtil } from './epsilon-stack-util.js';
3
+
4
+ describe('#EpsilonStackUtil', function () {
5
+ test('should extract apex domains', async () => {
6
+ expect(EpsilonStackUtil.extractApexDomain('a.b.test.com')).toEqual('test.com');
7
+ expect(EpsilonStackUtil.extractApexDomain('www.test.com')).toEqual('test.com');
8
+ expect(EpsilonStackUtil.extractApexDomain('test.com')).toEqual('test.com');
9
+ }, 500);
10
+ });
@@ -0,0 +1,164 @@
1
+ import { Logger } from '@bitblit/ratchet-common/logger/logger';
2
+ import { StringRatchet } from '@bitblit/ratchet-common/lang/string-ratchet';
3
+ import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
4
+ import { Topic } from 'aws-cdk-lib/aws-sns';
5
+ import { Queue } from 'aws-cdk-lib/aws-sqs';
6
+ import { EpsilonApiStackProps } from './epsilon-api-stack-props.js';
7
+ import { ErrorRatchet } from '@bitblit/ratchet-common/lang/error-ratchet';
8
+ import { HeadersFrameOption, HeadersReferrerPolicy, ResponseHeadersPolicy } from 'aws-cdk-lib/aws-cloudfront';
9
+ import { Construct } from 'constructs';
10
+ import { Duration } from 'aws-cdk-lib';
11
+
12
+ export class EpsilonStackUtil {
13
+ // Prevent instantiation
14
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
15
+ private constructor() {}
16
+
17
+ public static toEnvironmentVariables(input: Record<string, any>[]): Record<string, string> {
18
+ const rval: Record<string, string> = {};
19
+ input.forEach((inval) => {
20
+ Object.keys(inval).forEach((k) => {
21
+ rval[k] = StringRatchet.safeString(inval[k]);
22
+ });
23
+ });
24
+
25
+ return rval;
26
+ }
27
+
28
+ public static createDefaultPolicyStatementList(
29
+ props: EpsilonApiStackProps,
30
+ backgroundLambdaSqs: Queue,
31
+ backgroundLambdaSns: Topic,
32
+ interApiSns: Topic,
33
+ ): PolicyStatement[] {
34
+ const rval: PolicyStatement[] = (props.additionalPolicyStatements || []).concat([
35
+ new PolicyStatement({
36
+ effect: Effect.ALLOW,
37
+ actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
38
+ resources: ['arn:aws:logs:*:*:*'],
39
+ }),
40
+ new PolicyStatement({
41
+ effect: Effect.ALLOW,
42
+ actions: ['ses:SendEmail', 'ses:SendRawEmail'],
43
+ resources: ['arn:aws:ses:*'],
44
+ }),
45
+ new PolicyStatement({
46
+ effect: Effect.ALLOW,
47
+ actions: ['sqs:*'],
48
+ resources: [backgroundLambdaSqs.queueArn],
49
+ }),
50
+ new PolicyStatement({
51
+ effect: Effect.ALLOW,
52
+ actions: ['sns:*'],
53
+ resources: [backgroundLambdaSns.topicArn, interApiSns.topicArn],
54
+ }),
55
+ new PolicyStatement({
56
+ effect: Effect.ALLOW,
57
+ actions: ['batch:*'],
58
+ resources: ['*'],
59
+ }),
60
+ new PolicyStatement({
61
+ effect: Effect.ALLOW,
62
+ actions: ['ec2:DescribeSecurityGroups'],
63
+ resources: ['*'],
64
+ }),
65
+ new PolicyStatement({
66
+ effect: Effect.ALLOW,
67
+ actions: ['ec2:DescribeSubnets'],
68
+ resources: ['*'],
69
+ }),
70
+ new PolicyStatement({
71
+ effect: Effect.ALLOW,
72
+ actions: ['ec2:DescribeVpcs'],
73
+ resources: ['*'],
74
+ }),
75
+ ]);
76
+ Logger.info('Created policy statement list: %j', rval);
77
+ return rval;
78
+ }
79
+
80
+ public static readonly ALLOW_ECS: PolicyStatement = new PolicyStatement({
81
+ effect: Effect.ALLOW,
82
+ actions: ['ecs:*'],
83
+ resources: ['*'],
84
+ });
85
+
86
+ public static readonly ALLOW_ECR: PolicyStatement = new PolicyStatement({
87
+ effect: Effect.ALLOW,
88
+ actions: ['ecr:BatchCheckLayerAvailability', 'ecr:BatchGetImage', 'ecr:GetDownloadUrlForLayer', 'ecr:GetAuthorizationToken'],
89
+ resources: ['*'],
90
+ });
91
+
92
+ public static readonly ALLOW_RESTRICTED_LOGS: PolicyStatement = new PolicyStatement({
93
+ effect: Effect.ALLOW,
94
+ actions: ['logs:CreateLogStream', 'logs:PutLogEvents', 'logs:DescribeLogStreams', 'logs:CreateLogGroup'],
95
+ resources: ['*'],
96
+ });
97
+
98
+ // Used by fargate to read containers, etc
99
+ public static readonly ALLOW_FARGATE_SECRET_READING: PolicyStatement[] = [
100
+ new PolicyStatement({
101
+ effect: Effect.ALLOW,
102
+ actions: ['ssm:GetParameters'],
103
+ resources: ['*'],
104
+ }),
105
+ new PolicyStatement({
106
+ effect: Effect.ALLOW,
107
+ actions: ['secretsmanager:GetSecretValue'],
108
+ resources: ['*'],
109
+ }),
110
+ new PolicyStatement({
111
+ effect: Effect.ALLOW,
112
+ actions: ['kms:Decrypt'],
113
+ resources: ['*'],
114
+ }),
115
+ ];
116
+
117
+ public static readonly ECS_POLICY_STATEMENTS: PolicyStatement[] = [
118
+ EpsilonStackUtil.ALLOW_ECS,
119
+ EpsilonStackUtil.ALLOW_ECR,
120
+ EpsilonStackUtil.ALLOW_RESTRICTED_LOGS,
121
+ ].concat(EpsilonStackUtil.ALLOW_FARGATE_SECRET_READING);
122
+
123
+ public static extractApexDomain(domainName: string): string {
124
+ const pieces: string[] = StringRatchet.trimToEmpty(domainName).split('.');
125
+ if (pieces.length < 2) {
126
+ ErrorRatchet.throwFormattedErr('Not a valid domain name : %s', domainName);
127
+ }
128
+ return pieces[pieces.length - 2] + '.' + pieces[pieces.length - 1];
129
+ }
130
+
131
+ public static createForwardCorsPolicy(app: Construct, id: string, xssReportUri: string): ResponseHeadersPolicy {
132
+ // Creating a custom response headers policy -- all parameters optional
133
+ const rval: ResponseHeadersPolicy = new ResponseHeadersPolicy(app, id + 'CFRespHeadersPolicy', {
134
+ responseHeadersPolicyName: id + 'CustomCloudfrontPolicy',
135
+ comment: 'Policy allowing passthru for CORS headers',
136
+ corsBehavior: {
137
+ accessControlAllowCredentials: false,
138
+ accessControlAllowHeaders: ['*'],
139
+ accessControlAllowMethods: ['*'],
140
+ accessControlAllowOrigins: ['*'],
141
+ accessControlExposeHeaders: [],
142
+ accessControlMaxAge: Duration.seconds(600),
143
+ originOverride: false, // Use the origin values, if any
144
+ },
145
+ customHeadersBehavior: {
146
+ customHeaders: [
147
+ //{ header: 'X-Amz-Date', value: 'some-value', override: true },
148
+ //{ header: 'X-Amz-Security-Token', value: 'some-value', override: false },
149
+ ],
150
+ },
151
+ securityHeadersBehavior: {
152
+ contentSecurityPolicy: { contentSecurityPolicy: 'default-src https:;', override: true },
153
+ contentTypeOptions: { override: true },
154
+ frameOptions: { frameOption: HeadersFrameOption.DENY, override: true },
155
+ referrerPolicy: { referrerPolicy: HeadersReferrerPolicy.NO_REFERRER, override: true },
156
+ strictTransportSecurity: { accessControlMaxAge: Duration.seconds(600), includeSubdomains: true, override: true },
157
+ xssProtection: { protection: true, modeBlock: false, reportUri: xssReportUri, override: true },
158
+ },
159
+ removeHeaders: ['Server'],
160
+ serverTimingSamplingRate: 50,
161
+ });
162
+ return rval;
163
+ }
164
+ }
@@ -0,0 +1,5 @@
1
+ export enum EpsilonWebsiteCacheBehavior {
2
+ Default = 'Default',
3
+ NoCache = 'NoCache',
4
+ Custom = 'Custom',
5
+ }
@@ -0,0 +1,19 @@
1
+ import { StackProps } from 'aws-cdk-lib';
2
+ import { SimpleAdditionalS3WebsiteMapping } from './simple-additional-s3-website-mapping.js';
3
+ import { EpsilonLambdaToCloudfrontPathMapping } from './epsilon-lambda-to-cloudfront-path-mapping.js';
4
+ import { Behavior } from 'aws-cdk-lib/aws-cloudfront';
5
+ import { EpsilonWebsiteCacheBehavior } from './epsilon-website-cache-behavior.js';
6
+ import { EpsilonRoute53Handling } from './epsilon-route-53-handling.js';
7
+
8
+ export interface EpsilonWebsiteStackProps extends StackProps {
9
+ targetBucketName: string;
10
+ cloudFrontHttpsCertificateArn: string;
11
+ cloudFrontDomainNames: string[];
12
+ apiMappings: EpsilonLambdaToCloudfrontPathMapping[];
13
+ pathsToAssets: string[];
14
+ route53Handling: EpsilonRoute53Handling;
15
+ simpleAdditionalMappings?: SimpleAdditionalS3WebsiteMapping[];
16
+ websiteCacheBehavior?: EpsilonWebsiteCacheBehavior;
17
+ websiteBehaviorOverride?: Behavior[];
18
+ retainWebsiteBucketOnDestroy?: boolean;
19
+ }
@@ -0,0 +1,148 @@
1
+ import { Bucket, BucketEncryption } from 'aws-cdk-lib/aws-s3';
2
+ import { CfnOutput, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
3
+ import { Construct } from 'constructs';
4
+ import path from 'path';
5
+ import {
6
+ AllowedMethods,
7
+ BehaviorOptions,
8
+ CachePolicy,
9
+ Distribution,
10
+ DistributionProps,
11
+ PriceClass,
12
+ ResponseHeadersPolicy,
13
+ SSLMethod,
14
+ ViewerProtocolPolicy,
15
+ } from 'aws-cdk-lib/aws-cloudfront';
16
+ import { HostedZone, RecordSet, RecordType } from 'aws-cdk-lib/aws-route53';
17
+ import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets';
18
+ import { BucketDeployment, ISource, Source } from 'aws-cdk-lib/aws-s3-deployment';
19
+ import { EpsilonWebsiteStackProps } from './epsilon-website-stack-props.js';
20
+ import { EpsilonStackUtil } from './epsilon-stack-util.js';
21
+ import { EpsilonRoute53Handling } from './epsilon-route-53-handling';
22
+ import { FunctionUrlOrigin, S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
23
+ import { Certificate, ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
24
+ import { Logger } from '@bitblit/ratchet-common/logger/logger';
25
+
26
+ export class EpsilonWebsiteStack extends Stack {
27
+ constructor(scope: Construct, id: string, props?: EpsilonWebsiteStackProps) {
28
+ super(scope, id, props);
29
+
30
+ // Create the bucket to hold the SPA
31
+ const websiteBucket: Bucket = new Bucket(this, id + 'DeployBucket', {
32
+ bucketName: props.targetBucketName,
33
+ removalPolicy: props.retainWebsiteBucketOnDestroy ? undefined : RemovalPolicy.DESTROY,
34
+ autoDeleteObjects: !props.retainWebsiteBucketOnDestroy,
35
+ versioned: false,
36
+ publicReadAccess: false,
37
+ encryption: BucketEncryption.S3_MANAGED,
38
+ });
39
+ // Create the access id for that bucket
40
+ //const originAccessId: OriginAccessIdentity = new OriginAccessIdentity(this, id + 'OriginAccessId');
41
+ //websiteBucket.grantRead(originAccessId);
42
+ // Cache policy for that bucket
43
+ const cachePolicy: CachePolicy = new CachePolicy(this, id + 'ShortCachePolicy', {
44
+ defaultTtl: Duration.seconds(1),
45
+ maxTtl: Duration.seconds(1),
46
+ minTtl: Duration.seconds(1),
47
+ });
48
+
49
+ const defaultBehavior: BehaviorOptions = {
50
+ origin: S3BucketOrigin.withOriginAccessControl(websiteBucket), // new FunctionUrlOrigin(props.lambdaFunctionDomain),
51
+ compress: true,
52
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
53
+ cachePolicy: cachePolicy,
54
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
55
+ };
56
+
57
+ const httpsCertificate: ICertificate = Certificate.fromCertificateArn(this, id + 'HttpsCert', props.cloudFrontHttpsCertificateArn);
58
+
59
+ const distributionProps: DistributionProps = {
60
+ defaultBehavior: defaultBehavior,
61
+ defaultRootObject: 'index.html',
62
+ priceClass: PriceClass.PRICE_CLASS_ALL,
63
+ certificate: httpsCertificate,
64
+ domainNames: props.cloudFrontDomainNames,
65
+ sslSupportMethod: SSLMethod.SNI,
66
+ additionalBehaviors: {}, // Will be added after
67
+ errorResponses: [
68
+ {
69
+ httpStatus: 404,
70
+ ttl: Duration.seconds(300),
71
+ responseHttpStatus: 200,
72
+ responsePagePath: '/index.html',
73
+ },
74
+ {
75
+ httpStatus: 403,
76
+ ttl: Duration.seconds(300),
77
+ responseHttpStatus: 200,
78
+ responsePagePath: '/index.html',
79
+ },
80
+ ],
81
+ };
82
+
83
+ // Add api sources, if any
84
+ (props.apiMappings || []).forEach((s) => {
85
+ const next: BehaviorOptions = {
86
+ origin: new FunctionUrlOrigin(s.lambdaFunctionUrl),
87
+ compress: true,
88
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
89
+ cachePolicy: CachePolicy.CACHING_DISABLED,
90
+ allowedMethods: AllowedMethods.ALLOW_ALL,
91
+ responseHeadersPolicy: s.responseHeadersPolicyCreator
92
+ ? s.responseHeadersPolicyCreator(this, id)
93
+ : ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT_AND_SECURITY_HEADERS,
94
+ };
95
+ distributionProps.additionalBehaviors[s.pathPattern] = next;
96
+ });
97
+
98
+ // Add extra bucket mappings, if any
99
+ // They are assumed to have been created outside of Epsilon, so they are imported not created
100
+ (props.simpleAdditionalMappings || []).forEach((eb) => {
101
+ const nextBucket = Bucket.fromBucketAttributes(this, eb.bucketName + 'ImportedBucket', {
102
+ bucketName: eb.bucketName,
103
+ });
104
+
105
+ const behaviorOptions: BehaviorOptions = {
106
+ origin: S3BucketOrigin.withOriginAccessControl(nextBucket),
107
+ compress: true,
108
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
109
+ cachePolicy: cachePolicy,
110
+ allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
111
+ };
112
+
113
+ distributionProps.additionalBehaviors[eb.pathPattern] = behaviorOptions;
114
+ });
115
+
116
+ // Create the distro
117
+ const cloudfrontDistro: Distribution = new Distribution(this, id + 'CloudfrontDistro', distributionProps);
118
+ new CfnOutput(this, 'DistributionUrl', { value: 'https://' + cloudfrontDistro.domainName });
119
+
120
+ // Have to be able to skip this since SOME people don't do DNS in Route53
121
+ if (props?.route53Handling === EpsilonRoute53Handling.Update) {
122
+ if (props?.cloudFrontDomainNames?.length) {
123
+ props.cloudFrontDomainNames.forEach((dn, _idx) => {
124
+ const _domain: RecordSet = new RecordSet(this, id + 'DomainName-' + dn, {
125
+ recordType: RecordType.A,
126
+ recordName: dn,
127
+ target: {
128
+ aliasTarget: new CloudFrontTarget(cloudfrontDistro),
129
+ },
130
+ zone: HostedZone.fromLookup(this, id, { domainName: EpsilonStackUtil.extractApexDomain(dn) }),
131
+ });
132
+ });
133
+ }
134
+ }
135
+
136
+ const assetSources: ISource[] = props.pathsToAssets.map((inPath) => Source.asset(path.resolve(inPath)));
137
+ Logger.info('Found %d asset sources to push to S3', assetSources.length);
138
+
139
+ // Sync files to the S3 Bucket
140
+ // [Source.asset(path.resolve('../website/dist'))],
141
+ new BucketDeployment(this, id + 'SiteDeploy', {
142
+ sources: assetSources,
143
+ destinationBucket: websiteBucket,
144
+ distribution: cloudfrontDistro,
145
+ distributionPaths: ['/*'], //'/locales/*', '/index.html', '/manifest.webmanifest', '/service-worker.js']
146
+ });
147
+ }
148
+ }
@@ -0,0 +1,4 @@
1
+ export interface SimpleAdditionalS3WebsiteMapping {
2
+ bucketName: string;
3
+ pathPattern: string;
4
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file Automatically generated by barrelsby.
3
+ */
4
+
5
+ export * from './cdk/bucket-and-behavior-options';
6
+ export * from './cdk/epsilon-api-stack-feature.js';
7
+ export * from './cdk/epsilon-api-stack-props.js';
8
+ export * from './cdk/epsilon-api-stack.js';
9
+ export * from './cdk/epsilon-lambda-to-cloudfront-path-mapping.js';
10
+ export * from './cdk/epsilon-stack-util.js';
11
+ export * from './cdk/epsilon-website-cache-behavior.js';
12
+ export * from './cdk/epsilon-website-stack-props.js';
13
+ export * from './cdk/epsilon-website-stack.js';
14
+ export * from './cdk/simple-additional-s3-website-mapping.js';