Coverage for lambda / image-lookup / handler.py: 100.00%

100 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1""" 

2Lookup-or-create custom resource handler for ECR repositories. 

3 

4Handles CloudFormation custom resource events for ``gco/<name>`` 

5repositories. The handler implements the adopt-or-create pattern so 

6that a previously retained repository (left over from a prior deploy 

7with ``RemovalPolicy=RETAIN``) is rebound to the new stack rather than 

8failing with ``RepositoryAlreadyExistsException``. 

9 

10Event shape (CloudFormation custom resource): 

11 

12 event["RequestType"] — "Create" | "Update" | "Delete" 

13 event["ResourceProperties"]: 

14 RepositoryName — full repo name like ``gco/my-app`` 

15 RemovalPolicy — ``"retain"`` | ``"destroy"`` 

16 EmptyOnDelete — ``True`` | ``False`` 

17 LifecyclePolicy — optional JSON string of the lifecycle policy 

18 

19Behaviour: 

20 

21 Create / Update: 

22 DescribeRepositories → if found, adopt; else CreateRepository. 

23 Then PutLifecyclePolicy when ``LifecyclePolicy`` is provided. 

24 

25 Delete: 

26 Read tags via ListTagsForResource. If ``gco:retain=true`` is 

27 set on the repo, log + return success without deleting (the 

28 retain tag wins regardless of stack-level ``RemovalPolicy``). 

29 Else: when ``RemovalPolicy=="destroy"`` AND 

30 ``EmptyOnDelete==True``, BatchDeleteImage every image then 

31 DeleteRepository. When ``RemovalPolicy=="destroy"`` AND 

32 ``EmptyOnDelete==False``, DeleteRepository (which ECR rejects 

33 for non-empty repos — surfaces as a CloudFormation rollback). 

34 When ``RemovalPolicy=="retain"``, return success without any 

35 delete call. 

36 

37The handler returns the standard CloudFormation custom resource shape 

38``{"PhysicalResourceId": <repo_arn>, "Data": {...}}``. The CDK 

39``Provider`` framework wraps this into the protocol-required response 

40envelope when invoked through ``CustomResource``. 

41""" 

42 

43from __future__ import annotations 

44 

45import json 

46import logging 

47from typing import Any 

48 

49import boto3 

50 

51# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

52# Generated at (UTC): 2026-09-10T23:26:44Z 

53# Generated from Git commit: 4c42b84d53d6cc01cd2b3c7e4011a43f850678b6 

54# Flowchart(s) generated from this file: 

55# * ``lambda_handler`` -> ``diagrams/code_diagrams/lambda/image-lookup/handler.lambda_handler.html`` 

56# (PNG: ``diagrams/code_diagrams/lambda/image-lookup/handler.lambda_handler.png``) 

57# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

58# <pyflowchart-code-diagram> END 

59 

60 

61logger = logging.getLogger() 

62logger.setLevel(logging.INFO) 

63 

64 

65def _ecr_client() -> Any: 

66 """Return a region-default ECR boto3 client.""" 

67 return boto3.client("ecr") 

68 

69 

70def _describe_repository(ecr: Any, repository_name: str) -> dict[str, Any] | None: 

71 """Return the repository description if it exists, else ``None``. 

72 

73 ECR raises ``RepositoryNotFoundException`` when the named repository 

74 does not exist; we translate that into ``None`` so the caller can 

75 distinguish missing from an actual API error. 

76 """ 

77 try: 

78 resp = ecr.describe_repositories(repositoryNames=[repository_name]) 

79 except ecr.exceptions.RepositoryNotFoundException: 

80 return None 

81 except Exception as exc: # noqa: BLE001 

82 # Some boto3 stubs surface RepositoryNotFoundException via the 

83 # generic ClientError shape rather than the typed exception. Sniff 

84 # the error code and translate consistently. 

85 code = getattr(exc, "response", {}).get("Error", {}).get("Code", "") 

86 if code == "RepositoryNotFoundException": 

87 return None 

88 raise 

89 

90 repos = resp.get("repositories", []) 

91 return repos[0] if repos else None 

92 

93 

94def _create_repository(ecr: Any, repository_name: str) -> dict[str, Any]: 

95 """Create the named repository with project-standard configuration.""" 

96 resp = ecr.create_repository( 

97 repositoryName=repository_name, 

98 imageTagMutability="MUTABLE", 

99 imageScanningConfiguration={"scanOnPush": True}, 

100 ) 

101 repo: dict[str, Any] = resp.get("repository", {}) 

102 return repo 

103 

104 

105def _apply_lifecycle_policy(ecr: Any, repository_name: str, lifecycle_policy: str | None) -> None: 

106 """Apply ``lifecycle_policy`` (a JSON string) to the repository when set. 

107 

108 Silently no-ops when the value is empty or whitespace. Validates the 

109 JSON shape before calling ``put_lifecycle_policy`` so a malformed 

110 policy surfaces as a custom-resource error rather than a confusing 

111 ECR-side validation failure. 

112 """ 

113 if not lifecycle_policy or not lifecycle_policy.strip(): 

114 return 

115 # Validate the JSON parses; ``put_lifecycle_policy`` accepts the raw 

116 # string, but this gives a clearer error message on invalid input. 

117 json.loads(lifecycle_policy) 

118 ecr.put_lifecycle_policy( 

119 repositoryName=repository_name, 

120 lifecyclePolicyText=lifecycle_policy, 

121 ) 

122 

123 

124def _has_retain_tag(ecr: Any, repository_arn: str) -> bool: 

125 """Return True when ``gco:retain=true`` is present on the repository.""" 

126 try: 

127 resp = ecr.list_tags_for_resource(resourceArn=repository_arn) 

128 except Exception as exc: # noqa: BLE001 

129 logger.error("list_tags_for_resource failed for %s: %s", repository_arn, exc) 

130 raise RuntimeError( 

131 f"Unable to verify retention tags for {repository_arn}; refusing deletion" 

132 ) from exc 

133 for tag in resp.get("tags", []) or []: 

134 if tag.get("Key") == "gco:retain" and str(tag.get("Value", "")).lower() == "true": 

135 return True 

136 return False 

137 

138 

139def _delete_all_images(ecr: Any, repository_name: str) -> int: 

140 """BatchDeleteImage every image in the repository. 

141 

142 Returns the number of images deleted. ECR's ``batch_delete_image`` 

143 accepts up to 100 IDs per call so we paginate through both 

144 ``describe_images`` (to discover digests) and the chunked delete. 

145 """ 

146 digests: list[dict[str, str]] = [] 

147 paginator = ecr.get_paginator("describe_images") 

148 for page in paginator.paginate(repositoryName=repository_name): 

149 for detail in page.get("imageDetails", []): 

150 digest = detail.get("imageDigest") 

151 if digest: 

152 digests.append({"imageDigest": digest}) 

153 

154 deleted = 0 

155 for chunk_start in range(0, len(digests), 100): 

156 # range() stops before len(digests), so every slice holds at least 

157 # one digest and at most 100 — ECR's BatchDeleteImage ceiling. 

158 chunk = digests[chunk_start : chunk_start + 100] 

159 resp = ecr.batch_delete_image( 

160 repositoryName=repository_name, 

161 imageIds=chunk, 

162 ) 

163 deleted += len(resp.get("imageIds", [])) 

164 return deleted 

165 

166 

167def _handle_create_or_update(ecr: Any, properties: dict[str, Any]) -> dict[str, Any]: 

168 """Adopt-or-create the repository and apply the lifecycle policy.""" 

169 repository_name = properties["RepositoryName"] 

170 lifecycle_policy = properties.get("LifecyclePolicy") 

171 

172 existing = _describe_repository(ecr, repository_name) 

173 if existing is not None: 

174 repository_arn = existing.get("repositoryArn") 

175 repository_uri = existing.get("repositoryUri") 

176 adopted = True 

177 else: 

178 created = _create_repository(ecr, repository_name) 

179 repository_arn = created.get("repositoryArn") 

180 repository_uri = created.get("repositoryUri") 

181 adopted = False 

182 

183 if lifecycle_policy: 

184 _apply_lifecycle_policy(ecr, repository_name, lifecycle_policy) 

185 

186 return { 

187 "PhysicalResourceId": repository_arn or repository_name, 

188 "Data": { 

189 "RepositoryArn": repository_arn or "", 

190 "RepositoryUri": repository_uri or "", 

191 "RepositoryName": repository_name, 

192 "Adopted": "true" if adopted else "false", 

193 }, 

194 } 

195 

196 

197def _handle_delete(ecr: Any, properties: dict[str, Any], physical_id: str) -> dict[str, Any]: 

198 """Honor the retain tag, removal policy, and empty-on-delete switches.""" 

199 repository_name = properties["RepositoryName"] 

200 removal_policy = str(properties.get("RemovalPolicy", "retain")).lower() 

201 empty_on_delete = bool(properties.get("EmptyOnDelete", False)) 

202 

203 existing = _describe_repository(ecr, repository_name) 

204 if existing is None: 

205 # Already gone — treat as success. 

206 logger.info("Repository %s already absent on Delete; skipping.", repository_name) 

207 return {"PhysicalResourceId": physical_id, "Data": {"Deleted": "false"}} 

208 

209 repository_arn = existing.get("repositoryArn", physical_id) 

210 

211 if removal_policy != "destroy": 

212 logger.info( 

213 "removal_policy=%s for %s; leaving the repository in place.", 

214 removal_policy, 

215 repository_name, 

216 ) 

217 return { 

218 "PhysicalResourceId": physical_id, 

219 "Data": {"Deleted": "false", "Reason": "removal-policy-retain"}, 

220 } 

221 

222 if _has_retain_tag(ecr, repository_arn): 

223 logger.info( 

224 "Repository %s carries gco:retain=true; preserving despite removal_policy=%s.", 

225 repository_name, 

226 removal_policy, 

227 ) 

228 return { 

229 "PhysicalResourceId": physical_id, 

230 "Data": {"Deleted": "false", "Reason": "retain-tag"}, 

231 } 

232 

233 if empty_on_delete: 

234 deleted = _delete_all_images(ecr, repository_name) 

235 logger.info("Deleted %d images from %s before repo deletion.", deleted, repository_name) 

236 

237 ecr.delete_repository(repositoryName=repository_name, force=False) 

238 return {"PhysicalResourceId": physical_id, "Data": {"Deleted": "true"}} 

239 

240 

241def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: 

242 """Lookup-or-create custom resource handler for ECR repositories. 

243 

244 Dispatches on ``event["RequestType"]`` and returns the standard 

245 CloudFormation custom resource response shape. The CDK Provider 

246 framework wraps this dict into the protocol-required envelope. 

247 """ 

248 request_type = event.get("RequestType", "") 

249 properties = event.get("ResourceProperties", {}) or {} 

250 physical_id = event.get("PhysicalResourceId", "") 

251 logger.info("Image-Lookup CR event: %s for %s", request_type, properties.get("RepositoryName")) 

252 

253 ecr = _ecr_client() 

254 

255 if request_type in ("Create", "Update"): 

256 return _handle_create_or_update(ecr, properties) 

257 if request_type == "Delete": 

258 return _handle_delete(ecr, properties, physical_id) 

259 

260 raise ValueError(f"Unsupported RequestType: {request_type!r}")