From 7e93220211dbbd4a6882bc6370a529b500f27404 Mon Sep 17 00:00:00 2001 From: Penghao He Date: Wed, 8 Nov 2023 17:05:34 -0800 Subject: [PATCH] fix: clean the elb access bucket at `env deploy` when it is disabled in the env mft (#5437) Fixes https://github.com/aws/copilot-cli/issues/5428 By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License. --- cf-custom-resources/lib/bucket-cleaner.js | 191 ++++++++ cf-custom-resources/package.json | 2 +- .../test/bucket-cleaner-test.js | 418 ++++++++++++++++++ internal/pkg/cli/deploy/env_test.go | 2 + .../stack/env_integration_test.go | 2 +- .../deploy/cloudformation/stack/env_test.go | 4 + .../template-with-basic-manifest.yml | 3 + .../template-with-custom-security-group.yml | 3 + ...emplate-with-default-access-log-config.yml | 51 +++ .../template-with-defaultvpc-flowlogs.yml | 3 + ...-sslpolicy-custom-empty-security-group.yml | 3 + .../template-with-importedvpc-flowlogs.yml | 3 + .../upload/customresource/customresource.go | 3 + .../customresource/customresource_test.go | 8 +- .../pkg/template/templates/environment/cf.yml | 7 +- .../environment/partials/elb-access-logs.yml | 66 ++- 16 files changed, 760 insertions(+), 9 deletions(-) create mode 100644 cf-custom-resources/lib/bucket-cleaner.js create mode 100644 cf-custom-resources/test/bucket-cleaner-test.js diff --git a/cf-custom-resources/lib/bucket-cleaner.js b/cf-custom-resources/lib/bucket-cleaner.js new file mode 100644 index 00000000000..409dc3b8c13 --- /dev/null +++ b/cf-custom-resources/lib/bucket-cleaner.js @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +const aws = require("aws-sdk"); + +// These are used for test purposes only +let defaultResponseURL; +let defaultLogGroup; +let defaultLogStream; + +/** + * Upload a CloudFormation response object to S3. + * + * @param {object} event the Lambda event payload received by the handler function + * @param {object} context the Lambda context received by the handler function + * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' + * @param {string} physicalResourceId CloudFormation physical resource ID + * @param {object} [responseData] arbitrary response data object + * @param {string} [reason] reason for failure, if any, to convey to the user + * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response + */ +let report = function ( + event, + context, + responseStatus, + physicalResourceId, + responseData, + reason +) { + return new Promise((resolve, reject) => { + const https = require("https"); + const { URL } = require("url"); + + var responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physicalResourceId || context.logStreamName, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData, + }); + + const parsedUrl = new URL(event.ResponseURL || defaultResponseURL); + const options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.pathname + parsedUrl.search, + method: "PUT", + headers: { + "Content-Type": "", + "Content-Length": responseBody.length, + }, + }; + + https + .request(options) + .on("error", reject) + .on("response", (res) => { + res.resume(); + if (res.statusCode >= 400) { + reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`)); + } else { + resolve(); + } + }) + .end(responseBody, "utf8"); + }); +}; + +/** + * Delete all objects in a bucket. + * + * @param {string} bucketName Name of the bucket to be cleaned. + */ +const cleanBucket = async function (bucketName) { + const s3 = new aws.S3(); + // Make sure the bucket exists. + try { + await s3.headBucket({ Bucket: bucketName }).promise(); + } catch (err) { + if (err.name === "ResourceNotFoundException") { + return; + } + throw err; + } + const listObjectVersionsParam = { + Bucket: bucketName + } + while (true) { + const listResp = await s3.listObjectVersions(listObjectVersionsParam).promise(); + // After deleting other versions, remove delete markers version. + // For info on "delete marker": https://docs.aws.amazon.com/AmazonS3/latest/dev/DeleteMarker.html + let objectsToDelete = [ + ...listResp.Versions.map(version => ({ Key: version.Key, VersionId: version.VersionId })), + ...listResp.DeleteMarkers.map(marker => ({ Key: marker.Key, VersionId: marker.VersionId })) + ]; + if (objectsToDelete.length === 0) { + return + } + const delResp = await s3.deleteObjects({ + Bucket: bucketName, + Delete: { + Objects: objectsToDelete, + Quiet: true + } + }).promise() + if (delResp.Errors.length > 0) { + throw new AggregateError([new Error(`${delResp.Errors.length}/${objectsToDelete.length} objects failed to delete`), + new Error(`first failed on key "${delResp.Errors[0].Key}": ${delResp.Errors[0].Message}`)]); + } + if (!listResp.IsTruncated) { + return + } + listObjectVersionsParam.KeyMarker = listResp.NextKeyMarker + listObjectVersionsParam.VersionIdMarker = listResp.NextVersionIdMarker + } +}; + +/** + * Correct desired count handler, invoked by Lambda. + */ +exports.handler = async function (event, context) { + var responseData = {}; + const props = event.ResourceProperties; + const physicalResourceId = event.PhysicalResourceId || `bucket-cleaner-${event.LogicalResourceId}`; + + try { + switch (event.RequestType) { + case "Create": + case "Update": + break; + case "Delete": + await cleanBucket(props.BucketName); + break; + default: + throw new Error(`Unsupported request type ${event.RequestType}`); + } + await report(event, context, "SUCCESS", physicalResourceId, responseData); + } catch (err) { + console.log(`Caught error ${err}.`); + await report( + event, + context, + "FAILED", + physicalResourceId, + null, + `${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${defaultLogStream || context.logStreamName + })` + ); + } +}; + +/** + * @private + */ +exports.withDefaultResponseURL = function (url) { + defaultResponseURL = url; +}; + +/** + * @private + */ +exports.withDefaultLogStream = function (logStream) { + defaultLogStream = logStream; +}; + +/** + * @private + */ +exports.withDefaultLogGroup = function (logGroup) { + defaultLogGroup = logGroup; +}; + +class AggregateError extends Error { + #errors; + name = "AggregateError"; + constructor(errors) { + let message = errors + .map(error => + String(error), + ) + .join("\n"); + super(message); + this.#errors = errors; + } + get errors() { + return [...this.#errors]; + } +} \ No newline at end of file diff --git a/cf-custom-resources/package.json b/cf-custom-resources/package.json index 18d94742789..d484713d512 100644 --- a/cf-custom-resources/package.json +++ b/cf-custom-resources/package.json @@ -48,4 +48,4 @@ "ws": ">=7.4.6", "yargs-parser": ">=13.1.2" } -} +} \ No newline at end of file diff --git a/cf-custom-resources/test/bucket-cleaner-test.js b/cf-custom-resources/test/bucket-cleaner-test.js new file mode 100644 index 00000000000..0fdd90ea1c9 --- /dev/null +++ b/cf-custom-resources/test/bucket-cleaner-test.js @@ -0,0 +1,418 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +"use strict"; + +describe("Bucket Cleaner", () => { + const AWS = require("aws-sdk-mock"); + const LambdaTester = require("lambda-tester").noVersionCheck(); + const sinon = require("sinon"); + const bucketCleanerHandler = require("../lib/bucket-cleaner"); + const nock = require("nock"); + const ResponseURL = "https://cloudwatch-response-mock.example.com/"; + const LogGroup = "/aws/lambda/testLambda"; + const LogStream = "2021/06/28/[$LATEST]9b93a7dca7344adeb19asdgc092dbbfd"; + + let origLog = console.log; + + const testRequestId = "f4ef1b10-c39a-44e3-99c0-fbf6h23c3943"; + const testBucketName = "myBucket" + + beforeEach(() => { + bucketCleanerHandler.withDefaultResponseURL(ResponseURL); + bucketCleanerHandler.withDefaultLogGroup(LogGroup); + bucketCleanerHandler.withDefaultLogStream(LogStream); + console.log = function () { }; + }); + afterEach(() => { + AWS.restore(); + console.log = origLog; + }); + + test("Bogus operation fails", () => { + const bogusType = "bogus"; + const request = nock(ResponseURL) + .put("/", (body) => { + return ( + body.Status === "FAILED" && + body.Reason === + "Unsupported request type bogus (Log: /aws/lambda/testLambda/2021/06/28/[$LATEST]9b93a7dca7344adeb19asdgc092dbbfd)" + ); + }) + .reply(200); + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: bogusType, + RequestId: testRequestId, + ResourceProperties: {}, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + expect(request.isDone()).toBe(true); + }); + }); + + test("Create event is a no-op", () => { + const headBucketFake = sinon.fake.resolves({}); + AWS.mock("S3", "headBucket", headBucketFake); + + const requestType = "Create"; + const request = nock(ResponseURL) + .put("/", (body) => { + return body.Status === "SUCCESS"; + }) + .reply(200); + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: requestType, + RequestId: testRequestId, + ResourceProperties: { + BucketName: testBucketName + }, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + sinon.assert.notCalled(headBucketFake); + expect(request.isDone()).toBe(true); + }); + }); + + test("Update event is a no-op", () => { + const headBucketFake = sinon.fake.resolves({}); + AWS.mock("S3", "headBucket", headBucketFake); + + const requestType = "Update"; + const request = nock(ResponseURL) + .put("/", (body) => { + return body.Status === "SUCCESS"; + }) + .reply(200); + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: requestType, + RequestId: testRequestId, + ResourceProperties: { + BucketName: testBucketName + }, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + sinon.assert.notCalled(headBucketFake); + expect(request.isDone()).toBe(true); + }); + }); + + test("Return early when the bucket is gone", () => { + const notFoundError = new Error(); + notFoundError.name = "ResourceNotFoundException"; + + const headBucketFake = sinon.fake.rejects(notFoundError); + AWS.mock("S3", "headBucket", headBucketFake); + const listObjectVersionsFake = sinon.fake.resolves({}); + AWS.mock("S3", "listObjectVersions", listObjectVersionsFake); + + const request = nock(ResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && + body.PhysicalResourceId === "bucket-cleaner-mockID" + ); + }) + .reply(200); + + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: "Delete", + RequestId: testRequestId, + ResourceProperties: { + BucketName: testBucketName + }, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + sinon.assert.calledWith( + headBucketFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.notCalled(listObjectVersionsFake); + expect(request.isDone()).toBe(true); + }); + }); + + test("Return early when the bucket is empty", () => { + const headBucketFake = sinon.fake.resolves({}); + AWS.mock("S3", "headBucket", headBucketFake); + const listObjectVersionsFake = sinon.fake.resolves({ + Versions: [], + DeleteMarkers: [] + }); + AWS.mock("S3", "listObjectVersions", listObjectVersionsFake); + const deleteObjectsFake = sinon.fake.resolves({}); + AWS.mock("S3", "deleteBucket", deleteObjectsFake); + + const request = nock(ResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && + body.PhysicalResourceId === "bucket-cleaner-mockID" + ); + }) + .reply(200); + + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: "Delete", + RequestId: testRequestId, + ResourceProperties: { + BucketName: testBucketName + }, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + sinon.assert.calledWith( + headBucketFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.calledWith( + listObjectVersionsFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.notCalled(deleteObjectsFake); + expect(request.isDone()).toBe(true); + }); + }); + + test("Delete all objects with pagination", () => { + const headBucketFake = sinon.fake.resolves({}); + AWS.mock("S3", "headBucket", headBucketFake); + + const listObjectVersionsFake = sinon.stub(); + listObjectVersionsFake.onFirstCall().resolves({ + Versions: [ + { + Key: "mockKey1", + VersionId: "mockVersionId1" + }, + { + Key: "mockKey2", + VersionId: "mockVersionId2" + }, + ], + DeleteMarkers: [ + { + Key: "mockDeleteMarkerKey1", + VersionId: "mockDeleteMarkerVersionId1" + }, + ], + IsTruncated: true, + NextKeyMarker: "mockKeyMarker", + NextVersionIdMarker: "mockNextVersionIdMarker" + }); + listObjectVersionsFake.resolves({ + Versions: [ + { + Key: "mockKey3", + VersionId: "mockVersionId3" + }, + ], + DeleteMarkers: [ + { + Key: "mockDeleteMarkerKey2", + VersionId: "mockDeleteMarkerVersionId2" + }, + ] + }); + AWS.mock("S3", "listObjectVersions", listObjectVersionsFake); + + const deleteObjectsFake = sinon.fake.resolves({ + Errors: [] + }); + AWS.mock("S3", "deleteObjects", deleteObjectsFake); + + const request = nock(ResponseURL) + .put("/", (body) => { + return ( + body.Status === "SUCCESS" && + body.PhysicalResourceId === "bucket-cleaner-mockID" + ); + }) + .reply(200); + + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: "Delete", + RequestId: testRequestId, + ResourceProperties: { + BucketName: testBucketName + }, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + sinon.assert.calledWith( + headBucketFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.calledWith( + listObjectVersionsFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.calledWith( + listObjectVersionsFake, + sinon.match({ + Bucket: testBucketName, + KeyMarker: "mockKeyMarker", + VersionIdMarker: "mockNextVersionIdMarker" + }) + ); + sinon.assert.calledWith( + deleteObjectsFake, + sinon.match({ + Bucket: testBucketName, + Delete: { + Objects: [ + { + Key: "mockKey1", + VersionId: "mockVersionId1" + }, + { + Key: "mockKey2", + VersionId: "mockVersionId2" + }, + { + Key: "mockDeleteMarkerKey1", + VersionId: "mockDeleteMarkerVersionId1" + } + ], + Quiet: true + } + }) + ); + sinon.assert.calledWith( + deleteObjectsFake, + sinon.match({ + Bucket: testBucketName, + Delete: { + Objects: [ + { + Key: "mockKey3", + VersionId: "mockVersionId3" + }, + { + Key: "mockDeleteMarkerKey2", + VersionId: "mockDeleteMarkerVersionId2" + } + ], + Quiet: true + } + }) + ); + expect(request.isDone()).toBe(true); + }); + }); + + test("Aggregate the delete error", () => { + const headBucketFake = sinon.fake.resolves({}); + AWS.mock("S3", "headBucket", headBucketFake); + + const listObjectVersionsFake = sinon.fake.resolves({ + Versions: [ + { + Key: "mockKey1", + VersionId: "mockVersionId1" + }, + { + Key: "mockKey2", + VersionId: "mockVersionId2" + }, + ], + DeleteMarkers: [ + { + Key: "mockDeleteMarkerKey1", + VersionId: "mockDeleteMarkerVersionId1" + }, + ] + }); + AWS.mock("S3", "listObjectVersions", listObjectVersionsFake); + + const deleteObjectsFake = sinon.fake.resolves({ + Errors: [ + { + Key: "mockKey1", + Message: "mockMsg1" + }, + { + Key: "mockKey2", + Message: "mockMsg2" + } + ] + }); + AWS.mock("S3", "deleteObjects", deleteObjectsFake); + + const request = nock(ResponseURL) + .put("/", (body) => { + return ( + body.Status === "FAILED" && + body.PhysicalResourceId === "bucket-cleaner-mockID" && + body.Reason === "Error: 2/3 objects failed to delete\nError: first failed on key \"mockKey1\": mockMsg1 (Log: /aws/lambda/testLambda/2021/06/28/[$LATEST]9b93a7dca7344adeb19asdgc092dbbfd)" + ); + }) + .reply(200); + + return LambdaTester(bucketCleanerHandler.handler) + .event({ + RequestType: "Delete", + RequestId: testRequestId, + ResourceProperties: { + BucketName: testBucketName + }, + LogicalResourceId: "mockID", + }) + .expectResolve(() => { + sinon.assert.calledWith( + headBucketFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.calledWith( + listObjectVersionsFake, + sinon.match({ + Bucket: testBucketName + }) + ); + sinon.assert.calledWith( + deleteObjectsFake, + sinon.match({ + Bucket: testBucketName, + Delete: { + Objects: [ + { + Key: "mockKey1", + VersionId: "mockVersionId1" + }, + { + Key: "mockKey2", + VersionId: "mockVersionId2" + }, + { + Key: "mockDeleteMarkerKey1", + VersionId: "mockDeleteMarkerVersionId1" + } + ], + Quiet: true + } + }) + ); + expect(request.isDone()).toBe(true); + }); + }); +}); diff --git a/internal/pkg/cli/deploy/env_test.go b/internal/pkg/cli/deploy/env_test.go index 9d5b5c0a072..9fa0b56ef1e 100644 --- a/internal/pkg/cli/deploy/env_test.go +++ b/internal/pkg/cli/deploy/env_test.go @@ -175,6 +175,7 @@ func TestEnvDeployer_UploadArtifacts(t *testing.T) { "CertificateValidationFunction": "", "CustomDomainFunction": "", "DNSDelegationFunction": "", + "BucketCleanerFunction": "", "UniqueJSONValuesFunction": "", }, }, @@ -203,6 +204,7 @@ func TestEnvDeployer_UploadArtifacts(t *testing.T) { "CertificateValidationFunction": "", "CustomDomainFunction": "", "DNSDelegationFunction": "", + "BucketCleanerFunction": "", "UniqueJSONValuesFunction": "", }, }, diff --git a/internal/pkg/deploy/cloudformation/stack/env_integration_test.go b/internal/pkg/deploy/cloudformation/stack/env_integration_test.go index ed16c91c200..b44f1fa9765 100644 --- a/internal/pkg/deploy/cloudformation/stack/env_integration_test.go +++ b/internal/pkg/deploy/cloudformation/stack/env_integration_test.go @@ -444,7 +444,7 @@ func resetCustomResourceLocations(template map[any]any) { "EnvControllerFunction", "DynamicDesiredCountFunction", "BacklogPerTaskCalculatorFunction", "RulePriorityFunction", "NLBCustomDomainFunction", "NLBCertValidatorFunction", "CustomDomainFunction", "CertificateValidationFunction", "DNSDelegationFunction", - "CertificateReplicatorFunction", "UniqueJSONValuesFunction", "TriggerStateMachineFunction", + "CertificateReplicatorFunction", "UniqueJSONValuesFunction", "TriggerStateMachineFunction", "BucketCleanerFunction", } for _, fnName := range functions { resource, ok := resources[fnName] diff --git a/internal/pkg/deploy/cloudformation/stack/env_test.go b/internal/pkg/deploy/cloudformation/stack/env_test.go index ddfac11066f..124ed256a17 100644 --- a/internal/pkg/deploy/cloudformation/stack/env_test.go +++ b/internal/pkg/deploy/cloudformation/stack/env_test.go @@ -223,6 +223,10 @@ func TestEnv_Template(t *testing.T) { Bucket: "mockbucket", Key: "manual/scripts/custom-resources/customdomainfunction/8932747ba5dbff619d89b92d0033ef1d04f7dd1b055e073254907d4e38e3976d.zip", }, + "BucketCleanerFunction": { + Bucket: "mockbucket", + Key: "manual/scripts/custom-resources/bucketcleanerfunction/8932747ba5dbff619d89b92d0033ef1d04f7dd1b055e073254907d4e38e3976d.zip", + }, }, data.CustomResources) return &template.Content{Buffer: bytes.NewBufferString("mockTemplate")}, nil }) diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml index 9119767d703..cb0cb460c7d 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml @@ -695,6 +695,9 @@ Resources: Condition: CreateALB Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: + LoadBalancerAttributes: + - Key: 'access_logs.s3.enabled' + Value: false Scheme: internet-facing SecurityGroups: - !GetAtt PublicHTTPLoadBalancerSecurityGroup.GroupId diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml index dd3ae60f1f6..b7a4e5b6aa5 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml @@ -381,6 +381,9 @@ Resources: Condition: CreateALB Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: + LoadBalancerAttributes: + - Key: 'access_logs.s3.enabled' + Value: false Scheme: internet-facing SecurityGroups: - !GetAtt PublicHTTPLoadBalancerSecurityGroup.GroupId diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml index 6bc21606956..ec9825c9eda 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml @@ -768,6 +768,57 @@ Resources: - '/*' Principal: AWS: !Join [ "", [ !Ref 'arn:${AWS::Partition}:iam::', !FindInMap [ RegionalConfigs, !Ref 'AWS::Region', ElbAccountId ], ":root" ] ] + ELBAccessLogsBucketCleanerAction: + Metadata: + 'aws:copilot:description': 'A custom resource that empties the ELB access logs bucket' + Type: Custom::BucketCleanerFunction + Properties: + ServiceToken: !GetAtt BucketCleanerFunction.Arn + BucketName: !Ref ELBAccessLogsBucket + + BucketCleanerFunction: + Type: AWS::Lambda::Function + Properties: + Handler: "index.handler" + Timeout: 900 + MemorySize: 512 + Role: !GetAtt 'ELBAccessLogsBucketCleanerRole.Arn' + Runtime: nodejs16.x + + ELBAccessLogsBucketCleanerRole: + Metadata: + 'aws:copilot:description': 'An IAM role to clean the ELB access logs bucket' + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Path: / + Policies: + - PolicyName: "CleanELBAccessLogs" + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - s3:ListBucket + - s3:ListBucketVersions + - s3:DeleteObject + - s3:DeleteObjectVersion + Resource: + - !GetAtt ELBAccessLogsBucket.Arn + - !Sub + - ${ BucketARN }/* + - BucketARN: !GetAtt ELBAccessLogsBucket.Arn + ManagedPolicyArns: + - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole EnvironmentSecurityGroupIngressFromInternalALB: Type: AWS::EC2::SecurityGroupIngress Condition: CreateInternalALB diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml index 48a60b7b4bf..a31a995ca6e 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml @@ -700,6 +700,9 @@ Resources: Condition: CreateALB Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: + LoadBalancerAttributes: + - Key: 'access_logs.s3.enabled' + Value: false Scheme: internet-facing SecurityGroups: - !GetAtt PublicHTTPLoadBalancerSecurityGroup.GroupId diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml index 45c9cb4129a..ea8c9e81abe 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml @@ -357,6 +357,9 @@ Resources: Condition: CreateALB Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: + LoadBalancerAttributes: + - Key: 'access_logs.s3.enabled' + Value: false Scheme: internet-facing SecurityGroups: - !GetAtt PublicHTTPLoadBalancerSecurityGroup.GroupId diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml index 40e84fa76b1..758d670aaf8 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml @@ -529,6 +529,9 @@ Resources: Condition: CreateALB Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: + LoadBalancerAttributes: + - Key: 'access_logs.s3.enabled' + Value: false Scheme: internet-facing SecurityGroups: - !GetAtt PublicHTTPLoadBalancerSecurityGroup.GroupId diff --git a/internal/pkg/deploy/upload/customresource/customresource.go b/internal/pkg/deploy/upload/customresource/customresource.go index 004730a9c2e..e2a1a58559f 100644 --- a/internal/pkg/deploy/upload/customresource/customresource.go +++ b/internal/pkg/deploy/upload/customresource/customresource.go @@ -34,6 +34,7 @@ const ( customDomainFnName = "CustomDomainFunction" certValidationFnName = "CertificateValidationFunction" dnsDelegationFnName = "DNSDelegationFunction" + bucketCleanerFnName = "BucketCleanerFunction" certReplicatorFnName = "CertificateReplicatorFunction" uniqueJsonValuesFnName = "UniqueJSONValuesFunction" triggerStateMachineFnName = "TriggerStateMachineFunction" @@ -48,6 +49,7 @@ var ( desiredCountDelegationFilePath = path.Join(customResourcesDir, "desired-count-delegation.js") dnsCertValidationFilePath = path.Join(customResourcesDir, "dns-cert-validator.js") certReplicatorFilePath = path.Join(customResourcesDir, "cert-replicator.js") + bucketCleanerFilePath = path.Join(customResourcesDir, "bucket-cleaner.js") dnsDelegationFilePath = path.Join(customResourcesDir, "dns-delegation.js") envControllerFilePath = path.Join(customResourcesDir, "env-controller.js") wkldCertValidatorFilePath = path.Join(customResourcesDir, "wkld-cert-validator.js") @@ -165,6 +167,7 @@ func Env(fs template.Reader) ([]*CustomResource, error) { customDomainFnName: customDomainFilePath, dnsDelegationFnName: dnsDelegationFilePath, certReplicatorFnName: certReplicatorFilePath, + bucketCleanerFnName: bucketCleanerFilePath, uniqueJsonValuesFnName: uniqueJSONValuesFilePath, }) } diff --git a/internal/pkg/deploy/upload/customresource/customresource_test.go b/internal/pkg/deploy/upload/customresource/customresource_test.go index e0777281e86..ff4ae89e29b 100644 --- a/internal/pkg/deploy/upload/customresource/customresource_test.go +++ b/internal/pkg/deploy/upload/customresource/customresource_test.go @@ -369,6 +369,9 @@ func TestEnv(t *testing.T) { "custom-resources/unique-json-values.js": { Buffer: bytes.NewBufferString("unique json values"), }, + "custom-resources/bucket-cleaner.js": { + Buffer: bytes.NewBufferString("bucket cleaner"), + }, }, } fakePaths := map[string]string{ @@ -377,6 +380,7 @@ func TestEnv(t *testing.T) { "DNSDelegationFunction": "manual/scripts/custom-resources/dnsdelegationfunction/17ec5f580cdb9c1d7c6b5b91decee031592547629a6bfed7cd33b9229f61ab19.zip", "CertificateReplicatorFunction": "manual/scripts/custom-resources/certificatereplicatorfunction/647f83437e4736ddf2915784e13d023a7d342d162ffb42a9eec3d7c842072030.zip", "UniqueJSONValuesFunction": "manual/scripts/custom-resources/uniquejsonvaluesfunction/68c7ace14491d82ac4bb5ad81b3371743d669a26638f419265c18e9bdfca8dd1.zip", + "BucketCleanerFunction": "manual/scripts/custom-resources/bucketcleanerfunction/44c1eb88b269251952c25a0e17cd2c166d1de3f2340d60ad2d6b3899ceb058d9.zip", } // WHEN @@ -384,14 +388,14 @@ func TestEnv(t *testing.T) { // THEN require.NoError(t, err) - require.Equal(t, fakeFS.matchCount, 5, "expected path calls do not match") + require.Equal(t, fakeFS.matchCount, 6, "expected path calls do not match") actualFnNames := make([]string, len(crs)) for i, cr := range crs { actualFnNames[i] = cr.Name() } require.ElementsMatch(t, - []string{"CertificateValidationFunction", "CustomDomainFunction", "DNSDelegationFunction", "CertificateReplicatorFunction", "UniqueJSONValuesFunction"}, + []string{"CertificateValidationFunction", "CustomDomainFunction", "DNSDelegationFunction", "CertificateReplicatorFunction", "UniqueJSONValuesFunction", "BucketCleanerFunction"}, actualFnNames, "function names must match") // ensure the zip files contain an index.js file. diff --git a/internal/pkg/template/templates/environment/cf.yml b/internal/pkg/template/templates/environment/cf.yml index 10fdbdcc552..2dd82fe84b9 100644 --- a/internal/pkg/template/templates/environment/cf.yml +++ b/internal/pkg/template/templates/environment/cf.yml @@ -67,7 +67,7 @@ Conditions: Resources: {{include "bootstrap-resources" . | indent 2}} {{- if .PublicHTTPConfig.ELBAccessLogs.ShouldCreateBucket }} -{{include "elb-access-logs" .PublicHTTPConfig.ELBAccessLogs | indent 2}} +{{include "elb-access-logs" . | indent 2}} {{- end}} {{- if .CDNConfig}} {{include "cdn-resources" . | indent 2}} @@ -306,8 +306,8 @@ Resources: {{- end}} Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: - {{- if .PublicHTTPConfig.ELBAccessLogs }} LoadBalancerAttributes: + {{- if .PublicHTTPConfig.ELBAccessLogs }} - Key: 'access_logs.s3.enabled' Value: true {{- if .PublicHTTPConfig.ELBAccessLogs.Prefix }} @@ -316,6 +316,9 @@ Resources: {{- end }} - Key: 'access_logs.s3.bucket' Value: {{- if .PublicHTTPConfig.ELBAccessLogs.BucketName }} {{ .PublicHTTPConfig.ELBAccessLogs.BucketName }}{{- else }} !Ref ELBAccessLogsBucket {{- end }} + {{- else}} + - Key: 'access_logs.s3.enabled' + Value: false {{- end }} Scheme: internet-facing SecurityGroups: diff --git a/internal/pkg/template/templates/environment/partials/elb-access-logs.yml b/internal/pkg/template/templates/environment/partials/elb-access-logs.yml index c04e91d5f73..315a3e1c02f 100644 --- a/internal/pkg/template/templates/environment/partials/elb-access-logs.yml +++ b/internal/pkg/template/templates/environment/partials/elb-access-logs.yml @@ -17,9 +17,9 @@ ELBAccessLogsBucketPolicy: - !Ref AWS::Partition - ':s3:::' - !Ref ELBAccessLogsBucket - {{- if .Prefix }} + {{- if .PublicHTTPConfig.ELBAccessLogs.Prefix }} - '/' - - {{.Prefix }} + - {{ .PublicHTTPConfig.ELBAccessLogs.Prefix }} {{- end }} - '/AWSLogs/' - !Ref AWS::AccountId @@ -41,4 +41,64 @@ ELBAccessLogsBucket: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true - RestrictPublicBuckets: true \ No newline at end of file + RestrictPublicBuckets: true + +ELBAccessLogsBucketCleanerAction: + Metadata: + 'aws:copilot:description': 'A custom resource that empties the ELB access logs bucket' + Type: Custom::BucketCleanerFunction + Properties: + ServiceToken: !GetAtt BucketCleanerFunction.Arn + BucketName: !Ref ELBAccessLogsBucket + +BucketCleanerFunction: + Type: AWS::Lambda::Function + Properties: + {{- with $cr := index .CustomResources "BucketCleanerFunction" }} + Code: + S3Bucket: {{$cr.Bucket}} + S3Key: {{$cr.Key}} + {{- end}} + Handler: "index.handler" + Timeout: 900 + MemorySize: 512 + Role: !GetAtt 'ELBAccessLogsBucketCleanerRole.Arn' + Runtime: nodejs16.x + +ELBAccessLogsBucketCleanerRole: + Metadata: + 'aws:copilot:description': 'An IAM role {{- if .PermissionsBoundary}} with permissions boundary {{.PermissionsBoundary}} {{- end}} to clean the ELB access logs bucket' + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + {{- if .PermissionsBoundary}} + PermissionsBoundary: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/{{.PermissionsBoundary}}' + {{- end}} + Path: / + Policies: + - PolicyName: "CleanELBAccessLogs" + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - s3:ListBucket + - s3:ListBucketVersions + - s3:DeleteObject + - s3:DeleteObjectVersion + Resource: + - !GetAtt ELBAccessLogsBucket.Arn + - !Sub + - ${ BucketARN }/* + - BucketARN: !GetAtt ELBAccessLogsBucket.Arn + ManagedPolicyArns: + - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \ No newline at end of file