diff --git a/.changeset/lovely-falcons-occur.md b/.changeset/lovely-falcons-occur.md new file mode 100644 index 0000000000..83532c0f47 --- /dev/null +++ b/.changeset/lovely-falcons-occur.md @@ -0,0 +1,5 @@ +--- +"@guardian/cdk": minor +--- + +Support running on ECS Managed Instances instead of Fargate. diff --git a/package-lock.json b/package-lock.json index 18806c75da..70da45c95c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@guardian/cdk", - "version": "66.0.0", + "version": "67.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@guardian/cdk", - "version": "66.0.0", + "version": "67.0.0", "dependencies": { "@aws-sdk/client-ec2": "^3.1148.0", "@aws-sdk/client-ssm": "^3.1148.0", diff --git a/src/experimental/constructs/iam/roles/__snapshots__/ecs-managed-instances.test.ts.snap b/src/experimental/constructs/iam/roles/__snapshots__/ecs-managed-instances.test.ts.snap new file mode 100644 index 0000000000..2b19612b84 --- /dev/null +++ b/src/experimental/constructs/iam/roles/__snapshots__/ecs-managed-instances.test.ts.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`GuECSManagedInstanceRole matches snapshot of the generated CloudFormation template 1`] = ` +{ + "Metadata": { + "gu:cdk:constructs": [ + "GuStack", + "GuECSManagedInstanceRoleExperimental", + "GuECSInstanceRolePolicyForManagedInstancesExperimental", + ], + "gu:cdk:version": "TEST", + }, + "Resources": { + "ECSInstanceRole5196E36E": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + ], + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "ECSInstanceRolePolicyD0240271": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecs:DiscoverPollEndpoint", + "Effect": "Allow", + "Resource": "*", + }, + { + "Action": "ecs:RegisterContainerInstance", + "Effect": "Allow", + "Resource": "arn:aws:ecs:eu-west-1:123456789012:cluster/test-cluster", + }, + { + "Action": "ecs:Poll", + "Effect": "Allow", + "Resource": [ + "arn:aws:ecs:*:*:container-instance/*", + "arn:aws:ecs:*:*:task-set/*", + ], + }, + { + "Action": [ + "ecs:StartTelemetrySession", + "ecs:PutSystemLogEvents", + ], + "Effect": "Allow", + "Resource": "arn:aws:ecs:*:*:container-instance/*", + }, + { + "Action": [ + "ecs:SubmitAttachmentStateChanges", + "ecs:SubmitTaskStateChange", + ], + "Effect": "Allow", + "Resource": "arn:aws:ecs:eu-west-1:123456789012:cluster/test-cluster", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ECSInstanceRolePolicyD0240271", + "Roles": [ + { + "Ref": "ECSInstanceRole5196E36E", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + }, +} +`; diff --git a/src/experimental/constructs/iam/roles/ecs-managed-instances.test.ts b/src/experimental/constructs/iam/roles/ecs-managed-instances.test.ts new file mode 100644 index 0000000000..31cdfb819c --- /dev/null +++ b/src/experimental/constructs/iam/roles/ecs-managed-instances.test.ts @@ -0,0 +1,66 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { Cluster } from "aws-cdk-lib/aws-ecs"; +import { simpleGuStackForTesting } from "../../../../utils/test"; +import { GuECSManagedInstanceRoleExperimental } from "./ecs-managed-instances"; + +describe("GuECSManagedInstanceRole", () => { + it("matches snapshot of the generated CloudFormation template", () => { + const stack = simpleGuStackForTesting(); + const cluster = Cluster.fromClusterArn( + stack, + "testcluster", + "arn:aws:ecs:eu-west-1:123456789012:cluster/test-cluster", + ); + + new GuECSManagedInstanceRoleExperimental(stack, "ECSInstanceRole", { + cluster, + }); + + expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); + }); + + it("creates a role that can be assumed by EC2", () => { + const stack = simpleGuStackForTesting(); + const cluster = Cluster.fromClusterArn( + stack, + "testcluster", + "arn:aws:ecs:eu-west-1:123456789012:cluster/test-cluster", + ); + + new GuECSManagedInstanceRoleExperimental(stack, "ECSInstanceRole", { + cluster, + }); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "ec2.amazonaws.com", + }, + }, + ], + }, + }); + }); + + it("attaches the ECS managed policy for registry access", () => { + const stack = simpleGuStackForTesting(); + const cluster = Cluster.fromClusterArn( + stack, + "testcluster", + "arn:aws:ecs:eu-west-1:123456789012:cluster/test-cluster", + ); + + new GuECSManagedInstanceRoleExperimental(stack, "ECSInstanceRole", { + cluster, + }); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Role", { + ManagedPolicyArns: ["arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"], + }); + }); +}); diff --git a/src/experimental/constructs/iam/roles/ecs-managed-instances.ts b/src/experimental/constructs/iam/roles/ecs-managed-instances.ts new file mode 100644 index 0000000000..e38f85e743 --- /dev/null +++ b/src/experimental/constructs/iam/roles/ecs-managed-instances.ts @@ -0,0 +1,45 @@ +import type { ICluster } from "aws-cdk-lib/aws-ecs"; +import { ManagedPolicy, ServicePrincipal } from "aws-cdk-lib/aws-iam"; +import type { GuStack } from "../../../../constructs/core"; +import { GuRole } from "../../../../constructs/iam"; +import { GuECSInstanceRolePolicyForManagedInstancesExperimental } from "../../policies/ecs-managed-instances-role-policy"; + +export interface GuECSManagedInstanceRoleExperimentalProps { + /** + * ECS cluster that this role will be used with + */ + cluster: ICluster; +} + +/** + * Role for ECS Managed Instances that the ECS agent assumes to communicate + * with the Amazon ECS service. + * + * Scoped to a ECS cluster. + */ +export class GuECSManagedInstanceRoleExperimental extends GuRole { + constructor(scope: GuStack, id: string, props: GuECSManagedInstanceRoleExperimentalProps) { + // Allows the EC2 instance to pull images from the registry + const ecsRegistryPullPolicy = ManagedPolicy.fromManagedPolicyArn( + scope, + "ECSRegistryPullPolicy", + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + ); + + super(scope, id, { + assumedBy: new ServicePrincipal("ec2.amazonaws.com"), + managedPolicies: [ecsRegistryPullPolicy], + }); + + // Allows the EC2 instance to register with the cluster and ECS + const ecsInstanceRolePolicy = new GuECSInstanceRolePolicyForManagedInstancesExperimental( + scope, + "ECSInstanceRolePolicy", + { + cluster: props.cluster, + }, + ); + + ecsInstanceRolePolicy.attachToRole(this); + } +} diff --git a/src/experimental/constructs/iam/roles/index.ts b/src/experimental/constructs/iam/roles/index.ts index 5a5cdfab56..d4b8840c27 100644 --- a/src/experimental/constructs/iam/roles/index.ts +++ b/src/experimental/constructs/iam/roles/index.ts @@ -1 +1,2 @@ +export * from "./ecs-managed-instances"; export * from "./fastly-kinesis-log"; diff --git a/src/experimental/constructs/policies/ecs-managed-instances-role-policy.test.ts b/src/experimental/constructs/policies/ecs-managed-instances-role-policy.test.ts new file mode 100644 index 0000000000..8268f182f8 --- /dev/null +++ b/src/experimental/constructs/policies/ecs-managed-instances-role-policy.test.ts @@ -0,0 +1,55 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { Cluster } from "aws-cdk-lib/aws-ecs"; +import { attachPolicyToTestRole, simpleGuStackForTesting } from "../../../utils/test"; +import { GuECSInstanceRolePolicyForManagedInstancesExperimental } from "./ecs-managed-instances-role-policy"; + +describe("GuECSInstanceRolePolicyForManagedInstancesExperimental", () => { + it("creates a policy with correct ECS permissions for a specific cluster", () => { + const stack = simpleGuStackForTesting(); + + const cluster = Cluster.fromClusterArn( + stack, + "testcluster", + "arn:aws:ecs:eu-west-1:123456789012:cluster/test-cluster", + ); + + const policy = new GuECSInstanceRolePolicyForManagedInstancesExperimental(stack, "ECSPolicy", { + cluster, + }); + + attachPolicyToTestRole(stack, policy); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", { + PolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Action: "ecs:DiscoverPollEndpoint", + Effect: "Allow", + Resource: "*", + }, + { + Action: "ecs:RegisterContainerInstance", + Effect: "Allow", + Resource: cluster.clusterArn, + }, + { + Action: "ecs:Poll", + Effect: "Allow", + Resource: ["arn:aws:ecs:*:*:container-instance/*", "arn:aws:ecs:*:*:task-set/*"], + }, + { + Action: ["ecs:StartTelemetrySession", "ecs:PutSystemLogEvents"], + Effect: "Allow", + Resource: "arn:aws:ecs:*:*:container-instance/*", + }, + { + Action: ["ecs:SubmitAttachmentStateChanges", "ecs:SubmitTaskStateChange"], + Effect: "Allow", + Resource: cluster.clusterArn, + }, + ], + }, + }); + }); +}); diff --git a/src/experimental/constructs/policies/ecs-managed-instances-role-policy.ts b/src/experimental/constructs/policies/ecs-managed-instances-role-policy.ts new file mode 100644 index 0000000000..91ac9f03eb --- /dev/null +++ b/src/experimental/constructs/policies/ecs-managed-instances-role-policy.ts @@ -0,0 +1,52 @@ +import type { ICluster } from "aws-cdk-lib/aws-ecs"; +import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; +import type { GuStack } from "../../../constructs/core"; +import { GuPolicy } from "../../../constructs/iam"; + +export interface GuECSInstanceRolePolicyForManagedInstancesExperimentalProps { + /** Cluster the capacity provider is scoped to. */ + cluster: ICluster; +} + +/** + * Used by ECS Managed Instances to communicate with Amazon ECS service, scoped to a ECS cluster. + * + * Amazon provide a managed version of this policy but it is not scoped to a specific cluster, despite + * the fact that managed EC2 instances from a capacity provider can only be used with 1 cluster. + * + * See Amazons managed version: + * https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AmazonECSInstanceRolePolicyForManagedInstances.html + */ +export class GuECSInstanceRolePolicyForManagedInstancesExperimental extends GuPolicy { + constructor(scope: GuStack, id: string, props: GuECSInstanceRolePolicyForManagedInstancesExperimentalProps) { + super(scope, id, { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ecs:DiscoverPollEndpoint"], + resources: ["*"], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ecs:RegisterContainerInstance"], + resources: [props.cluster.clusterArn], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ecs:Poll"], + resources: ["arn:aws:ecs:*:*:container-instance/*", "arn:aws:ecs:*:*:task-set/*"], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ecs:StartTelemetrySession", "ecs:PutSystemLogEvents"], + resources: ["arn:aws:ecs:*:*:container-instance/*"], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ecs:SubmitAttachmentStateChanges", "ecs:SubmitTaskStateChange"], + resources: [props.cluster.clusterArn], + }), + ], + }); + } +} diff --git a/src/experimental/patterns/__snapshots__/gu-load-balanced-app.test.ts.snap b/src/experimental/patterns/__snapshots__/gu-load-balanced-app.test.ts.snap index d07a864b75..5265dfa117 100644 --- a/src/experimental/patterns/__snapshots__/gu-load-balanced-app.test.ts.snap +++ b/src/experimental/patterns/__snapshots__/gu-load-balanced-app.test.ts.snap @@ -2655,10 +2655,10 @@ exports[`the GuLoadBalancedAppExperimental pattern should support new ECS and hy "GuArtifactsAccountId", "GuRepository", "GuLoggingStreamNameParameter", + "GuHttpsEgressSecurityGroup", "GuRiffRaffDeploymentIdParameterExperimental", "GuLogShippingPolicy", "GuParameterStoreReadPolicy", - "GuHttpsEgressSecurityGroup", "GuApplicationTargetGroup", "GuCertificate", "GuApplicationLoadBalancer", @@ -3680,6 +3680,1294 @@ exports[`the GuLoadBalancedAppExperimental pattern should support new ECS and hy } `; +exports[`the GuLoadBalancedAppExperimental pattern should support new ECS and hybrid functionality with ECS Managed Instances should produce a functional ECS app with minimal arguments 1`] = ` +{ + "Metadata": { + "gu:cdk:constructs": [ + "GuStack", + "GuVpcParameter", + "GuSubnetListParameter", + "GuSubnetListParameter", + "GuLoadBalancedAppExperimental", + "GuRepository", + "GuArtifactsAccountId", + "GuRepository", + "GuLoggingStreamNameParameter", + "GuHttpsEgressSecurityGroup", + "GuECSManagedInstanceRoleExperimental", + "GuECSInstanceRolePolicyForManagedInstancesExperimental", + "GuRiffRaffDeploymentIdParameterExperimental", + "GuLogShippingPolicy", + "GuParameterStoreReadPolicy", + "GuApplicationTargetGroup", + "GuCertificate", + "GuApplicationLoadBalancer", + "GuAccessLoggingBucketParameter", + "GuHttpsApplicationListener", + ], + "gu:cdk:version": "TEST", + }, + "Outputs": { + "LoadBalancerTestguDnsName": { + "Description": "DNS entry for LoadBalancerTestgu", + "Value": { + "Fn::GetAtt": [ + "LoadBalancerTestgu9DB57B41", + "DNSName", + ], + }, + }, + }, + "Parameters": { + "AccessLoggingBucket": { + "Default": "/account/services/access-logging/bucket", + "Description": "S3 bucket to store your access logs", + "Type": "AWS::SSM::Parameter::Value", + }, + "ArtifactsAccountId": { + "Default": "/organisation/accounts/artifacts", + "Description": "Account ID of the Artifacts account. Useful as some shared resources live here.", + "Type": "AWS::SSM::Parameter::Value", + }, + "LoggingStreamName": { + "Default": "/account/services/logging.stream.name", + "Description": "SSM parameter containing the Name (not ARN) on the kinesis stream", + "Type": "AWS::SSM::Parameter::Value", + }, + "RiffRaffDeploymentId": { + "Description": "Used by Riff-Raff to inject the deployment ID.", + "Type": "String", + }, + "VpcId": { + "Default": "/account/vpc/primary/id", + "Description": "Virtual Private Cloud to run EC2 instances within. Should NOT be the account default VPC.", + "Type": "AWS::SSM::Parameter::Value", + }, + "testguPrivateSubnets": { + "Default": "/account/vpc/primary/subnets/private", + "Description": "A list of private subnets", + "Type": "AWS::SSM::Parameter::Value>", + }, + "testguPublicSubnets": { + "Default": "/account/vpc/primary/subnets/public", + "Description": "A list of public subnets", + "Type": "AWS::SSM::Parameter::Value>", + }, + }, + "Resources": { + "CertificateTestguA5ACC36C": { + "DeletionPolicy": "Retain", + "Properties": { + "DomainName": "domain-name-for-your-application.example", + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Name", + "Value": "Test/CertificateTestgu", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "ValidationMethod": "DNS", + }, + "Type": "AWS::CertificateManager::Certificate", + "UpdateReplacePolicy": "Retain", + }, + "ECSInfrastructureRoleE492121F": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForManagedInstances", + ], + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "iam:PassRole", + "Condition": { + "StringLike": { + "iam:PassedToService": "ec2.*", + }, + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ECSManagedInstancesRole1E652558", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "PassInstanceRoleToEC2", + }, + ], + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "ECSInstanceProfileBA4A4DA9": { + "Properties": { + "Roles": [ + { + "Ref": "ECSManagedInstancesRole1E652558", + }, + ], + }, + "Type": "AWS::IAM::InstanceProfile", + }, + "ECSInstanceRolePolicyD0240271": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecs:DiscoverPollEndpoint", + "Effect": "Allow", + "Resource": "*", + }, + { + "Action": "ecs:RegisterContainerInstance", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "testguEcsCluster02799D79", + "Arn", + ], + }, + }, + { + "Action": "ecs:Poll", + "Effect": "Allow", + "Resource": [ + "arn:aws:ecs:*:*:container-instance/*", + "arn:aws:ecs:*:*:task-set/*", + ], + }, + { + "Action": [ + "ecs:StartTelemetrySession", + "ecs:PutSystemLogEvents", + ], + "Effect": "Allow", + "Resource": "arn:aws:ecs:*:*:container-instance/*", + }, + { + "Action": [ + "ecs:SubmitAttachmentStateChanges", + "ecs:SubmitTaskStateChange", + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "testguEcsCluster02799D79", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ECSInstanceRolePolicyD0240271", + "Roles": [ + { + "Ref": "ECSManagedInstancesRole1E652558", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "ECSManagedInstancesRole1E652558": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + ], + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "EcsService81FC6EF6": { + "DependsOn": [ + "ECSInstanceProfileBA4A4DA9", + "ECSManagedInstancesRole1E652558", + "EcsTaskDefinitionTaskRoleB7B6D8DD", + "ListenerTestguCB542308", + "ManagedInstancesCapacityProviderC2946447", + ], + "Properties": { + "CapacityProviderStrategy": [ + { + "CapacityProvider": { + "Ref": "ManagedInstancesCapacityProviderC2946447", + }, + "Weight": 1, + }, + ], + "Cluster": { + "Ref": "testguEcsCluster02799D79", + }, + "DeploymentConfiguration": { + "Alarms": { + "AlarmNames": [], + "Enable": false, + "Rollback": false, + }, + "DeploymentCircuitBreaker": { + "Enable": true, + "Rollback": true, + }, + "MaximumPercent": 200, + "MinimumHealthyPercent": 100, + }, + "DeploymentController": { + "Type": "ECS", + }, + "EnableECSManagedTags": false, + "HealthCheckGracePeriodSeconds": 60, + "LoadBalancers": [ + { + "ContainerName": "test-gu", + "ContainerPort": 3000, + "TargetGroupArn": { + "Ref": "TestguEcsTargetGroupTestguC2E4D411", + }, + }, + ], + "Monitoring": { + "MetricConfigurations": [ + { + "MetricNames": [ + "CPUUtilization", + "MemoryUtilization", + ], + "ResolutionSeconds": 20, + }, + ], + }, + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "GuHttpsEgressSecurityGroupTestguecs783F2306", + "GroupId", + ], + }, + ], + "Subnets": { + "Ref": "testguPrivateSubnets", + }, + }, + }, + "PropagateTags": "SERVICE", + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "TaskDefinition": { + "Ref": "EcsTaskDefinition63157ED3", + }, + }, + "Type": "AWS::ECS::Service", + }, + "EcsServiceTaskCountTarget02FCCE22": { + "DependsOn": [ + "ECSInstanceProfileBA4A4DA9", + "ECSManagedInstancesRole1E652558", + "EcsTaskDefinitionTaskRoleB7B6D8DD", + "ManagedInstancesCapacityProviderC2946447", + ], + "Properties": { + "MaxCapacity": 6, + "MinCapacity": 3, + "ResourceId": { + "Fn::Join": [ + "", + [ + "service/", + { + "Ref": "testguEcsCluster02799D79", + }, + "/", + { + "Fn::GetAtt": [ + "EcsService81FC6EF6", + "Name", + ], + }, + ], + ], + }, + "RoleARN": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::", + { + "Ref": "AWS::AccountId", + }, + ":role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService", + ], + ], + }, + "ScalableDimension": "ecs:service:DesiredCount", + "ServiceNamespace": "ecs", + }, + "Type": "AWS::ApplicationAutoScaling::ScalableTarget", + }, + "EcsTaskDefinition63157ED3": { + "Properties": { + "ContainerDefinitions": [ + { + "DockerLabels": { + "RiffRaffDeploymentId": { + "Ref": "RiffRaffDeploymentId", + }, + }, + "Environment": [ + { + "Name": "STACK", + "Value": "test-stack", + }, + { + "Name": "STAGE", + "Value": "TEST", + }, + { + "Name": "APP", + "Value": "test-gu", + }, + { + "Name": "TASK_NAME", + "Value": "test-gu", + }, + { + "Name": "GU_REPO", + "Value": "guardian/cdk", + }, + ], + "Essential": true, + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "ArtifactsAccountId", + }, + ".dkr.ecr.eu-west-1.", + { + "Ref": "AWS::URLSuffix", + }, + "/guardian/cdk/test-gu@sha256:12345", + ], + ], + }, + "LogConfiguration": { + "LogDriver": "awsfirelens", + "Options": { + "Name": "kinesis_streams", + "region": "eu-west-1", + "retry_limit": "2", + "stream": { + "Ref": "LoggingStreamName", + }, + }, + }, + "Name": "test-gu", + "PortMappings": [ + { + "ContainerPort": 3000, + "Protocol": "tcp", + }, + ], + "ReadonlyRootFilesystem": true, + "VersionConsistency": "disabled", + }, + { + "Environment": [ + { + "Name": "STACK", + "Value": "test-stack", + }, + { + "Name": "STAGE", + "Value": "TEST", + }, + { + "Name": "APP", + "Value": "test-gu", + }, + { + "Name": "TASK_NAME", + "Value": "test-gu", + }, + { + "Name": "GU_REPO", + "Value": "guardian/cdk", + }, + ], + "Essential": true, + "FirelensConfiguration": { + "Type": "fluentbit", + }, + "Image": { + "Fn::Join": [ + "", + [ + { + "Ref": "ArtifactsAccountId", + }, + ".dkr.ecr.eu-west-1.", + { + "Ref": "AWS::URLSuffix", + }, + "/guardian/cdk/logging@sha256:32dce4e7a9e628b053ca42c271d7bbdf3697b4980c4612e845a15257c2bd1d99", + ], + ], + }, + "LogConfiguration": { + "LogDriver": "awslogs", + "Options": { + "awslogs-group": { + "Ref": "EcsTaskDefinitionLogShippingLogGroup0E405BD0", + }, + "awslogs-region": "eu-west-1", + "awslogs-stream-prefix": "test-stack/TEST/test-gu/devx-logs-sidecar", + }, + }, + "MountPoints": [ + { + "ContainerPath": "/init", + "ReadOnly": false, + "SourceVolume": "logging-volume", + }, + ], + "Name": "LogShipping", + "ReadonlyRootFilesystem": true, + "VersionConsistency": "disabled", + }, + ], + "Cpu": "1024", + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "EcsTaskDefinitionExecutionRoleBE450C73", + "Arn", + ], + }, + "Family": "TestEcsTaskDefinition87FD078C", + "Memory": "2048", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "MANAGED_INSTANCES", + ], + "RuntimePlatform": { + "CpuArchitecture": "ARM64", + "OperatingSystemFamily": "LINUX", + }, + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "EcsTaskDefinitionTaskRoleB7B6D8DD", + "Arn", + ], + }, + "Volumes": [ + { + "Name": "logging-volume", + }, + ], + }, + "Type": "AWS::ECS::TaskDefinition", + }, + "EcsTaskDefinitionExecutionRoleBE450C73": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "EcsTaskDefinitionExecutionRoleDefaultPolicy1611A942": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":ecr:eu-west-1:", + { + "Ref": "ArtifactsAccountId", + }, + ":repository/guardian/cdk/test-gu", + ], + ], + }, + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*", + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":ecr:eu-west-1:", + { + "Ref": "ArtifactsAccountId", + }, + ":repository/guardian/cdk/logging", + ], + ], + }, + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EcsTaskDefinitionLogShippingLogGroup0E405BD0", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "EcsTaskDefinitionExecutionRoleDefaultPolicy1611A942", + "Roles": [ + { + "Ref": "EcsTaskDefinitionExecutionRoleBE450C73", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "EcsTaskDefinitionLogShippingLogGroup0E405BD0": { + "DeletionPolicy": "Retain", + "Properties": { + "RetentionInDays": 1, + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "EcsTaskDefinitionTaskRoleB7B6D8DD": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "GuHttpsEgressSecurityGroupTestguecs783F2306": { + "Properties": { + "GroupDescription": "Allow all outbound HTTPS traffic", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound HTTPS traffic", + "FromPort": 443, + "IpProtocol": "tcp", + "ToPort": 443, + }, + ], + "Tags": [ + { + "Key": "App", + "Value": "test-gu-ecs", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "VpcId": { + "Ref": "VpcId", + }, + }, + "Type": "AWS::EC2::SecurityGroup", + }, + "GuHttpsEgressSecurityGroupTestguecsfromTestLoadBalancerTestguSecurityGroup48DFF40930009FAA3069": { + "Properties": { + "Description": "Load balancer to target", + "FromPort": 3000, + "GroupId": { + "Fn::GetAtt": [ + "GuHttpsEgressSecurityGroupTestguecs783F2306", + "GroupId", + ], + }, + "IpProtocol": "tcp", + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + "LoadBalancerTestguSecurityGroup03CCB607", + "GroupId", + ], + }, + "ToPort": 3000, + }, + "Type": "AWS::EC2::SecurityGroupIngress", + }, + "GuLogShippingPolicy981BFE5A": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kinesis:Describe*", + "kinesis:Put*", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:kinesis:eu-west-1:", + { + "Ref": "AWS::AccountId", + }, + ":stream/", + { + "Ref": "LoggingStreamName", + }, + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "GuLogShippingPolicy981BFE5A", + "Roles": [ + { + "Ref": "EcsTaskDefinitionTaskRoleB7B6D8DD", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "ListenerTestguCB542308": { + "Properties": { + "Certificates": [ + { + "CertificateArn": { + "Ref": "CertificateTestguA5ACC36C", + }, + }, + ], + "DefaultActions": [ + { + "TargetGroupArn": { + "Ref": "TestguEcsTargetGroupTestguC2E4D411", + }, + "Type": "forward", + }, + ], + "LoadBalancerArn": { + "Ref": "LoadBalancerTestgu9DB57B41", + }, + "Port": 443, + "Protocol": "HTTPS", + "SslPolicy": "ELBSecurityPolicy-TLS13-1-2-2021-06", + }, + "Type": "AWS::ElasticLoadBalancingV2::Listener", + }, + "LoadBalancerTestgu9DB57B41": { + "Properties": { + "LoadBalancerAttributes": [ + { + "Key": "deletion_protection.enabled", + "Value": "true", + }, + { + "Key": "routing.http.x_amzn_tls_version_and_cipher_suite.enabled", + "Value": "true", + }, + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "true", + }, + { + "Key": "access_logs.s3.enabled", + "Value": "true", + }, + { + "Key": "access_logs.s3.bucket", + "Value": { + "Ref": "AccessLoggingBucket", + }, + }, + { + "Key": "access_logs.s3.prefix", + "Value": "application-load-balancer/TEST/test-stack/test-gu", + }, + ], + "Scheme": "internet-facing", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "LoadBalancerTestguSecurityGroup03CCB607", + "GroupId", + ], + }, + ], + "Subnets": { + "Ref": "testguPublicSubnets", + }, + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Type": "application", + }, + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + }, + "LoadBalancerTestguSecurityGroup03CCB607": { + "Properties": { + "GroupDescription": "Automatically created Security Group for ELB TestLoadBalancerTestguFCD56751", + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow from anyone on port 443", + "FromPort": 443, + "IpProtocol": "tcp", + "ToPort": 443, + }, + ], + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "VpcId": { + "Ref": "VpcId", + }, + }, + "Type": "AWS::EC2::SecurityGroup", + }, + "LoadBalancerTestguSecurityGrouptoTestGuHttpsEgressSecurityGroupTestguecsB707C20F300037C41A41": { + "Properties": { + "Description": "Load balancer to target", + "DestinationSecurityGroupId": { + "Fn::GetAtt": [ + "GuHttpsEgressSecurityGroupTestguecs783F2306", + "GroupId", + ], + }, + "FromPort": 3000, + "GroupId": { + "Fn::GetAtt": [ + "LoadBalancerTestguSecurityGroup03CCB607", + "GroupId", + ], + }, + "IpProtocol": "tcp", + "ToPort": 3000, + }, + "Type": "AWS::EC2::SecurityGroupEgress", + }, + "ManagedInstancesCapacityProviderC2946447": { + "Properties": { + "ClusterName": { + "Ref": "testguEcsCluster02799D79", + }, + "ManagedInstancesProvider": { + "InfrastructureRoleArn": { + "Fn::GetAtt": [ + "ECSInfrastructureRoleE492121F", + "Arn", + ], + }, + "InstanceLaunchTemplate": { + "Ec2InstanceProfileArn": { + "Fn::GetAtt": [ + "ECSInstanceProfileBA4A4DA9", + "Arn", + ], + }, + "InstanceRequirements": { + "CpuManufacturers": [ + "amazon-web-services", + ], + "InstanceGenerations": [ + "current", + ], + "MemoryMiB": { + "Min": 2048, + }, + "VCpuCount": { + "Min": 1, + }, + }, + "NetworkConfiguration": { + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "GuHttpsEgressSecurityGroupTestguecs783F2306", + "GroupId", + ], + }, + ], + "Subnets": { + "Ref": "testguPrivateSubnets", + }, + }, + }, + }, + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::ECS::CapacityProvider", + }, + "ParameterStoreReadTestgu2D9B3F35": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:ssm:eu-west-1:", + { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/test-gu", + ], + ], + }, + }, + { + "Action": [ + "ssm:GetParameters", + "ssm:GetParameter", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:ssm:eu-west-1:", + { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/test-stack/test-gu/*", + ], + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "parameter-store-read-policy", + "Roles": [ + { + "Ref": "EcsTaskDefinitionTaskRoleB7B6D8DD", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "TestguEcsTargetGroupTestguC2E4D411": { + "Properties": { + "HealthCheckIntervalSeconds": 10, + "HealthCheckPath": "/healthcheck", + "HealthCheckProtocol": "HTTP", + "HealthCheckTimeoutSeconds": 5, + "HealthyThresholdCount": 5, + "Port": 3000, + "Protocol": "HTTP", + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "TargetGroupAttributes": [ + { + "Key": "deregistration_delay.timeout_seconds", + "Value": "30", + }, + { + "Key": "stickiness.enabled", + "Value": "false", + }, + ], + "TargetType": "ip", + "UnhealthyThresholdCount": 2, + "VpcId": { + "Ref": "VpcId", + }, + }, + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + }, + "testguCpuScalingEB76A159": { + "Properties": { + "PolicyName": "TesttestguCpuScalingFF1701D3", + "PolicyType": "TargetTrackingScaling", + "ScalingTargetId": { + "Ref": "EcsServiceTaskCountTarget02FCCE22", + }, + "TargetTrackingScalingPolicyConfiguration": { + "PredefinedMetricSpecification": { + "PredefinedMetricType": "ECSServiceAverageCPUUtilizationHighResolution", + }, + "TargetValue": 20, + }, + }, + "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", + }, + "testguEcsCluster02799D79": { + "Properties": { + "ClusterSettings": [ + { + "Name": "containerInsights", + "Value": "enhanced", + }, + ], + "Tags": [ + { + "Key": "App", + "Value": "test-gu", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::ECS::Cluster", + }, + }, +} +`; + exports[`the GuLoadBalancedAppExperimental pattern should support s3 sync for EC2 if desired should produce the correct snapshot if allowS3Sync is specified 1`] = ` { "Metadata": { diff --git a/src/experimental/patterns/gu-load-balanced-app.test.ts b/src/experimental/patterns/gu-load-balanced-app.test.ts index 6b30fc133d..4be5f415fe 100644 --- a/src/experimental/patterns/gu-load-balanced-app.test.ts +++ b/src/experimental/patterns/gu-load-balanced-app.test.ts @@ -21,730 +21,761 @@ import { acknowledgeUnusedParameters } from "../../utils/test/validations"; import { RollingUpdateDurations } from "./ec2-app"; import { getDefaultS3ConfigMount, GuLoadBalancedAppExperimental } from "./gu-load-balanced-app"; -describe("the GuLoadBalancedAppExperimental pattern should support new ECS and hybrid functionality", function () { - it("should produce a functional ECS app with minimal arguments", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, +function ecsHybridFunctionalityTests(desc: string, extraEcsProps: Record = {}) { + describe(desc, function () { + it("should produce a functional ECS app with minimal arguments", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + }); + expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); }); - expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); - }); - it("should create S3 Files resources and mount them in the ECS task definition", function () { - const stack = simpleGuStackForTesting({ app: "test-gu", env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6 }, - imageIdentifier: "sha256:12345", - s3Config: { - ...getDefaultS3ConfigMount(stack, { app: "test-gu" }), - containerPath: "/amiable", - path: "test-stack/TEST/test-gu/conf/", + it("should create S3 Files resources and mount them in the ECS task definition", function () { + const stack = simpleGuStackForTesting({ app: "test-gu", env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - }, - }); + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6 }, + imageIdentifier: "sha256:12345", + s3Config: { + ...getDefaultS3ConfigMount(stack, { app: "test-gu" }), + containerPath: "/amiable", + path: "test-stack/TEST/test-gu/conf/", + }, + ...extraEcsProps, + }, + }); - const template = Template.fromStack(stack); - template.hasResourceProperties("AWS::S3Files::FileSystem", { - Bucket: { + const template = Template.fromStack(stack); + template.hasResourceProperties("AWS::S3Files::FileSystem", { + Bucket: { + "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::", { Ref: "DistributionBucketName" }]], + }, + Prefix: "test-stack/TEST/test-gu/conf/", + RoleArn: { + "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesRole"), "Arn"], + }, + }); + template.hasResource("AWS::S3Files::FileSystem", { + DependsOn: Match.arrayWith([Match.stringLikeRegexp("^S3FilesRoleDefaultPolicy")]), + }); + const bucketArn = { "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::", { Ref: "DistributionBucketName" }]], - }, - Prefix: "test-stack/TEST/test-gu/conf/", - RoleArn: { - "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesRole"), "Arn"], - }, - }); - template.hasResource("AWS::S3Files::FileSystem", { - DependsOn: Match.arrayWith([Match.stringLikeRegexp("^S3FilesRoleDefaultPolicy")]), - }); - const bucketArn = { - "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::", { Ref: "DistributionBucketName" }]], - }; - const objectArn = { - "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::", { Ref: "DistributionBucketName" }, "/*"]], - }; - template.hasResourceProperties("AWS::IAM::Policy", { - Roles: [{ Ref: Match.stringLikeRegexp("^S3FilesRole") }], - PolicyDocument: { - Statement: Match.arrayWith([ - Match.objectLike({ - Action: ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketVersioning", "s3:GetBucketLocation"], - Effect: "Allow", - Resource: bucketArn, - }), + }; + const objectArn = { + "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::", { Ref: "DistributionBucketName" }, "/*"]], + }; + template.hasResourceProperties("AWS::IAM::Policy", { + Roles: [{ Ref: Match.stringLikeRegexp("^S3FilesRole") }], + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketVersioning", "s3:GetBucketLocation"], + Effect: "Allow", + Resource: bucketArn, + }), + Match.objectLike({ + Action: ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"], + Effect: "Allow", + Resource: objectArn, + }), + Match.objectLike({ + Action: ["kms:GenerateDataKey", "kms:Encrypt", "kms:Decrypt", "kms:ReEncryptFrom", "kms:ReEncryptTo"], + Condition: { + StringLike: { + "kms:ViaService": { + "Fn::Join": ["", ["s3.eu-west-1.", { Ref: "AWS::URLSuffix" }]], + }, + "kms:EncryptionContext:aws:s3:arn": [bucketArn, objectArn], + }, + }, + }), + ]), + }, + }); + template.hasResourceProperties("AWS::IAM::Policy", { + Roles: [{ Ref: Match.stringLikeRegexp("^EcsTaskDefinitionTaskRole") }], + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: ["s3:GetObject", "s3:GetObjectVersion"], + Effect: "Allow", + Resource: objectArn, + }), + ]), + }, + }); + + template.resourceCountIs("AWS::S3Files::MountTarget", 3); + template.hasResourceProperties("AWS::S3Files::MountTarget", { + FileSystemId: { + "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesFileSystem"), "FileSystemId"], + }, + SecurityGroups: [ + { + "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesMountTargetSecurityGroup"), "GroupId"], + }, + ], + }); + + template.hasResourceProperties("AWS::EC2::SecurityGroup", { + SecurityGroupIngress: Match.arrayWith([ Match.objectLike({ - Action: ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"], - Effect: "Allow", - Resource: objectArn, + FromPort: 2049, + IpProtocol: "tcp", + SourceSecurityGroupId: { + "Fn::GetAtt": [Match.stringLikeRegexp("^GuHttpsEgressSecurityGroup"), "GroupId"], + }, + ToPort: 2049, }), + ]), + }); + template.hasResourceProperties("AWS::EC2::SecurityGroupEgress", { + DestinationSecurityGroupId: { + "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesMountTargetSecurityGroup"), "GroupId"], + }, + FromPort: 2049, + IpProtocol: "tcp", + ToPort: 2049, + }); + + template.hasResource("AWS::ECS::Service", { + DependsOn: Match.arrayWith([Match.stringLikeRegexp("^S3FilesMountTarget")]), + }); + + template.hasResourceProperties("AWS::ECS::TaskDefinition", { + Volumes: Match.arrayWith([ Match.objectLike({ - Action: ["kms:GenerateDataKey", "kms:Encrypt", "kms:Decrypt", "kms:ReEncryptFrom", "kms:ReEncryptTo"], - Condition: { - StringLike: { - "kms:ViaService": { - "Fn::Join": ["", ["s3.eu-west-1.", { Ref: "AWS::URLSuffix" }]], - }, - "kms:EncryptionContext:aws:s3:arn": [bucketArn, objectArn], + Name: "s3files-volume", + S3FilesVolumeConfiguration: { + FileSystemArn: { + "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesFileSystem"), "FileSystemArn"], }, + RootDirectory: "/", }, }), ]), - }, - }); - template.hasResourceProperties("AWS::IAM::Policy", { - Roles: [{ Ref: Match.stringLikeRegexp("^EcsTaskDefinitionTaskRole") }], - PolicyDocument: { - Statement: Match.arrayWith([ + ContainerDefinitions: Match.arrayWith([ Match.objectLike({ - Action: ["s3:GetObject", "s3:GetObjectVersion"], - Effect: "Allow", - Resource: objectArn, + MountPoints: Match.arrayWith([ + Match.objectLike({ + ContainerPath: "/amiable", + SourceVolume: "s3files-volume", + ReadOnly: true, + }), + ]), }), ]), - }, + }); }); - template.resourceCountIs("AWS::S3Files::MountTarget", 3); - template.hasResourceProperties("AWS::S3Files::MountTarget", { - FileSystemId: { - "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesFileSystem"), "FileSystemId"], - }, - SecurityGroups: [ - { - "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesMountTargetSecurityGroup"), "GroupId"], + it("should allow overriding S3 Files source bucket and path", function () { + const stack = simpleGuStackForTesting({ app: "test-gu", env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - ], - }); - - template.hasResourceProperties("AWS::EC2::SecurityGroup", { - SecurityGroupIngress: Match.arrayWith([ - Match.objectLike({ - FromPort: 2049, - IpProtocol: "tcp", - SourceSecurityGroupId: { - "Fn::GetAtt": [Match.stringLikeRegexp("^GuHttpsEgressSecurityGroup"), "GroupId"], - }, - ToPort: 2049, - }), - ]), - }); - template.hasResourceProperties("AWS::EC2::SecurityGroupEgress", { - DestinationSecurityGroupId: { - "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesMountTargetSecurityGroup"), "GroupId"], - }, - FromPort: 2049, - IpProtocol: "tcp", - ToPort: 2049, - }); - - template.hasResource("AWS::ECS::Service", { - DependsOn: Match.arrayWith([Match.stringLikeRegexp("^S3FilesMountTarget")]), - }); - - template.hasResourceProperties("AWS::ECS::TaskDefinition", { - Volumes: Match.arrayWith([ - Match.objectLike({ - Name: "s3files-volume", - S3FilesVolumeConfiguration: { - FileSystemArn: { - "Fn::GetAtt": [Match.stringLikeRegexp("^S3FilesFileSystem"), "FileSystemArn"], - }, - RootDirectory: "/", + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6 }, + imageIdentifier: "sha256:12345", + s3Config: { + containerPath: "/override", + bucket: "custom-bucket", + path: "custom/path/conf", }, - }), - ]), - ContainerDefinitions: Match.arrayWith([ - Match.objectLike({ - MountPoints: Match.arrayWith([ - Match.objectLike({ - ContainerPath: "/amiable", - SourceVolume: "s3files-volume", - ReadOnly: true, - }), - ]), - }), - ]), - }); - }); - - it("should allow overriding S3 Files source bucket and path", function () { - const stack = simpleGuStackForTesting({ app: "test-gu", env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6 }, - imageIdentifier: "sha256:12345", - s3Config: { - containerPath: "/override", - bucket: "custom-bucket", - path: "custom/path/conf", + ...extraEcsProps, }, - }, - }); + }); - Template.fromStack(stack).hasResourceProperties("AWS::S3Files::FileSystem", { - Bucket: { - "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::custom-bucket"]], - }, - Prefix: "custom/path/conf/", - }); + Template.fromStack(stack).hasResourceProperties("AWS::S3Files::FileSystem", { + Bucket: { + "Fn::Join": ["", ["arn:", { Ref: "AWS::Partition" }, ":s3:::custom-bucket"]], + }, + Prefix: "custom/path/conf/", + }); - Template.fromStack(stack).hasResourceProperties("AWS::ECS::TaskDefinition", { - ContainerDefinitions: Match.arrayWith([ - Match.objectLike({ - MountPoints: Match.arrayWith([ - Match.objectLike({ - ContainerPath: "/override", - ReadOnly: true, - }), - ]), - }), - ]), + Template.fromStack(stack).hasResourceProperties("AWS::ECS::TaskDefinition", { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + MountPoints: Match.arrayWith([ + Match.objectLike({ + ContainerPath: "/override", + ReadOnly: true, + }), + ]), + }), + ]), + }); }); - }); - it("should apply standard tags to all taggable resources", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - }); + it("should apply standard tags to all taggable resources", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + }); - const taggableResources: CfnResource[] = stack.node - .findAll() - .filter((_) => TagManager.isTaggable(_) || TagManager.isTaggableV2(_)) - .filter((_) => CfnResource.isCfnResource(_)); + const taggableResources: CfnResource[] = stack.node + .findAll() + .filter((_) => TagManager.isTaggable(_) || TagManager.isTaggableV2(_)) + .filter((_) => CfnResource.isCfnResource(_)); - const template = GuTemplate.fromStack(stack); - taggableResources.forEach(({ cfnResourceType }) => { - template.hasGuTaggedResource(cfnResourceType, { appIdentity: { app: "test-gu" } }); + const template = GuTemplate.fromStack(stack); + taggableResources.forEach(({ cfnResourceType }) => { + template.hasGuTaggedResource(cfnResourceType, { appIdentity: { app: "test-gu" } }); + }); }); - }); - it("should be capable of splitting traffic between EC2 and ECS target groups", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - const { targetGroups } = new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + it("should be capable of splitting traffic between EC2 and ECS target groups", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + const { targetGroups } = new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 499, - ecs: 500, - }, - }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::Listener", { - DefaultActions: [ - { - ForwardConfig: { - TargetGroups: [ - { - TargetGroupArn: stack.resolve(targetGroups.ec2!.targetGroupArn) as string, - Weight: 499, - }, - { - TargetGroupArn: stack.resolve(targetGroups.ecs!.targetGroupArn) as string, - Weight: 500, - }, - ], + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, }, }, - ], - }); - }); - - it("should create listener rules to deterministically route requests to EC2 or ECS during migration", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - const { targetGroups } = new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 499, - ecs: 500, - }, - }); - - Template.fromStack(stack).resourceCountIs("AWS::ElasticLoadBalancingV2::ListenerRule", 2); - - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::ListenerRule", { - Conditions: Match.arrayWith([ - Match.objectLike({ - Field: "http-header", - HttpHeaderConfig: { - HttpHeaderName: "X-Gu-Target-Group", - Values: ["ec2"], + targetGroupWeights: { + ec2: 499, + ecs: 500, + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::Listener", { + DefaultActions: [ + { + ForwardConfig: { + TargetGroups: [ + { + TargetGroupArn: stack.resolve(targetGroups.ec2!.targetGroupArn) as string, + Weight: 499, + }, + { + TargetGroupArn: stack.resolve(targetGroups.ecs!.targetGroupArn) as string, + Weight: 500, + }, + ], + }, }, - }), - ]), - Actions: [ - Match.objectLike({ - Type: "forward", - TargetGroupArn: stack.resolve(targetGroups.ec2!.targetGroupArn) as string, - }), - ], + ], + }); }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::ListenerRule", { - Conditions: Match.arrayWith([ - Match.objectLike({ - Field: "http-header", - HttpHeaderConfig: { - HttpHeaderName: "X-Gu-Target-Group", - Values: ["ecs"], + it("should create listener rules to deterministically route requests to EC2 or ECS during migration", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + const { targetGroups } = new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, }, - }), - ]), - Actions: [ - Match.objectLike({ - Type: "forward", - TargetGroupArn: stack.resolve(targetGroups.ecs!.targetGroupArn) as string, - }), - ], - }); - }); + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 499, + ecs: 500, + }, + }); - it("should keep weighted forwarding as the default action", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - const { targetGroups } = new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { minimumInstances: 1 }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 499, - ecs: 500, - }, + Template.fromStack(stack).resourceCountIs("AWS::ElasticLoadBalancingV2::ListenerRule", 2); + + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::ListenerRule", { + Conditions: Match.arrayWith([ + Match.objectLike({ + Field: "http-header", + HttpHeaderConfig: { + HttpHeaderName: "X-Gu-Target-Group", + Values: ["ec2"], + }, + }), + ]), + Actions: [ + Match.objectLike({ + Type: "forward", + TargetGroupArn: stack.resolve(targetGroups.ec2!.targetGroupArn) as string, + }), + ], + }); + + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::ListenerRule", { + Conditions: Match.arrayWith([ + Match.objectLike({ + Field: "http-header", + HttpHeaderConfig: { + HttpHeaderName: "X-Gu-Target-Group", + Values: ["ecs"], + }, + }), + ]), + Actions: [ + Match.objectLike({ + Type: "forward", + TargetGroupArn: stack.resolve(targetGroups.ecs!.targetGroupArn) as string, + }), + ], + }); }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::Listener", { - DefaultActions: [ - { - ForwardConfig: { - TargetGroups: [ - { - TargetGroupArn: stack.resolve(targetGroups.ec2!.targetGroupArn) as string, - Weight: 499, - }, - { - TargetGroupArn: stack.resolve(targetGroups.ecs!.targetGroupArn) as string, - Weight: 500, - }, - ], - }, + it("should keep weighted forwarding as the default action", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + const { targetGroups } = new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - ], - }); - }); + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { minimumInstances: 1 }, + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 499, + ecs: 500, + }, + }); - it("should throw an error if EC2 and ECS are both present but no weights are provided", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - expect( - () => - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::Listener", { + DefaultActions: [ + { + ForwardConfig: { + TargetGroups: [ + { + TargetGroupArn: stack.resolve(targetGroups.ec2!.targetGroupArn) as string, + Weight: 499, + }, + { + TargetGroupArn: stack.resolve(targetGroups.ecs!.targetGroupArn) as string, + Weight: 500, + }, + ], }, }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - }), - ).toThrow("EC2 and ECS are both enabled but no target group weights were provided"); - }); + ], + }); + }); - it("should throw an error if illegal weights are provided", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - expect( - () => - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + it("should throw an error if EC2 and ECS are both present but no weights are provided", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + expect( + () => + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 1000, // Outside of limit - ecs: 1, - }, - }), - ).toThrow("targetGroupWeights.ec2 must be between 0 and 999"); - }); - - it("should throw an error if a target receives traffic, but has no compute", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - expect( - () => - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 0, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, + }, }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 1, - ecs: 1, - }, - }), - ).toThrow("Invalid configuration: EC2 is set to serve traffic (1 of 2 requests), but has 0 instances."); - - expect( - () => - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "another-test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 0, maximumTasks: 0 }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 1, - ecs: 1, - }, - }), - ).toThrow("Invalid configuration: ECS is set to serve traffic (1 of 2 requests), but has 0 tasks."); - }); - - it("should throw an error if EC2 and ECS props are both omitted", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - expect( - () => - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - }), - ).toThrow("At least one of 'ec2Props' or 'ecsProps' must be specified"); - }); - - it("should use the ECR repo name from ecsProps if the user sets this explicitly", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - repositoryName: "guardian/some-other-repo/app", - }, - }); - Template.fromStack(stack).hasResourceProperties("AWS::ECS::TaskDefinition", { - ContainerDefinitions: Match.arrayWith([ - Match.objectLike({ - Image: { - "Fn::Join": ["", Match.arrayWith(["/guardian/some-other-repo/app@sha256:12345"])], - }, - }), - ]), + }), + ).toThrow("EC2 and ECS are both enabled but no target group weights were provided"); }); - }); - it("should throw an error if we cannot determine the right repository for ECR", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - // Remove the repositoryName - Object.defineProperty(stack, "repositoryName", { value: undefined }); - expect( - () => - new GuLoadBalancedAppExperimental(stack, { - monitoringConfiguration: { noMonitoring: true }, - applicationPort: 3000, - access: { scope: AccessScope.PUBLIC }, - app: "test-gu", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - }), - ).toThrow( - "Could not determine an ECR repository name; please either set this manually via ecsProps, or ensure your GuStack has a repositoryName and props has an app.", - ); - }); + it("should throw an error if illegal weights are provided", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + expect( + () => + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, + }, + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 1000, // Outside of limit + ecs: 1, + }, + }), + ).toThrow("targetGroupWeights.ec2 must be between 0 and 999"); + }); - it("allows a custom healthcheck to be used for the ECS target group", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - applicationPort: 3000, - app: "test-gu-ec2-app", - access: { scope: AccessScope.PUBLIC }, - monitoringConfiguration: { noMonitoring: true }, - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - healthcheck: { - path: "/custom-healthcheck", - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, + it("should throw an error if a target receives traffic, but has no compute", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + expect( + () => + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 0, + }, + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 1, + ecs: 1, + }, + }), + ).toThrow("Invalid configuration: EC2 is set to serve traffic (1 of 2 requests), but has 0 instances."); + + expect( + () => + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "another-test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, + }, + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 0, maximumTasks: 0 }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 1, + ecs: 1, + }, + }), + ).toThrow("Invalid configuration: ECS is set to serve traffic (1 of 2 requests), but has 0 tasks."); }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { - HealthCheckPath: "/custom-healthcheck", - TargetType: "ip", // This target type helps to confirm that its the ECS target group + + it("should throw an error if EC2 and ECS props are both omitted", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + expect( + () => + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + }), + ).toThrow("At least one of 'ec2Props' or 'ecsProps' must be specified"); }); - }); - it("applies the custom healthcheck settings to both target groups when operating in hybrid mode", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - applicationPort: 3000, - app: "test-gu-ec2-app", - access: { scope: AccessScope.PUBLIC }, - monitoringConfiguration: { noMonitoring: true }, - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - healthcheck: { - path: "/custom-healthcheck", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + it("should use the ECR repo name from ecsProps if the user sets this explicitly", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 499, - ecs: 500, - }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + repositoryName: "guardian/some-other-repo/app", + ...extraEcsProps, + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ECS::TaskDefinition", { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Image: { + "Fn::Join": ["", Match.arrayWith(["/guardian/some-other-repo/app@sha256:12345"])], + }, + }), + ]), + }); }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { - HealthCheckPath: "/custom-healthcheck", - TargetType: "instance", // The EC2 target group + + it("should throw an error if we cannot determine the right repository for ECR", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + // Remove the repositoryName + Object.defineProperty(stack, "repositoryName", { value: undefined }); + expect( + () => + new GuLoadBalancedAppExperimental(stack, { + monitoringConfiguration: { noMonitoring: true }, + applicationPort: 3000, + access: { scope: AccessScope.PUBLIC }, + app: "test-gu", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + }), + ).toThrow( + "Could not determine an ECR repository name; please either set this manually via ecsProps, or ensure your GuStack has a repositoryName and props has an app.", + ); }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { - HealthCheckPath: "/custom-healthcheck", - TargetType: "ip", // The ECS target group + + it("allows a custom healthcheck to be used for the ECS target group", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app", + access: { scope: AccessScope.PUBLIC }, + monitoringConfiguration: { noMonitoring: true }, + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + healthcheck: { + path: "/custom-healthcheck", + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckPath: "/custom-healthcheck", + TargetType: "ip", // This target type helps to confirm that its the ECS target group + }); }); - }); - it("should scale on CPU utilisation using aws target tracking", function () { - const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); - new GuLoadBalancedAppExperimental(stack, { - applicationPort: 3000, - app: "test-gu-ec2-app", - access: { scope: AccessScope.PUBLIC }, - monitoringConfiguration: { noMonitoring: true }, - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - healthcheck: { - path: "/custom-healthcheck", - }, - ec2Props: { - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - instanceMetricGranularity: "5Minute", - userData: UserData.forLinux(), - scaling: { - minimumInstances: 1, + it("applies the custom healthcheck settings to both target groups when operating in hybrid mode", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app", + access: { scope: AccessScope.PUBLIC }, + monitoringConfiguration: { noMonitoring: true }, + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - }, - ecsProps: { - cpu: 1024, - memoryLimitMiB: 2048, - scaling: { - minimumTasks: 3, - maximumTasks: 6, - cpuScaling: { - targetValue: 20, - scaleInCooldown: Duration.seconds(60), - scaleOutCooldown: Duration.seconds(60), + healthcheck: { + path: "/custom-healthcheck", + }, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, }, }, - imageIdentifier: "sha256:12345", - }, - targetGroupWeights: { - ec2: 499, - ecs: 500, - }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { minimumTasks: 3, maximumTasks: 6, cpuScaling: { targetValue: 20 } }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 499, + ecs: 500, + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckPath: "/custom-healthcheck", + TargetType: "instance", // The EC2 target group + }); + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckPath: "/custom-healthcheck", + TargetType: "ip", // The ECS target group + }); }); - Template.fromStack(stack).hasResourceProperties("AWS::ApplicationAutoScaling::ScalingPolicy", { - TargetTrackingScalingPolicyConfiguration: { - TargetValue: 20, - PredefinedMetricSpecification: { - PredefinedMetricType: "ECSServiceAverageCPUUtilizationHighResolution", + + it("should scale on CPU utilisation using aws target tracking", function () { + const stack = simpleGuStackForTesting({ env: { region: "eu-west-1" } }); + new GuLoadBalancedAppExperimental(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app", + access: { scope: AccessScope.PUBLIC }, + monitoringConfiguration: { noMonitoring: true }, + certificateProps: { + domainName: "domain-name-for-your-application.example", }, - }, + healthcheck: { + path: "/custom-healthcheck", + }, + ec2Props: { + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + instanceMetricGranularity: "5Minute", + userData: UserData.forLinux(), + scaling: { + minimumInstances: 1, + }, + }, + ecsProps: { + cpu: 1024, + memoryLimitMiB: 2048, + scaling: { + minimumTasks: 3, + maximumTasks: 6, + cpuScaling: { + targetValue: 20, + scaleInCooldown: Duration.seconds(60), + scaleOutCooldown: Duration.seconds(60), + }, + }, + imageIdentifier: "sha256:12345", + ...extraEcsProps, + }, + targetGroupWeights: { + ec2: 499, + ecs: 500, + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ApplicationAutoScaling::ScalingPolicy", { + TargetTrackingScalingPolicyConfiguration: { + TargetValue: 20, + PredefinedMetricSpecification: { + PredefinedMetricType: "ECSServiceAverageCPUUtilizationHighResolution", + }, + }, + }); }); }); -}); +} + +// Default behaviour is to use Fargate ECS Constructs +ecsHybridFunctionalityTests( + "the GuLoadBalancedAppExperimental pattern should support new ECS and hybrid functionality", +); + +// Managed Instances case +ecsHybridFunctionalityTests( + "the GuLoadBalancedAppExperimental pattern should support new ECS and hybrid functionality with ECS Managed Instances", + { + managedInstancesProps: {}, + }, +); describe("the GuLoadBalancedAppExperimental pattern should support all existing GuEc2App functionality", function () { it("should produce a functional EC2 app with minimal arguments", function () { diff --git a/src/experimental/patterns/gu-load-balanced-app.ts b/src/experimental/patterns/gu-load-balanced-app.ts index 8b5044096e..2f8068f187 100644 --- a/src/experimental/patterns/gu-load-balanced-app.ts +++ b/src/experimental/patterns/gu-load-balanced-app.ts @@ -1,4 +1,4 @@ -import { Aspects, Aws, Duration, SecretValue, Tags } from "aws-cdk-lib"; +import { Aspects, Aws, Duration, SecretValue, Size, Tags } from "aws-cdk-lib"; import type { PredefinedMetric, TargetTrackingScalingPolicyProps } from "aws-cdk-lib/aws-applicationautoscaling"; import { TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling"; import type { BlockDevice, CfnAutoScalingGroup, UpdatePolicy } from "aws-cdk-lib/aws-autoscaling"; @@ -9,11 +9,12 @@ import { UserPoolClientIdentityProvider, UserPoolIdentityProviderGoogle, } from "aws-cdk-lib/aws-cognito"; -import type { InstanceType, ISubnet, IVpc } from "aws-cdk-lib/aws-ec2"; -import { CfnSecurityGroupEgress, Peer, Port, UserData } from "aws-cdk-lib/aws-ec2"; +import type { InstanceRequirementsConfig, InstanceType, ISecurityGroup, ISubnet, IVpc } from "aws-cdk-lib/aws-ec2"; +import { CfnSecurityGroupEgress, CpuManufacturer, InstanceGeneration, Peer, Port, UserData } from "aws-cdk-lib/aws-ec2"; import type { CfnService, CfnTaskDefinition, Volume } from "aws-cdk-lib/aws-ecs"; import { Cluster, + Compatibility, ContainerInsights, CpuArchitecture, FargateService, @@ -21,14 +22,25 @@ import { FireLensLogDriver, FirelensLogRouterType, LogDriver, + ManagedInstancesCapacityProvider, + NetworkMode, OperatingSystemFamily, - PropagatedTagSource, + TaskDefinition, VersionConsistency, } from "aws-cdk-lib/aws-ecs"; +import { PropagatedTagSource } from "aws-cdk-lib/aws-ecs"; import type { HealthCheck as ALBHealthCheck } from "aws-cdk-lib/aws-elasticloadbalancingv2"; import { ApplicationProtocol, ListenerAction, ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2"; import { AuthenticateCognitoAction } from "aws-cdk-lib/aws-elasticloadbalancingv2-actions"; -import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam"; +import { + Effect, + InstanceProfile, + ManagedPolicy, + PolicyDocument, + PolicyStatement, + Role, + ServicePrincipal, +} from "aws-cdk-lib/aws-iam"; import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda"; import { RetentionDays } from "aws-cdk-lib/aws-logs"; import { CfnFileSystem, CfnMountTarget } from "aws-cdk-lib/aws-s3files"; @@ -71,6 +83,7 @@ import type { GuAsgCapacity, GuDomainName } from "../../types"; import { AppAccess } from "../../types"; import type { AmigoProps } from "../../types/amigo"; import { getUserPoolDomainPrefix } from "../../utils/cognito/cognito"; +import { GuECSManagedInstanceRoleExperimental } from "../constructs/iam/roles"; import { GuRiffRaffDeploymentIdParameterExperimental } from "../constructs/riff-raff-deployment-id"; import { GuAutoScalingRollingUpdateTimeoutExperimental, @@ -103,6 +116,74 @@ export interface GuS3ConfigMount { path: string; } +/** + * If you want to use an ECS service and ECS tasks to serve requests, then pass in relevant props here. + * + * If you are setting both `ecsProps` and `ec2Props` (i.e. if you are migrating from EC2 to ECS) then you must also + * specify weights for each compute type using `targetGroupWeights`. + */ +export interface EcsProps { + /** + * Which image to run. + * This should be the image digest (e.g. 'sha256:abc123') to ensure immutable deployments. + * + * @see https://docs.docker.com/dhi/core-concepts/digests + */ + imageIdentifier: string; + cpu: number; + memoryLimitMiB: number; + /** + * ECR repository name which contains your images. + */ + repositoryName?: ContainerRepositoryName; + /** + * Mount an S3 Files volume directly into the application container. + * + * Supply a single config object. For common defaults, use getDefaultS3ConfigMount(scope). + */ + s3Config?: GuS3ConfigMount; + /** + * The number of tasks that you want to run. We recommend running 3 tasks for production services which need a high + * level of availability so that all 3 Availability Zones are utilised. + */ + scaling: { + /** + * Scaling actions will never scale down below this threshold. This also controls the number of tasks that + * your ECS service will launch when it is first created. + */ + minimumTasks: number; + /** + * Scaling actions will never scale up above this threshold. + * + * Note that this max can be exceeded when a deployment runs (unlike the ASG max size). E.g. if maximumTasks is 6, + * the service is running 6 tasks and a deployment starts, the ECS service will briefly run with 12 tasks to get + * the deployment through. + */ + maximumTasks: number; + cpuScaling?: Pick; + }; + /** + * Use ECS Managed Instances instead of Fargate to run the app. + * + * @default - If left unspecified, the app will run on ECS Fargate. + */ + managedInstancesProps?: { + /** + * Requirements for the Managed EC2 Instances tasks will run on. + * + * @default - Default requirements are for managed instances to have at minimum the same amount of memory as the provided task, and minimum number of equivalent vCPUs to the tasks CPU units. Additionally, we require latest generation AWS Graviton CPUs for consistent performance. We strongly recommend sticking with the latest generation AWS Graviton CPUs. + */ + instancesRequirements?: InstanceRequirementsConfig; + }; + /** + * If true we provision the instanceRole and profile for managed instances. + * This needs to be set to true when using Fargate and deployed after managed instances, for a clean rollback. + * + * @default - false + */ + provisionManagedSecurityRole?: boolean; +} + /** * Default values for "normal" applications. * @@ -382,47 +463,7 @@ export interface GuLoadBalancedAppExperimentalProps extends AppIdentity { * If you are setting both `ecsProps` and `ec2Props` (i.e. if you are migrating from EC2 to ECS) then you must also * specify weights for each compute type using `targetGroupWeights`. */ - ecsProps?: { - /** - * Which image to run. - * This should be the image digest (e.g. 'sha256:abc123') to ensure immutable deployments. - * - * @see https://docs.docker.com/dhi/core-concepts/digests - */ - imageIdentifier: string; - cpu: number; - memoryLimitMiB: number; - /** - * ECR repository name which contains your images. - */ - repositoryName?: ContainerRepositoryName; - /** - * Mount an S3 Files volume directly into the application container. - * - * Supply a single config object. For common defaults, use getDefaultS3ConfigMount(scope). - */ - s3Config?: GuS3ConfigMount; - /** - * The number of tasks that you want to run. We recommend running 3 tasks for production services which need a high - * level of availability so that all 3 Availability Zones are utilised. - */ - scaling: { - /** - * Scaling actions will never scale down below this threshold. This also controls the number of tasks that - * your ECS service will launch when it is first created. - */ - minimumTasks: number; - /** - * Scaling actions will never scale up above this threshold. - * - * Note that this max can be exceeded when a deployment runs (unlike the ASG max size). E.g. if maximumTasks is 6, - * the service is running 6 tasks and a deployment starts, the ECS service will briefly run with 12 tasks to get - * the deployment through. - */ - maximumTasks: number; - cpuScaling?: Pick; - }; - }; + ecsProps?: EcsProps; /** * If you are specifying `ec2Props` and `ecsProps` use these weights to distribute traffic across the different compute types. * Each weight must be between 0 and 999. AWS will work out the relative percentage. @@ -726,341 +767,719 @@ export class GuLoadBalancedAppExperimental extends Construct { // Add the GitHub repo if we can const environment = scope.repositoryName ? { ...env, GU_REPO: scope.repositoryName } : env; - const taskDefinition = new FargateTaskDefinition(scope, "EcsTaskDefinition", { - memoryLimitMiB, - cpu, - runtimePlatform: { cpuArchitecture: CpuArchitecture.ARM64, operatingSystemFamily: OperatingSystemFamily.LINUX }, - }); - - const appContainer = taskDefinition.addContainer(app, { - image, - dockerLabels: { - RiffRaffDeploymentId: GuRiffRaffDeploymentIdParameterExperimental.getInstance(scope).valueAsString, - }, - // This speeds up deployment. We don't need this enabled if users follow the advice to refer to their image - // using the immutable digest - versionConsistency: VersionConsistency.DISABLED, - portMappings: [{ containerPort: applicationPort }], - logging: this.fireLensLogDriver, - readonlyRootFilesystem: true, - environment, - }); - - // Permissions passed to the ECS task... - const applicationPermissions: GuPolicy[] = [ - // ...to allow writing logs to Kinesis - GuLogShippingPolicy.getInstance(scope), - - // ...to allow reading application config from SSM - GuParameterStoreReadPolicy.getInstance(scope, { app }), - - // ...permissions specific for this application (provided by client) - ...additionalPolicies, - ]; - - applicationPermissions.forEach((policy) => policy.attachToRole(taskDefinition.taskRole)); - - /* - GuardDuty is enabled at the organisation level and runs as a sidecar. - We need to add specific permissions to allow pulling the GuardDuty image. - See https://docs.aws.amazon.com/guardduty/latest/ug/prereq-runtime-monitoring-ecs-support.html. - */ - const guardDutyPolicies = [ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["ecr:GetAuthorizationToken"], - resources: ["*"], - }), - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"], - resources: [ - // See https://docs.aws.amazon.com/guardduty/latest/ug/runtime-monitoring-ecr-repository-gdu-agent.html - "arn:aws:ecr:eu-west-1:694911143906:repository/aws-guardduty-agent-fargate", - ], - }), - ]; - - guardDutyPolicies.forEach((policy) => taskDefinition.addToExecutionRolePolicy(policy)); - - const ecsSecurityGroup = GuHttpsEgressSecurityGroup.forVpc(scope, { + const httpsEgressSecurityGroup = GuHttpsEgressSecurityGroup.forVpc(scope, { app: `${app}-ecs`, vpc, }); - const ecsService = new FargateService(scope, "EcsService", { - cluster, - taskDefinition, - vpcSubnets: { subnets: privateSubnets }, - // Important for service deployments; with the AWS defaults the service can be scaled down when deploying - minHealthyPercent: 100, - // Also important for service deployments; with the AWS defaults we don't get a fast failure when deploying a 'bad' build - circuitBreaker: { enable: true, rollback: true }, - propagateTags: PropagatedTagSource.SERVICE, - // By default, AWS will create a new security group which allows all outbound traffic - // We don't want this so explicitly allow outbound HTTPS only - // This is what we do for the current GuEc2App pattern: - // https://github.com/guardian/cdk/blob/3b5688637024642055ed0bf576f668e56e40830d/src/constructs/autoscaling/asg.ts#L143-L145 - securityGroups: [ecsSecurityGroup], - }); - const cfnService = ecsService.node.defaultChild as CfnService; - cfnService.addPropertyOverride("Monitoring", { - MetricConfigurations: [ - { - MetricNames: ["CPUUtilization", "MemoryUtilization"], - ResolutionSeconds: 20, - }, - ], - }); - - const ecsScalableTarget = ecsService.autoScaleTaskCount({ - minCapacity: scaling.minimumTasks, - maxCapacity: scaling.maximumTasks, - }); - - if (scaling.cpuScaling) { - const { targetValue, scaleOutCooldown, scaleInCooldown } = scaling.cpuScaling; + // If we are not using managed instances but we do want to provision the roles (for an easy rollback) + // If we are using managed instances these roles will be created in createManagedInstancesConstructs + // + // TODO: Remove when switching to one of fargate or managed instances. + if (!ecsProps.managedInstancesProps && ecsProps.provisionManagedSecurityRole) { + const ecsInstanceRole = new GuECSManagedInstanceRoleExperimental(scope, "ECSManagedInstancesRole", { + cluster: cluster, + }); - // The high resolution predefined metric evaluates every 20s rather than 60s, - // so scaling reacts far faster. It isn't in the CDK `PredefinedMetric` enum yet. - // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/target-tracking-faster-auto-scaling.html - // - // TODO: Update in line with `PredefinedMetric` enum - https://github.com/aws/aws-cdk/issues/38207 - new TargetTrackingScalingPolicy(this, "CpuScaling", { - scalingTarget: ecsScalableTarget, - targetValue: targetValue, - predefinedMetric: "ECSServiceAverageCPUUtilizationHighResolution" as unknown as PredefinedMetric, - scaleOutCooldown: scaleOutCooldown, - scaleInCooldown: scaleInCooldown, + new InstanceProfile(scope, "ECSInstanceProfile", { + role: ecsInstanceRole, }); } - this.ecsService = ecsService; - - // It's possible to opt-out of log shipping to ELK in the EC2 patterns; should we mirror that here? - const logRouter = taskDefinition.addFirelensLogRouter("LogShipping", { - // See https://github.com/guardian/devx-logs - image: loggingImage, - environment, - versionConsistency: VersionConsistency.DISABLED, - // Send this container's logs to CloudWatch logs, retained for 1 day - logging: LogDriver.awsLogs({ - streamPrefix: [scope.stack, scope.stage, app, "devx-logs-sidecar"].join("/"), - logRetention: RetentionDays.ONE_DAY, - }), + if (!ecsProps.managedInstancesProps) { + // Fargate infrastructure + const taskDefinition = new FargateTaskDefinition(scope, "EcsTaskDefinition", { + memoryLimitMiB, + cpu, + runtimePlatform: { + cpuArchitecture: CpuArchitecture.ARM64, + operatingSystemFamily: OperatingSystemFamily.LINUX, + }, + }); - firelensConfig: { - type: FirelensLogRouterType.FLUENTBIT, - }, + const appContainer = taskDefinition.addContainer(app, { + image, + dockerLabels: { + RiffRaffDeploymentId: GuRiffRaffDeploymentIdParameterExperimental.getInstance(scope).valueAsString, + }, + // This speeds up deployment. We don't need this enabled if users follow the advice to refer to their image + // using the immutable digest + versionConsistency: VersionConsistency.DISABLED, + portMappings: [{ containerPort: applicationPort }], + logging: this.fireLensLogDriver, + readonlyRootFilesystem: true, + environment, + }); - // To comply with FSBP ECS.5 - readonlyRootFilesystem: true, - }); + // Permissions passed to the ECS task... + const applicationPermissions: GuPolicy[] = [ + // ...to allow writing logs to Kinesis + GuLogShippingPolicy.getInstance(scope), - const logVolume: Volume = { - name: "logging-volume", - }; + // ...to allow reading application config from SSM + GuParameterStoreReadPolicy.getInstance(scope, { app }), - // Capitalised because this is raw cfn json: cdk does not have a first-class construct for volumes yet. - const volumes: unknown[] = [{ Name: logVolume.name }]; + // ...permissions specific for this application (provided by client) + ...additionalPolicies, + ]; - if (s3Config) { - const normalizedPrefix = s3Config.path.endsWith("/") ? s3Config.path : `${s3Config.path}/`; - const bucketArn = GuS3Bucket.fromBucketName(this, "ConfigBucket", s3Config.bucket).bucketArn; - const role = new Role(scope, `S3FilesRole`, { - assumedBy: new ServicePrincipal("elasticfilesystem.amazonaws.com").withConditions({ - StringEquals: { - "aws:SourceAccount": scope.account, - }, - ArnLike: { - "aws:SourceArn": `arn:${Aws.PARTITION}:s3files:${scope.region}:${scope.account}:file-system/*`, - }, - }), - description: `Role used by the S3 Files filesystem for ${app} mount`, - }); + applicationPermissions.forEach((policy) => policy.attachToRole(taskDefinition.taskRole)); - // S3 Files needs synchronization permissions even when the container mount is read-only. - // The file system prefix does not implicitly restrict these IAM permissions. - role.addToPrincipalPolicy( + const guardDutyPolicies = [ new PolicyStatement({ effect: Effect.ALLOW, - actions: ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketVersioning", "s3:GetBucketLocation"], - resources: [bucketArn], + actions: ["ecr:GetAuthorizationToken"], + resources: ["*"], }), - ); - role.addToPrincipalPolicy( new PolicyStatement({ effect: Effect.ALLOW, - actions: ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"], - resources: [`${bucketArn}/*`], + actions: ["ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"], + resources: [ + // See https://docs.aws.amazon.com/guardduty/latest/ug/runtime-monitoring-ecr-repository-gdu-agent.html + "arn:aws:ecr:eu-west-1:694911143906:repository/aws-guardduty-agent-fargate", + ], }), - ); - role.addToPrincipalPolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["kms:GenerateDataKey", "kms:Encrypt", "kms:Decrypt", "kms:ReEncryptFrom", "kms:ReEncryptTo"], - resources: [`arn:${Aws.PARTITION}:kms:${scope.region}:${scope.account}:key/*`], - conditions: { - StringLike: { - "kms:ViaService": `s3.${scope.region}.${Aws.URL_SUFFIX}`, - "kms:EncryptionContext:aws:s3:arn": [bucketArn, `${bucketArn}/*`], - }, + ]; + + guardDutyPolicies.forEach((policy) => taskDefinition.addToExecutionRolePolicy(policy)); + + const ecsService = new FargateService(scope, "EcsService", { + cluster, + taskDefinition, + vpcSubnets: { subnets: privateSubnets }, + // Important for service deployments; with the AWS defaults the service can be scaled down when deploying + minHealthyPercent: 100, + // Also important for service deployments; with the AWS defaults we don't get a fast failure when deploying a 'bad' build + circuitBreaker: { enable: true, rollback: true }, + propagateTags: PropagatedTagSource.SERVICE, + // By default, AWS will create a new security group which allows all outbound traffic + // We don't want this so explicitly allow outbound HTTPS only + // This is what we do for the current GuEc2App pattern: + // https://github.com/guardian/cdk/blob/3b5688637024642055ed0bf576f668e56e40830d/src/constructs/autoscaling/asg.ts#L143-L145 + securityGroups: [httpsEgressSecurityGroup], + }); + + const cfnService = ecsService.node.defaultChild as CfnService; + cfnService.addPropertyOverride("Monitoring", { + MetricConfigurations: [ + { + MetricNames: ["CPUUtilization", "MemoryUtilization"], + ResolutionSeconds: 20, }, + ], + }); + + const ecsScalableTarget = ecsService.autoScaleTaskCount({ + minCapacity: scaling.minimumTasks, + maxCapacity: scaling.maximumTasks, + }); + + if (scaling.cpuScaling) { + const { targetValue, scaleOutCooldown, scaleInCooldown } = scaling.cpuScaling; + + // The high resolution predefined metric evaluates every 20s rather than 60s, + // so scaling reacts far faster. It isn't in the CDK `PredefinedMetric` enum yet. + // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/target-tracking-faster-auto-scaling.html + // + // TODO: Update in line with `PredefinedMetric` enum - https://github.com/aws/aws-cdk/issues/38207 + new TargetTrackingScalingPolicy(this, "CpuScaling", { + scalingTarget: ecsScalableTarget, + targetValue: targetValue, + predefinedMetric: "ECSServiceAverageCPUUtilizationHighResolution" as unknown as PredefinedMetric, + scaleOutCooldown: scaleOutCooldown, + scaleInCooldown: scaleInCooldown, + }); + } + + this.ecsService = ecsService; + + // It's possible to opt-out of log shipping to ELK in the EC2 patterns; should we mirror that here? + const logRouter = taskDefinition.addFirelensLogRouter("LogShipping", { + // See https://github.com/guardian/devx-logs + image: loggingImage, + environment, + versionConsistency: VersionConsistency.DISABLED, + // Send this container's logs to CloudWatch logs, retained for 1 day + logging: LogDriver.awsLogs({ + streamPrefix: [scope.stack, scope.stage, app, "devx-logs-sidecar"].join("/"), + logRetention: RetentionDays.ONE_DAY, }), - ); - // EventBridge permissions: S3 Files creates rules prefixed "DO-NOT-DELETE-S3-Files" - // to detect S3 object changes and trigger data synchronization - role.addToPrincipalPolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: [ - "events:DeleteRule", - "events:DisableRule", - "events:EnableRule", - "events:PutRule", - "events:PutTargets", - "events:RemoveTargets", + firelensConfig: { + type: FirelensLogRouterType.FLUENTBIT, + }, + + // To comply with FSBP ECS.5 + readonlyRootFilesystem: true, + }); + + const logVolume: Volume = { + name: "logging-volume", + }; + + // Capitalised because this is raw cfn json: cdk does not have a first-class construct for volumes yet. + const volumes: unknown[] = [{ Name: logVolume.name }]; + + if (s3Config) { + const normalizedPrefix = s3Config.path.endsWith("/") ? s3Config.path : `${s3Config.path}/`; + const bucketArn = GuS3Bucket.fromBucketName(this, "ConfigBucket", s3Config.bucket).bucketArn; + const role = new Role(scope, `S3FilesRole`, { + assumedBy: new ServicePrincipal("elasticfilesystem.amazonaws.com").withConditions({ + StringEquals: { + "aws:SourceAccount": scope.account, + }, + ArnLike: { + "aws:SourceArn": `arn:${Aws.PARTITION}:s3files:${scope.region}:${scope.account}:file-system/*`, + }, + }), + description: `Role used by the S3 Files filesystem for ${app} mount`, + }); + + // S3 Files needs synchronization permissions even when the container mount is read-only. + // The file system prefix does not implicitly restrict these IAM permissions. + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketVersioning", "s3:GetBucketLocation"], + resources: [bucketArn], + }), + ); + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"], + resources: [`${bucketArn}/*`], + }), + ); + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["kms:GenerateDataKey", "kms:Encrypt", "kms:Decrypt", "kms:ReEncryptFrom", "kms:ReEncryptTo"], + resources: [`arn:${Aws.PARTITION}:kms:${scope.region}:${scope.account}:key/*`], + conditions: { + StringLike: { + "kms:ViaService": `s3.${scope.region}.${Aws.URL_SUFFIX}`, + "kms:EncryptionContext:aws:s3:arn": [bucketArn, `${bucketArn}/*`], + }, + }, + }), + ); + + // EventBridge permissions: S3 Files creates rules prefixed "DO-NOT-DELETE-S3-Files" + // to detect S3 object changes and trigger data synchronization + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + "events:DeleteRule", + "events:DisableRule", + "events:EnableRule", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + ], + resources: [`arn:${Aws.PARTITION}:events:*:*:rule/DO-NOT-DELETE-S3-Files*`], + conditions: { StringEquals: { "events:ManagedBy": "elasticfilesystem.amazonaws.com" } }, + }), + ); + + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["events:DescribeRule", "events:ListTargetsByRule"], + resources: [`arn:${Aws.PARTITION}:events:*:*:rule/*`], + }), + ); + + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["events:ListRuleNamesByTarget", "events:ListRules"], + resources: ["*"], + }), + ); + + const fileSystem = new CfnFileSystem(scope, `S3FilesFileSystem`, { + bucket: bucketArn, + prefix: normalizedPrefix, + roleArn: role.roleArn, + }); + // Referencing the role ARN alone does not wait for its separately provisioned policy. + fileSystem.node.addDependency(role); + + const mountTargetSecurityGroup = new GuSecurityGroup(scope, "S3FilesMountTargetSecurityGroup", { + app: `${app}-s3files`, + vpc, + allowAllOutbound: false, + ingresses: [ + { + range: Peer.securityGroupId(httpsEgressSecurityGroup.securityGroupId), + port: Port.tcp(2049), + description: "Allow ECS tasks to mount S3 Files", + }, ], - resources: [`arn:${Aws.PARTITION}:events:*:*:rule/DO-NOT-DELETE-S3-Files*`], - conditions: { StringEquals: { "events:ManagedBy": "elasticfilesystem.amazonaws.com" } }, - }), - ); + egresses: [], + }); + new CfnSecurityGroupEgress(scope, "S3FilesTaskEgress", { + groupId: httpsEgressSecurityGroup.securityGroupId, + destinationSecurityGroupId: mountTargetSecurityGroup.securityGroupId, + ipProtocol: "tcp", + fromPort: 2049, // Network file system (NFS) port + toPort: 2049, + description: "Allow NFS traffic to S3 Files mount targets", + }); + + const mountTargets = privateSubnets.map( + (subnet, index) => + new CfnMountTarget(scope, `S3FilesMountTarget${index}`, { + fileSystemId: fileSystem.attrFileSystemId, + subnetId: subnet.subnetId, + securityGroups: [mountTargetSecurityGroup.securityGroupId], + }), + ); + ecsService.node.addDependency(...mountTargets); + + const actions: string[] = ["s3files:ClientMount"]; + + taskDefinition.addToTaskRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions, + resources: [fileSystem.attrFileSystemArn], + }), + ); + + // Direct S3 access for read optimization + taskDefinition.addToTaskRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:ListBucket"], + resources: [bucketArn], + }), + ); - role.addToPrincipalPolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["events:DescribeRule", "events:ListTargetsByRule"], - resources: [`arn:${Aws.PARTITION}:events:*:*:rule/*`], - }), + taskDefinition.addToTaskRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:GetObject", "s3:GetObjectVersion"], + resources: [`${bucketArn}/*`], + }), + ); + + appContainer.addMountPoints({ + containerPath: s3Config.containerPath, + sourceVolume: `s3files-volume`, + readOnly: true, + }); + + // Capitalised because this is raw cfn json: cdk does not have a first-class construct for S3 Files volumes yet. + volumes.push({ + Name: `s3files-volume`, + S3FilesVolumeConfiguration: { + FileSystemArn: fileSystem.attrFileSystemArn, + RootDirectory: "/", + }, + }); + } + + const cfnTaskDefinition = taskDefinition.node.defaultChild as CfnTaskDefinition; + cfnTaskDefinition.addPropertyOverride("Volumes", volumes); + + logRouter.addMountPoints({ + containerPath: "/init", + sourceVolume: logVolume.name, + readOnly: false, + }); + + // We need a new target group even if we share the other load balancer components with the EC2 infrastructure + const ecsTargetGroup = new GuApplicationTargetGroup( + scope, + + // This ID parameter is used to form the resource's logical ID. + // If a target group is not explicitly named, CloudFormation will use the logical ID to generate a name of form `--<12 CHAR GUID>` to a max of 32 chars. + // Add the App to the start of the logical ID to make the generated names (slightly) glanceable, e.g "MyAppE-123456123456" vs. "EcsTar-123456123456". + AppIdentity.addAppToStringStart(props, "EcsTargetGroup"), + + { + vpc, + app, + port: applicationPort, + targets: [ecsService], + healthCheck: healthcheck, + }, ); - role.addToPrincipalPolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["events:ListRuleNamesByTarget", "events:ListRules"], - resources: ["*"], - }), + targetGroups = { + ...targetGroups, + ecs: ecsTargetGroup, + }; + + // Specifically apply App tag to resources. + // Other resources obtain this tag by extending `GuAppAwareConstruct`. + [cluster, taskDefinition, ecsService].forEach((_) => AppIdentity.taggedConstruct(props, _)); + } else { + // Managed Instances infrastructure + + // GuardDuty is enabled at the organisation level and runs as a sidecar. + // We need to add specific permissions to allow pulling the GuardDuty image. + // See https://docs.aws.amazon.com/guardduty/latest/ug/prereq-runtime-monitoring-ecs-support.html. + // + // GuardDuty only supports runtime monitoring for ECS Fargate. + // For ECS Managed Instances, it supports the Malware Protection for EC2. + // See https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_managed-instances.html + + // This is the same as the recommend role for ecs managed instances capacity providers, + // except without the `instanceProfileName` set to "ecsInstanceRole" in the `instanceProfile`. + // + // AWS recommend instanceProfileName is set to ecsInstanceRole as the managed policy + // AmazonECSInfrastructureRolePolicyForManagedInstances is by default set to scope `iam:PassRole` + // to `arn:aws:iam::*:role/ecsInstanceRole*`. + // + // Unfortunately setting `instanceProfileName` to ecsInstanceRole means that cloud formation can not + // re-deploy the instance role, as it can't generate a different name to cycle between them. For this + // reason we do not specify `instanceProfileName`, and we create a custom infrastructure role that + // uses the AmazonECSInfrastructureRolePolicyForManagedInstances managed policy and explicitly scopes + // `iam:PassRole` to EC2 instances. + // + // To read more {@link https://docs.aws.amazon.com/AmazonECS/latest/developerguide/managed-instances-instance-profile.html} + const ecsInstanceRole = new GuECSManagedInstanceRoleExperimental(scope, "ECSManagedInstancesRole", { + cluster: cluster, + }); + + const instanceProfile = new InstanceProfile(scope, "ECSInstanceProfile", { + role: ecsInstanceRole, + }); + + const managedInstancesCapacityProvider = this.createManagedInstanceCapacityProvider( + scope, + [httpsEgressSecurityGroup], + privateSubnets, + ecsProps.managedInstancesProps.instancesRequirements ?? + defaultManagedInstanceRequirements(memoryLimitMiB, cpu), + ecsInstanceRole, + instanceProfile, ); - const fileSystem = new CfnFileSystem(scope, `S3FilesFileSystem`, { - bucket: bucketArn, - prefix: normalizedPrefix, - roleArn: role.roleArn, + cluster.addManagedInstancesCapacityProvider(managedInstancesCapacityProvider); + + const taskDefinition = this.createManagedInstanceTaskDefinition(scope, cpu, memoryLimitMiB); + + const appContainer = taskDefinition.addContainer(app, { + image, + dockerLabels: { + RiffRaffDeploymentId: GuRiffRaffDeploymentIdParameterExperimental.getInstance(scope).valueAsString, + }, + // This speeds up deployment. We don't need this enabled if users follow the advice to refer to their image + // using the immutable digest + versionConsistency: VersionConsistency.DISABLED, + portMappings: [{ containerPort: applicationPort }], + logging: this.fireLensLogDriver, + readonlyRootFilesystem: true, + environment, }); - // Referencing the role ARN alone does not wait for its separately provisioned policy. - fileSystem.node.addDependency(role); - const mountTargetSecurityGroup = new GuSecurityGroup(scope, "S3FilesMountTargetSecurityGroup", { - app: `${app}-s3files`, - vpc, - allowAllOutbound: false, - ingresses: [ + // Permissions passed to the ECS task... + const applicationPermissions: GuPolicy[] = [ + // ...to allow writing logs to Kinesis + GuLogShippingPolicy.getInstance(scope), + + // ...to allow reading application config from SSM + GuParameterStoreReadPolicy.getInstance(scope, { app }), + + // ...permissions specific for this application (provided by client) + ...additionalPolicies, + ]; + + applicationPermissions.forEach((policy) => policy.attachToRole(taskDefinition.taskRole)); + + const ecsService = new FargateService(scope, "EcsService", { + cluster, + taskDefinition, + vpcSubnets: { subnets: privateSubnets }, + // Important for service deployments; with the AWS defaults the service can be scaled down when deploying + minHealthyPercent: 100, + // Also important for service deployments; with the AWS defaults we don't get a fast failure when deploying a 'bad' build + circuitBreaker: { enable: true, rollback: true }, + propagateTags: PropagatedTagSource.SERVICE, + // By default, AWS will create a new security group which allows all outbound traffic + // We don't want this so explicitly allow outbound HTTPS only + // This is what we do for the current GuEc2App pattern: + // https://github.com/guardian/cdk/blob/3b5688637024642055ed0bf576f668e56e40830d/src/constructs/autoscaling/asg.ts#L143-L145 + securityGroups: [httpsEgressSecurityGroup], + capacityProviderStrategies: [ { - range: Peer.securityGroupId(ecsSecurityGroup.securityGroupId), - port: Port.tcp(2049), - description: "Allow ECS tasks to mount S3 Files", + capacityProvider: managedInstancesCapacityProvider.capacityProviderName, + weight: 1, }, ], - egresses: [], - }); - new CfnSecurityGroupEgress(scope, "S3FilesTaskEgress", { - groupId: ecsSecurityGroup.securityGroupId, - destinationSecurityGroupId: mountTargetSecurityGroup.securityGroupId, - ipProtocol: "tcp", - fromPort: 2049, // Network file system (NFS) port - toPort: 2049, - description: "Allow NFS traffic to S3 Files mount targets", }); - const mountTargets = privateSubnets.map( - (subnet, index) => - new CfnMountTarget(scope, `S3FilesMountTarget${index}`, { - fileSystemId: fileSystem.attrFileSystemId, - subnetId: subnet.subnetId, - securityGroups: [mountTargetSecurityGroup.securityGroupId], - }), - ); - ecsService.node.addDependency(...mountTargets); + // Important to make sure the ECS service is deleted before the capacity provider. + // Without this dependency the capacity provider will fail to be deleted since + // it is part of the default strategy for the `ecsService`. + ecsService.node.addDependency(managedInstancesCapacityProvider, instanceProfile, ecsInstanceRole); - const actions: string[] = ["s3files:ClientMount"]; + const cfnService = ecsService.node.defaultChild as CfnService; + cfnService.addPropertyOverride("Monitoring", { + MetricConfigurations: [ + { + MetricNames: ["CPUUtilization", "MemoryUtilization"], + ResolutionSeconds: 20, + }, + ], + }); - taskDefinition.addToTaskRolePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions, - resources: [fileSystem.attrFileSystemArn], - }), - ); + const ecsScalableTarget = ecsService.autoScaleTaskCount({ + minCapacity: scaling.minimumTasks, + maxCapacity: scaling.maximumTasks, + }); - // Direct S3 access for read optimization - taskDefinition.addToTaskRolePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["s3:ListBucket"], - resources: [bucketArn], - }), - ); + if (scaling.cpuScaling) { + const { targetValue, scaleOutCooldown, scaleInCooldown } = scaling.cpuScaling; + + // The high resolution predefined metric evaluates every 20s rather than 60s, + // so scaling reacts far faster. It isn't in the CDK `PredefinedMetric` enum yet. + // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/target-tracking-faster-auto-scaling.html + // + // TODO: Update in line with `PredefinedMetric` enum - https://github.com/aws/aws-cdk/issues/38207 + new TargetTrackingScalingPolicy(this, "CpuScaling", { + scalingTarget: ecsScalableTarget, + targetValue: targetValue, + predefinedMetric: "ECSServiceAverageCPUUtilizationHighResolution" as unknown as PredefinedMetric, + scaleOutCooldown: scaleOutCooldown, + scaleInCooldown: scaleInCooldown, + }); + } - taskDefinition.addToTaskRolePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["s3:GetObject", "s3:GetObjectVersion"], - resources: [`${bucketArn}/*`], + this.ecsService = ecsService; + + // It's possible to opt-out of log shipping to ELK in the EC2 patterns; should we mirror that here? + const logRouter = taskDefinition.addFirelensLogRouter("LogShipping", { + // See https://github.com/guardian/devx-logs + image: loggingImage, + environment, + versionConsistency: VersionConsistency.DISABLED, + // Send this container's logs to CloudWatch logs, retained for 1 day + logging: LogDriver.awsLogs({ + streamPrefix: [scope.stack, scope.stage, app, "devx-logs-sidecar"].join("/"), + logRetention: RetentionDays.ONE_DAY, }), - ); - appContainer.addMountPoints({ - containerPath: s3Config.containerPath, - sourceVolume: `s3files-volume`, - readOnly: true, - }); - - // Capitalised because this is raw cfn json: cdk does not have a first-class construct for S3 Files volumes yet. - volumes.push({ - Name: `s3files-volume`, - S3FilesVolumeConfiguration: { - FileSystemArn: fileSystem.attrFileSystemArn, - RootDirectory: "/", + firelensConfig: { + type: FirelensLogRouterType.FLUENTBIT, }, + + // To comply with FSBP ECS.5 + readonlyRootFilesystem: true, }); - } - const cfnTaskDefinition = taskDefinition.node.defaultChild as CfnTaskDefinition; - cfnTaskDefinition.addPropertyOverride("Volumes", volumes); + const logVolume: Volume = { + name: "logging-volume", + }; - logRouter.addMountPoints({ - containerPath: "/init", - sourceVolume: logVolume.name, - readOnly: false, - }); + // Capitalised because this is raw cfn json: cdk does not have a first-class construct for volumes yet. + const volumes: unknown[] = [{ Name: logVolume.name }]; - // We need a new target group even if we share the other load balancer components with the EC2 infrastructure - const ecsTargetGroup = new GuApplicationTargetGroup( - scope, + if (s3Config) { + const normalizedPrefix = s3Config.path.endsWith("/") ? s3Config.path : `${s3Config.path}/`; + const bucketArn = GuS3Bucket.fromBucketName(this, "ConfigBucket", s3Config.bucket).bucketArn; + const role = new Role(scope, `S3FilesRole`, { + assumedBy: new ServicePrincipal("elasticfilesystem.amazonaws.com").withConditions({ + StringEquals: { + "aws:SourceAccount": scope.account, + }, + ArnLike: { + "aws:SourceArn": `arn:${Aws.PARTITION}:s3files:${scope.region}:${scope.account}:file-system/*`, + }, + }), + description: `Role used by the S3 Files filesystem for ${app} mount`, + }); + + // S3 Files needs synchronization permissions even when the container mount is read-only. + // The file system prefix does not implicitly restrict these IAM permissions. + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketVersioning", "s3:GetBucketLocation"], + resources: [bucketArn], + }), + ); + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:AbortMultipartUpload", "s3:DeleteObject*", "s3:GetObject*", "s3:List*", "s3:PutObject*"], + resources: [`${bucketArn}/*`], + }), + ); + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["kms:GenerateDataKey", "kms:Encrypt", "kms:Decrypt", "kms:ReEncryptFrom", "kms:ReEncryptTo"], + resources: [`arn:${Aws.PARTITION}:kms:${scope.region}:${scope.account}:key/*`], + conditions: { + StringLike: { + "kms:ViaService": `s3.${scope.region}.${Aws.URL_SUFFIX}`, + "kms:EncryptionContext:aws:s3:arn": [bucketArn, `${bucketArn}/*`], + }, + }, + }), + ); + + // EventBridge permissions: S3 Files creates rules prefixed "DO-NOT-DELETE-S3-Files" + // to detect S3 object changes and trigger data synchronization + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + "events:DeleteRule", + "events:DisableRule", + "events:EnableRule", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + ], + resources: [`arn:${Aws.PARTITION}:events:*:*:rule/DO-NOT-DELETE-S3-Files*`], + conditions: { StringEquals: { "events:ManagedBy": "elasticfilesystem.amazonaws.com" } }, + }), + ); - // This ID parameter is used to form the resource's logical ID. - // If a target group is not explicitly named, CloudFormation will use the logical ID to generate a name of form `--<12 CHAR GUID>` to a max of 32 chars. - // Add the App to the start of the logical ID to make the generated names (slightly) glanceable, e.g "MyAppE-123456123456" vs. "EcsTar-123456123456". - AppIdentity.addAppToStringStart(props, "EcsTargetGroup"), + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["events:DescribeRule", "events:ListTargetsByRule"], + resources: [`arn:${Aws.PARTITION}:events:*:*:rule/*`], + }), + ); - { - vpc, - app, - port: applicationPort, - targets: [ecsService], - healthCheck: healthcheck, - }, - ); + role.addToPrincipalPolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["events:ListRuleNamesByTarget", "events:ListRules"], + resources: ["*"], + }), + ); + + const fileSystem = new CfnFileSystem(scope, `S3FilesFileSystem`, { + bucket: bucketArn, + prefix: normalizedPrefix, + roleArn: role.roleArn, + }); + // Referencing the role ARN alone does not wait for its separately provisioned policy. + fileSystem.node.addDependency(role); + + const mountTargetSecurityGroup = new GuSecurityGroup(scope, "S3FilesMountTargetSecurityGroup", { + app: `${app}-s3files`, + vpc, + allowAllOutbound: false, + ingresses: [ + { + range: Peer.securityGroupId(httpsEgressSecurityGroup.securityGroupId), + port: Port.tcp(2049), + description: "Allow ECS tasks to mount S3 Files", + }, + ], + egresses: [], + }); + new CfnSecurityGroupEgress(scope, "S3FilesTaskEgress", { + groupId: httpsEgressSecurityGroup.securityGroupId, + destinationSecurityGroupId: mountTargetSecurityGroup.securityGroupId, + ipProtocol: "tcp", + fromPort: 2049, // Network file system (NFS) port + toPort: 2049, + description: "Allow NFS traffic to S3 Files mount targets", + }); + + const mountTargets = privateSubnets.map( + (subnet, index) => + new CfnMountTarget(scope, `S3FilesMountTarget${index}`, { + fileSystemId: fileSystem.attrFileSystemId, + subnetId: subnet.subnetId, + securityGroups: [mountTargetSecurityGroup.securityGroupId], + }), + ); + ecsService.node.addDependency(...mountTargets); + + const actions: string[] = ["s3files:ClientMount"]; + + taskDefinition.addToTaskRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions, + resources: [fileSystem.attrFileSystemArn], + }), + ); + + // Direct S3 access for read optimization + taskDefinition.addToTaskRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:ListBucket"], + resources: [bucketArn], + }), + ); - targetGroups = { - ...targetGroups, - ecs: ecsTargetGroup, - }; + taskDefinition.addToTaskRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["s3:GetObject", "s3:GetObjectVersion"], + resources: [`${bucketArn}/*`], + }), + ); + + appContainer.addMountPoints({ + containerPath: s3Config.containerPath, + sourceVolume: `s3files-volume`, + readOnly: true, + }); + + // Capitalised because this is raw cfn json: cdk does not have a first-class construct for S3 Files volumes yet. + volumes.push({ + Name: `s3files-volume`, + S3FilesVolumeConfiguration: { + FileSystemArn: fileSystem.attrFileSystemArn, + RootDirectory: "/", + }, + }); + } + + const cfnTaskDefinition = taskDefinition.node.defaultChild as CfnTaskDefinition; + cfnTaskDefinition.addPropertyOverride("Volumes", volumes); + + logRouter.addMountPoints({ + containerPath: "/init", + sourceVolume: logVolume.name, + readOnly: false, + }); + + // We need a new target group even if we share the other load balancer components with the EC2 infrastructure + const ecsTargetGroup = new GuApplicationTargetGroup( + scope, + + // This ID parameter is used to form the resource's logical ID. + // If a target group is not explicitly named, CloudFormation will use the logical ID to generate a name of form `--<12 CHAR GUID>` to a max of 32 chars. + // Add the App to the start of the logical ID to make the generated names (slightly) glanceable, e.g "MyAppE-123456123456" vs. "EcsTar-123456123456". + AppIdentity.addAppToStringStart(props, "EcsTargetGroup"), + + { + vpc, + app, + port: applicationPort, + targets: [ecsService], + healthCheck: healthcheck, + }, + ); - // Specifically apply App tag to resources. - // Other resources obtain this tag by extending `GuAppAwareConstruct`. - [cluster, taskDefinition, ecsService].forEach((_) => AppIdentity.taggedConstruct(props, _)); + targetGroups = { + ...targetGroups, + ecs: ecsTargetGroup, + }; + + // Specifically apply App tag to resources. + // Other resources obtain this tag by extending `GuAppAwareConstruct`. + [managedInstancesCapacityProvider, cluster, taskDefinition, ecsService].forEach((_) => + AppIdentity.taggedConstruct(props, _), + ); + } } // Set up the load balancer and listener components @@ -1289,6 +1708,107 @@ export class GuLoadBalancedAppExperimental extends Construct { this.listener = listener; this.targetGroups = targetGroups; } + + /** + * Returns a managed instances capacity provider that has been added to construct scope `scope`. + * + * @param scope - construct scope + * @param instanceSecurityGroups - {@link ManagedInstancesCapacityProviderProps#securityGroups} + * @param instanceSubnets - {@link ManagedInstancesCapacityProviderProps#subnets} + * @param cpuMin - Minimum task CPU units required for EC2 instance. This is converted to vCPU. + * @param memoryMinMiB - Minimum memory in MiB for EC2 instance tasks are placed on. + * @param clusterArn - ARN of the cluster this capacity provider is scoped to. + */ + private createManagedInstanceCapacityProvider( + scope: GuStack, + instanceSecurityGroups: ISecurityGroup[], + instanceSubnets: ISubnet[], + instanceRequirements: InstanceRequirementsConfig, + ecsInstanceRole: GuECSManagedInstanceRoleExperimental, + instanceProfile: InstanceProfile, + ): ManagedInstancesCapacityProvider { + const ecsInfrastructurePolicy = ManagedPolicy.fromManagedPolicyArn( + scope, + "ECSInfrastructurePolicy", + "arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForManagedInstances", + ); + + // Needs to be custom to allow for the instance profile not having the standard `instanceProfileName` + // set to ecsInstanceRole. See long comment above + const infrastructureRole = new Role(scope, "ECSInfrastructureRole", { + managedPolicies: [ecsInfrastructurePolicy], + assumedBy: ServicePrincipal.fromStaticServicePrincipleName("ecs.amazonaws.com"), + inlinePolicies: { + PassInstanceRoleToEC2: new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["iam:PassRole"], + resources: [ecsInstanceRole.roleArn], + conditions: { + StringLike: { + "iam:PassedToService": "ec2.*", + }, + }, + }), + ], + }), + }, + }); + + const managedInstancesCapacityProvider = new ManagedInstancesCapacityProvider( + scope, + "ManagedInstancesCapacityProvider", + { + subnets: instanceSubnets, + securityGroups: instanceSecurityGroups, + ec2InstanceProfile: instanceProfile, + infrastructureRole, + instanceRequirements, + }, + ); + + return managedInstancesCapacityProvider; + } + + /** + * Creates an ECS Task Definition configured for Managed Instances. + * + * @param scope - TaskDefinition construct scope + * @param cpu - {@link TaskDefinitionProps#memoryMiB} + * @param memoryLimitMiB - {@link TaskDefinitionProps#memoryLimitMiB} + */ + private createManagedInstanceTaskDefinition(scope: Construct, cpu: number, memoryLimitMiB: number): TaskDefinition { + // TODO: If you specify too high minimum memory/cpu then the cloud formation deployment will fail. + // Can we add some validation to ensure the task size can resolve to at least 1 instance? Helps surface issues earlier. + return new TaskDefinition(scope, "EcsTaskDefinition", { + compatibility: Compatibility.MANAGED_INSTANCES, + networkMode: NetworkMode.AWS_VPC, + memoryMiB: memoryLimitMiB.toString(), + cpu: cpu.toString(), + runtimePlatform: { + cpuArchitecture: CpuArchitecture.ARM64, + operatingSystemFamily: OperatingSystemFamily.LINUX, + }, + }); + } +} + +/** + * Returns default EC2 instance requirements for ECS managed instances. + * + * @param memoryMinMiB - Minimum memory in MiB for managed instance + * @param cpu - cpu units for the task, this will be converted to equivelant vCPU. + */ +function defaultManagedInstanceRequirements(memoryMinMiB: number, cpu: number): InstanceRequirementsConfig { + return { + // Latest Graviton + instanceGenerations: [InstanceGeneration.CURRENT], + cpuManufacturers: [CpuManufacturer.AWS], + memoryMin: Size.mebibytes(memoryMinMiB), + // We are specifying EC2 vcpu, which is different from cpu units for task definitions. + vCpuCountMin: Math.ceil(cpu / 1024), + }; } function configureListenerActions(