Skip to content

Commit

Permalink
refactor(virus-scanner): use Lambda as runtime
Browse files Browse the repository at this point in the history
- Port constructs from cdklabs/cdk-ecr-deployment to copy Docker images
  from Docker Hub to private ECR
  - Use bootstrap code hosted on public S3 bucket for assoc lambda fn
- Replace ECS-based virus scanner with Lambda-based one,
  - grant invoke rights to FormSG
  - ensure Docker image in ECR before creating associated lambda
  - Use Cloudwatch Event to keep virus-scanner warm every 3 min
- Take the chance to fix CORS permissions in S3
- Remove default value for `emailHost` param
  • Loading branch information
LoneRifle committed Dec 4, 2024
1 parent b4a4f5a commit 120bda7
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 376 deletions.
238 changes: 238 additions & 0 deletions lib/constructs/cdk-ecr-deployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import * as path from 'path'
import { aws_ec2 as ec2, aws_iam as iam, aws_lambda as lambda, Duration, CustomResource, Token, RemovalPolicy } from 'aws-cdk-lib'
import { PolicyStatement, AddToPrincipalPolicyResult } from 'aws-cdk-lib/aws-iam'
import { RuntimeFamily } from 'aws-cdk-lib/aws-lambda'
import { Construct } from 'constructs'

export interface ECRDeploymentProps {

/**
* Lambda code to use for the Golang lambda for custom resource.
*
* In most cases, this should not be specified. Instead, the code for the lambda
* should be obtained from {@link getCode}, which would build it, or load it from local disk.
*
* Specify this only when your deployment environment prevents you from doing either of
* these things, eg, when you are using this in a CloudFormation template synthesized from CDK.
*
* @default - code for the lambda is built using {@link buildImage}
*/
readonly code?: lambda.Code

/**
* Image to use to build Golang lambda for custom resource, if download fails or is not wanted.
*
* Might be needed for local build if all images need to come from own registry.
*
* Note that image should use yum as a package manager and have golang available.
*
* If {@link code} is specified, this property is ignored.
*
* @default - public.ecr.aws/sam/build-go1.x:latest
*/
readonly buildImage?: string
/**
* The source of the docker image.
*/
readonly src: IImageName

/**
* The destination of the docker image.
*/
readonly dest: IImageName

/**
* The amount of memory (in MiB) to allocate to the AWS Lambda function which
* replicates the files from the CDK bucket to the destination bucket.
*
* If you are deploying large files, you will need to increase this number
* accordingly.
*
* @default - 512
*/
readonly memoryLimit?: number

/**
* Execution role associated with this function
*
* @default - A role is automatically created
*/
readonly role?: iam.IRole

/**
* The VPC network to place the deployment lambda handler in.
*
* @default - None
*/
readonly vpc?: ec2.IVpc

/**
* Where in the VPC to place the deployment lambda handler.
* Only used if 'vpc' is supplied.
*
* @default - the Vpc default strategy if not specified
*/
readonly vpcSubnets?: ec2.SubnetSelection

/**
* The list of security groups to associate with the Lambda's network interfaces.
*
* Only used if 'vpc' is supplied.
*
* @default - If the function is placed within a VPC and a security group is
* not specified, either by this or securityGroup prop, a dedicated security
* group will be created for this function.
*/
readonly securityGroups?: ec2.SecurityGroup[]

/**
* The lambda function runtime environment.
*
* @default - lambda.Runtime.PROVIDED_AL2023
*/
readonly lambdaRuntime?: lambda.Runtime

/**
* The name of the lambda handler.
*
* @default - bootstrap
*/
readonly lambdaHandler?: string

/**
* The environment variable to set
*/
readonly environment?: { [key: string]: string }
}

export interface IImageName {
/**
* The uri of the docker image.
*
* The uri spec follows https://github.com/containers/skopeo
*/
readonly uri: string

/**
* The credentials of the docker image. Format `user:password` or `AWS Secrets Manager secret arn` or `AWS Secrets Manager secret name`
*/
creds?: string
}

function getCode(buildImage: string): lambda.AssetCode {
return lambda.Code.fromDockerBuild(path.join(__dirname, '../lambda'), {
buildArgs: {
buildImage,
},
})
}

export class DockerImageName implements IImageName {
public constructor(private name: string, public creds?: string) { }
public get uri(): string { return `docker://${this.name}` }
}

export class S3ArchiveName implements IImageName {
private name: string
public constructor(p: string, ref?: string, public creds?: string) {
this.name = p
if (ref) {
this.name += ':' + ref
}
}
public get uri(): string { return `s3://${this.name}` }
}

export class ECRDeployment extends Construct {
private handler: lambda.SingletonFunction
readonly customResource: CustomResource

constructor(scope: Construct, id: string, props: ECRDeploymentProps) {
super(scope, id)
const memoryLimit = props.memoryLimit ?? 512
this.handler = new lambda.SingletonFunction(this, 'CustomResourceHandler', {
uuid: this.renderSingletonUuid(memoryLimit),
code: props.code ?? getCode(props.buildImage ?? 'public.ecr.aws/docker/library/golang:1'),
runtime: props.lambdaRuntime ?? new lambda.Runtime('provided.al2023', RuntimeFamily.OTHER), // not using Runtime.PROVIDED_AL2023 to support older CDK versions (< 2.105.0)
handler: props.lambdaHandler ?? 'bootstrap',
environment: props.environment,
lambdaPurpose: 'Custom::CDKECRDeployment',
timeout: Duration.minutes(15),
role: props.role,
memorySize: memoryLimit,
vpc: props.vpc,
vpcSubnets: props.vpcSubnets,
securityGroups: props.securityGroups,
})

const handlerRole = this.handler.role
if (!handlerRole) { throw new Error('lambda.SingletonFunction should have created a Role') }

handlerRole.addToPrincipalPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'ecr:GetAuthorizationToken',
'ecr:BatchCheckLayerAvailability',
'ecr:GetDownloadUrlForLayer',
'ecr:GetRepositoryPolicy',
'ecr:DescribeRepositories',
'ecr:ListImages',
'ecr:DescribeImages',
'ecr:BatchGetImage',
'ecr:ListTagsForResource',
'ecr:DescribeImageScanFindings',
'ecr:InitiateLayerUpload',
'ecr:UploadLayerPart',
'ecr:CompleteLayerUpload',
'ecr:PutImage',
],
resources: ['*'],
}))
handlerRole.addToPrincipalPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
's3:GetObject',
],
resources: ['*'],
}))

this.customResource = new CustomResource(this, 'CustomResource', {
serviceToken: this.handler.functionArn,
resourceType: 'Custom::CDKBucketDeployment',
properties: {
SrcImage: props.src.uri,
SrcCreds: props.src.creds,
DestImage: props.dest.uri,
DestCreds: props.dest.creds,
},
})
}

public addToPrincipalPolicy(statement: PolicyStatement): AddToPrincipalPolicyResult {
const handlerRole = this.handler.role
if (!handlerRole) { throw new Error('lambda.SingletonFunction should have created a Role') }

return handlerRole.addToPrincipalPolicy(statement)
}

private renderSingletonUuid(memoryLimit?: number) {
let uuid = 'bd07c930-edb9-4112-a20f-03f096f53666'

// if user specify a custom memory limit, define another singleton handler
// with this configuration. otherwise, it won't be possible to use multiple
// configurations since we have a singleton.
if (memoryLimit) {
if (Token.isUnresolved(memoryLimit)) {
throw new Error('Can\'t use tokens when specifying "memoryLimit" since we use it to identify the singleton custom resource handler')
}

uuid += `-${memoryLimit.toString()}MiB`
}

return uuid
}
}
21 changes: 14 additions & 7 deletions lib/constructs/ecr.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Construct } from 'constructs'
import * as ecrdeploy from 'cdk-ecr-deployment'
import * as ecrdeploy from './cdk-ecr-deployment'
import { Repository } from 'aws-cdk-lib/aws-ecr'
import * as ecr from 'aws-cdk-lib/aws-ecr-assets'
import { RemovalPolicy } from 'aws-cdk-lib'
import { RemovalPolicy, Stack } from 'aws-cdk-lib'
import { Code } from 'aws-cdk-lib/aws-lambda'
import { Bucket } from 'aws-cdk-lib/aws-s3'

export interface FormsgEcrProps {
}
Expand All @@ -15,6 +16,8 @@ export class FormsgEcr extends Construct {
repository: Repository
}

readonly deployment: ecrdeploy.ECRDeployment

constructor(
scope: Construct,
props: FormsgEcrProps = {}
Expand All @@ -24,10 +27,14 @@ export class FormsgEcr extends Construct {
repositoryName: 'lambda-virus-scanner',
removalPolicy: RemovalPolicy.DESTROY,
})
// new ecrdeploy.ECRDeployment(scope, 'ecr-deployment-lambda-virus-scanner', {
// src: new ecrdeploy.DockerImageName('opengovsg/lambda-virus-scanner:latest'),
// dest: new ecrdeploy.DockerImageName(this.lambdaVirusScanner.repositoryUriForTag('latest')),
// })
this.deployment = new ecrdeploy.ECRDeployment(scope, 'ecr-deployment-lambda-virus-scanner', {
code: Code.fromBucket(Bucket.fromBucketAttributes(this, 'cdk-ecr-deployment', {
bucketArn: 'arn:aws:s3:::formsg-on-cdk',
bucketRegionalDomainName: `formsg-on-cdk.s3.ap-southeast-1.${Stack.of(this).urlSuffix}`
}), 'cdk-ecr-deployment/bootstrap.zip'),
src: new ecrdeploy.DockerImageName('opengovsg/lambda-virus-scanner:latest'),
dest: new ecrdeploy.DockerImageName(repositoryLambdaVirusScanner.repositoryUriForTag('latest')),
})
this.lambdaVirusScanner = {
repository: repositoryLambdaVirusScanner,
}
Expand Down
7 changes: 6 additions & 1 deletion lib/constructs/form-ecs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Construct } from 'constructs'
import * as ecs from 'aws-cdk-lib/aws-ecs';
import { FormsgS3Buckets } from './s3';
import { ApplicationLoadBalancer, ApplicationProtocol } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import { Duration } from 'aws-cdk-lib';
import { type FormsgS3Buckets } from './s3';
import { type FormsgLambdas } from './lambdas';


export class FormEcs extends Construct {
Expand All @@ -20,13 +21,15 @@ export class FormEcs extends Construct {
environment,
secrets,
loadBalancer,
lambdas,
} : {
cluster: ecs.Cluster;
logGroupSuffix: string;
s3Buckets: FormsgS3Buckets;
environment: Record<string, string>;
secrets: Record<string, ecs.Secret>;
loadBalancer: ApplicationLoadBalancer
lambdas: FormsgLambdas
}
) {
super(scope, 'form')
Expand Down Expand Up @@ -114,6 +117,8 @@ export class FormEcs extends Construct {
})
)

lambdas.virusScanner.grantInvoke(taskDefinition.taskRole)

const service = new ecs.FargateService(this, 'service', {
cluster,
taskDefinition,
Expand Down
22 changes: 17 additions & 5 deletions lib/constructs/lambdas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@

import { Duration } from 'aws-cdk-lib'
import { Duration, RemovalPolicy } from 'aws-cdk-lib'
import { Rule, Schedule } from 'aws-cdk-lib/aws-events'
import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'
import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'
import { Code, Function, Handler, Runtime } from 'aws-cdk-lib/aws-lambda'
import { Construct } from 'constructs'
Expand All @@ -22,14 +24,15 @@ export class FormsgLambdas extends Construct {
},
ecr: {
lambdaVirusScanner,
deployment,
}
}: FormsgLambdasProps
) {
super(scope, 'lambdas')
const virusScanner = new Function(scope, 'virus-scanner', {
const virusScanner = new Function(this, 'virus-scanner', {
functionName: 'virus-scanner',
timeout: Duration.seconds(300),
memorySize: 2048,
memorySize: 1536,
runtime: Runtime.FROM_IMAGE,
handler: Handler.FROM_IMAGE,
code: Code.fromEcrImage(lambdaVirusScanner.repository),
Expand All @@ -38,7 +41,7 @@ export class FormsgLambdas extends Construct {
VIRUS_SCANNER_CLEAN_S3_BUCKET: s3VirusScannerClean.bucketName,
}
})
virusScanner.role?.attachInlinePolicy(new Policy(scope, 'manage-quarantine', {
virusScanner.role?.attachInlinePolicy(new Policy(this, 'manage-quarantine', {
statements: [
new PolicyStatement({
actions: [
Expand All @@ -52,7 +55,7 @@ export class FormsgLambdas extends Construct {
}),
],
}))
virusScanner.role?.attachInlinePolicy(new Policy(scope, 'put-clean', {
virusScanner.role?.attachInlinePolicy(new Policy(this, 'put-clean', {
statements: [
new PolicyStatement({
actions: [
Expand All @@ -63,7 +66,16 @@ export class FormsgLambdas extends Construct {
}),
],
}))
virusScanner.node.addDependency(deployment.customResource)
this.virusScanner = virusScanner


// Trigger the virus scanner once every 3 minutes to keep it warm
const eventRule = new Rule(this, 'keep-warm-trigger', {
schedule: Schedule.rate(Duration.minutes(3)),
})
eventRule.applyRemovalPolicy(RemovalPolicy.DESTROY)
eventRule.addTarget(new LambdaFunction(this.virusScanner))
}

}
Loading

0 comments on commit 120bda7

Please sign in to comment.