Coverage for scripts / live_release_validation / inventory / scanners.py: 100.00%
464 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Per-service read-only scanners for project-owned resources.
3Each ``_list_*`` helper answers one question — which resources of this
4service does this project own in this Region — and fails closed by
5returning nothing rather than guessing when a tag or name is ambiguous.
6``project.collect_project_resources`` fans these out."""
8from __future__ import annotations
10from collections.abc import Iterable
11from typing import Any
12from urllib.parse import urlparse
14from botocore.exceptions import ClientError
16from ._shared import (
17 _GLOBAL_ACCELERATOR_CONTROL_REGIONS,
18 _arn_is_project_owned,
19 _ec2_resource_is_project_owned,
20 _iam_resource_is_project_owned,
21 _mapping_tags,
22 _name_or_path_is_project_owned,
23 _project_owned_name,
24 _tags_are_project_owned,
25 _tags_to_dict,
26)
27from .ecr import (
28 collect_ecr_inventory,
29)
32def _list_eks_clusters(
33 session: Any,
34 region: str,
35 project_name: str | None,
36) -> list[str]:
37 """List all clusters, optionally narrowing the authoritative result to the project."""
38 client = session.client("eks", region_name=region)
39 names: set[str] = set()
40 for page in client.get_paginator("list_clusters").paginate():
41 for raw_name in page.get("clusters", []):
42 name = str(raw_name or "")
43 if not name:
44 raise RuntimeError(f"EKS returned a cluster without a name in {region}")
45 names.add(name)
46 if project_name is None:
47 return sorted(names)
48 return sorted(name for name in names if _project_owned_name(name, project_name))
51def _list_sqs_queues(session: Any, region: str, project_name: str) -> list[str]:
52 client = session.client("sqs", region_name=region)
53 urls: list[str] = []
54 for page in client.get_paginator("list_queues").paginate(QueueNamePrefix=project_name):
55 for queue_url in page.get("QueueUrls", []):
56 queue_name = urlparse(str(queue_url)).path.rsplit("/", 1)[-1]
57 if _project_owned_name(queue_name, project_name):
58 urls.append(str(queue_url))
59 return sorted(set(urls))
62def _list_dynamodb_tables(session: Any, region: str, project_name: str) -> list[str]:
63 client = session.client("dynamodb", region_name=region)
64 names: list[str] = []
65 for page in client.get_paginator("list_tables").paginate():
66 names.extend(
67 str(name)
68 for name in page.get("TableNames", [])
69 if _project_owned_name(str(name), project_name)
70 )
71 return sorted(set(names))
74def _list_load_balancers(session: Any, region: str, project_name: str) -> list[str]:
75 client = session.client("elbv2", region_name=region)
76 load_balancers: list[dict[str, Any]] = []
77 for page in client.get_paginator("describe_load_balancers").paginate():
78 load_balancers.extend(page.get("LoadBalancers", []))
80 owned: list[str] = []
81 for start in range(0, len(load_balancers), 20):
82 batch = load_balancers[start : start + 20]
83 arns = [str(item["LoadBalancerArn"]) for item in batch]
84 tags_by_arn = {
85 str(item["ResourceArn"]): _tags_to_dict(item.get("Tags", []))
86 for item in client.describe_tags(ResourceArns=arns).get("TagDescriptions", [])
87 }
88 for load_balancer in batch:
89 arn = str(load_balancer["LoadBalancerArn"])
90 name = str(load_balancer.get("LoadBalancerName") or "")
91 if _project_owned_name(name, project_name) or _tags_are_project_owned(
92 tags_by_arn.get(arn, {}), project_name
93 ):
94 owned.append(arn)
95 return sorted(set(owned))
98def _list_target_groups(session: Any, region: str, project_name: str) -> list[str]:
99 """Return ELBv2 target groups owned by a project or its EKS controller."""
100 client = session.client("elbv2", region_name=region)
101 target_groups: list[dict[str, Any]] = []
102 for page in client.get_paginator("describe_target_groups").paginate():
103 target_groups.extend(page.get("TargetGroups", []))
105 owned: set[str] = set()
106 for start in range(0, len(target_groups), 20):
107 batch = target_groups[start : start + 20]
108 arns = [str(item.get("TargetGroupArn") or "") for item in batch]
109 if any(not arn for arn in arns):
110 raise RuntimeError(f"ELBv2 returned a target group without an ARN in {region}")
111 tags_by_arn = {
112 str(item["ResourceArn"]): _tags_to_dict(item.get("Tags", []))
113 for item in client.describe_tags(ResourceArns=arns).get("TagDescriptions", [])
114 }
115 for target_group, arn in zip(batch, arns, strict=True):
116 tags = tags_by_arn.get(arn, {})
117 cluster_names = {
118 tags.get("elbv2.k8s.aws/cluster", ""),
119 tags.get("eks:eks-cluster-name", ""),
120 }
121 name = str(target_group.get("TargetGroupName") or "")
122 if (
123 _project_owned_name(name, project_name)
124 or _tags_are_project_owned(tags, project_name)
125 or any(_project_owned_name(cluster, project_name) for cluster in cluster_names)
126 ):
127 owned.add(arn)
128 return sorted(owned)
131def _list_instance_inventory(
132 session: Any,
133 region: str,
134 project_name: str,
135) -> tuple[list[str], list[str]]:
136 """Return project-owned and all active EC2 instance IDs separately."""
137 client = session.client("ec2", region_name=region)
138 state_filter = {
139 "Name": "instance-state-name",
140 "Values": ["pending", "running", "stopping", "stopped", "shutting-down"],
141 }
142 project_instance_ids: set[str] = set()
143 all_instance_ids: set[str] = set()
144 paginator = client.get_paginator("describe_instances")
145 for page in paginator.paginate(Filters=[state_filter]):
146 for reservation in page.get("Reservations", []):
147 for instance in reservation.get("Instances", []):
148 instance_id = str(instance.get("InstanceId") or "")
149 if not instance_id:
150 raise RuntimeError(f"EC2 returned an instance without an ID in {region}")
151 all_instance_ids.add(instance_id)
152 if _ec2_resource_is_project_owned(instance, project_name):
153 project_instance_ids.add(instance_id)
154 return sorted(project_instance_ids), sorted(all_instance_ids)
157def _list_instances(session: Any, region: str, project_name: str) -> list[str]:
158 return _list_instance_inventory(session, region, project_name)[0]
161def _list_project_kms_keys(
162 session: Any,
163 region: str,
164 project_name: str,
165 validation_run_id: str | None = None,
166) -> list[dict[str, Any]]:
167 """List project keys while isolating prior validation runs pending deletion."""
168 client = session.client("kms", region_name=region)
169 keys: list[dict[str, Any]] = []
170 for page in client.get_paginator("list_keys").paginate():
171 for summary in page.get("Keys", []):
172 key_id = str(summary.get("KeyId") or "")
173 if not key_id:
174 continue
175 metadata = client.describe_key(KeyId=key_id).get("KeyMetadata", {})
176 if metadata.get("KeyManager") != "CUSTOMER":
177 continue
178 tags: dict[str, str] = {}
179 marker: str | None = None
180 while True:
181 kwargs = {"KeyId": key_id}
182 if marker:
183 kwargs["Marker"] = marker
184 response = client.list_resource_tags(**kwargs)
185 tags.update(
186 {
187 str(tag["TagKey"]): str(tag.get("TagValue") or "")
188 for tag in response.get("Tags", [])
189 if tag.get("TagKey") is not None
190 }
191 )
192 marker = response.get("NextMarker") if response.get("Truncated") else None
193 if not marker:
194 break
195 project_owned = _tags_are_project_owned(tags, project_name)
196 validation_owner = tags.get("GcoLiveValidationRun")
197 state = str(metadata.get("KeyState") or "")
198 if validation_run_id:
199 if validation_owner and validation_owner != validation_run_id:
200 if state == "PendingDeletion":
201 # Successful runs leave exact keys pending for seven days.
202 # They are neither baseline contamination nor authority for
203 # this run; active keys from another run still fail closed.
204 continue
205 elif not validation_owner and not project_owned:
206 continue
207 elif not project_owned:
208 continue
209 deletion_date = metadata.get("DeletionDate")
210 keys.append(
211 {
212 "key_id": key_id,
213 "arn": str(metadata.get("Arn") or ""),
214 "state": str(metadata.get("KeyState") or ""),
215 "description": str(metadata.get("Description") or ""),
216 "deletion_date": (
217 deletion_date.isoformat() if deletion_date is not None else None
218 ),
219 "tags": tags,
220 }
221 )
222 return sorted(keys, key=lambda item: (item["arn"], item["key_id"]))
225def _list_project_ecr_repositories(
226 session: Any,
227 region: str,
228 project_name: str,
229) -> list[str]:
230 repositories = collect_ecr_inventory(session, [region])[region]
231 return sorted(
232 item["name"] for item in repositories if _project_owned_name(item["name"], project_name)
233 )
236def _global_accelerator_control_region(session: Any, seed_region: str) -> str | None:
237 """Return the partition's supported Global Accelerator control Region."""
238 partition = session.get_partition_for_region(seed_region)
239 control_region = _GLOBAL_ACCELERATOR_CONTROL_REGIONS.get(str(partition))
240 if control_region is None:
241 return None
242 service_regions = set(
243 session.get_available_regions("globalaccelerator", partition_name=partition)
244 )
245 if control_region not in service_regions:
246 raise RuntimeError(
247 "AWS SDK does not advertise the required Global Accelerator control Region "
248 f"{control_region} for partition {partition}"
249 )
250 return control_region
253def _list_global_accelerators(
254 session: Any,
255 control_region: str | None,
256 project_name: str,
257) -> list[str]:
258 if control_region is None:
259 return []
260 client = session.client("globalaccelerator", region_name=control_region)
261 accelerators: list[dict[str, Any]] = []
262 token: str | None = None
263 while True:
264 kwargs = {"NextToken": token} if token else {}
265 response = client.list_accelerators(**kwargs)
266 accelerators.extend(response.get("Accelerators", []))
267 token = response.get("NextToken")
268 if not token:
269 break
271 owned: list[str] = []
272 for accelerator in accelerators:
273 arn = str(accelerator.get("AcceleratorArn") or "")
274 name = str(accelerator.get("Name") or "")
275 tags = _tags_to_dict(client.list_tags_for_resource(ResourceArn=arn).get("Tags", []))
276 if _project_owned_name(name, project_name) or _tags_are_project_owned(tags, project_name):
277 owned.append(arn)
278 return sorted(set(owned))
281def _list_project_tagged_resources(
282 session: Any,
283 region: str,
284 project_name: str,
285) -> list[dict[str, Any]]:
286 """List project-scoped resources exposed by the regional Tagging API."""
287 client = session.client("resourcegroupstaggingapi", region_name=region)
288 resources: dict[str, dict[str, Any]] = {}
289 for page in client.get_paginator("get_resources").paginate():
290 for mapping in page.get("ResourceTagMappingList", []):
291 arn = str(mapping.get("ResourceARN") or "")
292 if not arn:
293 raise RuntimeError(f"Resource Groups Tagging API omitted an ARN in {region}")
294 tags = _tags_to_dict(mapping.get("Tags", []))
295 if _tags_are_project_owned(tags, project_name) or _arn_is_project_owned(
296 arn, project_name
297 ):
298 resources[arn] = {"arn": arn, "tags": tags}
299 return [resources[arn] for arn in sorted(resources)]
302def _ec2_items(client: Any, operation: str, response_key: str) -> list[dict[str, Any]]:
303 items: list[dict[str, Any]] = []
304 for page in client.get_paginator(operation).paginate():
305 items.extend(page.get(response_key, []))
306 return items
309_CLUSTER_TAG_PREFIX = "kubernetes.io/cluster/"
311#: Volume states that mean EC2 is already removing the volume, so it is not
312#: residual. Everything else -- ``available``, ``in-use``, ``creating``,
313#: ``error`` -- counts against the all-zero teardown gate.
314_VOLUME_TERMINAL_STATES = frozenset({"deleting", "deleted"})
317def _list_cluster_volumes(session: Any, region: str, project_name: str) -> list[str]:
318 """Return EBS volumes tagged for a project cluster by the EKS CSI driver.
320 These are the only project resources whose sole ownership marker is a
321 Kubernetes tag: the CSI driver writes ``kubernetes.io/cluster/<cluster>``
322 and never the CloudFormation or ``gco:project`` tags every other scanner
323 matches on. Deleting a cluster does not delete the PersistentVolumes its
324 driver provisioned, so without this scanner a teardown could strand
325 billable volumes and still pass the all-zero final-inventory gate.
327 Ownership is decided from the cluster name inside the tag key, which is the
328 regional stack name, so a volume belonging to another project's cluster in
329 the same account is never claimed.
331 Volumes EC2 is already removing (``deleting``/``deleted``) are not counted.
332 The cluster tag is not exclusive to CSI-provisioned PersistentVolumes: EKS
333 Auto Mode writes it onto node root volumes too, which live and die with
334 their instances. Those pass through ``deleting`` while a cluster tears
335 down, and a run that observed one mid-transition would fail the all-zero
336 gate for a volume that was already on its way out. Every other state --
337 ``available``, ``in-use``, ``creating``, ``error`` -- still counts, because
338 after teardown nothing should be holding a cluster-tagged volume at all.
340 Enumerates unfiltered and matches client-side, like the other EC2 scanners
341 here. A server-side ``tag-key`` filter would need wildcard semantics to
342 match the cluster-name suffix, and depending on that in the gate that
343 authorizes calling a teardown clean is not worth the round-trip saved.
344 """
345 client = session.client("ec2", region_name=region)
346 volume_ids: set[str] = set()
347 for volume in _ec2_items(client, "describe_volumes", "Volumes"):
348 volume_id = str(volume.get("VolumeId") or "")
349 if not volume_id:
350 raise RuntimeError(f"EC2 returned a volume without an ID in {region}")
351 if str(volume.get("State") or "") in _VOLUME_TERMINAL_STATES:
352 continue
353 for key in _tags_to_dict(volume.get("Tags", [])):
354 if not key.startswith(_CLUSTER_TAG_PREFIX):
355 continue
356 cluster_name = key[len(_CLUSTER_TAG_PREFIX) :]
357 if cluster_name and _project_owned_name(cluster_name, project_name):
358 volume_ids.add(volume_id)
359 break
360 return sorted(volume_ids)
363def _list_project_ec2_networking(
364 session: Any,
365 region: str,
366 project_name: str,
367 project_instance_ids: Iterable[str],
368) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
369 """Return project-owned networking and unfiltered live ID authority separately."""
370 client = session.client("ec2", region_name=region)
372 vpc_ids: set[str] = set()
373 all_vpc_ids: set[str] = set()
374 for vpc in _ec2_items(client, "describe_vpcs", "Vpcs"):
375 vpc_id = str(vpc.get("VpcId") or "")
376 if not vpc_id:
377 raise RuntimeError(f"EC2 returned a VPC without an ID in {region}")
378 all_vpc_ids.add(vpc_id)
379 if _ec2_resource_is_project_owned(vpc, project_name):
380 vpc_ids.add(vpc_id)
382 subnet_ids: set[str] = set()
383 all_subnet_ids: set[str] = set()
384 for subnet in _ec2_items(client, "describe_subnets", "Subnets"):
385 subnet_id = str(subnet.get("SubnetId") or "")
386 if not subnet_id:
387 raise RuntimeError(f"EC2 returned a subnet without an ID in {region}")
388 all_subnet_ids.add(subnet_id)
389 if (
390 _ec2_resource_is_project_owned(subnet, project_name)
391 or str(subnet.get("VpcId") or "") in vpc_ids
392 ):
393 subnet_ids.add(subnet_id)
395 nat_gateway_ids: set[str] = set()
396 all_nat_gateway_ids: set[str] = set()
397 for nat_gateway in _ec2_items(client, "describe_nat_gateways", "NatGateways"):
398 nat_gateway_id = str(nat_gateway.get("NatGatewayId") or "")
399 if not nat_gateway_id:
400 raise RuntimeError(f"EC2 returned a NAT gateway without an ID in {region}")
401 if str(nat_gateway.get("State") or "") == "deleted":
402 continue
403 all_nat_gateway_ids.add(nat_gateway_id)
404 if (
405 _ec2_resource_is_project_owned(nat_gateway, project_name)
406 or str(nat_gateway.get("VpcId") or "") in vpc_ids
407 or str(nat_gateway.get("SubnetId") or "") in subnet_ids
408 ):
409 nat_gateway_ids.add(nat_gateway_id)
411 security_group_ids: set[str] = set()
412 all_security_group_ids: set[str] = set()
413 for security_group in _ec2_items(client, "describe_security_groups", "SecurityGroups"):
414 group_id = str(security_group.get("GroupId") or "")
415 if not group_id:
416 raise RuntimeError(f"EC2 returned a security group without an ID in {region}")
417 all_security_group_ids.add(group_id)
418 if (
419 _ec2_resource_is_project_owned(security_group, project_name)
420 or _project_owned_name(str(security_group.get("GroupName") or ""), project_name)
421 or str(security_group.get("VpcId") or "") in vpc_ids
422 ):
423 security_group_ids.add(group_id)
425 instance_ids = set(project_instance_ids)
426 network_interface_ids: set[str] = set()
427 all_network_interface_ids: set[str] = set()
428 for interface in _ec2_items(
429 client,
430 "describe_network_interfaces",
431 "NetworkInterfaces",
432 ):
433 interface_id = str(interface.get("NetworkInterfaceId") or "")
434 if not interface_id:
435 raise RuntimeError(f"EC2 returned a network interface without an ID in {region}")
436 all_network_interface_ids.add(interface_id)
437 group_ids = {str(group.get("GroupId") or "") for group in interface.get("Groups", [])}
438 attachment_instance_id = str((interface.get("Attachment") or {}).get("InstanceId") or "")
439 if (
440 _ec2_resource_is_project_owned(interface, project_name)
441 or str(interface.get("VpcId") or "") in vpc_ids
442 or str(interface.get("SubnetId") or "") in subnet_ids
443 or bool(group_ids & security_group_ids)
444 or attachment_instance_id in instance_ids
445 ):
446 network_interface_ids.add(interface_id)
448 flow_log_ids: set[str] = set()
449 all_flow_log_ids: set[str] = set()
450 project_network_resource_ids = vpc_ids | subnet_ids | network_interface_ids | instance_ids
451 for flow_log in _ec2_items(client, "describe_flow_logs", "FlowLogs"):
452 flow_log_id = str(flow_log.get("FlowLogId") or "")
453 if not flow_log_id:
454 raise RuntimeError(f"EC2 returned a flow log without an ID in {region}")
455 all_flow_log_ids.add(flow_log_id)
456 if (
457 _ec2_resource_is_project_owned(flow_log, project_name)
458 or str(flow_log.get("ResourceId") or "") in project_network_resource_ids
459 ):
460 flow_log_ids.add(flow_log_id)
462 elastic_ip_ids: set[str] = set()
463 all_elastic_ip_ids: set[str] = set()
464 for address in client.describe_addresses().get("Addresses", []):
465 identifier = str(address.get("AllocationId") or address.get("PublicIp") or "")
466 if not identifier:
467 raise RuntimeError(f"EC2 returned an Elastic IP without an identity in {region}")
468 all_elastic_ip_ids.add(identifier)
469 if (
470 _ec2_resource_is_project_owned(address, project_name)
471 or str(address.get("NetworkInterfaceId") or "") in network_interface_ids
472 or str(address.get("InstanceId") or "") in instance_ids
473 ):
474 elastic_ip_ids.add(identifier)
476 project_resources = {
477 "vpcs": sorted(vpc_ids),
478 "subnets": sorted(subnet_ids),
479 "nat_gateways": sorted(nat_gateway_ids),
480 "flow_logs": sorted(flow_log_ids),
481 "network_interfaces": sorted(network_interface_ids),
482 "security_groups": sorted(security_group_ids),
483 "elastic_ips": sorted(elastic_ip_ids),
484 }
485 authoritative_resources = {
486 "vpcs": sorted(all_vpc_ids),
487 "subnets": sorted(all_subnet_ids),
488 "nat_gateways": sorted(all_nat_gateway_ids),
489 "flow_logs": sorted(all_flow_log_ids),
490 "network_interfaces": sorted(all_network_interface_ids),
491 "security_groups": sorted(all_security_group_ids),
492 "elastic_ips": sorted(all_elastic_ip_ids),
493 }
494 return project_resources, authoritative_resources
497def _list_lambda_functions(session: Any, region: str, project_name: str) -> list[str]:
498 client = session.client("lambda", region_name=region)
499 functions: set[str] = set()
500 for page in client.get_paginator("list_functions").paginate():
501 for function in page.get("Functions", []):
502 name = str(function.get("FunctionName") or "")
503 arn = str(function.get("FunctionArn") or "")
504 if not name or not arn:
505 raise RuntimeError(f"Lambda returned a function without identity in {region}")
506 tags = _mapping_tags(client.list_tags(Resource=arn).get("Tags"))
507 if _project_owned_name(name, project_name) or _tags_are_project_owned(
508 tags, project_name
509 ):
510 functions.add(arn)
511 return sorted(functions)
514def _list_api_gateway_v1_apis(session: Any, region: str, project_name: str) -> list[str]:
515 client = session.client("apigateway", region_name=region)
516 apis: set[str] = set()
517 for page in client.get_paginator("get_rest_apis").paginate():
518 for api in page.get("items", []):
519 api_id = str(api.get("id") or "")
520 name = str(api.get("name") or "")
521 tags = _mapping_tags(api.get("tags"))
522 if _project_owned_name(name, project_name) or _tags_are_project_owned(
523 tags, project_name
524 ):
525 if not api_id:
526 raise RuntimeError(f"API Gateway v1 returned an API without an ID in {region}")
527 apis.add(api_id)
528 return sorted(apis)
531def _list_api_gateway_v2_apis(session: Any, region: str, project_name: str) -> list[str]:
532 client = session.client("apigatewayv2", region_name=region)
533 apis: set[str] = set()
534 for page in client.get_paginator("get_apis").paginate():
535 for api in page.get("Items", []):
536 api_id = str(api.get("ApiId") or "")
537 name = str(api.get("Name") or "")
538 tags = _mapping_tags(api.get("Tags"))
539 if _project_owned_name(name, project_name) or _tags_are_project_owned(
540 tags, project_name
541 ):
542 if not api_id:
543 raise RuntimeError(f"API Gateway v2 returned an API without an ID in {region}")
544 apis.add(api_id)
545 return sorted(apis)
548def _list_cloudwatch_log_groups(
549 session: Any,
550 region: str,
551 project_name: str,
552) -> list[str]:
553 client = session.client("logs", region_name=region)
554 log_groups: set[str] = set()
555 for page in client.get_paginator("describe_log_groups").paginate():
556 for log_group in page.get("logGroups", []):
557 name = str(log_group.get("logGroupName") or "")
558 arn = str(log_group.get("logGroupArn") or log_group.get("arn") or "").removesuffix(":*")
559 if not name or not arn:
560 raise RuntimeError(
561 f"CloudWatch Logs returned a log group without identity in {region}"
562 )
563 tags = _mapping_tags(client.list_tags_for_resource(resourceArn=arn).get("tags"))
564 if _name_or_path_is_project_owned(name, project_name) or _tags_are_project_owned(
565 tags, project_name
566 ):
567 log_groups.add(name)
568 return sorted(log_groups)
571def _list_secrets(session: Any, region: str, project_name: str) -> list[str]:
572 client = session.client("secretsmanager", region_name=region)
573 secrets: set[str] = set()
574 for page in client.get_paginator("list_secrets").paginate(IncludePlannedDeletion=True):
575 for secret in page.get("SecretList", []):
576 name = str(secret.get("Name") or "")
577 arn = str(secret.get("ARN") or "")
578 tags = _tags_to_dict(secret.get("Tags", []))
579 if _project_owned_name(name, project_name) or _tags_are_project_owned(
580 tags, project_name
581 ):
582 if not arn:
583 raise RuntimeError(
584 f"Secrets Manager returned a project-owned secret without an ARN in {region}"
585 )
586 secrets.add(arn)
587 return sorted(secrets)
590def _list_s3_bucket_tags(client: Any, bucket_name: str) -> dict[str, str]:
591 try:
592 response = client.get_bucket_tagging(Bucket=bucket_name)
593 except ClientError as exc:
594 if exc.response.get("Error", {}).get("Code") in {"NoSuchTagSet", "NoSuchTagSetError"}:
595 return {}
596 raise
597 return _tags_to_dict(response.get("TagSet", []))
600def _list_project_s3_buckets(session: Any, seed_region: str, project_name: str) -> list[str]:
601 client = session.client("s3", region_name=seed_region)
602 buckets: set[str] = set()
603 for bucket in client.list_buckets().get("Buckets", []):
604 name = str(bucket.get("Name") or "")
605 if not name:
606 raise RuntimeError("S3 returned a bucket without a name")
607 tags = _list_s3_bucket_tags(client, name)
608 if _project_owned_name(name, project_name) or _tags_are_project_owned(tags, project_name):
609 buckets.add(name)
610 return sorted(buckets)
613def _list_iam_tags(
614 client: Any,
615 operation: str,
616 identifier_name: str,
617 identifier: str,
618) -> dict[str, str]:
619 tags: dict[str, str] = {}
620 marker: str | None = None
621 while True:
622 kwargs: dict[str, Any] = {identifier_name: identifier}
623 if marker:
624 kwargs["Marker"] = marker
625 response = getattr(client, operation)(**kwargs)
626 tags.update(_tags_to_dict(response.get("Tags", [])))
627 if not response.get("IsTruncated"):
628 return tags
629 marker = str(response.get("Marker") or "")
630 if not marker:
631 raise RuntimeError(f"IAM {operation} truncated its response without a Marker")
634def _list_project_iam_resources(
635 session: Any,
636 seed_region: str,
637 project_name: str,
638) -> dict[str, list[str]]:
639 client = session.client("iam", region_name=seed_region)
640 resources: dict[str, set[str]] = {
641 "iam_roles": set(),
642 "iam_policies": set(),
643 "iam_instance_profiles": set(),
644 "iam_users": set(),
645 "iam_groups": set(),
646 }
648 for page in client.get_paginator("list_roles").paginate():
649 for role in page.get("Roles", []):
650 name = str(role.get("RoleName") or "")
651 arn = str(role.get("Arn") or "")
652 if not name or not arn:
653 raise RuntimeError("IAM returned a role without identity")
654 tags = _list_iam_tags(client, "list_role_tags", "RoleName", name)
655 if _iam_resource_is_project_owned(
656 name, str(role.get("Path") or ""), tags, project_name
657 ):
658 resources["iam_roles"].add(arn)
660 for page in client.get_paginator("list_policies").paginate(Scope="Local"):
661 for policy in page.get("Policies", []):
662 name = str(policy.get("PolicyName") or "")
663 arn = str(policy.get("Arn") or "")
664 if not name or not arn:
665 raise RuntimeError("IAM returned a customer-managed policy without identity")
666 tags = _list_iam_tags(client, "list_policy_tags", "PolicyArn", arn)
667 if _iam_resource_is_project_owned(
668 name, str(policy.get("Path") or ""), tags, project_name
669 ):
670 resources["iam_policies"].add(arn)
672 for page in client.get_paginator("list_instance_profiles").paginate():
673 for profile in page.get("InstanceProfiles", []):
674 name = str(profile.get("InstanceProfileName") or "")
675 arn = str(profile.get("Arn") or "")
676 if not name or not arn:
677 raise RuntimeError("IAM returned an instance profile without identity")
678 tags = _list_iam_tags(
679 client,
680 "list_instance_profile_tags",
681 "InstanceProfileName",
682 name,
683 )
684 if _iam_resource_is_project_owned(
685 name, str(profile.get("Path") or ""), tags, project_name
686 ):
687 resources["iam_instance_profiles"].add(arn)
689 for page in client.get_paginator("list_users").paginate():
690 for user in page.get("Users", []):
691 name = str(user.get("UserName") or "")
692 arn = str(user.get("Arn") or "")
693 if not name or not arn:
694 raise RuntimeError("IAM returned a user without identity")
695 tags = _list_iam_tags(client, "list_user_tags", "UserName", name)
696 if _iam_resource_is_project_owned(
697 name, str(user.get("Path") or ""), tags, project_name
698 ):
699 resources["iam_users"].add(arn)
701 for page in client.get_paginator("list_groups").paginate():
702 for group in page.get("Groups", []):
703 name = str(group.get("GroupName") or "")
704 arn = str(group.get("Arn") or "")
705 if not name or not arn:
706 raise RuntimeError("IAM returned a group without identity")
707 if _project_owned_name(name, project_name) or _name_or_path_is_project_owned(
708 str(group.get("Path") or ""), project_name
709 ):
710 resources["iam_groups"].add(arn)
712 return {key: sorted(values) for key, values in resources.items()}
715def _backup_tags(client: Any, arn: str) -> dict[str, str]:
716 return _mapping_tags(client.list_tags(ResourceArn=arn).get("Tags"))
719def _list_project_backup_resources(
720 session: Any,
721 region: str,
722 project_name: str,
723) -> dict[str, list[str]]:
724 client = session.client("backup", region_name=region)
725 resources: dict[str, set[str]] = {
726 "backup_vaults": set(),
727 "backup_plans": set(),
728 "backup_selections": set(),
729 "backup_recovery_points": set(),
730 }
732 vaults: list[dict[str, Any]] = []
733 for page in client.get_paginator("list_backup_vaults").paginate():
734 vaults.extend(page.get("BackupVaultList", []))
735 owned_vault_names: set[str] = set()
736 for vault in vaults:
737 name = str(vault.get("BackupVaultName") or "")
738 arn = str(vault.get("BackupVaultArn") or "")
739 if not name or not arn:
740 raise RuntimeError(f"AWS Backup returned a vault without identity in {region}")
741 tags = _backup_tags(client, arn)
742 if _project_owned_name(name, project_name) or _tags_are_project_owned(tags, project_name):
743 owned_vault_names.add(name)
744 resources["backup_vaults"].add(arn)
746 for vault in vaults:
747 vault_name = str(vault["BackupVaultName"])
748 for page in client.get_paginator("list_recovery_points_by_backup_vault").paginate(
749 BackupVaultName=vault_name
750 ):
751 for recovery_point in page.get("RecoveryPoints", []):
752 arn = str(recovery_point.get("RecoveryPointArn") or "")
753 if not arn:
754 raise RuntimeError(
755 f"AWS Backup returned a recovery point without an ARN in {region}"
756 )
757 tags = _backup_tags(client, arn)
758 resource_name = str(recovery_point.get("ResourceName") or "")
759 resource_arn = str(recovery_point.get("ResourceArn") or "")
760 if (
761 vault_name in owned_vault_names
762 or _name_or_path_is_project_owned(resource_name, project_name)
763 or _arn_is_project_owned(resource_arn, project_name)
764 or _tags_are_project_owned(tags, project_name)
765 ):
766 resources["backup_recovery_points"].add(arn)
768 plans: list[dict[str, Any]] = []
769 for page in client.get_paginator("list_backup_plans").paginate():
770 plans.extend(page.get("BackupPlansList", []))
771 for plan in plans:
772 plan_id = str(plan.get("BackupPlanId") or "")
773 name = str(plan.get("BackupPlanName") or "")
774 arn = str(plan.get("BackupPlanArn") or "")
775 if not plan_id or not name or not arn:
776 raise RuntimeError(f"AWS Backup returned a plan without identity in {region}")
777 tags = _backup_tags(client, arn)
778 owned_plan = _project_owned_name(name, project_name) or _tags_are_project_owned(
779 tags, project_name
780 )
781 if owned_plan:
782 resources["backup_plans"].add(arn)
783 for page in client.get_paginator("list_backup_selections").paginate(BackupPlanId=plan_id):
784 for selection in page.get("BackupSelectionsList", []):
785 selection_id = str(selection.get("SelectionId") or "")
786 selection_name = str(selection.get("SelectionName") or "")
787 if not selection_id:
788 raise RuntimeError(f"AWS Backup returned a selection without an ID in {region}")
789 if owned_plan or _project_owned_name(selection_name, project_name):
790 resources["backup_selections"].add(f"{plan_id}:{selection_id}")
792 return {key: sorted(values) for key, values in resources.items()}