From 0520fb3b65572d3aa71a6277326a282d9e039635 Mon Sep 17 00:00:00 2001 From: Daniel Van Der Ploeg Date: Tue, 29 Aug 2023 14:18:12 +0930 Subject: [PATCH 1/8] feat: add initial construct for mesh hosting --- .gitignore | 4 + README.md | 3 + packages/graphql-mesh-server/.npmignore | 11 + packages/graphql-mesh-server/README.md | 15 ++ packages/graphql-mesh-server/index.ts | 3 + packages/graphql-mesh-server/lib/fargate.ts | 220 ++++++++++++++++++ .../lib/graphql-mesh-server.ts | 95 ++++++++ packages/graphql-mesh-server/lib/pipeline.ts | 91 ++++++++ .../lib/redis-construct.ts | 96 ++++++++ .../lib/web-application-firewall.ts | 164 +++++++++++++ packages/graphql-mesh-server/package.json | 23 ++ packages/graphql-mesh-server/tsconfig.json | 3 + 12 files changed, 728 insertions(+) create mode 100644 packages/graphql-mesh-server/.npmignore create mode 100644 packages/graphql-mesh-server/README.md create mode 100644 packages/graphql-mesh-server/index.ts create mode 100644 packages/graphql-mesh-server/lib/fargate.ts create mode 100644 packages/graphql-mesh-server/lib/graphql-mesh-server.ts create mode 100644 packages/graphql-mesh-server/lib/pipeline.ts create mode 100644 packages/graphql-mesh-server/lib/redis-construct.ts create mode 100644 packages/graphql-mesh-server/lib/web-application-firewall.ts create mode 100644 packages/graphql-mesh-server/package.json create mode 100644 packages/graphql-mesh-server/tsconfig.json diff --git a/.gitignore b/.gitignore index 8f77f768..8f11aef6 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ dist/ # JetBrains IDE .idea/ +# VSCode IDE +.vscode/ + # Unit test reports TEST*.xml @@ -53,6 +56,7 @@ Thumbs.db # CDK asset staging directory .cdk.staging cdk.out +*.tsbuildinfo *.d.ts *.js diff --git a/README.md b/README.md index 0ea9aeab..e3934567 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,6 @@ When making a release (including experimental releases), the release tag should |---------------|---------------| | Experimental | 1.1.0-beta | | Final | 1.1.0 | + +## Testing locally + diff --git a/packages/graphql-mesh-server/.npmignore b/packages/graphql-mesh-server/.npmignore new file mode 100644 index 00000000..bfd115ba --- /dev/null +++ b/packages/graphql-mesh-server/.npmignore @@ -0,0 +1,11 @@ +*.ts +!lib/handlers/*.ts +!*.d.ts +!*.js + +# CDK asset staging directory +.cdk.staging +cdk.out + +# Samples +sample/ diff --git a/packages/graphql-mesh-server/README.md b/packages/graphql-mesh-server/README.md new file mode 100644 index 00000000..4263295b --- /dev/null +++ b/packages/graphql-mesh-server/README.md @@ -0,0 +1,15 @@ +# Prerender in Fargate +A construct host [GraphQL Mesh](https://the-guild.dev/graphql/mesh) server in Fargate. + +## Props + - `vpc?`: VPC to attach Redis and Fargate instances to (default: create a vpc) + - `vpcName?`: If no VPC is provided create one with this name (default: 'graphql-server-vpc') + - `cacheNodeType?`: Cache node type (default: 'cache.t2.micro') + - `repository?`: Repository to pull the container image from + - `certificateArn:` ARN of the certificate to add to the load balancer + - `minCapacity?`: Minimum number of Fargate instances + - `maxCapacity?`: Maximum number of Fargate instances + - `cpu?`: Amount of vCPU per Fargate instance (default: 512) + - `memory?`: Amount of memory per Fargate instance (default: 1024) + - `redis?`: Redis instance to use for mesh caching + - `secrets?`: SSM values to pass through to the container as secrets diff --git a/packages/graphql-mesh-server/index.ts b/packages/graphql-mesh-server/index.ts new file mode 100644 index 00000000..cd739cad --- /dev/null +++ b/packages/graphql-mesh-server/index.ts @@ -0,0 +1,3 @@ +import { MeshHosting } from "./lib/graphql-mesh-server"; + +export { MeshHosting }; diff --git a/packages/graphql-mesh-server/lib/fargate.ts b/packages/graphql-mesh-server/lib/fargate.ts new file mode 100644 index 00000000..7a36cfb6 --- /dev/null +++ b/packages/graphql-mesh-server/lib/fargate.ts @@ -0,0 +1,220 @@ +import { Construct } from 'constructs'; +import { Duration } from 'aws-cdk-lib'; +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as acm from 'aws-cdk-lib/aws-certificatemanager'; +import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as ecr from 'aws-cdk-lib/aws-ecr'; +import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as ssm from 'aws-cdk-lib/aws-ssm'; +import * as auto_scaling from 'aws-cdk-lib/aws-autoscaling'; +import { Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; +import { RedisService } from './redis-construct'; +import { ManagedRule, Scope, WebApplicationFirewall } from './web-application-firewall'; + +export interface MeshServiceProps { + /** + * VPC to attach Redis instance to + */ + vpc?: Vpc; + /** + * Repository to pull the container image from + */ + repository?: ecr.Repository; + /** + * ARN of the certificate to add to the load balancer + */ + certificateArn: string; + /** + * Minimum number of Fargate instances + */ + minCapacity?: number; + /** + * Maximum number of Fargate instances + */ + maxCapacity?: number; + /** + * Amount of vCPU per instance (default: 512) + */ + cpu?: number; + /** + * Amount of memory per instance (default: 1024) + */ + memory?: number; + /** + * Redis instance to use for mesh caching + */ + redis: RedisService; + /** + * SSM values to pass through to the container as secrets + */ + secrets?: {[key: string]: ssm.IStringParameter | ssm.IStringListParameter}; +} + +export class MeshService extends Construct { + public readonly vpc: Vpc; + public readonly repository: ecr.Repository; + public readonly service: ecs.FargateService; + public readonly firewall: WebApplicationFirewall; + + constructor(scope: Construct, id: string, props: MeshServiceProps) { + super(scope, id); + + const certificate = acm.Certificate.fromCertificateArn( + this, + `certificate`, + props.certificateArn + ); + + this.vpc = + props.vpc || + new Vpc(this, 'vpc', { + natGateways: 1, + }); + + this.repository = + props.repository || + new ecr.Repository(this, 'repo', { + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteImages: true, + }); + + if (!props.repository) { + // Delete all images older than 90 days BUT keep 10 from the latest tag + this.repository.addLifecycleRule({ + tagPrefixList: ['latest'], + maxImageCount: 10, + }); + this.repository.addLifecycleRule({ + maxImageAge: Duration.days(90), + }); + } + + // Create a deploy user to push images to ECR + const deployUser = new iam.User(this, 'deploy-user'); + + const deployPolicy = new iam.Policy(this, 'deploy-policy'); + deployPolicy.addStatements( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'ecr:CompleteLayerUpload', + 'ecr:UploadLayerPart', + 'ecr:InitiateLayerUpload', + 'ecr:BatchCheckLayerAvailability', + 'ecr:PutImage', + ], + resources: [this.repository.repositoryArn], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['ecr:GetAuthorizationToken'], + resources: ['*'], + }) + ); + + deployUser.attachInlinePolicy(deployPolicy); + + const securityGroup = new SecurityGroup(this, 'security-group', { + vpc: this.vpc, + }); + + const cluster = new ecs.Cluster(this, `cluster`, { + vpc: this.vpc, + }); + + const environment: { [key: string]: string } = {}; + + // If using Redis configure security group and pass connection string to container + if (props.redis) { + props.redis.securityGroup.addIngressRule( + securityGroup, + Port.tcp(Number(props.redis.connectionPort)) + ); + + environment['REDIS_ENDPOINT'] = props.redis.connectionEndPoint; + environment['REDIS_PORT'] = props.redis.connectionPort; + } + + // Construct secrets from provided ssm values + const secrets: {[key: string]: ecs.Secret} = {}; + props.secrets = props.secrets || {}; + for (const [key, ssm] of Object.entries(props.secrets)) { + secrets[key] = ecs.Secret.fromSsmParameter(ssm); + } + // Create a load-balanced Fargate service and make it public + const fargateService = + new ecsPatterns.ApplicationLoadBalancedFargateService( + this, + `fargate`, + { + cluster, + certificate, + enableExecuteCommand: true, + cpu: props.cpu || 512, // 0.5 vCPU + memoryLimitMiB: props.memory || 1024, // 1 GB + taskImageOptions: { + image: ecs.ContainerImage.fromEcrRepository( + this.repository + ), + enableLogging: true, // default + containerPort: 4000, // graphql mesh gateway port + secrets: secrets, + environment: environment, + }, + publicLoadBalancer: true, // default, + taskSubnets: { + subnets: [...this.vpc.privateSubnets], + }, + securityGroups: [securityGroup], + } + ); + + this.service = fargateService.service; + + this.firewall = new WebApplicationFirewall(this, 'waf', { + scope: Scope.REGIONAL, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "firewall-request", + sampledRequestsEnabled: true + }, + managedRules: [ + { + name: ManagedRule.COMMON_RULE_SET, + excludedRules: [ + { + name: 'SizeRestrictions_QUERYSTRING' + } + ] + }, + { + name: ManagedRule.KNOWN_BAD_INPUTS_RULE_SET, + } + ] + }); + + this.firewall.addAssociation('loadbalancer-association', fargateService.loadBalancer.loadBalancerArn); + + fargateService.targetGroup.configureHealthCheck({ + path: '/healthcheck', + }); + + // Setup auto scaling policy + const scaling = fargateService.service.autoScaleTaskCount({ + minCapacity: props.minCapacity || 1, + maxCapacity: props.maxCapacity || 5, + }); + + const cpuUtilization = fargateService.service.metricCpuUtilization(); + scaling.scaleOnMetric('auto-scale-cpu', { + metric: cpuUtilization, + scalingSteps: [ + { upper: 30, change: -1 }, + { lower: 50, change: +1 }, + { lower: 85, change: +3 }, + ], + adjustmentType: auto_scaling.AdjustmentType.CHANGE_IN_CAPACITY, + }); + } +} diff --git a/packages/graphql-mesh-server/lib/graphql-mesh-server.ts b/packages/graphql-mesh-server/lib/graphql-mesh-server.ts new file mode 100644 index 00000000..cec04774 --- /dev/null +++ b/packages/graphql-mesh-server/lib/graphql-mesh-server.ts @@ -0,0 +1,95 @@ +import { Construct } from 'constructs'; +import { MeshService, MeshServiceProps } from './fargate'; +import { RedisService, RedisServiceProps } from './redis-construct'; +import { CodePipelineService } from './pipeline'; +import { SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; +import { Repository } from 'aws-cdk-lib/aws-ecr'; +import { FargateService } from 'aws-cdk-lib/aws-ecs'; +import { CfnCacheCluster } from 'aws-cdk-lib/aws-elasticache'; +import * as ssm from 'aws-cdk-lib/aws-ssm'; + +export type MeshHostingProps = { + /** + * VPC to attach Redis and Fargate instances to (default: create a vpc) + */ + vpc?: Vpc; + /** + * If no VPC is provided create one with this name (default: 'graphql-server-vpc') + */ + vpcName?: string; + /** + * Cache node type (default: 'cache.t2.micro') + */ + cacheNodeType?: string; + /** + * Repository to pull the container image from + */ + repository?: Repository; + /** + * ARN of the certificate to add to the load balancer + */ + certificateArn: string; + /** + * Minimum number of Fargate instances + */ + minCapacity?: number; + /** + * Maximum number of Fargate instances + */ + maxCapacity?: number; + /** + * Amount of vCPU per Fargate instance (default: 512) + */ + cpu?: number; + /** + * Amount of memory per Fargate instance (default: 1024) + */ + memory?: number; + /** + * Redis instance to use for mesh caching + */ + redis?: RedisService; + /** + * SSM values to pass through to the container as secrets + */ + secrets?: {[key: string]: ssm.IStringParameter | ssm.IStringListParameter}; +}; + +export class MeshHosting extends Construct { + public readonly vpc: Vpc; + public readonly repository: Repository; + public readonly service: FargateService; + public readonly cacheCluster: CfnCacheCluster; + public readonly securityGroup: SecurityGroup; + + constructor(scope: Construct, id: string, props: MeshHostingProps) { + super(scope, id); + + this.vpc = props.vpc || new Vpc(this, 'graphql-server-vpc', { + vpcName: props.vpcName || 'graphql-server-vpc', + natGateways: 1 + }); + + const redis = props.redis || new RedisService(this, 'redis', { + ...props, + vpc: this.vpc + }); + + this.cacheCluster = redis.cacheCluster; + this.securityGroup = redis.securityGroup; + + const mesh = new MeshService(this, 'mesh', { + ...props, + vpc: this.vpc, + redis, + }); + + this.service = mesh.service; + this.repository = mesh.repository; + + new CodePipelineService(this, 'pipeline', { + repository: this.repository, + service: this.service, + }); + } +} diff --git a/packages/graphql-mesh-server/lib/pipeline.ts b/packages/graphql-mesh-server/lib/pipeline.ts new file mode 100644 index 00000000..a6189049 --- /dev/null +++ b/packages/graphql-mesh-server/lib/pipeline.ts @@ -0,0 +1,91 @@ +import { Duration } from 'aws-cdk-lib'; +import { Artifact, Pipeline } from 'aws-cdk-lib/aws-codepipeline'; +import { Repository } from 'aws-cdk-lib/aws-ecr'; +import { FargateService } from 'aws-cdk-lib/aws-ecs'; +import * as pipe_actions from 'aws-cdk-lib/aws-codepipeline-actions'; +import * as codebuild from 'aws-cdk-lib/aws-codebuild'; +import { Construct } from 'constructs'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as YAML from 'yaml'; + +export interface CodePipelineServiceProps { + /** + * Repository the code container is pushed too + */ + repository: Repository; + + /** + * Services to deploy Code container updates to + */ + service: FargateService; + + /** + * Path to buildspec.yml (default: '../assets/buildspec.yml') + */ + buildspecPath?: string; +} + +export class CodePipelineService extends Construct { + public readonly pipeline: Pipeline; + + constructor(scope: Construct, id: string, props: CodePipelineServiceProps) { + super(scope, id); + + this.pipeline = new Pipeline(this, 'deploy-pipeline'); + + const sourceOutput = new Artifact(); + const sourceAction = new pipe_actions.EcrSourceAction({ + actionName: 'ECR', + repository: props.repository, + output: sourceOutput, + }); + + this.pipeline.addStage({ + stageName: 'Source', + actions: [sourceAction], + }); + + const file = fs.readFileSync( + path.resolve(__dirname, props.buildspecPath || '../assets/buildspec.yml'), + 'utf8' + ); + const project: codebuild.PipelineProject = + new codebuild.PipelineProject(this, 'project', { + buildSpec: codebuild.BuildSpec.fromObject(YAML.parse(file)), + }); + + const buildOutput = new Artifact(); + this.pipeline.addStage({ + stageName: 'Build', + actions: [ + new pipe_actions.CodeBuildAction({ + actionName: 'CodeBuild', + project, + input: sourceOutput, + outputs: [buildOutput], + environmentVariables: { + IMAGE_URI: { + value: sourceAction.variables.imageUri, + }, + CONTAINER_NAME: { + value: props.service.taskDefinition.defaultContainer + ?.containerName, + }, + }, + }), + ], + }); + this.pipeline.addStage({ + stageName: 'Deploy', + actions: [ + new pipe_actions.EcsDeployAction({ + actionName: 'DeployAction', + service: props.service, + input: buildOutput, + deploymentTimeout: Duration.minutes(10), + }), + ], + }); + } +} diff --git a/packages/graphql-mesh-server/lib/redis-construct.ts b/packages/graphql-mesh-server/lib/redis-construct.ts new file mode 100644 index 00000000..a0319f43 --- /dev/null +++ b/packages/graphql-mesh-server/lib/redis-construct.ts @@ -0,0 +1,96 @@ +import { SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; +import { + CfnCacheCluster, + CfnSubnetGroup, + CfnParameterGroup, +} from 'aws-cdk-lib/aws-elasticache'; +import { CfnOutput } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +export interface RedisServiceProps { + /** + * VPC to attach Redis instance to + */ + vpc: Vpc; + /** + * Cache node type (default: 'cache.t2.micro') + */ + cacheNodeType?: string; +} + +export class RedisService extends Construct { + public readonly cacheCluster: CfnCacheCluster; + public readonly vpc: Vpc; + public readonly securityGroup: SecurityGroup; + + constructor(scope: Construct, id: string, props: RedisServiceProps) { + super(scope, id); + + this.vpc = props.vpc; + + this.securityGroup = new SecurityGroup(this, 'RedisSecurityGroup', { + vpc: this.vpc, + }); + + const privateSubnets: string[] = this.vpc.privateSubnets.map( + (subnet) => { + return subnet.subnetId; + } + ); + + const cacheSubnetGroup = new CfnSubnetGroup(this, 'CacheSubnetGroup', { + description: 'Subnet Group for Mesh Cache', + subnetIds: privateSubnets, + }); + + const cacheParameterGroup = new CfnParameterGroup( + this, + 'CacheParameterGroup', + { + cacheParameterGroupFamily: 'redis7', + description: 'Parameter Group for Mesh Cache', + properties: { + 'maxmemory-policy': 'allkeys-lru', + }, + } + ); + + this.cacheCluster = new CfnCacheCluster(this, 'cache-cluster', { + cacheNodeType: props.cacheNodeType || 'cache.t2.micro', + engine: 'redis', + numCacheNodes: 1, + autoMinorVersionUpgrade: true, + vpcSecurityGroupIds: [this.securityGroup.securityGroupId], + cacheSubnetGroupName: cacheSubnetGroup.ref, + cacheParameterGroupName: cacheParameterGroup.ref, + }); + + this.cacheCluster.addDependency(cacheParameterGroup); + this.cacheCluster.addDependency(cacheSubnetGroup); + + new CfnOutput(this, 'RedisConnectionString', { + description: 'RedisConnectionString', + value: this.cacheConnectionString, + }); + } + + public get cacheConnectionString(): string { + return `redis://${this.cacheCluster + .getAtt('RedisEndpoint.Address') + .toString()}:${this.cacheCluster + .getAtt('RedisEndpoint.Port') + .toString()}`; + } + + public get connectionEndPoint(): string { + return this.cacheCluster + .getAtt('RedisEndpoint.Address') + .toString(); + } + + public get connectionPort(): string { + return this.cacheCluster + .getAtt('RedisEndpoint.Port') + .toString(); + } +} diff --git a/packages/graphql-mesh-server/lib/web-application-firewall.ts b/packages/graphql-mesh-server/lib/web-application-firewall.ts new file mode 100644 index 00000000..57e3c6ce --- /dev/null +++ b/packages/graphql-mesh-server/lib/web-application-firewall.ts @@ -0,0 +1,164 @@ +import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2"; +import { Construct } from "constructs"; + +export enum Action { + BLOCK = 'BLOCK', + ALLOW = 'ALLOW', +} + +export enum Scope { + CLOUDFRONT = 'CLOUDFRONT', + REGIONAL = 'REGIONAL', +} + +export enum ManagedRule { + BOT_CONTROL_RULE_SET = "AWSManagedRulesBotControlRuleSet", + KNOWN_BAD_INPUTS_RULE_SET = "AWSManagedRulesKnownBadInputsRuleSet", + COMMON_RULE_SET = "AWSManagedRulesCommonRuleSet", + ANNONYMOUS_IP_LIST = "AWSManagedRulesAnonymousIpList", + AMAZON_IP_REPUTATION_LIST = "AWSManagedRulesAmazonIpReputationList", + ADMIN_PROTECTION_RULE_SET = "AWSManagedRulesAdminProtectionRuleSet", + SQLI_RULE_SET = "AWSManagedRulesSQLiRuleSet", + PHP_RULE_SET = "AWSManagedRulesPHPRuleSet" +} + +export interface VisibilityConfig { + /** + * Whether cloudwatch metrics are enabled or nor + */ + cloudWatchMetricsEnabled: boolean, + + /** + * Name of the metric in cloudwatch + */ + metricName: string, + + /** + * Whether to keep samples of blocked requests + */ + sampledRequestsEnabled: boolean +} + +export interface AWSManagedRule { + /** + * Which AWS Rule to add + */ + name: ManagedRule, + + /** + * @default to the name property + */ + metricName?: string, + + /** + * @default false + */ + sampledRequestsEnabled?: boolean, + + /** + * Any rules from this ruleset you wish to disable/exclude + */ + excludedRules?: Array<{ + name: string + }>, + + /** + * Whether to override the default action to COUNT + */ + count?: boolean +} + +export interface WebApplicationFirewallProps { + /** + * Name of the WAF + */ + name?: string, + + /** + * The action to perform if none of the `Rules` contained in the `WebACL` match. + * @default Action.ALLOW + */ + defaultAction?: Action, + + /** + * Specifies whether this is for an Amazon CloudFront distribution or for a regional application. + * @default Scope.REGIONAL + */ + scope?: Scope + + /** + * Default visibility configuration + */ + visibilityConfig: VisibilityConfig, + + /** + * List of AWS Managed rules to add to the WAF + */ + managedRules?: AWSManagedRule[], + + /** + * List of custom rules + */ + rules?: CfnWebACL.RuleProperty[] +} + +export class WebApplicationFirewall extends Construct { + readonly acl: CfnWebACL; + readonly associations: CfnWebACLAssociation[]; + + constructor(scope: Construct, id: string, props: WebApplicationFirewallProps) { + super(scope, id); + + let defaultAction: CfnWebACL.DefaultActionProperty = { allow: {} }; + + if (props.defaultAction == Action.BLOCK) { + defaultAction = { block: {} }; + } + + this.associations = []; + + const rules: CfnWebACL.RuleProperty[] = props.rules || []; + + // Convert from our AWSManagedRule type to a CfnWebACL.RuleProperty + if (props.managedRules) { + props.managedRules.forEach((rule, index) => { + rules.push({ + name: rule.name, + priority: index, + visibilityConfig: { + // if no metric name is passed then don't enable metrics + cloudWatchMetricsEnabled: rule.metricName ? true : false, + // Default to the rule name if a metric name isn't passed + metricName: rule.metricName || rule.name, + sampledRequestsEnabled: rule.sampledRequestsEnabled || false + }, + statement: { + managedRuleGroupStatement: { + name: rule.name, + vendorName: "AWS", + excludedRules: rule.excludedRules || [], + } + }, + overrideAction: rule.count ? { count: {} } : { none: {} } + }) + }); + } + + this.acl = new CfnWebACL(this, "WebAcl", { + name: props.name, + defaultAction, + scope: props.scope || Scope.REGIONAL, + visibilityConfig: props.visibilityConfig, + rules: rules + }); + } + + public addAssociation(id: string, resourceArn: string) { + this.associations.push( + new CfnWebACLAssociation(this, id, { + webAclArn: this.acl.attrArn, + resourceArn + }) + ); + } +} \ No newline at end of file diff --git a/packages/graphql-mesh-server/package.json b/packages/graphql-mesh-server/package.json new file mode 100644 index 00000000..a2368821 --- /dev/null +++ b/packages/graphql-mesh-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@aligent/cdk-graphql-mesh-server", + "version": "0.0.1", + "description": "A construct to host Prerender in Fargate", + "main": "index.js", + "scripts": { + "build": "tsc", + "prepublish": "tsc" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/aligent/cdk-constructs.git" + }, + "license": "GPL-3.0-only", + "bugs": { + "url": "https://github.com/aligent/cdk-constructs/issues" + }, + "homepage": "https://github.com/aligent/cdk-constructs/tree/main/packages/prerender-fargate#readme", + "dependencies": { + "@types/yaml": "^1.9.7", + "yaml": "^2.3.1" + } +} diff --git a/packages/graphql-mesh-server/tsconfig.json b/packages/graphql-mesh-server/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/packages/graphql-mesh-server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} From ad275fa0576c4949391ac8b2d619fc9759126e4e Mon Sep 17 00:00:00 2001 From: Daniel Van Der Ploeg Date: Tue, 29 Aug 2023 14:22:47 +0930 Subject: [PATCH 2/8] chore: update package name and homepage --- packages/graphql-mesh-server/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/graphql-mesh-server/package.json b/packages/graphql-mesh-server/package.json index a2368821..1e0ef0b7 100644 --- a/packages/graphql-mesh-server/package.json +++ b/packages/graphql-mesh-server/package.json @@ -1,7 +1,7 @@ { "name": "@aligent/cdk-graphql-mesh-server", "version": "0.0.1", - "description": "A construct to host Prerender in Fargate", + "description": "A construct to host Graphql Mesh in Fargate", "main": "index.js", "scripts": { "build": "tsc", @@ -15,7 +15,7 @@ "bugs": { "url": "https://github.com/aligent/cdk-constructs/issues" }, - "homepage": "https://github.com/aligent/cdk-constructs/tree/main/packages/prerender-fargate#readme", + "homepage": "https://github.com/aligent/cdk-constructs/tree/main/packages/graphql-mesh-server#readme", "dependencies": { "@types/yaml": "^1.9.7", "yaml": "^2.3.1" From 17457679fe82386cce2ebbd7dbcef31319dd646f Mon Sep 17 00:00:00 2001 From: Daniel Van Der Ploeg Date: Tue, 29 Aug 2023 14:32:18 +0930 Subject: [PATCH 3/8] chore: remove title from README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index e3934567..0ea9aeab 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,3 @@ When making a release (including experimental releases), the release tag should |---------------|---------------| | Experimental | 1.1.0-beta | | Final | 1.1.0 | - -## Testing locally - From 741a8394193b33280ccc86a4475dee5e5aa4b82c Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Thu, 31 Aug 2023 14:07:07 +0930 Subject: [PATCH 4/8] Add missing buildspec for pipeline --- packages/graphql-mesh-server/assets/buildspec.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/graphql-mesh-server/assets/buildspec.yml diff --git a/packages/graphql-mesh-server/assets/buildspec.yml b/packages/graphql-mesh-server/assets/buildspec.yml new file mode 100644 index 00000000..c2db9f73 --- /dev/null +++ b/packages/graphql-mesh-server/assets/buildspec.yml @@ -0,0 +1,10 @@ +version: 0.2 + +phases: + build: + commands: + - jq -n --arg image_uri $IMAGE_URI --arg container_name $CONTAINER_NAME '[{"name":"\($container_name)","imageUri":"\($image_uri)"}]' > imagedefinitions.json +artifacts: + files: + - 'imagedefinitions.json' + name: images-$(date +%Y-%m-%d) From 433283ee6b7a8d1852e31ef3505276f54430573b Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Fri, 1 Sep 2023 11:10:20 +0930 Subject: [PATCH 5/8] Update connection strings to use tokens for attribute references as they are evaluating to null when using the toString() method --- packages/graphql-mesh-server/lib/fargate.ts | 6 +++--- packages/graphql-mesh-server/lib/redis-construct.ts | 12 ++++-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/graphql-mesh-server/lib/fargate.ts b/packages/graphql-mesh-server/lib/fargate.ts index 7a36cfb6..10081be1 100644 --- a/packages/graphql-mesh-server/lib/fargate.ts +++ b/packages/graphql-mesh-server/lib/fargate.ts @@ -1,5 +1,5 @@ import { Construct } from 'constructs'; -import { Duration } from 'aws-cdk-lib'; +import { Duration, Token } from 'aws-cdk-lib'; import { RemovalPolicy } from 'aws-cdk-lib'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as ecs from 'aws-cdk-lib/aws-ecs'; @@ -129,11 +129,11 @@ export class MeshService extends Construct { if (props.redis) { props.redis.securityGroup.addIngressRule( securityGroup, - Port.tcp(Number(props.redis.connectionPort)) + Port.tcp(props.redis.connectionPort) ); environment['REDIS_ENDPOINT'] = props.redis.connectionEndPoint; - environment['REDIS_PORT'] = props.redis.connectionPort; + environment['REDIS_PORT'] = props.redis.connectionPort.toString(); } // Construct secrets from provided ssm values diff --git a/packages/graphql-mesh-server/lib/redis-construct.ts b/packages/graphql-mesh-server/lib/redis-construct.ts index a0319f43..f88526b0 100644 --- a/packages/graphql-mesh-server/lib/redis-construct.ts +++ b/packages/graphql-mesh-server/lib/redis-construct.ts @@ -4,7 +4,7 @@ import { CfnSubnetGroup, CfnParameterGroup, } from 'aws-cdk-lib/aws-elasticache'; -import { CfnOutput } from 'aws-cdk-lib'; +import { CfnOutput, Reference, Token } from 'aws-cdk-lib'; import { Construct } from 'constructs'; export interface RedisServiceProps { @@ -83,14 +83,10 @@ export class RedisService extends Construct { } public get connectionEndPoint(): string { - return this.cacheCluster - .getAtt('RedisEndpoint.Address') - .toString(); + return Token.asString(this.cacheCluster.getAtt('RedisEndpoint.Address')) } - public get connectionPort(): string { - return this.cacheCluster - .getAtt('RedisEndpoint.Port') - .toString(); + public get connectionPort(): number { + return Token.asNumber(this.cacheCluster.getAtt('RedisEndpoint.Port')); } } From e9d89914ec807a9b7ab48cc7a9458b384f75a63f Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Fri, 13 Oct 2023 13:17:49 +1030 Subject: [PATCH 6/8] Bring dependencies inline --- package-lock.json | 42 +++++++++++++++++++++++ packages/graphql-mesh-server/package.json | 17 +++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bfc990fc..6d4adf6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,10 @@ "resolved": "packages/geoip-redirect", "link": true }, + "node_modules/@aligent/cdk-graphql-mesh-server": { + "resolved": "packages/graphql-mesh-server", + "link": true + }, "node_modules/@aligent/cdk-lambda-at-edge-handlers": { "resolved": "packages/lambda-at-edge-handlers", "link": true @@ -1876,6 +1880,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/yaml": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz", + "integrity": "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==", + "deprecated": "This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.", + "dependencies": { + "yaml": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.25", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.25.tgz", @@ -5900,6 +5913,14 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -6028,6 +6049,27 @@ "typescript": "~5.2.2" } }, + "packages/graphql-mesh-server": { + "version": "0.0.1", + "license": "GPL-3.0-only", + "dependencies": { + "@types/yaml": "^1.9.7", + "aws-cdk-lib": "2.97.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21", + "yaml": "^2.3.1" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.122", + "@types/jest": "^29.5.5", + "@types/node": "20.6.3", + "aws-cdk": "2.97.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "~5.2.2" + } + }, "packages/lambda-at-edge-handlers": { "name": "@aligent/cdk-lambda-at-edge-handlers", "version": "0.1.0", diff --git a/packages/graphql-mesh-server/package.json b/packages/graphql-mesh-server/package.json index 1e0ef0b7..bd3b73bd 100644 --- a/packages/graphql-mesh-server/package.json +++ b/packages/graphql-mesh-server/package.json @@ -15,9 +15,22 @@ "bugs": { "url": "https://github.com/aligent/cdk-constructs/issues" }, - "homepage": "https://github.com/aligent/cdk-constructs/tree/main/packages/graphql-mesh-server#readme", + "homepage": "https://github.com/aligent/cdk-constructs#readme", + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "20.6.3", + "@types/aws-lambda": "^8.10.122", + "aws-cdk": "2.97.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "~5.2.2" + }, "dependencies": { "@types/yaml": "^1.9.7", - "yaml": "^2.3.1" + "yaml": "^2.3.1", + "aws-cdk-lib": "2.97.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" } } From 89d3898567dfe4047d9920642ae52c6458237265 Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Fri, 13 Oct 2023 13:25:53 +1030 Subject: [PATCH 7/8] Move types to devDependencies --- package-lock.json | 4 +++- packages/graphql-mesh-server/package.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d4adf6c..adc7dce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1885,6 +1885,7 @@ "resolved": "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz", "integrity": "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==", "deprecated": "This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.", + "dev": true, "dependencies": { "yaml": "*" } @@ -6050,10 +6051,10 @@ } }, "packages/graphql-mesh-server": { + "name": "@aligent/cdk-graphql-mesh-server", "version": "0.0.1", "license": "GPL-3.0-only", "dependencies": { - "@types/yaml": "^1.9.7", "aws-cdk-lib": "2.97.0", "constructs": "^10.0.0", "source-map-support": "^0.5.21", @@ -6063,6 +6064,7 @@ "@types/aws-lambda": "^8.10.122", "@types/jest": "^29.5.5", "@types/node": "20.6.3", + "@types/yaml": "^1.9.7", "aws-cdk": "2.97.0", "jest": "^29.7.0", "ts-jest": "^29.1.1", diff --git a/packages/graphql-mesh-server/package.json b/packages/graphql-mesh-server/package.json index bd3b73bd..b3a40c28 100644 --- a/packages/graphql-mesh-server/package.json +++ b/packages/graphql-mesh-server/package.json @@ -17,6 +17,7 @@ }, "homepage": "https://github.com/aligent/cdk-constructs#readme", "devDependencies": { + "@types/yaml": "^1.9.7", "@types/jest": "^29.5.5", "@types/node": "20.6.3", "@types/aws-lambda": "^8.10.122", @@ -27,7 +28,6 @@ "typescript": "~5.2.2" }, "dependencies": { - "@types/yaml": "^1.9.7", "yaml": "^2.3.1", "aws-cdk-lib": "2.97.0", "constructs": "^10.0.0", From 3e596957c9cd3c4f3b53fb5fe18f72c0a9ce8dd3 Mon Sep 17 00:00:00 2001 From: Adam Hall Date: Fri, 13 Oct 2023 13:28:14 +1030 Subject: [PATCH 8/8] Formatting pass --- packages/graphql-mesh-server/lib/fargate.ts | 427 +++++++++--------- .../lib/graphql-mesh-server.ts | 168 +++---- packages/graphql-mesh-server/lib/pipeline.ts | 157 +++---- .../lib/redis-construct.ts | 144 +++--- .../lib/web-application-firewall.ts | 282 ++++++------ 5 files changed, 594 insertions(+), 584 deletions(-) diff --git a/packages/graphql-mesh-server/lib/fargate.ts b/packages/graphql-mesh-server/lib/fargate.ts index 10081be1..b4a13e25 100644 --- a/packages/graphql-mesh-server/lib/fargate.ts +++ b/packages/graphql-mesh-server/lib/fargate.ts @@ -1,220 +1,221 @@ -import { Construct } from 'constructs'; -import { Duration, Token } from 'aws-cdk-lib'; -import { RemovalPolicy } from 'aws-cdk-lib'; -import * as acm from 'aws-cdk-lib/aws-certificatemanager'; -import * as ecs from 'aws-cdk-lib/aws-ecs'; -import * as ecr from 'aws-cdk-lib/aws-ecr'; -import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns'; -import * as iam from 'aws-cdk-lib/aws-iam'; -import * as ssm from 'aws-cdk-lib/aws-ssm'; -import * as auto_scaling from 'aws-cdk-lib/aws-autoscaling'; -import { Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; -import { RedisService } from './redis-construct'; -import { ManagedRule, Scope, WebApplicationFirewall } from './web-application-firewall'; +import { Construct } from "constructs"; +import { Duration, Token } from "aws-cdk-lib"; +import { RemovalPolicy } from "aws-cdk-lib"; +import * as acm from "aws-cdk-lib/aws-certificatemanager"; +import * as ecs from "aws-cdk-lib/aws-ecs"; +import * as ecr from "aws-cdk-lib/aws-ecr"; +import * as ecsPatterns from "aws-cdk-lib/aws-ecs-patterns"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as ssm from "aws-cdk-lib/aws-ssm"; +import * as auto_scaling from "aws-cdk-lib/aws-autoscaling"; +import { Port, SecurityGroup, Vpc } from "aws-cdk-lib/aws-ec2"; +import { RedisService } from "./redis-construct"; +import { + ManagedRule, + Scope, + WebApplicationFirewall, +} from "./web-application-firewall"; export interface MeshServiceProps { - /** - * VPC to attach Redis instance to - */ - vpc?: Vpc; - /** - * Repository to pull the container image from - */ - repository?: ecr.Repository; - /** - * ARN of the certificate to add to the load balancer - */ - certificateArn: string; - /** - * Minimum number of Fargate instances - */ - minCapacity?: number; - /** - * Maximum number of Fargate instances - */ - maxCapacity?: number; - /** - * Amount of vCPU per instance (default: 512) - */ - cpu?: number; - /** - * Amount of memory per instance (default: 1024) - */ - memory?: number; - /** - * Redis instance to use for mesh caching - */ - redis: RedisService; - /** - * SSM values to pass through to the container as secrets - */ - secrets?: {[key: string]: ssm.IStringParameter | ssm.IStringListParameter}; + /** + * VPC to attach Redis instance to + */ + vpc?: Vpc; + /** + * Repository to pull the container image from + */ + repository?: ecr.Repository; + /** + * ARN of the certificate to add to the load balancer + */ + certificateArn: string; + /** + * Minimum number of Fargate instances + */ + minCapacity?: number; + /** + * Maximum number of Fargate instances + */ + maxCapacity?: number; + /** + * Amount of vCPU per instance (default: 512) + */ + cpu?: number; + /** + * Amount of memory per instance (default: 1024) + */ + memory?: number; + /** + * Redis instance to use for mesh caching + */ + redis: RedisService; + /** + * SSM values to pass through to the container as secrets + */ + secrets?: { [key: string]: ssm.IStringParameter | ssm.IStringListParameter }; } export class MeshService extends Construct { - public readonly vpc: Vpc; - public readonly repository: ecr.Repository; - public readonly service: ecs.FargateService; - public readonly firewall: WebApplicationFirewall; - - constructor(scope: Construct, id: string, props: MeshServiceProps) { - super(scope, id); - - const certificate = acm.Certificate.fromCertificateArn( - this, - `certificate`, - props.certificateArn - ); - - this.vpc = - props.vpc || - new Vpc(this, 'vpc', { - natGateways: 1, - }); - - this.repository = - props.repository || - new ecr.Repository(this, 'repo', { - removalPolicy: RemovalPolicy.DESTROY, - autoDeleteImages: true, - }); - - if (!props.repository) { - // Delete all images older than 90 days BUT keep 10 from the latest tag - this.repository.addLifecycleRule({ - tagPrefixList: ['latest'], - maxImageCount: 10, - }); - this.repository.addLifecycleRule({ - maxImageAge: Duration.days(90), - }); - } - - // Create a deploy user to push images to ECR - const deployUser = new iam.User(this, 'deploy-user'); - - const deployPolicy = new iam.Policy(this, 'deploy-policy'); - deployPolicy.addStatements( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - 'ecr:CompleteLayerUpload', - 'ecr:UploadLayerPart', - 'ecr:InitiateLayerUpload', - 'ecr:BatchCheckLayerAvailability', - 'ecr:PutImage', - ], - resources: [this.repository.repositoryArn], - }), - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ['ecr:GetAuthorizationToken'], - resources: ['*'], - }) - ); - - deployUser.attachInlinePolicy(deployPolicy); - - const securityGroup = new SecurityGroup(this, 'security-group', { - vpc: this.vpc, - }); - - const cluster = new ecs.Cluster(this, `cluster`, { - vpc: this.vpc, - }); - - const environment: { [key: string]: string } = {}; - - // If using Redis configure security group and pass connection string to container - if (props.redis) { - props.redis.securityGroup.addIngressRule( - securityGroup, - Port.tcp(props.redis.connectionPort) - ); - - environment['REDIS_ENDPOINT'] = props.redis.connectionEndPoint; - environment['REDIS_PORT'] = props.redis.connectionPort.toString(); - } - - // Construct secrets from provided ssm values - const secrets: {[key: string]: ecs.Secret} = {}; - props.secrets = props.secrets || {}; - for (const [key, ssm] of Object.entries(props.secrets)) { - secrets[key] = ecs.Secret.fromSsmParameter(ssm); - } - // Create a load-balanced Fargate service and make it public - const fargateService = - new ecsPatterns.ApplicationLoadBalancedFargateService( - this, - `fargate`, - { - cluster, - certificate, - enableExecuteCommand: true, - cpu: props.cpu || 512, // 0.5 vCPU - memoryLimitMiB: props.memory || 1024, // 1 GB - taskImageOptions: { - image: ecs.ContainerImage.fromEcrRepository( - this.repository - ), - enableLogging: true, // default - containerPort: 4000, // graphql mesh gateway port - secrets: secrets, - environment: environment, - }, - publicLoadBalancer: true, // default, - taskSubnets: { - subnets: [...this.vpc.privateSubnets], - }, - securityGroups: [securityGroup], - } - ); - - this.service = fargateService.service; - - this.firewall = new WebApplicationFirewall(this, 'waf', { - scope: Scope.REGIONAL, - visibilityConfig: { - cloudWatchMetricsEnabled: true, - metricName: "firewall-request", - sampledRequestsEnabled: true - }, - managedRules: [ - { - name: ManagedRule.COMMON_RULE_SET, - excludedRules: [ - { - name: 'SizeRestrictions_QUERYSTRING' - } - ] - }, - { - name: ManagedRule.KNOWN_BAD_INPUTS_RULE_SET, - } - ] - }); - - this.firewall.addAssociation('loadbalancer-association', fargateService.loadBalancer.loadBalancerArn); - - fargateService.targetGroup.configureHealthCheck({ - path: '/healthcheck', - }); - - // Setup auto scaling policy - const scaling = fargateService.service.autoScaleTaskCount({ - minCapacity: props.minCapacity || 1, - maxCapacity: props.maxCapacity || 5, - }); - - const cpuUtilization = fargateService.service.metricCpuUtilization(); - scaling.scaleOnMetric('auto-scale-cpu', { - metric: cpuUtilization, - scalingSteps: [ - { upper: 30, change: -1 }, - { lower: 50, change: +1 }, - { lower: 85, change: +3 }, - ], - adjustmentType: auto_scaling.AdjustmentType.CHANGE_IN_CAPACITY, - }); + public readonly vpc: Vpc; + public readonly repository: ecr.Repository; + public readonly service: ecs.FargateService; + public readonly firewall: WebApplicationFirewall; + + constructor(scope: Construct, id: string, props: MeshServiceProps) { + super(scope, id); + + const certificate = acm.Certificate.fromCertificateArn( + this, + `certificate`, + props.certificateArn + ); + + this.vpc = + props.vpc || + new Vpc(this, "vpc", { + natGateways: 1, + }); + + this.repository = + props.repository || + new ecr.Repository(this, "repo", { + removalPolicy: RemovalPolicy.DESTROY, + autoDeleteImages: true, + }); + + if (!props.repository) { + // Delete all images older than 90 days BUT keep 10 from the latest tag + this.repository.addLifecycleRule({ + tagPrefixList: ["latest"], + maxImageCount: 10, + }); + this.repository.addLifecycleRule({ + maxImageAge: Duration.days(90), + }); + } + + // Create a deploy user to push images to ECR + const deployUser = new iam.User(this, "deploy-user"); + + const deployPolicy = new iam.Policy(this, "deploy-policy"); + deployPolicy.addStatements( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "ecr:CompleteLayerUpload", + "ecr:UploadLayerPart", + "ecr:InitiateLayerUpload", + "ecr:BatchCheckLayerAvailability", + "ecr:PutImage", + ], + resources: [this.repository.repositoryArn], + }), + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["ecr:GetAuthorizationToken"], + resources: ["*"], + }) + ); + + deployUser.attachInlinePolicy(deployPolicy); + + const securityGroup = new SecurityGroup(this, "security-group", { + vpc: this.vpc, + }); + + const cluster = new ecs.Cluster(this, `cluster`, { + vpc: this.vpc, + }); + + const environment: { [key: string]: string } = {}; + + // If using Redis configure security group and pass connection string to container + if (props.redis) { + props.redis.securityGroup.addIngressRule( + securityGroup, + Port.tcp(props.redis.connectionPort) + ); + + environment["REDIS_ENDPOINT"] = props.redis.connectionEndPoint; + environment["REDIS_PORT"] = props.redis.connectionPort.toString(); } + + // Construct secrets from provided ssm values + const secrets: { [key: string]: ecs.Secret } = {}; + props.secrets = props.secrets || {}; + for (const [key, ssm] of Object.entries(props.secrets)) { + secrets[key] = ecs.Secret.fromSsmParameter(ssm); + } + // Create a load-balanced Fargate service and make it public + const fargateService = + new ecsPatterns.ApplicationLoadBalancedFargateService(this, `fargate`, { + cluster, + certificate, + enableExecuteCommand: true, + cpu: props.cpu || 512, // 0.5 vCPU + memoryLimitMiB: props.memory || 1024, // 1 GB + taskImageOptions: { + image: ecs.ContainerImage.fromEcrRepository(this.repository), + enableLogging: true, // default + containerPort: 4000, // graphql mesh gateway port + secrets: secrets, + environment: environment, + }, + publicLoadBalancer: true, // default, + taskSubnets: { + subnets: [...this.vpc.privateSubnets], + }, + securityGroups: [securityGroup], + }); + + this.service = fargateService.service; + + this.firewall = new WebApplicationFirewall(this, "waf", { + scope: Scope.REGIONAL, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "firewall-request", + sampledRequestsEnabled: true, + }, + managedRules: [ + { + name: ManagedRule.COMMON_RULE_SET, + excludedRules: [ + { + name: "SizeRestrictions_QUERYSTRING", + }, + ], + }, + { + name: ManagedRule.KNOWN_BAD_INPUTS_RULE_SET, + }, + ], + }); + + this.firewall.addAssociation( + "loadbalancer-association", + fargateService.loadBalancer.loadBalancerArn + ); + + fargateService.targetGroup.configureHealthCheck({ + path: "/healthcheck", + }); + + // Setup auto scaling policy + const scaling = fargateService.service.autoScaleTaskCount({ + minCapacity: props.minCapacity || 1, + maxCapacity: props.maxCapacity || 5, + }); + + const cpuUtilization = fargateService.service.metricCpuUtilization(); + scaling.scaleOnMetric("auto-scale-cpu", { + metric: cpuUtilization, + scalingSteps: [ + { upper: 30, change: -1 }, + { lower: 50, change: +1 }, + { lower: 85, change: +3 }, + ], + adjustmentType: auto_scaling.AdjustmentType.CHANGE_IN_CAPACITY, + }); + } } diff --git a/packages/graphql-mesh-server/lib/graphql-mesh-server.ts b/packages/graphql-mesh-server/lib/graphql-mesh-server.ts index cec04774..399c2457 100644 --- a/packages/graphql-mesh-server/lib/graphql-mesh-server.ts +++ b/packages/graphql-mesh-server/lib/graphql-mesh-server.ts @@ -1,95 +1,99 @@ -import { Construct } from 'constructs'; -import { MeshService, MeshServiceProps } from './fargate'; -import { RedisService, RedisServiceProps } from './redis-construct'; -import { CodePipelineService } from './pipeline'; -import { SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; -import { Repository } from 'aws-cdk-lib/aws-ecr'; -import { FargateService } from 'aws-cdk-lib/aws-ecs'; -import { CfnCacheCluster } from 'aws-cdk-lib/aws-elasticache'; -import * as ssm from 'aws-cdk-lib/aws-ssm'; +import { Construct } from "constructs"; +import { MeshService, MeshServiceProps } from "./fargate"; +import { RedisService, RedisServiceProps } from "./redis-construct"; +import { CodePipelineService } from "./pipeline"; +import { SecurityGroup, Vpc } from "aws-cdk-lib/aws-ec2"; +import { Repository } from "aws-cdk-lib/aws-ecr"; +import { FargateService } from "aws-cdk-lib/aws-ecs"; +import { CfnCacheCluster } from "aws-cdk-lib/aws-elasticache"; +import * as ssm from "aws-cdk-lib/aws-ssm"; export type MeshHostingProps = { - /** - * VPC to attach Redis and Fargate instances to (default: create a vpc) - */ - vpc?: Vpc; - /** - * If no VPC is provided create one with this name (default: 'graphql-server-vpc') - */ - vpcName?: string; - /** - * Cache node type (default: 'cache.t2.micro') - */ - cacheNodeType?: string; - /** - * Repository to pull the container image from - */ - repository?: Repository; - /** - * ARN of the certificate to add to the load balancer - */ - certificateArn: string; - /** - * Minimum number of Fargate instances - */ - minCapacity?: number; - /** - * Maximum number of Fargate instances - */ - maxCapacity?: number; - /** - * Amount of vCPU per Fargate instance (default: 512) - */ - cpu?: number; - /** - * Amount of memory per Fargate instance (default: 1024) - */ - memory?: number; - /** - * Redis instance to use for mesh caching - */ - redis?: RedisService; - /** - * SSM values to pass through to the container as secrets - */ - secrets?: {[key: string]: ssm.IStringParameter | ssm.IStringListParameter}; + /** + * VPC to attach Redis and Fargate instances to (default: create a vpc) + */ + vpc?: Vpc; + /** + * If no VPC is provided create one with this name (default: 'graphql-server-vpc') + */ + vpcName?: string; + /** + * Cache node type (default: 'cache.t2.micro') + */ + cacheNodeType?: string; + /** + * Repository to pull the container image from + */ + repository?: Repository; + /** + * ARN of the certificate to add to the load balancer + */ + certificateArn: string; + /** + * Minimum number of Fargate instances + */ + minCapacity?: number; + /** + * Maximum number of Fargate instances + */ + maxCapacity?: number; + /** + * Amount of vCPU per Fargate instance (default: 512) + */ + cpu?: number; + /** + * Amount of memory per Fargate instance (default: 1024) + */ + memory?: number; + /** + * Redis instance to use for mesh caching + */ + redis?: RedisService; + /** + * SSM values to pass through to the container as secrets + */ + secrets?: { [key: string]: ssm.IStringParameter | ssm.IStringListParameter }; }; export class MeshHosting extends Construct { - public readonly vpc: Vpc; - public readonly repository: Repository; - public readonly service: FargateService; - public readonly cacheCluster: CfnCacheCluster; - public readonly securityGroup: SecurityGroup; + public readonly vpc: Vpc; + public readonly repository: Repository; + public readonly service: FargateService; + public readonly cacheCluster: CfnCacheCluster; + public readonly securityGroup: SecurityGroup; - constructor(scope: Construct, id: string, props: MeshHostingProps) { - super(scope, id); + constructor(scope: Construct, id: string, props: MeshHostingProps) { + super(scope, id); - this.vpc = props.vpc || new Vpc(this, 'graphql-server-vpc', { - vpcName: props.vpcName || 'graphql-server-vpc', - natGateways: 1 - }); + this.vpc = + props.vpc || + new Vpc(this, "graphql-server-vpc", { + vpcName: props.vpcName || "graphql-server-vpc", + natGateways: 1, + }); - const redis = props.redis || new RedisService(this, 'redis', { - ...props, - vpc: this.vpc - }); + const redis = + props.redis || + new RedisService(this, "redis", { + ...props, + vpc: this.vpc, + }); - this.cacheCluster = redis.cacheCluster; - this.securityGroup = redis.securityGroup; + this.cacheCluster = redis.cacheCluster; + this.securityGroup = redis.securityGroup; - const mesh = new MeshService(this, 'mesh', { - ...props, - vpc: this.vpc, - redis, - }); + const mesh = new MeshService(this, "mesh", { + ...props, + vpc: this.vpc, + redis, + }); - this.service = mesh.service; - this.repository = mesh.repository; + this.service = mesh.service; + this.repository = mesh.repository; - new CodePipelineService(this, 'pipeline', { - repository: this.repository, - service: this.service, - }); - } + new CodePipelineService(this, "pipeline", { + repository: this.repository, + service: this.service, + }); + } } diff --git a/packages/graphql-mesh-server/lib/pipeline.ts b/packages/graphql-mesh-server/lib/pipeline.ts index a6189049..a4e7c7c6 100644 --- a/packages/graphql-mesh-server/lib/pipeline.ts +++ b/packages/graphql-mesh-server/lib/pipeline.ts @@ -1,91 +1,94 @@ -import { Duration } from 'aws-cdk-lib'; -import { Artifact, Pipeline } from 'aws-cdk-lib/aws-codepipeline'; -import { Repository } from 'aws-cdk-lib/aws-ecr'; -import { FargateService } from 'aws-cdk-lib/aws-ecs'; -import * as pipe_actions from 'aws-cdk-lib/aws-codepipeline-actions'; -import * as codebuild from 'aws-cdk-lib/aws-codebuild'; -import { Construct } from 'constructs'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as YAML from 'yaml'; +import { Duration } from "aws-cdk-lib"; +import { Artifact, Pipeline } from "aws-cdk-lib/aws-codepipeline"; +import { Repository } from "aws-cdk-lib/aws-ecr"; +import { FargateService } from "aws-cdk-lib/aws-ecs"; +import * as pipe_actions from "aws-cdk-lib/aws-codepipeline-actions"; +import * as codebuild from "aws-cdk-lib/aws-codebuild"; +import { Construct } from "constructs"; +import * as fs from "fs"; +import * as path from "path"; +import * as YAML from "yaml"; export interface CodePipelineServiceProps { - /** - * Repository the code container is pushed too - */ - repository: Repository; + /** + * Repository the code container is pushed too + */ + repository: Repository; - /** - * Services to deploy Code container updates to - */ - service: FargateService; + /** + * Services to deploy Code container updates to + */ + service: FargateService; - /** - * Path to buildspec.yml (default: '../assets/buildspec.yml') - */ - buildspecPath?: string; + /** + * Path to buildspec.yml (default: '../assets/buildspec.yml') + */ + buildspecPath?: string; } export class CodePipelineService extends Construct { - public readonly pipeline: Pipeline; + public readonly pipeline: Pipeline; - constructor(scope: Construct, id: string, props: CodePipelineServiceProps) { - super(scope, id); + constructor(scope: Construct, id: string, props: CodePipelineServiceProps) { + super(scope, id); - this.pipeline = new Pipeline(this, 'deploy-pipeline'); + this.pipeline = new Pipeline(this, "deploy-pipeline"); - const sourceOutput = new Artifact(); - const sourceAction = new pipe_actions.EcrSourceAction({ - actionName: 'ECR', - repository: props.repository, - output: sourceOutput, - }); + const sourceOutput = new Artifact(); + const sourceAction = new pipe_actions.EcrSourceAction({ + actionName: "ECR", + repository: props.repository, + output: sourceOutput, + }); - this.pipeline.addStage({ - stageName: 'Source', - actions: [sourceAction], - }); + this.pipeline.addStage({ + stageName: "Source", + actions: [sourceAction], + }); - const file = fs.readFileSync( - path.resolve(__dirname, props.buildspecPath || '../assets/buildspec.yml'), - 'utf8' - ); - const project: codebuild.PipelineProject = - new codebuild.PipelineProject(this, 'project', { - buildSpec: codebuild.BuildSpec.fromObject(YAML.parse(file)), - }); + const file = fs.readFileSync( + path.resolve(__dirname, props.buildspecPath || "../assets/buildspec.yml"), + "utf8" + ); + const project: codebuild.PipelineProject = new codebuild.PipelineProject( + this, + "project", + { + buildSpec: codebuild.BuildSpec.fromObject(YAML.parse(file)), + } + ); - const buildOutput = new Artifact(); - this.pipeline.addStage({ - stageName: 'Build', - actions: [ - new pipe_actions.CodeBuildAction({ - actionName: 'CodeBuild', - project, - input: sourceOutput, - outputs: [buildOutput], - environmentVariables: { - IMAGE_URI: { - value: sourceAction.variables.imageUri, - }, - CONTAINER_NAME: { - value: props.service.taskDefinition.defaultContainer - ?.containerName, - }, - }, - }), - ], - }); - this.pipeline.addStage({ - stageName: 'Deploy', - actions: [ - new pipe_actions.EcsDeployAction({ - actionName: 'DeployAction', - service: props.service, - input: buildOutput, - deploymentTimeout: Duration.minutes(10), - }), - ], - }); - } + const buildOutput = new Artifact(); + this.pipeline.addStage({ + stageName: "Build", + actions: [ + new pipe_actions.CodeBuildAction({ + actionName: "CodeBuild", + project, + input: sourceOutput, + outputs: [buildOutput], + environmentVariables: { + IMAGE_URI: { + value: sourceAction.variables.imageUri, + }, + CONTAINER_NAME: { + value: + props.service.taskDefinition.defaultContainer?.containerName, + }, + }, + }), + ], + }); + this.pipeline.addStage({ + stageName: "Deploy", + actions: [ + new pipe_actions.EcsDeployAction({ + actionName: "DeployAction", + service: props.service, + input: buildOutput, + deploymentTimeout: Duration.minutes(10), + }), + ], + }); + } } diff --git a/packages/graphql-mesh-server/lib/redis-construct.ts b/packages/graphql-mesh-server/lib/redis-construct.ts index f88526b0..5a9ba5b0 100644 --- a/packages/graphql-mesh-server/lib/redis-construct.ts +++ b/packages/graphql-mesh-server/lib/redis-construct.ts @@ -1,92 +1,90 @@ -import { SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; +import { SecurityGroup, Vpc } from "aws-cdk-lib/aws-ec2"; import { - CfnCacheCluster, - CfnSubnetGroup, - CfnParameterGroup, -} from 'aws-cdk-lib/aws-elasticache'; -import { CfnOutput, Reference, Token } from 'aws-cdk-lib'; -import { Construct } from 'constructs'; + CfnCacheCluster, + CfnSubnetGroup, + CfnParameterGroup, +} from "aws-cdk-lib/aws-elasticache"; +import { CfnOutput, Reference, Token } from "aws-cdk-lib"; +import { Construct } from "constructs"; export interface RedisServiceProps { - /** - * VPC to attach Redis instance to - */ - vpc: Vpc; - /** - * Cache node type (default: 'cache.t2.micro') - */ - cacheNodeType?: string; + /** + * VPC to attach Redis instance to + */ + vpc: Vpc; + /** + * Cache node type (default: 'cache.t2.micro') + */ + cacheNodeType?: string; } export class RedisService extends Construct { - public readonly cacheCluster: CfnCacheCluster; - public readonly vpc: Vpc; - public readonly securityGroup: SecurityGroup; + public readonly cacheCluster: CfnCacheCluster; + public readonly vpc: Vpc; + public readonly securityGroup: SecurityGroup; - constructor(scope: Construct, id: string, props: RedisServiceProps) { - super(scope, id); + constructor(scope: Construct, id: string, props: RedisServiceProps) { + super(scope, id); - this.vpc = props.vpc; + this.vpc = props.vpc; - this.securityGroup = new SecurityGroup(this, 'RedisSecurityGroup', { - vpc: this.vpc, - }); + this.securityGroup = new SecurityGroup(this, "RedisSecurityGroup", { + vpc: this.vpc, + }); - const privateSubnets: string[] = this.vpc.privateSubnets.map( - (subnet) => { - return subnet.subnetId; - } - ); + const privateSubnets: string[] = this.vpc.privateSubnets.map(subnet => { + return subnet.subnetId; + }); - const cacheSubnetGroup = new CfnSubnetGroup(this, 'CacheSubnetGroup', { - description: 'Subnet Group for Mesh Cache', - subnetIds: privateSubnets, - }); + const cacheSubnetGroup = new CfnSubnetGroup(this, "CacheSubnetGroup", { + description: "Subnet Group for Mesh Cache", + subnetIds: privateSubnets, + }); - const cacheParameterGroup = new CfnParameterGroup( - this, - 'CacheParameterGroup', - { - cacheParameterGroupFamily: 'redis7', - description: 'Parameter Group for Mesh Cache', - properties: { - 'maxmemory-policy': 'allkeys-lru', - }, - } - ); + const cacheParameterGroup = new CfnParameterGroup( + this, + "CacheParameterGroup", + { + cacheParameterGroupFamily: "redis7", + description: "Parameter Group for Mesh Cache", + properties: { + "maxmemory-policy": "allkeys-lru", + }, + } + ); - this.cacheCluster = new CfnCacheCluster(this, 'cache-cluster', { - cacheNodeType: props.cacheNodeType || 'cache.t2.micro', - engine: 'redis', - numCacheNodes: 1, - autoMinorVersionUpgrade: true, - vpcSecurityGroupIds: [this.securityGroup.securityGroupId], - cacheSubnetGroupName: cacheSubnetGroup.ref, - cacheParameterGroupName: cacheParameterGroup.ref, - }); + this.cacheCluster = new CfnCacheCluster(this, "cache-cluster", { + cacheNodeType: props.cacheNodeType || "cache.t2.micro", + engine: "redis", + numCacheNodes: 1, + autoMinorVersionUpgrade: true, + vpcSecurityGroupIds: [this.securityGroup.securityGroupId], + cacheSubnetGroupName: cacheSubnetGroup.ref, + cacheParameterGroupName: cacheParameterGroup.ref, + }); - this.cacheCluster.addDependency(cacheParameterGroup); - this.cacheCluster.addDependency(cacheSubnetGroup); + this.cacheCluster.addDependency(cacheParameterGroup); + this.cacheCluster.addDependency(cacheSubnetGroup); - new CfnOutput(this, 'RedisConnectionString', { - description: 'RedisConnectionString', - value: this.cacheConnectionString, - }); - } + new CfnOutput(this, "RedisConnectionString", { + description: "RedisConnectionString", + value: this.cacheConnectionString, + }); + } - public get cacheConnectionString(): string { - return `redis://${this.cacheCluster - .getAtt('RedisEndpoint.Address') - .toString()}:${this.cacheCluster - .getAtt('RedisEndpoint.Port') - .toString()}`; - } + public get cacheConnectionString(): string { + return `redis://${this.cacheCluster + .getAtt("RedisEndpoint.Address") + .toString()}:${this.cacheCluster + .getAtt("RedisEndpoint.Port") + .toString()}`; + } - public get connectionEndPoint(): string { - return Token.asString(this.cacheCluster.getAtt('RedisEndpoint.Address')) - } + public get connectionEndPoint(): string { + return Token.asString(this.cacheCluster.getAtt("RedisEndpoint.Address")); + } - public get connectionPort(): number { - return Token.asNumber(this.cacheCluster.getAtt('RedisEndpoint.Port')); - } + public get connectionPort(): number { + return Token.asNumber(this.cacheCluster.getAtt("RedisEndpoint.Port")); + } } diff --git a/packages/graphql-mesh-server/lib/web-application-firewall.ts b/packages/graphql-mesh-server/lib/web-application-firewall.ts index 57e3c6ce..98837990 100644 --- a/packages/graphql-mesh-server/lib/web-application-firewall.ts +++ b/packages/graphql-mesh-server/lib/web-application-firewall.ts @@ -2,163 +2,167 @@ import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2"; import { Construct } from "constructs"; export enum Action { - BLOCK = 'BLOCK', - ALLOW = 'ALLOW', + BLOCK = "BLOCK", + ALLOW = "ALLOW", } export enum Scope { - CLOUDFRONT = 'CLOUDFRONT', - REGIONAL = 'REGIONAL', + CLOUDFRONT = "CLOUDFRONT", + REGIONAL = "REGIONAL", } export enum ManagedRule { - BOT_CONTROL_RULE_SET = "AWSManagedRulesBotControlRuleSet", - KNOWN_BAD_INPUTS_RULE_SET = "AWSManagedRulesKnownBadInputsRuleSet", - COMMON_RULE_SET = "AWSManagedRulesCommonRuleSet", - ANNONYMOUS_IP_LIST = "AWSManagedRulesAnonymousIpList", - AMAZON_IP_REPUTATION_LIST = "AWSManagedRulesAmazonIpReputationList", - ADMIN_PROTECTION_RULE_SET = "AWSManagedRulesAdminProtectionRuleSet", - SQLI_RULE_SET = "AWSManagedRulesSQLiRuleSet", - PHP_RULE_SET = "AWSManagedRulesPHPRuleSet" + BOT_CONTROL_RULE_SET = "AWSManagedRulesBotControlRuleSet", + KNOWN_BAD_INPUTS_RULE_SET = "AWSManagedRulesKnownBadInputsRuleSet", + COMMON_RULE_SET = "AWSManagedRulesCommonRuleSet", + ANNONYMOUS_IP_LIST = "AWSManagedRulesAnonymousIpList", + AMAZON_IP_REPUTATION_LIST = "AWSManagedRulesAmazonIpReputationList", + ADMIN_PROTECTION_RULE_SET = "AWSManagedRulesAdminProtectionRuleSet", + SQLI_RULE_SET = "AWSManagedRulesSQLiRuleSet", + PHP_RULE_SET = "AWSManagedRulesPHPRuleSet", } export interface VisibilityConfig { - /** - * Whether cloudwatch metrics are enabled or nor - */ - cloudWatchMetricsEnabled: boolean, - - /** - * Name of the metric in cloudwatch - */ - metricName: string, - - /** - * Whether to keep samples of blocked requests - */ - sampledRequestsEnabled: boolean + /** + * Whether cloudwatch metrics are enabled or nor + */ + cloudWatchMetricsEnabled: boolean; + + /** + * Name of the metric in cloudwatch + */ + metricName: string; + + /** + * Whether to keep samples of blocked requests + */ + sampledRequestsEnabled: boolean; } export interface AWSManagedRule { - /** - * Which AWS Rule to add - */ - name: ManagedRule, - - /** - * @default to the name property - */ - metricName?: string, - - /** - * @default false - */ - sampledRequestsEnabled?: boolean, - - /** - * Any rules from this ruleset you wish to disable/exclude - */ - excludedRules?: Array<{ - name: string - }>, - - /** - * Whether to override the default action to COUNT - */ - count?: boolean + /** + * Which AWS Rule to add + */ + name: ManagedRule; + + /** + * @default to the name property + */ + metricName?: string; + + /** + * @default false + */ + sampledRequestsEnabled?: boolean; + + /** + * Any rules from this ruleset you wish to disable/exclude + */ + excludedRules?: Array<{ + name: string; + }>; + + /** + * Whether to override the default action to COUNT + */ + count?: boolean; } export interface WebApplicationFirewallProps { - /** - * Name of the WAF - */ - name?: string, - - /** - * The action to perform if none of the `Rules` contained in the `WebACL` match. - * @default Action.ALLOW - */ - defaultAction?: Action, - - /** - * Specifies whether this is for an Amazon CloudFront distribution or for a regional application. - * @default Scope.REGIONAL - */ - scope?: Scope - - /** - * Default visibility configuration - */ - visibilityConfig: VisibilityConfig, - - /** - * List of AWS Managed rules to add to the WAF - */ - managedRules?: AWSManagedRule[], - - /** - * List of custom rules - */ - rules?: CfnWebACL.RuleProperty[] + /** + * Name of the WAF + */ + name?: string; + + /** + * The action to perform if none of the `Rules` contained in the `WebACL` match. + * @default Action.ALLOW + */ + defaultAction?: Action; + + /** + * Specifies whether this is for an Amazon CloudFront distribution or for a regional application. + * @default Scope.REGIONAL + */ + scope?: Scope; + + /** + * Default visibility configuration + */ + visibilityConfig: VisibilityConfig; + + /** + * List of AWS Managed rules to add to the WAF + */ + managedRules?: AWSManagedRule[]; + + /** + * List of custom rules + */ + rules?: CfnWebACL.RuleProperty[]; } export class WebApplicationFirewall extends Construct { - readonly acl: CfnWebACL; - readonly associations: CfnWebACLAssociation[]; - - constructor(scope: Construct, id: string, props: WebApplicationFirewallProps) { - super(scope, id); - - let defaultAction: CfnWebACL.DefaultActionProperty = { allow: {} }; - - if (props.defaultAction == Action.BLOCK) { - defaultAction = { block: {} }; - } - - this.associations = []; - - const rules: CfnWebACL.RuleProperty[] = props.rules || []; - - // Convert from our AWSManagedRule type to a CfnWebACL.RuleProperty - if (props.managedRules) { - props.managedRules.forEach((rule, index) => { - rules.push({ - name: rule.name, - priority: index, - visibilityConfig: { - // if no metric name is passed then don't enable metrics - cloudWatchMetricsEnabled: rule.metricName ? true : false, - // Default to the rule name if a metric name isn't passed - metricName: rule.metricName || rule.name, - sampledRequestsEnabled: rule.sampledRequestsEnabled || false - }, - statement: { - managedRuleGroupStatement: { - name: rule.name, - vendorName: "AWS", - excludedRules: rule.excludedRules || [], - } - }, - overrideAction: rule.count ? { count: {} } : { none: {} } - }) - }); - } - - this.acl = new CfnWebACL(this, "WebAcl", { - name: props.name, - defaultAction, - scope: props.scope || Scope.REGIONAL, - visibilityConfig: props.visibilityConfig, - rules: rules - }); + readonly acl: CfnWebACL; + readonly associations: CfnWebACLAssociation[]; + + constructor( + scope: Construct, + id: string, + props: WebApplicationFirewallProps + ) { + super(scope, id); + + let defaultAction: CfnWebACL.DefaultActionProperty = { allow: {} }; + + if (props.defaultAction == Action.BLOCK) { + defaultAction = { block: {} }; } - public addAssociation(id: string, resourceArn: string) { - this.associations.push( - new CfnWebACLAssociation(this, id, { - webAclArn: this.acl.attrArn, - resourceArn - }) - ); + this.associations = []; + + const rules: CfnWebACL.RuleProperty[] = props.rules || []; + + // Convert from our AWSManagedRule type to a CfnWebACL.RuleProperty + if (props.managedRules) { + props.managedRules.forEach((rule, index) => { + rules.push({ + name: rule.name, + priority: index, + visibilityConfig: { + // if no metric name is passed then don't enable metrics + cloudWatchMetricsEnabled: rule.metricName ? true : false, + // Default to the rule name if a metric name isn't passed + metricName: rule.metricName || rule.name, + sampledRequestsEnabled: rule.sampledRequestsEnabled || false, + }, + statement: { + managedRuleGroupStatement: { + name: rule.name, + vendorName: "AWS", + excludedRules: rule.excludedRules || [], + }, + }, + overrideAction: rule.count ? { count: {} } : { none: {} }, + }); + }); } -} \ No newline at end of file + + this.acl = new CfnWebACL(this, "WebAcl", { + name: props.name, + defaultAction, + scope: props.scope || Scope.REGIONAL, + visibilityConfig: props.visibilityConfig, + rules: rules, + }); + } + + public addAssociation(id: string, resourceArn: string) { + this.associations.push( + new CfnWebACLAssociation(this, id, { + webAclArn: this.acl.attrArn, + resourceArn, + }) + ); + } +}