diff --git a/dotcom-rendering/cdk/lib/HttpTrafficMirroring.ts b/dotcom-rendering/cdk/lib/HttpTrafficMirroring.ts new file mode 100644 index 00000000000..d29462ab1f9 --- /dev/null +++ b/dotcom-rendering/cdk/lib/HttpTrafficMirroring.ts @@ -0,0 +1,270 @@ +import type { GuStack } from '@guardian/cdk/lib/constructs/core'; +import { GuHttpsEgressSecurityGroup } from '@guardian/cdk/lib/constructs/ec2/security-groups/base'; +import { Duration, type aws_ec2 as ec2 } from 'aws-cdk-lib'; +import type { AutoScalingGroup } from 'aws-cdk-lib/aws-autoscaling'; +import type { ISubnet, IVpc } from 'aws-cdk-lib/aws-ec2'; +import { + CfnTrafficMirrorFilter, + CfnTrafficMirrorFilterRule, + CfnTrafficMirrorTarget, +} from 'aws-cdk-lib/aws-ec2'; +import { + Cluster, + ContainerImage, + CpuArchitecture, + Protocol as ECSProtocol, + FargateService, + FargateTaskDefinition, + OperatingSystemFamily, + PropagatedTagSource, +} from 'aws-cdk-lib/aws-ecs'; +import { + type ApplicationLoadBalancer, + Protocol as ELBProtocol, + NetworkLoadBalancer, +} from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as events from 'aws-cdk-lib/aws-events'; +import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; + +export interface HttpTrafficMirroringProps { + readonly vpc: IVpc; + readonly privateSubnets: ISubnet[]; + readonly app: GuStack; + readonly availabilityZone?: string; + readonly trafficSource: AutoScalingGroup; + readonly trafficTarget: ApplicationLoadBalancer; +} + +export class HttpTrafficMirroring extends Construct { + constructor( + scope: Construct, + id: string, + props: HttpTrafficMirroringProps, + ) { + super(scope, id); + + // if (props.trafficTarget.vpc === undefined) { + // throw new Error("VPC not defined in mirroring target application load balancer."); + // } + // const vpc = props.trafficTarget.vpc; + + this.node.addDependency(props.trafficSource); + this.node.addDependency(props.trafficTarget); + + const handlerNlb = this.createHandler( + props.vpc, + props.privateSubnets, + props.app, + props.trafficTarget, + ); + + const mirrorTarget: CfnTrafficMirrorTarget = new CfnTrafficMirrorTarget( + this, + 'Target', + { + networkLoadBalancerArn: handlerNlb.loadBalancerArn, + }, + ); + + const mirrorFilter: ec2.CfnTrafficMirrorFilter = + new CfnTrafficMirrorFilter(this, 'Filter', { + description: `Traffic mirror filter created by ${id}`, + }); + + new CfnTrafficMirrorFilterRule(this, 'AllowAllInbound', { + trafficMirrorFilterId: mirrorFilter.attrId, + ruleAction: 'accept', + ruleNumber: 100, + trafficDirection: 'ingress', + destinationCidrBlock: '0.0.0.0/0', // TODO: Narrow the CIDR block scopes. + sourceCidrBlock: '0.0.0.0/0', + }); + + // Lambda function to attach Mirror Session on ASG instance launch + // TODO: Will this always attach and run before the very first asg instances are created? + const attacherLambda = new lambda.Function( + this, + 'SessionAttacherLambda', + { + runtime: lambda.Runtime.NODEJS_24_X, + handler: 'index.handler', + timeout: Duration.seconds(30), + code: lambda.Code.fromInline(` + const { EC2Client, DescribeInstancesCommand, CreateTrafficMirrorSessionCommand } = require('@aws-sdk/client-ec2'); + const ec2 = new EC2Client(); + + exports.handler = async (event) => { + const instanceId = event.detail.EC2InstanceId; + const targetId = process.env.TARGET_ID; + const filterId = process.env.FILTER_ID; + + console.log(\`Processing launch event for instance: \${instanceId}\`); + + // Fetch instance details to get primary ENI ID + const describeRes = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] })); + const instance = describeRes.Reservations?.[0]?.Instances?.[0]; + const primaryEniId = instance?.NetworkInterfaces?.[0]?.NetworkInterfaceId; + + if (!primaryEniId) { + throw new Error(\`Unable to find primary ENI for instance: \${instanceId}\`); + } + + // Attach Traffic Mirror Session (ASG instance ENI -> EC2 Worker Target ENI) + const sessionRes = await ec2.send(new CreateTrafficMirrorSessionCommand({ + NetworkInterfaceId: primaryEniId, + TrafficMirrorTargetId: targetId, + TrafficMirrorFilterId: filterId, + SessionNumber: 1, + Description: \`Auto-attached traffic mirror for instance \${instanceId}\`, + })); + + console.log(\`Successfully created session: \${sessionRes.TrafficMirrorSession.TrafficMirrorSessionId}\`); + }; + `), + environment: { + TARGET_ID: mirrorTarget.attrId, + FILTER_ID: mirrorFilter.attrId, + }, + }, + ); + + // Grant Lambda EC2 access permissions + attacherLambda.addToRolePolicy( + new iam.PolicyStatement({ + actions: [ + 'ec2:DescribeInstances', + 'ec2:CreateTrafficMirrorSession', + ], + resources: ['*'], + }), + ); + + // EventBridge Rule to trigger Lambda on ASG Instance Launch + const launchRule = new events.Rule(this, 'AsgInstanceLaunchRule', { + eventPattern: { + source: ['aws.autoscaling'], + detailType: ['EC2 Instance Launch Successful'], + detail: { + AutoScalingGroupName: [ + props.trafficSource.autoScalingGroupName, + ], + }, + }, + }); + + launchRule.addTarget(new LambdaFunction(attacherLambda)); + } + + private createHandler( + vpc: ec2.IVpc, + subnets: ISubnet[], + stack: GuStack, + target: ApplicationLoadBalancer, + ): NetworkLoadBalancer { + const ecsCluster = Cluster.fromClusterAttributes( + this, + 'MirroringHandlerEcsCluster', + { + clusterName: 'MirroringHandlerEcsCluster', + vpc, + }, + ); + + const taskDefinition = new FargateTaskDefinition( + this, + 'MirroringHandlerEcsTaskDefinition', + { + memoryLimitMiB: 2048, + cpu: 1024, + runtimePlatform: { + cpuArchitecture: CpuArchitecture.ARM64, + operatingSystemFamily: OperatingSystemFamily.LINUX, + }, + }, + ); + + // TCP for health check + // We have to add this first as the network load balancer will send health check traffic to the default container. + // If we don't add this first then we fail to add the ECS service to the target group as there is no tcp endpoint. + // Can not do health check over UDP. + // + // Nginx by default serves a simple welcome page on port 80, which can pass the health check. + taskDefinition.addContainer('MirroringHandlerHealthCheckContainer', { + image: ContainerImage.fromRegistry('nginx'), + portMappings: [ + { containerPort: 80, protocol: ECSProtocol.TCP, hostPort: 80 }, + ], + // TODO: logging: fireLensLogDriver, + readonlyRootFilesystem: true, + }); + + taskDefinition.addContainer('MirroringHandlerContainer', { + image: ContainerImage.fromRegistry('jauderho/goreplay'), + portMappings: [ + { + containerPort: 4789, + protocol: ECSProtocol.UDP, + hostPort: 4789, + }, + ], + command: [ + '--input-raw', + ':80', + '--input-raw-engine', + 'vxlan', + '--output-http', + `http://${target.loadBalancerDnsName}`, + ], + // TODO: logging: fireLensLogDriver, + readonlyRootFilesystem: true, + }); + + const fargateService = new FargateService( + this, + 'MirroringHandlerFargateService', + { + cluster: ecsCluster, + taskDefinition, + vpcSubnets: { subnets }, + // 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: [ + GuHttpsEgressSecurityGroup.forVpc(stack, { + app: `${stack.app}-ecs`, + vpc, + }), + ], + }, + ); + + const nlb = new NetworkLoadBalancer(this, 'MirroringHandlerNLB', { + vpc, + internetFacing: false, // Don't think this is needed given the subnets, but i want to be safe + vpcSubnets: { subnets }, + }); + + const listener = nlb.addListener('MirroringHandlerListener', { + port: 4789, + protocol: ELBProtocol.UDP, + }); + + const targetGroup = listener.addTargets('ECSHandlers', { + port: 4789, + protocol: ELBProtocol.UDP, + }); + + targetGroup.addTarget(fargateService); + + return nlb; + } +} diff --git a/dotcom-rendering/cdk/lib/__snapshots__/renderingStack.test.ts.snap b/dotcom-rendering/cdk/lib/__snapshots__/renderingStack.test.ts.snap index 3ca44abe4b8..a52083cc69e 100644 --- a/dotcom-rendering/cdk/lib/__snapshots__/renderingStack.test.ts.snap +++ b/dotcom-rendering/cdk/lib/__snapshots__/renderingStack.test.ts.snap @@ -5,11 +5,11 @@ exports[`The RenderingCDKStack matches the snapshot 1`] = ` "Metadata": { "gu:cdk:constructs": [ "GuDistributionBucketParameter", + "GuVpcParameter", + "GuSubnetListParameter", "GuAllowPolicy", "GuAllowPolicy", "GuAllowPolicy", - "GuVpcParameter", - "GuSubnetListParameter", "GuSubnetListParameter", "GuLoadBalancedAppExperimental", "GuInstanceRole", @@ -1620,11 +1620,11 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE "Metadata": { "gu:cdk:constructs": [ "GuDistributionBucketParameter", + "GuVpcParameter", + "GuSubnetListParameter", "GuAllowPolicy", "GuAllowPolicy", "GuAllowPolicy", - "GuVpcParameter", - "GuSubnetListParameter", "GuSubnetListParameter", "GuLoadBalancedAppExperimental", "GuInstanceRole", @@ -1646,6 +1646,7 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE "GuAccessLoggingBucketParameter", "GuHttpsApplicationListener", "GuSecurityGroup", + "GuHttpsEgressSecurityGroup", "GuCname", ], "gu:cdk:version": "TEST", @@ -1956,17 +1957,312 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE }, "Type": "AWS::IAM::Policy", }, - "EcsService81FC6EF6": { + "Ec2ToEcsTrafficMirrorAllowAllInbound46A802A0": { "DependsOn": [ - "EcsTaskDefinitionTaskRoleB7B6D8DD", - "ListenerTagpagerenderingDeterministicRouteToEc2Rule1C5C95A4", - "ListenerTagpagerenderingDeterministicRouteToEcsRuleAB05C756", - "ListenerTagpagerendering92E57078", + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", ], "Properties": { - "Cluster": { - "Ref": "tagpagerenderingEcsClusterE7696595", + "DestinationCidrBlock": "0.0.0.0/0", + "RuleAction": "accept", + "RuleNumber": 100, + "SourceCidrBlock": "0.0.0.0/0", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "TrafficDirection": "ingress", + "TrafficMirrorFilterId": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorFilter8854C552", + "Id", + ], + }, + }, + "Type": "AWS::EC2::TrafficMirrorFilterRule", + }, + "Ec2ToEcsTrafficMirrorAsgInstanceLaunchRule32C9EB6F": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "EventPattern": { + "detail": { + "AutoScalingGroupName": [ + { + "Ref": "AutoScalingGroupTagpagerenderingASG7F7E0748", + }, + ], + }, + "detail-type": [ + "EC2 Instance Launch Successful", + ], + "source": [ + "aws.autoscaling", + ], + }, + "State": "ENABLED", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaCA9F6846", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "Ec2ToEcsTrafficMirrorAsgInstanceLaunchRuleAllowEventRuleTagPageRenderingCODEEc2ToEcsTrafficMirrorSessionAttacherLambda0B880B1929CEF922": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaCA9F6846", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorAsgInstanceLaunchRule32C9EB6F", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "Ec2ToEcsTrafficMirrorFilter8854C552": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "Description": "Traffic mirror filter created by Ec2ToEcsTrafficMirror", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + }, + "Type": "AWS::EC2::TrafficMirrorFilter", + }, + "Ec2ToEcsTrafficMirrorMirroringHandlerEcsTaskDefinitionCCF3FF96": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "ContainerDefinitions": [ + { + "Essential": true, + "Image": "nginx", + "Name": "MirroringHandlerHealthCheckContainer", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 80, + "Protocol": "tcp", + }, + ], + "ReadonlyRootFilesystem": true, + }, + { + "Command": [ + "--input-raw", + ":80", + "--input-raw-engine", + "vxlan", + "--output-http", + { + "Fn::Join": [ + "", + [ + "http://", + { + "Fn::GetAtt": [ + "LoadBalancerTagpagerenderingB0B7AC4E", + "DNSName", + ], + }, + ], + ], + }, + ], + "Essential": true, + "Image": "jauderho/goreplay", + "Name": "MirroringHandlerContainer", + "PortMappings": [ + { + "ContainerPort": 4789, + "HostPort": 4789, + "Protocol": "udp", + }, + ], + "ReadonlyRootFilesystem": true, + }, + ], + "Cpu": "1024", + "Family": "TagPageRenderingCODEEc2ToEcsTrafficMirrorMirroringHandlerEcsTaskDefinition7E6AD87A", + "Memory": "2048", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE", + ], + "RuntimePlatform": { + "CpuArchitecture": "ARM64", + "OperatingSystemFamily": "LINUX", + }, + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorMirroringHandlerEcsTaskDefinitionTaskRoleD1815E9F", + "Arn", + ], + }, + }, + "Type": "AWS::ECS::TaskDefinition", + }, + "Ec2ToEcsTrafficMirrorMirroringHandlerEcsTaskDefinitionTaskRoleD1815E9F": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", }, + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "Ec2ToEcsTrafficMirrorMirroringHandlerFargateService6F2CB37C": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "Ec2ToEcsTrafficMirrorMirroringHandlerEcsTaskDefinitionTaskRoleD1815E9F", + "Ec2ToEcsTrafficMirrorMirroringHandlerNLBMirroringHandlerListenerECSHandlersGroupCB72232A", + "Ec2ToEcsTrafficMirrorMirroringHandlerNLBMirroringHandlerListenerDC91CFB0", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "Cluster": "MirroringHandlerEcsCluster", "DeploymentConfiguration": { "Alarms": { "AlarmNames": [], @@ -1988,31 +2284,20 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE "LaunchType": "FARGATE", "LoadBalancers": [ { - "ContainerName": "tag-page-rendering", - "ContainerPort": 9000, + "ContainerName": "MirroringHandlerHealthCheckContainer", + "ContainerPort": 80, "TargetGroupArn": { - "Ref": "TagpagerenderingEcsTargetGroupTagpagerendering0976D2E5", + "Ref": "Ec2ToEcsTrafficMirrorMirroringHandlerNLBMirroringHandlerListenerECSHandlersGroupCB72232A", }, }, ], - "Monitoring": { - "MetricConfigurations": [ - { - "MetricNames": [ - "CPUUtilization", - "MemoryUtilization", - ], - "ResolutionSeconds": 20, - }, - ], - }, "NetworkConfiguration": { "AwsvpcConfiguration": { "AssignPublicIp": "DISABLED", "SecurityGroups": [ { "Fn::GetAtt": [ - "GuHttpsEgressSecurityGroupTagpagerenderingecsF0505C36", + "GuHttpsEgressSecurityGroupUndefinedecs0AE96607", "GroupId", ], }, @@ -2024,10 +2309,6 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE }, "PropagateTags": "SERVICE", "Tags": [ - { - "Key": "App", - "Value": "tag-page-rendering", - }, { "Key": "gu:cdk:version", "Value": "TEST", @@ -2046,29 +2327,437 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE }, ], "TaskDefinition": { - "Ref": "EcsTaskDefinition63157ED3", + "Ref": "Ec2ToEcsTrafficMirrorMirroringHandlerEcsTaskDefinitionCCF3FF96", }, }, "Type": "AWS::ECS::Service", }, - "EcsServiceTaskCountTarget02FCCE22": { + "Ec2ToEcsTrafficMirrorMirroringHandlerNLB41FDD9B0": { "DependsOn": [ - "EcsTaskDefinitionTaskRoleB7B6D8DD", + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", ], "Properties": { - "MaxCapacity": 2, - "MinCapacity": 1, - "ResourceId": { - "Fn::Join": [ - "", - [ - "service/", - { - "Ref": "tagpagerenderingEcsClusterE7696595", - }, - "/", - { - "Fn::GetAtt": [ + "LoadBalancerAttributes": [ + { + "Key": "deletion_protection.enabled", + "Value": "false", + }, + ], + "Scheme": "internal", + "Subnets": { + "Ref": "tagpagerenderingPrivateSubnets", + }, + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "Type": "network", + }, + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + }, + "Ec2ToEcsTrafficMirrorMirroringHandlerNLBMirroringHandlerListenerDC91CFB0": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "DefaultActions": [ + { + "TargetGroupArn": { + "Ref": "Ec2ToEcsTrafficMirrorMirroringHandlerNLBMirroringHandlerListenerECSHandlersGroupCB72232A", + }, + "Type": "forward", + }, + ], + "LoadBalancerArn": { + "Ref": "Ec2ToEcsTrafficMirrorMirroringHandlerNLB41FDD9B0", + }, + "Port": 4789, + "Protocol": "UDP", + }, + "Type": "AWS::ElasticLoadBalancingV2::Listener", + }, + "Ec2ToEcsTrafficMirrorMirroringHandlerNLBMirroringHandlerListenerECSHandlersGroupCB72232A": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "Port": 4789, + "Protocol": "UDP", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "TargetType": "ip", + "VpcId": { + "Ref": "VpcId", + }, + }, + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + }, + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaCA9F6846": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleDefaultPolicyA9AD3ECB", + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleBCEFCFAB", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "Code": { + "ZipFile": " + const { EC2Client, DescribeInstancesCommand, CreateTrafficMirrorSessionCommand } = require('@aws-sdk/client-ec2'); + const ec2 = new EC2Client(); + + exports.handler = async (event) => { + const instanceId = event.detail.EC2InstanceId; + const targetId = process.env.TARGET_ID; + const filterId = process.env.FILTER_ID; + + console.log(\`Processing launch event for instance: \${instanceId}\`); + + // Fetch instance details to get primary ENI ID + const describeRes = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] })); + const instance = describeRes.Reservations?.[0]?.Instances?.[0]; + const primaryEniId = instance?.NetworkInterfaces?.[0]?.NetworkInterfaceId; + + if (!primaryEniId) { + throw new Error(\`Unable to find primary ENI for instance: \${instanceId}\`); + } + + // Attach Traffic Mirror Session (ASG instance ENI -> EC2 Worker Target ENI) + const sessionRes = await ec2.send(new CreateTrafficMirrorSessionCommand({ + NetworkInterfaceId: primaryEniId, + TrafficMirrorTargetId: targetId, + TrafficMirrorFilterId: filterId, + SessionNumber: 1, + Description: \`Auto-attached traffic mirror for instance \${instanceId}\`, + })); + + console.log(\`Successfully created session: \${sessionRes.TrafficMirrorSession.TrafficMirrorSessionId}\`); + }; + ", + }, + "Environment": { + "Variables": { + "FILTER_ID": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorFilter8854C552", + "Id", + ], + }, + "TARGET_ID": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorTarget64F0FC7C", + "Id", + ], + }, + }, + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleBCEFCFAB", + "Arn", + ], + }, + "Runtime": "nodejs24.x", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleBCEFCFAB": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleDefaultPolicyA9AD3ECB": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ec2:DescribeInstances", + "ec2:CreateTrafficMirrorSession", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleDefaultPolicyA9AD3ECB", + "Roles": [ + { + "Ref": "Ec2ToEcsTrafficMirrorSessionAttacherLambdaServiceRoleBCEFCFAB", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "Ec2ToEcsTrafficMirrorTarget64F0FC7C": { + "DependsOn": [ + "AutoScalingGroupTagpagerenderingASG7F7E0748", + "LoadBalancerTagpagerenderingB0B7AC4E", + "LoadBalancerTagpagerenderingSecurityGroup4D481A16", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerendering51E7AF38900020A4AE4F", + "LoadBalancerTagpagerenderingSecurityGrouptoTagPageRenderingCODEGuHttpsEgressSecurityGroupTagpagerenderingecsBADC1D1E900047DE752F", + ], + "Properties": { + "NetworkLoadBalancerArn": { + "Ref": "Ec2ToEcsTrafficMirrorMirroringHandlerNLB41FDD9B0", + }, + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + }, + "Type": "AWS::EC2::TrafficMirrorTarget", + }, + "EcsService81FC6EF6": { + "DependsOn": [ + "EcsTaskDefinitionTaskRoleB7B6D8DD", + "ListenerTagpagerenderingDeterministicRouteToEc2Rule1C5C95A4", + "ListenerTagpagerenderingDeterministicRouteToEcsRuleAB05C756", + "ListenerTagpagerendering92E57078", + ], + "Properties": { + "Cluster": { + "Ref": "tagpagerenderingEcsClusterE7696595", + }, + "DeploymentConfiguration": { + "Alarms": { + "AlarmNames": [], + "Enable": false, + "Rollback": false, + }, + "DeploymentCircuitBreaker": { + "Enable": true, + "Rollback": true, + }, + "MaximumPercent": 200, + "MinimumHealthyPercent": 100, + }, + "DeploymentController": { + "Type": "ECS", + }, + "EnableECSManagedTags": false, + "HealthCheckGracePeriodSeconds": 60, + "LaunchType": "FARGATE", + "LoadBalancers": [ + { + "ContainerName": "tag-page-rendering", + "ContainerPort": 9000, + "TargetGroupArn": { + "Ref": "TagpagerenderingEcsTargetGroupTagpagerendering0976D2E5", + }, + }, + ], + "Monitoring": { + "MetricConfigurations": [ + { + "MetricNames": [ + "CPUUtilization", + "MemoryUtilization", + ], + "ResolutionSeconds": 20, + }, + ], + }, + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "GuHttpsEgressSecurityGroupTagpagerenderingecsF0505C36", + "GroupId", + ], + }, + ], + "Subnets": { + "Ref": "tagpagerenderingPrivateSubnets", + }, + }, + }, + "PropagateTags": "SERVICE", + "Tags": [ + { + "Key": "App", + "Value": "tag-page-rendering", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "TaskDefinition": { + "Ref": "EcsTaskDefinition63157ED3", + }, + }, + "Type": "AWS::ECS::Service", + }, + "EcsServiceTaskCountTarget02FCCE22": { + "DependsOn": [ + "EcsTaskDefinitionTaskRoleB7B6D8DD", + ], + "Properties": { + "MaxCapacity": 2, + "MinCapacity": 1, + "ResourceId": { + "Fn::Join": [ + "", + [ + "service/", + { + "Ref": "tagpagerenderingEcsClusterE7696595", + }, + "/", + { + "Fn::GetAtt": [ "EcsService81FC6EF6", "Name", ], @@ -2655,6 +3344,46 @@ exports[`The RenderingCDKStack matches the snapshot for Tag Page Rendering CODE }, "Type": "AWS::EC2::SecurityGroupIngress", }, + "GuHttpsEgressSecurityGroupUndefinedecs0AE96607": { + "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": "undefined-ecs", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/dotcom-rendering", + }, + { + "Key": "Stack", + "Value": "frontend", + }, + { + "Key": "Stage", + "Value": "CODE", + }, + ], + "VpcId": { + "Ref": "VpcId", + }, + }, + "Type": "AWS::EC2::SecurityGroup", + }, "GuLogShippingPolicy981BFE5A": { "Properties": { "PolicyDocument": { diff --git a/dotcom-rendering/cdk/lib/renderingStack.ts b/dotcom-rendering/cdk/lib/renderingStack.ts index 05b116e337b..d8f079b3d54 100644 --- a/dotcom-rendering/cdk/lib/renderingStack.ts +++ b/dotcom-rendering/cdk/lib/renderingStack.ts @@ -3,10 +3,12 @@ import { AccessScope } from '@guardian/cdk/lib/constants'; import type { NoMonitoring } from '@guardian/cdk/lib/constructs/cloudwatch'; import type { GuStackProps } from '@guardian/cdk/lib/constructs/core'; import { + AppIdentity, GuStack as CDKStack, GuDistributionBucketParameter, } from '@guardian/cdk/lib/constructs/core'; import { GuCname } from '@guardian/cdk/lib/constructs/dns/dns-records'; +import { GuVpc, SubnetType } from '@guardian/cdk/lib/constructs/ec2'; import { GuAllowPolicy } from '@guardian/cdk/lib/constructs/iam'; import { GuLoadBalancedAppExperimental } from '@guardian/cdk/lib/experimental/patterns/gu-load-balanced-app'; import type { GuAsgCapacity } from '@guardian/cdk/lib/types'; @@ -21,6 +23,7 @@ import type { CfnService } from 'aws-cdk-lib/aws-ecs'; import { ClusterSettings } from 'aws-cdk-lib/aws-ecs/mixins'; import { Subscription, SubscriptionProtocol, Topic } from 'aws-cdk-lib/aws-sns'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { HttpTrafficMirroring } from './HttpTrafficMirroring'; import { getUserData } from './userData'; export interface RenderingCDKStackProps extends Omit { @@ -221,6 +224,16 @@ export class RenderingCDKStack extends CDKStack { } satisfies Alarms) : ({ noMonitoring: true } satisfies NoMonitoring); + // Same as defaults in GuLoadBalancedAppExperimental, but we need reference to configure traffic mirroring + const vpc = GuVpc.fromIdParameter( + this, + AppIdentity.addAppToStringEnd({ app: guApp }, 'VPC'), + ); + const privateSubnets = GuVpc.subnetsFromParameter(this, { + type: SubnetType.PRIVATE, + app: guApp, + }); + const app = new GuLoadBalancedAppExperimental(this, { app: guApp, access: { @@ -272,6 +285,9 @@ export class RenderingCDKStack extends CDKStack { }), }, + vpc, + privateSubnets, + // Provision ECS resources only when `imageIdentifier` has been provided ...(imageIdentifier == null ? {} @@ -333,6 +349,18 @@ export class RenderingCDKStack extends CDKStack { }, ], }); + + const availabilityZones = this.availabilityZones; + if (app.autoScalingGroup) { + new HttpTrafficMirroring(this, 'Ec2ToEcsTrafficMirror', { + vpc, + privateSubnets, + app: this, + availabilityZone: availabilityZones[0], + trafficSource: app.autoScalingGroup, + trafficTarget: app.loadBalancer, + }); + } } }