Coverage for lambda / analytics-cleanup / handler.py: 100.00%

293 statements  

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

1"""Analytics stack cleanup handler. 

2 

3Runs as a CloudFormation custom resource on stack deletion. Removes all 

4SageMaker apps, spaces and user profiles from the Studio domain (waiting 

5for each to fully drain) and all EFS access points from the Studio file 

6system so CloudFormation can delete the domain and EFS cleanly. 

7 

8If draining apps/spaces/user-profiles fails, the handler raises — this 

9fails the custom resource and stops CloudFormation before it tries (and 

10fails) to delete the domain. EFS/security-group cleanup errors are 

11logged but non-fatal. 

12 

13Environment variables: 

14 DOMAIN_ID: SageMaker Studio domain ID 

15 EFS_ID: EFS file system ID 

16 REGION: AWS region 

17""" 

18 

19from __future__ import annotations 

20 

21import logging 

22import os 

23import time 

24from typing import Any 

25 

26import boto3 

27from botocore.exceptions import ClientError 

28 

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

30# Generated at (UTC): 2026-09-01T14:42:56Z 

31# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

32# Flowchart(s) generated from this file: 

33# * ``handler`` -> ``diagrams/code_diagrams/lambda/analytics-cleanup/handler.handler.html`` 

34# (PNG: ``diagrams/code_diagrams/lambda/analytics-cleanup/handler.handler.png``) 

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

36# <pyflowchart-code-diagram> END 

37 

38 

39logger = logging.getLogger(__name__) 

40logger.setLevel(logging.INFO) 

41 

42# --------------------------------------------------------------------------- 

43# Tunables 

44# --------------------------------------------------------------------------- 

45# 

46# These constants govern how aggressively we poll SageMaker/EFS while waiting 

47# for asynchronous delete operations to drain. The defaults are sized for the 

48# worst case we've observed in production (a domain with a handful of users, 

49# one JupyterLab app each). If you see timeouts in CloudWatch, raise the 

50# ``*_WAIT_SECONDS`` values — and correspondingly bump ``timeout=`` on the 

51# ``CleanupFunction`` in ``gco/stacks/analytics_stack.py`` so the Lambda 

52# doesn't exceed its own execution timeout. 

53# 

54# Lower the values if you want the custom resource to fail fast during 

55# local testing, but remember that CloudFormation will then hit the domain 

56# delete before the async drains finish and fail with 

57# ``Unable to delete Domain ... because UserProfile(s) are associated``. 

58 

59# Interval between list-and-check iterations of every drain wait loop. 

60# Small enough to keep typical stacks responsive, large enough to avoid 

61# throttling SageMaker/EFS control-plane APIs. 

62DRAIN_POLL_INTERVAL_SECONDS = 5 

63 

64# Brief pause after issuing ``delete_space`` / ``delete_user_profile`` to 

65# avoid ThrottlingException when a domain has dozens of resources. 

66DELETE_PACE_SECONDS = 1 

67 

68# Maximum time to wait for SageMaker apps to reach ``Deleted``/``Failed``. 

69# Apps typically go terminal within 30-60 seconds; 2 minutes leaves plenty 

70# of headroom without risking a Lambda timeout. 

71APP_DELETE_WAIT_SECONDS = 120 

72 

73# Maximum time to wait for SageMaker spaces to disappear from ``list_spaces``. 

74# Spaces drain faster than user profiles but may be gated by app deletion. 

75SPACE_DELETE_WAIT_SECONDS = 180 

76 

77# Maximum time to wait for SageMaker user profiles to disappear from 

78# ``list_user_profiles``. This is the critical wait — if CloudFormation 

79# reaches ``DeleteDomain`` with any profiles still lingering, the whole 

80# stack fails with a UserProfile-in-use error and has to be manually 

81# unstuck. 

82USER_PROFILE_DELETE_WAIT_SECONDS = 180 

83 

84# Maximum time to wait for SageMaker-managed EFS mount targets to drain. 

85# Mount target deletion is usually sub-30s but can stall briefly while 

86# the ENIs are detached. 

87MOUNT_TARGET_DELETE_WAIT_SECONDS = 120 

88 

89# SageMaker-managed NFS security group retry behaviour. Right after 

90# ``DeleteDomain``, SageMaker's control plane briefly holds an internal 

91# reference on one of the two NFS SGs (typically the outbound one), 

92# causing ``delete_security_group`` to fail with ``DependencyViolation`` 

93# for ~30-60 seconds before clearing. We retry that many times, pausing 

94# between attempts; if the reference is still there on the final try we 

95# promote the failure to a critical error so CloudFormation stops 

96# before the VPC delete hits the same dependency and fails the stack. 

97SG_DELETE_MAX_ATTEMPTS = 4 

98SG_DELETE_RETRY_BACKOFF_SECONDS = 15 

99 

100 

101def _poll_iterations(total_wait_seconds: int) -> int: 

102 """Return the number of iterations for a drain loop with 

103 ``DRAIN_POLL_INTERVAL_SECONDS`` between polls.""" 

104 return max(1, total_wait_seconds // DRAIN_POLL_INTERVAL_SECONDS) 

105 

106 

107def handler(event: dict[str, Any], context: object) -> dict[str, Any]: 

108 """CloudFormation custom resource handler.""" 

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

110 physical_id = event.get("PhysicalResourceId", "analytics-cleanup") 

111 

112 if request_type != "Delete": 

113 logger.info("RequestType=%s, nothing to do", request_type) 

114 return {"Status": "SUCCESS", "PhysicalResourceId": physical_id} 

115 

116 region = os.environ["REGION"] 

117 domain_id = os.environ["DOMAIN_ID"] 

118 efs_id = os.environ.get("EFS_ID", "") 

119 vpc_id = os.environ.get("VPC_ID", "") 

120 

121 errors: list[str] = [] 

122 # Errors from deleting apps/spaces/user-profiles block the domain 

123 # delete — if we return SUCCESS with these still present, CloudFormation 

124 # will immediately fail on ``AWS::SageMaker::Domain`` with a 

125 # ``Unable to delete Domain ... because UserProfile(s) are associated 

126 # with it`` error. We track these separately so we can fail the custom 

127 # resource and give the operator a useful log pointer instead. 

128 critical_errors: list[str] = [] 

129 

130 # Delete all apps (must be deleted before spaces/profiles) 

131 app_errors = _delete_apps(region, domain_id) 

132 errors.extend(app_errors) 

133 critical_errors.extend(app_errors) 

134 

135 # Delete all spaces 

136 space_errors = _delete_spaces(region, domain_id) 

137 errors.extend(space_errors) 

138 critical_errors.extend(space_errors) 

139 

140 # Delete all user profiles from the domain 

141 profile_errors = _delete_user_profiles(region, domain_id) 

142 errors.extend(profile_errors) 

143 critical_errors.extend(profile_errors) 

144 

145 # Remove EFS resource policies that trigger the intersection 

146 # authorization model. Both the CDK-managed EFS and the SageMaker- 

147 # managed EFS have resource policies that block DescribeMountTargets. 

148 if efs_id: 

149 _delete_efs_resource_policy(region, efs_id) 

150 sm_efs_id = _get_sagemaker_home_efs_id(region, domain_id) 

151 if sm_efs_id: 

152 _delete_efs_resource_policy(region, sm_efs_id) 

153 

154 # Delete SageMaker-managed EFS (created internally by the domain) 

155 errors.extend(_delete_sagemaker_managed_efs(region, domain_id)) 

156 

157 # Delete SageMaker-managed NFS security groups 

158 if vpc_id: 

159 errors.extend(_delete_sagemaker_security_groups(region, domain_id, vpc_id)) 

160 

161 if errors: 

162 logger.warning("Cleanup completed with %d errors: %s", len(errors), errors) 

163 else: 

164 logger.info("Cleanup completed successfully") 

165 

166 # If apps/spaces/user-profiles still have unresolved errors, fail the 

167 # custom resource. CloudFormation will stop before attempting to 

168 # delete the domain, surface the error to the operator, and the stack 

169 # stays in a retriable state. EFS/security-group errors are logged 

170 # but non-fatal — they don't block the domain delete and are cleaned 

171 # up best-effort on the next attempt. 

172 if critical_errors: 

173 raise RuntimeError( 

174 "Analytics cleanup failed to fully drain the SageMaker domain " 

175 f"({len(critical_errors)} error(s)). See CloudWatch Logs for " 

176 f"details: {critical_errors}" 

177 ) 

178 

179 return {"Status": "SUCCESS", "PhysicalResourceId": physical_id} 

180 

181 

182def _delete_apps(region: str, domain_id: str) -> list[str]: 

183 """Delete all apps in the domain. Apps must be deleted before spaces/profiles.""" 

184 errors: list[str] = [] 

185 sm = boto3.client("sagemaker", region_name=region) 

186 

187 try: 

188 paginator = sm.get_paginator("list_apps") 

189 for page in paginator.paginate(DomainIdEquals=domain_id): 

190 for app in page.get("Apps", []): 

191 if app.get("Status") in ("Deleted", "Failed"): 

192 continue 

193 app_name = app["AppName"] 

194 app_type = app["AppType"] 

195 space_name = app.get("SpaceName") 

196 user_profile = app.get("UserProfileName") 

197 try: 

198 kwargs = { 

199 "DomainId": domain_id, 

200 "AppType": app_type, 

201 "AppName": app_name, 

202 } 

203 if space_name: 

204 kwargs["SpaceName"] = space_name 

205 if user_profile: 

206 kwargs["UserProfileName"] = user_profile 

207 sm.delete_app(**kwargs) 

208 logger.info("Deleted app: %s (%s)", app_name, app_type) 

209 except ClientError as e: 

210 if "does not exist" not in str(e): 

211 msg = f"Failed to delete app {app_name}: {e}" 

212 logger.error(msg) 

213 errors.append(msg) 

214 

215 # Wait for apps to finish deleting. A timeout must be surfaced to the 

216 # custom resource handler; otherwise CloudFormation immediately moves 

217 # on to space/profile/domain deletion while apps are still attached. 

218 active: list[str] = [] 

219 for _ in range(_poll_iterations(APP_DELETE_WAIT_SECONDS)): 

220 time.sleep(DRAIN_POLL_INTERVAL_SECONDS) 

221 active = [] 

222 for page in paginator.paginate(DomainIdEquals=domain_id): 

223 for app in page.get("Apps", []): 

224 if app.get("Status") not in ("Deleted", "Failed"): 

225 active.append(app["AppName"]) 

226 if not active: 

227 break 

228 logger.info("Waiting for %d app(s) to delete: %s", len(active), active) 

229 else: 

230 msg = f"Timed out waiting for apps to delete in {domain_id}: {active}" 

231 logger.error(msg) 

232 errors.append(msg) 

233 

234 except ClientError as e: 

235 msg = f"Failed to list apps: {e}" 

236 logger.error(msg) 

237 errors.append(msg) 

238 

239 return errors 

240 

241 

242def _delete_spaces(region: str, domain_id: str) -> list[str]: 

243 """Delete all spaces in the domain and wait for them to be gone. 

244 

245 Spaces must be fully removed before user profiles can be deleted, and 

246 user profiles must be fully removed before the domain can be deleted. 

247 ``delete_space`` is asynchronous, so after issuing the deletes we poll 

248 ``list_spaces`` until it returns empty (or a timeout elapses). 

249 """ 

250 errors: list[str] = [] 

251 sm = boto3.client("sagemaker", region_name=region) 

252 

253 try: 

254 paginator = sm.get_paginator("list_spaces") 

255 for page in paginator.paginate(DomainIdEquals=domain_id): 

256 for space in page.get("Spaces", []): 

257 space_name = space["SpaceName"] 

258 # Skip spaces already being deleted; the wait loop below 

259 # will still account for them. 

260 if space.get("Status") == "Deleting": 

261 continue 

262 try: 

263 sm.delete_space(DomainId=domain_id, SpaceName=space_name) 

264 logger.info("Deleted space: %s", space_name) 

265 time.sleep(DELETE_PACE_SECONDS) 

266 except ClientError as e: 

267 if "does not exist" not in str(e): 

268 msg = f"Failed to delete space {space_name}: {e}" 

269 logger.error(msg) 

270 errors.append(msg) 

271 

272 # Wait for spaces to finish deleting. 

273 for _ in range(_poll_iterations(SPACE_DELETE_WAIT_SECONDS)): 

274 remaining: list[str] = [] 

275 for page in paginator.paginate(DomainIdEquals=domain_id): 

276 for space in page.get("Spaces", []): 

277 remaining.append(space["SpaceName"]) 

278 if not remaining: 

279 break 

280 logger.info("Waiting for %d space(s) to delete: %s", len(remaining), remaining) 

281 time.sleep(DRAIN_POLL_INTERVAL_SECONDS) 

282 else: 

283 msg = f"Timed out waiting for spaces to delete in {domain_id}: {remaining}" 

284 logger.error(msg) 

285 errors.append(msg) 

286 except ClientError as e: 

287 msg = f"Failed to list spaces: {e}" 

288 logger.error(msg) 

289 errors.append(msg) 

290 

291 return errors 

292 

293 

294def _delete_user_profiles(region: str, domain_id: str) -> list[str]: 

295 """Delete all user profiles in the domain and wait for them to be gone. 

296 

297 ``delete_user_profile`` is asynchronous — it puts the profile into 

298 ``Deleting`` state and returns immediately. If we don't wait for the 

299 list to drain, CloudFormation will race ahead to delete the domain 

300 and fail with ``Unable to delete Domain ... because UserProfile(s) 

301 are associated with it``. This function polls ``list_user_profiles`` 

302 until it's empty (or a timeout elapses). 

303 """ 

304 errors: list[str] = [] 

305 sm = boto3.client("sagemaker", region_name=region) 

306 

307 try: 

308 paginator = sm.get_paginator("list_user_profiles") 

309 for page in paginator.paginate(DomainIdEquals=domain_id): 

310 for profile in page.get("UserProfiles", []): 

311 name = profile["UserProfileName"] 

312 # Skip profiles already being deleted; the wait loop below 

313 # will still account for them. 

314 if profile.get("Status") == "Deleting": 

315 continue 

316 try: 

317 sm.delete_user_profile(DomainId=domain_id, UserProfileName=name) 

318 logger.info("Deleted user profile: %s", name) 

319 # Brief pause to avoid throttling 

320 time.sleep(DELETE_PACE_SECONDS) 

321 except ClientError as e: 

322 msg = f"Failed to delete profile {name}: {e}" 

323 logger.error(msg) 

324 errors.append(msg) 

325 

326 # Wait for profiles to finish deleting. Without this, CloudFormation 

327 # will race ahead to delete the domain while profiles are still in 

328 # ``Deleting`` state and fail the stack. 

329 remaining: list[str] = [] 

330 for _ in range(_poll_iterations(USER_PROFILE_DELETE_WAIT_SECONDS)): 

331 remaining = [] 

332 for page in paginator.paginate(DomainIdEquals=domain_id): 

333 for profile in page.get("UserProfiles", []): 

334 remaining.append(profile["UserProfileName"]) 

335 if not remaining: 

336 break 

337 logger.info( 

338 "Waiting for %d user profile(s) to delete: %s", 

339 len(remaining), 

340 remaining, 

341 ) 

342 time.sleep(DRAIN_POLL_INTERVAL_SECONDS) 

343 else: 

344 msg = f"Timed out waiting for user profiles to delete in {domain_id}: {remaining}" 

345 logger.error(msg) 

346 errors.append(msg) 

347 except ClientError as e: 

348 msg = f"Failed to list user profiles: {e}" 

349 logger.error(msg) 

350 errors.append(msg) 

351 

352 return errors 

353 

354 

355def _delete_access_points(region: str, efs_id: str) -> list[str]: 

356 """Delete all access points on the file system. Returns a list of error messages.""" 

357 errors: list[str] = [] 

358 efs = boto3.client("efs", region_name=region) 

359 

360 try: 

361 paginator = efs.get_paginator("describe_access_points") 

362 for page in paginator.paginate(FileSystemId=efs_id): 

363 for ap in page.get("AccessPoints", []): 

364 ap_id = ap["AccessPointId"] 

365 try: 

366 efs.delete_access_point(AccessPointId=ap_id) 

367 logger.info("Deleted access point: %s", ap_id) 

368 except ClientError as e: 

369 msg = f"Failed to delete access point {ap_id}: {e}" 

370 logger.error(msg) 

371 errors.append(msg) 

372 except ClientError as e: 

373 msg = f"Failed to list access points: {e}" 

374 logger.error(msg) 

375 errors.append(msg) 

376 

377 return errors 

378 

379 

380def _delete_sagemaker_security_groups(region: str, domain_id: str, vpc_id: str) -> list[str]: 

381 """Delete SageMaker-managed security groups for the domain. 

382 

383 SageMaker creates security groups named 

384 ``security-group-for-outbound-nfs-<domain-id>`` and 

385 ``security-group-for-inbound-nfs-<domain-id>`` when the domain uses 

386 a custom EFS. These are tagged "[DO NOT DELETE]" but must be removed 

387 for the VPC to be deletable. 

388 

389 The two SGs cross-reference each other (outbound rules on one point 

390 to the other), creating a circular dependency. We must revoke all 

391 ingress/egress rules before deleting. 

392 

393 Right after ``DeleteDomain``, SageMaker's control plane briefly 

394 retains an internal reference on one of the NFS SGs — typically the 

395 outbound one — causing ``delete_security_group`` to fail with 

396 ``DependencyViolation``. The reference reliably clears within 30-60s. 

397 We retry the delete ``SG_DELETE_MAX_ATTEMPTS`` times with 

398 ``SG_DELETE_RETRY_BACKOFF_SECONDS`` between attempts, and only 

399 surface an error if the SG is still undeletable after the final try. 

400 """ 

401 errors: list[str] = [] 

402 ec2 = boto3.client("ec2", region_name=region) 

403 

404 try: 

405 response = ec2.describe_security_groups( 

406 Filters=[ 

407 {"Name": "vpc-id", "Values": [vpc_id]}, 

408 {"Name": "group-name", "Values": [f"*{domain_id}*"]}, 

409 ] 

410 ) 

411 sgs = response.get("SecurityGroups", []) 

412 

413 # First pass: revoke all rules to break cross-references. 

414 for sg in sgs: 

415 sg_id = sg["GroupId"] 

416 try: 

417 if sg.get("IpPermissions"): 

418 ec2.revoke_security_group_ingress( 

419 GroupId=sg_id, IpPermissions=sg["IpPermissions"] 

420 ) 

421 if sg.get("IpPermissionsEgress"): 

422 ec2.revoke_security_group_egress( 

423 GroupId=sg_id, IpPermissions=sg["IpPermissionsEgress"] 

424 ) 

425 except ClientError as e: 

426 logger.warning("Failed to revoke rules on %s: %s", sg_id, e) 

427 

428 # Second pass: delete the security groups, retrying on 

429 # ``DependencyViolation`` for ones where SageMaker still holds 

430 # a transient reference post-DeleteDomain. 

431 pending = [(sg["GroupId"], sg.get("GroupName", "")) for sg in sgs] 

432 for attempt in range(1, SG_DELETE_MAX_ATTEMPTS + 1): 

433 still_pending: list[tuple[str, str]] = [] 

434 for sg_id, sg_name in pending: 

435 try: 

436 ec2.delete_security_group(GroupId=sg_id) 

437 logger.info("Deleted SageMaker security group: %s (%s)", sg_id, sg_name) 

438 except ClientError as e: 

439 code = e.response.get("Error", {}).get("Code", "") 

440 # ``InvalidGroup.NotFound`` means some other actor 

441 # already deleted it — treat as success. 

442 if code == "InvalidGroup.NotFound": 

443 logger.info("SG %s (%s) already deleted", sg_id, sg_name) 

444 continue 

445 if code == "DependencyViolation": 

446 logger.warning( 

447 "SG %s (%s) has a dependent object (attempt %d/%d); will retry", 

448 sg_id, 

449 sg_name, 

450 attempt, 

451 SG_DELETE_MAX_ATTEMPTS, 

452 ) 

453 still_pending.append((sg_id, sg_name)) 

454 continue 

455 msg = f"Failed to delete security group {sg_id}: {e}" 

456 logger.error(msg) 

457 errors.append(msg) 

458 if not still_pending: 

459 break 

460 pending = still_pending 

461 if attempt < SG_DELETE_MAX_ATTEMPTS: 

462 time.sleep(SG_DELETE_RETRY_BACKOFF_SECONDS) 

463 else: 

464 # Exhausted retries with SGs still undeletable. 

465 for sg_id, sg_name in pending: 

466 msg = ( 

467 f"Failed to delete security group {sg_id} ({sg_name}) " 

468 f"after {SG_DELETE_MAX_ATTEMPTS} attempts: " 

469 "DependencyViolation did not clear. CloudFormation " 

470 "will fail to delete the VPC; manually delete the " 

471 "SG after its dependent object releases." 

472 ) 

473 logger.error(msg) 

474 errors.append(msg) 

475 except ClientError as e: 

476 msg = f"Failed to list SageMaker security groups: {e}" 

477 logger.error(msg) 

478 errors.append(msg) 

479 

480 return errors 

481 

482 

483def _get_sagemaker_home_efs_id(region: str, domain_id: str) -> str: 

484 """Get the HomeEfsFileSystemId from the SageMaker domain.""" 

485 sm = boto3.client("sagemaker", region_name=region) 

486 try: 

487 resp = sm.describe_domain(DomainId=domain_id) 

488 return str(resp.get("HomeEfsFileSystemId", "")) 

489 except ClientError as e: 

490 logger.warning("Failed to get HomeEfsFileSystemId for %s: %s", domain_id, e) 

491 return "" 

492 

493 

494def _delete_efs_resource_policy(region: str, efs_id: str) -> None: 

495 """Delete the resource policy on the CDK-managed EFS. 

496 

497 The EFS resource policy triggers the intersection authorization model 

498 which blocks DescribeFileSystems/DescribeAccessPoints calls even when 

499 the caller has IAM Resource:* permissions. Removing the policy before 

500 other EFS operations ensures they succeed. 

501 """ 

502 efs_client = boto3.client("efs", region_name=region) 

503 try: 

504 efs_client.delete_file_system_policy(FileSystemId=efs_id) 

505 logger.info("Deleted EFS resource policy on %s", efs_id) 

506 except ClientError as e: 

507 # PolicyNotFound is fine — means there's no policy to delete. 

508 if "PolicyNotFound" not in str(e): 

509 logger.warning("Failed to delete EFS resource policy on %s: %s", efs_id, e) 

510 

511 

512def _delete_sagemaker_managed_efs(region: str, domain_id: str) -> list[str]: 

513 """Delete the SageMaker-managed EFS created internally by the domain. 

514 

515 Uses sagemaker:DescribeDomain to get the HomeEfsFileSystemId directly, 

516 avoiding DescribeFileSystems which is blocked by the EFS resource 

517 policy intersection model. The domain still exists when this Lambda 

518 runs (it's deleted after the custom resource completes). 

519 """ 

520 errors: list[str] = [] 

521 sm = boto3.client("sagemaker", region_name=region) 

522 efs = boto3.client("efs", region_name=region) 

523 

524 try: 

525 # Get the EFS ID from the domain itself — no DescribeFileSystems needed. 

526 domain_info = sm.describe_domain(DomainId=domain_id) 

527 target_fs = domain_info.get("HomeEfsFileSystemId") 

528 if not target_fs: 

529 logger.info("No HomeEfsFileSystemId found for domain %s", domain_id) 

530 return errors 

531 

532 logger.info("Found SageMaker-managed EFS: %s", target_fs) 

533 

534 # Delete all mount targets first 

535 mt_response = efs.describe_mount_targets(FileSystemId=target_fs) 

536 for mt in mt_response.get("MountTargets", []): 

537 mt_id = mt["MountTargetId"] 

538 try: 

539 efs.delete_mount_target(MountTargetId=mt_id) 

540 logger.info("Deleted mount target: %s", mt_id) 

541 except ClientError as e: 

542 msg = f"Failed to delete mount target {mt_id}: {e}" 

543 logger.error(msg) 

544 errors.append(msg) 

545 

546 # Wait for mount targets to be deleted. 

547 for _ in range(_poll_iterations(MOUNT_TARGET_DELETE_WAIT_SECONDS)): 

548 time.sleep(DRAIN_POLL_INTERVAL_SECONDS) 

549 remaining = efs.describe_mount_targets(FileSystemId=target_fs) 

550 if not remaining.get("MountTargets"): 

551 break 

552 

553 # Delete the file system 

554 try: 

555 efs.delete_file_system(FileSystemId=target_fs) 

556 logger.info("Deleted SageMaker-managed EFS: %s", target_fs) 

557 except ClientError as e: 

558 msg = f"Failed to delete EFS {target_fs}: {e}" 

559 logger.error(msg) 

560 errors.append(msg) 

561 

562 except ClientError as e: 

563 msg = f"Failed to find/delete SageMaker-managed EFS: {e}" 

564 logger.error(msg) 

565 errors.append(msg) 

566 

567 return errors