Skip to content

Commit a96ce7f

Browse files
committed
Pattern 2 cleanup
1 parent aaf9119 commit a96ce7f

File tree

3 files changed

+239
-8
lines changed

3 files changed

+239
-8
lines changed

patterns/pattern-2/template.yaml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,6 @@ Resources:
188188

189189
Pattern2ECRRepository:
190190
Type: AWS::ECR::Repository
191-
DeletionPolicy: Retain
192-
UpdateReplacePolicy: Retain
193191
Properties:
194192
ImageScanningConfiguration:
195193
ScanOnPush: true
@@ -284,6 +282,12 @@ Resources:
284282
- logs:CreateLogStream
285283
- logs:PutLogEvents
286284
Resource: "*"
285+
- Effect: Allow
286+
Action:
287+
- ecr:ListImages
288+
- ecr:BatchDeleteImage
289+
Resource:
290+
- !GetAtt Pattern2ECRRepository.Arn
287291
# Used by custom resource helper poller
288292
# https://github.com/aws-cloudformation/custom-resource-helper
289293
- PolicyName: CustomResourcePoller
@@ -351,6 +355,15 @@ Resources:
351355
BuildProjectName: !Ref Pattern2DockerBuildProject
352356
# Force rebuild when source changes (filename includes content hash)
353357
CodeLocation: !Sub "arn:${AWS::Partition}:s3:::${ArtifactBucket}/${ArtifactPrefix}/${Pattern2SourceZipfile}"
358+
359+
Pattern2EcrRepositoryCleanup:
360+
Type: Custom::ECRRepositoryCleanup
361+
DependsOn:
362+
- Pattern2DockerBuildRun
363+
- Pattern2ECRRepository
364+
Properties:
365+
ServiceToken: !GetAtt Pattern2CodeBuildTrigger.Arn
366+
RepositoryName: !Ref Pattern2ECRRepository
354367
# Shared IAM policy for Lambda functions to pull container images from ECR
355368
LambdaECRAccessPolicy:
356369
Type: AWS::IAM::ManagedPolicy

src/lambda/start_codebuild/index.py

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
3-
"""CodeBuild Starter Lambda Function"""
3+
"""CodeBuild starter and ECR cleanup Lambda function used by Pattern 2 deployments."""
44
import logging
55
from os import getenv
66
import json
7+
from typing import List
78

89
import boto3
910
from botocore.config import Config as BotoCoreConfig
11+
from botocore.exceptions import ClientError
1012
from crhelper import CfnResource
1113

1214

@@ -24,7 +26,8 @@
2426
CLIENT_CONFIG = BotoCoreConfig(
2527
retries={"mode": "adaptive", "max_attempts": 5},
2628
)
27-
CLIENT = boto3.client("codebuild", config=CLIENT_CONFIG)
29+
CODEBUILD_CLIENT = boto3.client("codebuild", config=CLIENT_CONFIG)
30+
ECR_CLIENT = boto3.client("ecr", config=CLIENT_CONFIG)
2831
except Exception as init_exception: # pylint: disable=broad-except
2932
HELPER.init_failure(init_exception)
3033

@@ -39,7 +42,7 @@ def create_or_update(event, _):
3942
if resource_type == "Custom::CodeBuildRun":
4043
try:
4144
project_name = resource_properties["BuildProjectName"]
42-
response = CLIENT.start_build(projectName=project_name)
45+
response = CODEBUILD_CLIENT.start_build(projectName=project_name)
4346
build_id = response["build"]["id"]
4447
HELPER.Data["build_id"] = build_id
4548
except Exception as exception: # pylint: disable=broad-except
@@ -48,6 +51,12 @@ def create_or_update(event, _):
4851

4952
return
5053

54+
if resource_type == "Custom::ECRRepositoryCleanup":
55+
repository_name = resource_properties["RepositoryName"]
56+
LOGGER.info("registered ECR cleanup resource for repository %s", repository_name)
57+
HELPER.Data["repository_name"] = repository_name
58+
return
59+
5160
raise ValueError(f"invalid resource type: {resource_type}")
5261

5362

@@ -61,7 +70,7 @@ def poll_create_or_update(event, _):
6170
if resource_type == "Custom::CodeBuildRun":
6271
try:
6372
build_id = helper_data["build_id"]
64-
response = CLIENT.batch_get_builds(ids=[build_id])
73+
response = CODEBUILD_CLIENT.batch_get_builds(ids=[build_id])
6574
LOGGER.info(response)
6675

6776
builds = response["builds"]
@@ -86,13 +95,79 @@ def poll_create_or_update(event, _):
8695
LOGGER.error("build poller - exception: %s", exception)
8796
raise
8897

98+
if resource_type == "Custom::ECRRepositoryCleanup":
99+
LOGGER.info("ECR cleanup resource create/update completed")
100+
return True
101+
89102
raise RuntimeError(f"Invalid resource type: {resource_type}")
90103

91104

92105
@HELPER.delete
93-
def delete_no_op(event, _):
106+
def delete_resource(event, _):
94107
"""Delete Resource"""
95-
LOGGER.info("delete event ignored: %s", event)
108+
resource_type = event["ResourceType"]
109+
110+
if resource_type == "Custom::CodeBuildRun":
111+
LOGGER.info("delete event ignored for CodeBuild custom resource: %s", event)
112+
return
113+
114+
if resource_type == "Custom::ECRRepositoryCleanup":
115+
repository_name = event["ResourceProperties"]["RepositoryName"]
116+
LOGGER.info("starting cleanup for repository %s", repository_name)
117+
try:
118+
_delete_all_ecr_images(repository_name)
119+
except ClientError as error:
120+
if error.response["Error"]["Code"] == "RepositoryNotFoundException":
121+
LOGGER.info("repository %s already deleted", repository_name)
122+
return
123+
LOGGER.error(
124+
"failed to purge repository %s - error: %s",
125+
repository_name,
126+
error,
127+
)
128+
raise
129+
except Exception as unknown_exception: # pylint: disable=broad-except
130+
LOGGER.error(
131+
"unexpected error while cleaning repository %s - error: %s",
132+
repository_name,
133+
unknown_exception,
134+
)
135+
raise
136+
137+
LOGGER.info("cleanup for repository %s completed", repository_name)
138+
return
139+
140+
LOGGER.warning("received delete for unsupported resource type: %s", resource_type)
141+
142+
143+
def _delete_all_ecr_images(repository_name: str) -> None:
144+
"""Delete every image (tagged and untagged) from the ECR repository."""
145+
paginator = ECR_CLIENT.get_paginator("list_images")
146+
images_to_delete: List[dict] = []
147+
148+
for page in paginator.paginate(repositoryName=repository_name):
149+
image_ids = page.get("imageIds", [])
150+
if not image_ids:
151+
continue
152+
images_to_delete.extend(image_ids)
153+
LOGGER.debug(
154+
"queued %s images for deletion from repository %s",
155+
len(image_ids),
156+
repository_name,
157+
)
158+
159+
if not images_to_delete:
160+
LOGGER.info("no images found in repository %s", repository_name)
161+
return
162+
163+
for chunk_start in range(0, len(images_to_delete), 100):
164+
chunk = images_to_delete[chunk_start : chunk_start + 100]
165+
LOGGER.debug(
166+
"deleting %s images from repository %s",
167+
len(chunk),
168+
repository_name,
169+
)
170+
ECR_CLIENT.batch_delete_image(repositoryName=repository_name, imageIds=chunk)
96171

97172

98173
def handler(event, context):
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import importlib
2+
import sys
3+
import types
4+
import unittest
5+
from unittest.mock import MagicMock
6+
7+
8+
class ClientError(Exception):
9+
"""Lightweight stand-in for botocore.exceptions.ClientError."""
10+
11+
def __init__(self, error_response, operation_name):
12+
super().__init__(error_response)
13+
self.response = error_response
14+
self.operation_name = operation_name
15+
16+
17+
fake_boto3 = types.ModuleType("boto3")
18+
19+
20+
def _fake_client(service_name, config=None): # pylint: disable=unused-argument
21+
return MagicMock(name=f"{service_name}_client")
22+
23+
24+
fake_boto3.client = _fake_client
25+
sys.modules.setdefault("boto3", fake_boto3)
26+
27+
fake_botocore = types.ModuleType("botocore")
28+
fake_botocore_config = types.ModuleType("botocore.config")
29+
fake_botocore_config.Config = MagicMock # type: ignore[attr-defined]
30+
31+
fake_botocore_exceptions = types.ModuleType("botocore.exceptions")
32+
fake_botocore_exceptions.ClientError = ClientError
33+
34+
fake_botocore.config = fake_botocore_config
35+
fake_botocore.exceptions = fake_botocore_exceptions
36+
37+
sys.modules.setdefault("botocore", fake_botocore)
38+
sys.modules.setdefault("botocore.config", fake_botocore_config)
39+
sys.modules.setdefault("botocore.exceptions", fake_botocore_exceptions)
40+
41+
fake_crhelper = types.ModuleType("crhelper")
42+
43+
44+
class _FakeCfnResource:
45+
def __init__(self, *args, **kwargs):
46+
self.Data = {}
47+
48+
def create(self, func):
49+
return func
50+
51+
def update(self, func):
52+
return func
53+
54+
def poll_create(self, func):
55+
return func
56+
57+
def poll_update(self, func):
58+
return func
59+
60+
def delete(self, func):
61+
return func
62+
63+
def init_failure(self, exception):
64+
raise exception
65+
66+
def __call__(self, event, context):
67+
return None
68+
69+
70+
fake_crhelper.CfnResource = _FakeCfnResource
71+
sys.modules.setdefault("crhelper", fake_crhelper)
72+
73+
start_codebuild = importlib.import_module("src.lambda.start_codebuild.index")
74+
start_codebuild.ClientError = ClientError # ensure same reference in tests
75+
76+
77+
class StartCodebuildCleanupTests(unittest.TestCase):
78+
def setUp(self):
79+
self.repo_name = "pattern-2"
80+
self.ecr_client = MagicMock(name="ecr_client")
81+
self.paginator = MagicMock(name="list_images_paginator")
82+
self.ecr_client.get_paginator.return_value = self.paginator
83+
start_codebuild.ECR_CLIENT = self.ecr_client
84+
85+
def test_delete_all_ecr_images_handles_pagination_and_chunking(self):
86+
images_page_one = [{"imageDigest": f"sha256:{i:064d}"} for i in range(100)]
87+
images_page_two = [{"imageDigest": f"sha256:{100 + i:064d}"} for i in range(50)]
88+
self.paginator.paginate.return_value = [
89+
{"imageIds": images_page_one},
90+
{"imageIds": images_page_two},
91+
]
92+
93+
start_codebuild._delete_all_ecr_images(self.repo_name) # pylint: disable=protected-access
94+
95+
self.ecr_client.batch_delete_image.assert_any_call(
96+
repositoryName=self.repo_name, imageIds=images_page_one
97+
)
98+
self.ecr_client.batch_delete_image.assert_any_call(
99+
repositoryName=self.repo_name, imageIds=images_page_two
100+
)
101+
102+
def test_delete_all_ecr_images_skips_when_repository_empty(self):
103+
self.paginator.paginate.return_value = [{"imageIds": []}]
104+
105+
start_codebuild._delete_all_ecr_images(self.repo_name) # pylint: disable=protected-access
106+
107+
self.ecr_client.batch_delete_image.assert_not_called()
108+
109+
def test_delete_resource_handles_repository_not_found(self):
110+
cleanup_event = {
111+
"ResourceType": "Custom::ECRRepositoryCleanup",
112+
"ResourceProperties": {"RepositoryName": self.repo_name},
113+
}
114+
self.paginator.paginate.side_effect = ClientError(
115+
{"Error": {"Code": "RepositoryNotFoundException"}}, "ListImages"
116+
)
117+
118+
start_codebuild.delete_resource(cleanup_event, None)
119+
120+
self.ecr_client.batch_delete_image.assert_not_called()
121+
122+
def test_delete_resource_raises_unexpected_ecr_error(self):
123+
cleanup_event = {
124+
"ResourceType": "Custom::ECRRepositoryCleanup",
125+
"ResourceProperties": {"RepositoryName": self.repo_name},
126+
}
127+
self.paginator.paginate.side_effect = ClientError(
128+
{"Error": {"Code": "AccessDeniedException"}}, "ListImages"
129+
)
130+
131+
with self.assertRaises(ClientError):
132+
start_codebuild.delete_resource(cleanup_event, None)
133+
134+
def test_delete_resource_ignores_non_cleanup_types(self):
135+
event = {"ResourceType": "Custom::CodeBuildRun", "ResourceProperties": {}}
136+
137+
start_codebuild.delete_resource(event, None)
138+
139+
self.ecr_client.batch_delete_image.assert_not_called()
140+
141+
142+
if __name__ == "__main__":
143+
unittest.main()

0 commit comments

Comments
 (0)