diff --git a/.changeset/gold-maps-give.md b/.changeset/gold-maps-give.md new file mode 100644 index 0000000000..cf77cdad61 --- /dev/null +++ b/.changeset/gold-maps-give.md @@ -0,0 +1,5 @@ +--- +"@guardian/cdk": minor +--- + +Support multiple EC2 apps with load balancer access logs enabled diff --git a/src/constructs/core/parameters/s3.ts b/src/constructs/core/parameters/s3.ts index e3ad8cb810..da69e66a3f 100644 --- a/src/constructs/core/parameters/s3.ts +++ b/src/constructs/core/parameters/s3.ts @@ -38,3 +38,27 @@ export class GuPrivateConfigBucketParameter extends GuStringParameter { }); } } + +/** + * Creates a CloudFormation parameter which references the bucket used to store load balancer access logs. + * By default, the bucket name is stored in an SSM Parameter called `/account/services/access-logging/bucket`. + */ +export class GuAccessLoggingBucketParameter extends GuStringParameter { + private static instance: GuAccessLoggingBucketParameter | undefined; + + private constructor(scope: GuStack) { + super(scope, "AccessLoggingBucket", { + description: NAMED_SSM_PARAMETER_PATHS.AccessLoggingBucket.description, + default: NAMED_SSM_PARAMETER_PATHS.AccessLoggingBucket.path, + fromSSM: true, + }); + } + + public static getInstance(stack: GuStack): GuAccessLoggingBucketParameter { + if (!this.instance || !isSingletonPresentInStack(stack, this.instance)) { + this.instance = new GuAccessLoggingBucketParameter(stack); + } + + return this.instance; + } +} diff --git a/src/patterns/ec2-app/base.test.ts b/src/patterns/ec2-app/base.test.ts index 25497e5be8..15cc4e2c5f 100644 --- a/src/patterns/ec2-app/base.test.ts +++ b/src/patterns/ec2-app/base.test.ts @@ -934,84 +934,142 @@ describe("the GuEC2App pattern", function () { }), ).toThrowError("googleAuth.allowedGroups must use the @guardian.co.uk domain."); }); -}); -it("should provides a default healthcheck", function () { - const stack = simpleGuStackForTesting(); - new GuEc2App(stack, { - applicationPort: 3000, - app: "test-gu-ec2-app", - access: { scope: AccessScope.PUBLIC }, - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - monitoringConfiguration: { noMonitoring: true }, - userData: "#!/bin/dev foobarbaz", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - scaling: { - minimumInstances: 1, - }, - }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { - HealthCheckIntervalSeconds: 10, - HealthCheckPath: "/healthcheck", - HealthCheckProtocol: "HTTP", - HealthCheckTimeoutSeconds: 5, - HealthyThresholdCount: 5, + it("should provides a default healthcheck", function () { + const stack = simpleGuStackForTesting(); + new GuEc2App(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app", + access: { scope: AccessScope.PUBLIC }, + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + monitoringConfiguration: { noMonitoring: true }, + userData: "#!/bin/dev foobarbaz", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + scaling: { + minimumInstances: 1, + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckIntervalSeconds: 10, + HealthCheckPath: "/healthcheck", + HealthCheckProtocol: "HTTP", + HealthCheckTimeoutSeconds: 5, + HealthyThresholdCount: 5, + }); }); -}); -it("allows a custom healthcheck", function () { - const stack = simpleGuStackForTesting(); - new GuEc2App(stack, { - applicationPort: 3000, - app: "test-gu-ec2-app", - access: { scope: AccessScope.PUBLIC }, - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - monitoringConfiguration: { noMonitoring: true }, - userData: "#!/bin/dev foobarbaz", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - scaling: { - minimumInstances: 1, - }, - healthcheck: { - path: "/custom-healthcheck", - }, - }); - Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { - HealthCheckIntervalSeconds: 10, - HealthCheckPath: "/custom-healthcheck", - HealthCheckProtocol: "HTTP", - HealthCheckTimeoutSeconds: 5, - HealthyThresholdCount: 5, + it("allows a custom healthcheck", function () { + const stack = simpleGuStackForTesting(); + new GuEc2App(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app", + access: { scope: AccessScope.PUBLIC }, + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + monitoringConfiguration: { noMonitoring: true }, + userData: "#!/bin/dev foobarbaz", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + scaling: { + minimumInstances: 1, + }, + healthcheck: { + path: "/custom-healthcheck", + }, + }); + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckIntervalSeconds: 10, + HealthCheckPath: "/custom-healthcheck", + HealthCheckProtocol: "HTTP", + HealthCheckTimeoutSeconds: 5, + HealthyThresholdCount: 5, + }); }); -}); -it("can specify instance metadata hop limit", function () { - const stack = simpleGuStackForTesting(); - new GuEc2App(stack, { - applicationPort: 3000, - app: "test-gu-ec2-app", - access: { scope: AccessScope.PUBLIC }, - instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), - monitoringConfiguration: { noMonitoring: true }, - userData: "#!/bin/dev foobarbaz", - certificateProps: { - domainName: "domain-name-for-your-application.example", - }, - scaling: { - minimumInstances: 1, - }, - instanceMetadataHopLimit: 2, + it("can specify instance metadata hop limit", function () { + const stack = simpleGuStackForTesting(); + new GuEc2App(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app", + access: { scope: AccessScope.PUBLIC }, + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + monitoringConfiguration: { noMonitoring: true }, + userData: "#!/bin/dev foobarbaz", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + scaling: { + minimumInstances: 1, + }, + instanceMetadataHopLimit: 2, + }); + Template.fromStack(stack).hasResourceProperties("AWS::EC2::LaunchTemplate", { + LaunchTemplateData: { + MetadataOptions: { + HttpPutResponseHopLimit: 2, + HttpTokens: "required", + }, + }, + }); }); - Template.fromStack(stack).hasResourceProperties("AWS::EC2::LaunchTemplate", { - LaunchTemplateData: { - MetadataOptions: { - HttpPutResponseHopLimit: 2, - HttpTokens: "required", + + it("supports more than one EC2 app with load balancer access logs enabled", () => { + const stack = simpleGuStackForTesting({ + env: { + region: "test", }, - }, + }); + + new GuEc2App(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app-1", + access: { scope: AccessScope.PUBLIC }, + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + monitoringConfiguration: { noMonitoring: true }, + userData: "#!/bin/dev foobarbaz", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + scaling: { + minimumInstances: 1, + }, + instanceMetadataHopLimit: 2, + accessLogging: { + enabled: true, + prefix: "test-1", + }, + }); + + new GuEc2App(stack, { + applicationPort: 3000, + app: "test-gu-ec2-app-2", + access: { scope: AccessScope.PUBLIC }, + instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM), + monitoringConfiguration: { noMonitoring: true }, + userData: "#!/bin/dev foobarbaz", + certificateProps: { + domainName: "domain-name-for-your-application.example", + }, + scaling: { + minimumInstances: 1, + }, + instanceMetadataHopLimit: 2, + accessLogging: { + enabled: true, + prefix: "test-2", + }, + }); + + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::LoadBalancer", { + Tags: Match.arrayWith([Match.objectLike({ Key: "App", Value: "test-gu-ec2-app-1" })]), + LoadBalancerAttributes: Match.arrayWith([Match.objectLike({ Key: "access_logs.s3.prefix", Value: "test-1" })]), + }); + + Template.fromStack(stack).hasResourceProperties("AWS::ElasticLoadBalancingV2::LoadBalancer", { + Tags: Match.arrayWith([Match.objectLike({ Key: "App", Value: "test-gu-ec2-app-2" })]), + LoadBalancerAttributes: Match.arrayWith([Match.objectLike({ Key: "access_logs.s3.prefix", Value: "test-2" })]), + }); }); }); diff --git a/src/patterns/ec2-app/base.ts b/src/patterns/ec2-app/base.ts index 51cc79a2aa..f81f81a56d 100644 --- a/src/patterns/ec2-app/base.ts +++ b/src/patterns/ec2-app/base.ts @@ -30,7 +30,7 @@ import { GuUnhealthyInstancesAlarm, } from "../../constructs/cloudwatch"; import type { GuStack } from "../../constructs/core"; -import { AppIdentity, GuLoggingStreamNameParameter, GuStringParameter } from "../../constructs/core"; +import { AppIdentity, GuAccessLoggingBucketParameter, GuLoggingStreamNameParameter } from "../../constructs/core"; import { GuHttpsEgressSecurityGroup, GuSecurityGroup, GuVpc, SubnetType } from "../../constructs/ec2"; import type { GuInstanceRoleProps } from "../../constructs/iam"; import { GuGetPrivateConfigPolicy, GuInstanceRole } from "../../constructs/iam"; @@ -420,11 +420,7 @@ export class GuEc2App extends Construct { }); if (accessLogging.enabled) { - const accessLoggingBucket = new GuStringParameter(scope, "AccessLoggingBucket", { - description: NAMED_SSM_PARAMETER_PATHS.AccessLoggingBucket.description, - default: NAMED_SSM_PARAMETER_PATHS.AccessLoggingBucket.path, - fromSSM: true, - }); + const accessLoggingBucket = GuAccessLoggingBucketParameter.getInstance(scope); loadBalancer.logAccessLogs( Bucket.fromBucketName(