Coverage for scripts / live_release_validation / actions / preflight.py: 100.00%
103 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""preflight: verify exact git, account, configuration, and ownership identity."""
3from __future__ import annotations
5import json
6import shutil
7from pathlib import Path
8from typing import Any
10from cli.stacks import check_asset_sources_readable
12from ..constants import (
13 _CLUSTER_TUNNEL_ACTIONS,
14 _HEALTHY_STACK_STATUSES,
15)
16from ..context import (
17 _direct_regional_access_enabled,
18 _resolve_branch,
19 _run_git,
20 _topology_regions,
21 _validate_profile,
22)
23from ..inventory import (
24 collect_project_stacks,
25 describe_stack,
26 discover_enabled_regions,
27)
28from ..models import RunContext, RunSettings
29from ..ownership.ecr import (
30 _expected_ecr_images,
31)
32from ..ownership.stacks import (
33 _reconcile_stack_ownership,
34)
36_GIB = float(1024**3)
39def _nearest_existing(path: Path) -> Path:
40 """Walk up until a path that exists (the report dir may not yet)."""
41 candidate = path
42 while not candidate.exists() and candidate.parent != candidate:
43 candidate = candidate.parent
44 return candidate
47def _check_free_disk(settings: RunSettings) -> dict[str, float]:
48 """Refuse to deploy from a host that cannot absorb this run's disk usage.
50 ``deploy`` builds every service image locally before publishing, the
51 checkpoint grows to tens of megabytes, and the container runtime's image
52 store lives under the home volume on macOS. A host that fills up mid-run
53 fails the image build, then fails to persist the checkpoint, and that
54 second failure aborts the guaranteed cleanup too — leaving stacks behind
55 that only a resume can reclaim. Measure the floor before anything is
56 created. Every probed location is reported so the operator sees where
57 the space went; the check fails on the first location below the floor.
58 """
59 floor_gib = float(settings.min_free_disk_gib)
60 probes = {
61 "repo_root": Path(settings.repo_root),
62 "report_dir": Path(settings.report_dir),
63 "home": Path.home(),
64 }
65 observed: dict[str, float] = {}
66 short: list[str] = []
67 for label, path in probes.items():
68 free_gib = shutil.disk_usage(_nearest_existing(path)).free / _GIB
69 observed[label] = round(free_gib, 2)
70 if free_gib < floor_gib:
71 short.append(f"{label} ({path}) has {free_gib:.1f} GiB free")
72 if short:
73 raise RuntimeError(
74 f"Free disk space is below the {floor_gib:g} GiB floor deploy needs for "
75 "container image builds and checkpoint persistence: "
76 + "; ".join(short)
77 + ". Reclaim space (stale container images from earlier runs are the usual "
78 "culprit) or lower --min-free-disk-gib, then rerun."
79 )
80 return observed
83def action_preflight(ctx: RunContext) -> dict[str, Any]:
84 """Validate exact git/AWS/config identity and prove project ownership."""
85 settings = ctx.settings
86 head = _run_git(settings.repo_root, "rev-parse", "HEAD")
87 if head != settings.expected_sha:
88 raise RuntimeError(f"HEAD {head} does not match expected SHA {settings.expected_sha}")
90 branch = _resolve_branch(settings.repo_root)
91 if branch != settings.expected_branch:
92 raise RuntimeError(
93 f"Current branch {branch!r} does not match expected branch {settings.expected_branch!r}"
94 )
96 dirty = _run_git(
97 settings.repo_root,
98 "status",
99 "--porcelain=v1",
100 "--untracked-files=all",
101 )
102 if dirty:
103 raise RuntimeError(
104 "Live validation requires a clean worktree; commit or remove these paths:\n" + dirty
105 )
107 selected = set(ctx.report.selected_actions)
108 session_manager_plugin = None
109 tunnel_actions = sorted(selected & _CLUSTER_TUNNEL_ACTIONS)
110 if tunnel_actions:
111 session_manager_plugin = shutil.which("session-manager-plugin")
112 if session_manager_plugin is None:
113 raise RuntimeError(
114 f"The {', '.join(tunnel_actions)} action(s) reach the private cluster "
115 "endpoint through an SSM tunnel and require the AWS Session Manager plugin "
116 "before deploy. Install session-manager-plugin and ensure it is on PATH, "
117 "then resume."
118 )
120 free_disk_gib: dict[str, float] | None = None
121 if "deploy" in selected and settings.min_free_disk_gib > 0:
122 free_disk_gib = _check_free_disk(settings)
124 # A checkout made under a restrictive umask deploys Lambda code that the
125 # Lambda runtime cannot read; refuse it before the stack listing below
126 # synthesizes the app, and long before anything is created.
127 if "deploy" in selected:
128 check_asset_sources_readable(settings.repo_root)
130 identity = ctx.session.client("sts", region_name=ctx.config.global_region).get_caller_identity()
131 account = str(identity.get("Account") or "")
132 if account != settings.expected_account:
133 raise RuntimeError(
134 f"AWS caller account {account or 'unknown'} does not match expected "
135 f"account {settings.expected_account}"
136 )
138 _validate_profile(ctx)
139 if "deploy" in selected and not settings.confirm_kms_key_deletion:
140 raise RuntimeError(
141 "Deployment creates retained EKS encryption keys. Pass "
142 "--confirm-kms-key-deletion to explicitly authorize scheduling only "
143 "this run's exact keys for deletion during cleanup."
144 )
145 direct_regional_access = _direct_regional_access_enabled(ctx)
146 if (
147 len(ctx.deployment_regions) > 1
148 and selected.intersection({"api", "sqs", "central-queue"})
149 and not direct_regional_access
150 ):
151 raise RuntimeError(
152 "Multi-Region Job actions require api_gateway.regional_api_enabled=true; "
153 "the global API cannot prove which same-named regional Job it observed"
154 )
156 enabled_regions = discover_enabled_regions(ctx.session, ctx.config.global_region)
157 target_stacks = ctx.stack_manager.list_stacks()
158 if not target_stacks:
159 raise RuntimeError("CDK returned no target stacks")
160 expected_ecr_images = _expected_ecr_images(ctx, target_stacks)
161 unexpected_names = [
162 name
163 for name in target_stacks
164 if not (name == ctx.config.project_name or name.startswith(f"{ctx.config.project_name}-"))
165 ]
166 if unexpected_names:
167 raise RuntimeError(
168 "Refusing to own non-project CDK stacks: " + ", ".join(sorted(unexpected_names))
169 )
171 target_stack_regions = {
172 stack_name: ctx.stack_manager._get_destroy_region(stack_name)
173 for stack_name in target_stacks
174 }
175 if any(not region for region in target_stack_regions.values()):
176 raise RuntimeError(
177 "Could not resolve target stack Regions: "
178 + json.dumps(target_stack_regions, sort_keys=True)
179 )
180 target_region_set = {str(region) for region in target_stack_regions.values()}
181 unavailable_targets = sorted(target_region_set - set(enabled_regions))
182 if unavailable_targets:
183 raise RuntimeError(
184 "Target Regions are not enabled for this account: " + ", ".join(unavailable_targets)
185 )
187 bootstrap_stacks: dict[str, Any] = {}
188 for region in sorted(target_region_set):
189 bootstrap = describe_stack(ctx.session, region, "CDKToolkit")
190 if bootstrap is None or bootstrap.get("status") not in _HEALTHY_STACK_STATUSES:
191 status = bootstrap.get("status") if bootstrap else "absent"
192 raise RuntimeError(
193 f"Region {region} must already contain a healthy CDKToolkit stack; found {status}. "
194 "Live validation never auto-bootstraps or mutates the protected baseline."
195 )
196 bootstrap_stacks[region] = {
197 "stack_id": bootstrap["stack_id"],
198 "status": bootstrap["status"],
199 }
201 previous_bootstrap = ctx.checkpoint.state.get("bootstrap_stacks")
202 if previous_bootstrap is not None and previous_bootstrap != bootstrap_stacks:
203 raise RuntimeError(
204 "Checkpointed CDKToolkit ARN/status changed; refusing bootstrap adoption"
205 )
206 previous_ecr_targets = ctx.checkpoint.state.get("expected_ecr_images")
207 if previous_ecr_targets is not None and previous_ecr_targets != expected_ecr_images:
208 raise RuntimeError("Cloud-assembly ECR image targets changed since checkpoint creation")
210 existing = collect_project_stacks(
211 ctx.session,
212 enabled_regions,
213 ctx.config.project_name,
214 )
215 if not ctx.checkpoint.deployment_attempted and existing:
216 raise RuntimeError(
217 "Fresh runs refuse pre-existing project stacks because ownership is unproven: "
218 + json.dumps(existing, sort_keys=True)
219 )
221 previous_targets = ctx.checkpoint.state.get("target_stack_regions")
222 if previous_targets is not None and previous_targets != target_stack_regions:
223 raise RuntimeError(
224 "CDK target stacks changed since the checkpoint was created; refusing resume"
225 )
227 ctx.checkpoint.state.update(
228 {
229 "account_arn": str(identity.get("Arn") or ""),
230 "enabled_regions": enabled_regions,
231 "target_stack_regions": target_stack_regions,
232 "topology_regions": list(_topology_regions(ctx)),
233 "bootstrap_stacks": bootstrap_stacks,
234 "expected_ecr_images": expected_ecr_images,
235 "direct_regional_access": direct_regional_access,
236 "preexisting_project_stacks": existing
237 if not ctx.checkpoint.deployment_attempted
238 else ctx.checkpoint.state.get("preexisting_project_stacks", {}),
239 }
240 )
241 ctx.persist()
242 if ctx.checkpoint.deployment_attempted:
243 _reconcile_stack_ownership(ctx)
245 return {
246 "account": account,
247 "caller_arn": identity.get("Arn"),
248 "sha": head,
249 "branch": branch,
250 "profile": settings.profile,
251 "deployment_regions": list(ctx.deployment_regions),
252 "topology_regions": list(_topology_regions(ctx)),
253 "enabled_regions": enabled_regions,
254 "target_stack_regions": target_stack_regions,
255 "bootstrap_stacks": bootstrap_stacks,
256 "expected_ecr_images": expected_ecr_images,
257 "direct_regional_access": direct_regional_access,
258 "session_manager_plugin": session_manager_plugin or "not-required",
259 "min_free_disk_gib": settings.min_free_disk_gib,
260 "free_disk_gib": free_disk_gib if free_disk_gib is not None else "not-required",
261 "asset_sources_readable": True if "deploy" in selected else "not-required",
262 "kms_key_deletion_confirmed": settings.confirm_kms_key_deletion,
263 "resume": settings.resume,
264 }