Coverage for scripts / live_release_validation / inventory / stacks.py: 100.00%
84 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"""CloudFormation stack discovery, description, and fingerprinting."""
3from __future__ import annotations
5import hashlib
6import json
7from collections.abc import Iterable
8from typing import Any
10from botocore.exceptions import ClientError
12from ._shared import (
13 _normalize_json_text,
14 _project_owned_name,
15 _tags_to_dict,
16)
19def discover_enabled_regions(session: Any, seed_region: str) -> list[str]:
20 """Return every enabled Region in the caller's partition that supports CFN."""
21 partition = session.get_partition_for_region(seed_region)
22 if not partition:
23 raise RuntimeError(f"Could not resolve AWS partition for {seed_region}")
24 available = set(session.get_available_regions("cloudformation", partition_name=partition))
25 ec2 = session.client("ec2", region_name=seed_region)
26 response = ec2.describe_regions(AllRegions=False)
27 enabled = {
28 str(item["RegionName"])
29 for item in response.get("Regions", [])
30 if item.get("RegionName")
31 and item.get("OptInStatus") in {None, "opt-in-not-required", "opted-in"}
32 }
33 regions = sorted(enabled & available)
34 if not regions:
35 raise RuntimeError(
36 f"No enabled CloudFormation Regions were discovered in partition {partition}"
37 )
38 return regions
41def list_active_stacks(session: Any, region: str) -> list[dict[str, str]]:
42 """List every non-deleted CloudFormation stack in one Region."""
43 client = session.client("cloudformation", region_name=region)
44 stacks: list[dict[str, str]] = []
45 for page in client.get_paginator("list_stacks").paginate():
46 for summary in page.get("StackSummaries", []):
47 status = str(summary.get("StackStatus") or "")
48 if status == "DELETE_COMPLETE":
49 continue
50 name = str(summary.get("StackName") or "")
51 stack_id = str(summary.get("StackId") or "")
52 if name:
53 stacks.append({"name": name, "stack_id": stack_id, "status": status})
54 return sorted(stacks, key=lambda item: (item["name"], item["stack_id"]))
57def describe_stack(session: Any, region: str, stack_name: str) -> dict[str, Any] | None:
58 """Describe one stack; return None only for authoritative nonexistence."""
59 client = session.client("cloudformation", region_name=region)
60 try:
61 response = client.describe_stacks(StackName=stack_name)
62 except ClientError as exc:
63 error = exc.response.get("Error", {})
64 if (
65 error.get("Code") == "ValidationError"
66 and "does not exist" in str(error.get("Message", "")).lower()
67 ):
68 return None
69 raise
70 stacks = response.get("Stacks", [])
71 if not stacks:
72 return None
73 stack = stacks[0]
74 return {
75 "name": str(stack.get("StackName") or stack_name),
76 "stack_id": str(stack.get("StackId") or ""),
77 "status": str(stack.get("StackStatus") or ""),
78 "parameters": sorted(
79 (
80 {
81 "key": str(item["ParameterKey"]),
82 "value": str(item.get("ParameterValue") or ""),
83 "resolved_value": str(item.get("ResolvedValue") or ""),
84 }
85 for item in stack.get("Parameters", [])
86 if item.get("ParameterKey") is not None
87 ),
88 key=lambda item: item["key"],
89 ),
90 "outputs": {
91 str(item["OutputKey"]): str(item["OutputValue"])
92 for item in stack.get("Outputs", [])
93 if item.get("OutputKey") is not None and item.get("OutputValue") is not None
94 },
95 "tags": _tags_to_dict(stack.get("Tags", [])),
96 "termination_protection": bool(stack.get("EnableTerminationProtection", False)),
97 }
100def _list_stack_resource_identities(client: Any, stack_id: str) -> list[dict[str, str]]:
101 """Return every stack resource's exact physical identity in stable order."""
102 identities: list[dict[str, str]] = []
103 logical_ids: set[str] = set()
104 for page in client.get_paginator("list_stack_resources").paginate(StackName=stack_id):
105 for resource in page.get("StackResourceSummaries", []):
106 logical_id = str(resource.get("LogicalResourceId") or "")
107 resource_type = str(resource.get("ResourceType") or "")
108 physical_id = str(resource.get("PhysicalResourceId") or "")
109 if not logical_id or not resource_type or not physical_id:
110 raise RuntimeError(
111 "CloudFormation omitted a protected stack resource identity for "
112 f"{stack_id}: {json.dumps(resource, sort_keys=True, default=str)}"
113 )
114 if logical_id in logical_ids:
115 raise RuntimeError(
116 f"CloudFormation duplicated protected stack resource {stack_id}:{logical_id}"
117 )
118 logical_ids.add(logical_id)
119 identities.append(
120 {
121 "logical_id": logical_id,
122 "resource_type": resource_type,
123 "physical_id": physical_id,
124 }
125 )
126 return sorted(
127 identities,
128 key=lambda item: (item["logical_id"], item["resource_type"], item["physical_id"]),
129 )
132def describe_stack_fingerprint(
133 session: Any,
134 region: str,
135 stack_name: str,
136) -> dict[str, Any] | None:
137 """Return template, policy, and exact physical-resource fingerprints."""
138 stack = describe_stack(session, region, stack_name)
139 if stack is None:
140 return None
141 client = session.client("cloudformation", region_name=region)
142 template = _normalize_json_text(
143 client.get_template(
144 StackName=stack["stack_id"],
145 TemplateStage="Original",
146 ).get("TemplateBody", "")
147 )
148 if isinstance(template, (dict, list)):
149 template_bytes = json.dumps(
150 template,
151 sort_keys=True,
152 separators=(",", ":"),
153 ).encode("utf-8")
154 else:
155 template_bytes = str(template).encode("utf-8")
157 try:
158 stack_policy = _normalize_json_text(
159 client.get_stack_policy(StackName=stack["stack_id"]).get("StackPolicyBody")
160 )
161 except ClientError as exc:
162 error = exc.response.get("Error", {})
163 message = str(error.get("Message", "")).lower()
164 if error.get("Code") == "ValidationError" and "stack policy" in message:
165 stack_policy = None
166 else:
167 raise
168 return {
169 **stack,
170 "template_sha256": hashlib.sha256(template_bytes).hexdigest(),
171 "stack_policy": stack_policy,
172 "physical_resources": _list_stack_resource_identities(client, stack["stack_id"]),
173 }
176def collect_stack_inventory(
177 session: Any, regions: Iterable[str]
178) -> dict[str, list[dict[str, str]]]:
179 """Collect active stacks across Regions with deterministic ordering."""
180 return {region: list_active_stacks(session, region) for region in sorted(set(regions))}
183def collect_project_stacks(
184 session: Any,
185 regions: Iterable[str],
186 project_name: str,
187) -> dict[str, list[dict[str, str]]]:
188 """Collect all project-prefixed stacks, including unexpected/orphan stacks."""
189 inventory = collect_stack_inventory(session, regions)
190 return {
191 region: [stack for stack in stacks if _project_owned_name(stack["name"], project_name)]
192 for region, stacks in inventory.items()
193 if any(_project_owned_name(stack["name"], project_name) for stack in stacks)
194 }