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

1"""preflight: verify exact git, account, configuration, and ownership identity.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import shutil 

7from pathlib import Path 

8from typing import Any 

9 

10from cli.stacks import check_asset_sources_readable 

11 

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) 

35 

36_GIB = float(1024**3) 

37 

38 

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 

45 

46 

47def _check_free_disk(settings: RunSettings) -> dict[str, float]: 

48 """Refuse to deploy from a host that cannot absorb this run's disk usage. 

49 

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 

81 

82 

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}") 

89 

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 ) 

95 

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 ) 

106 

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 ) 

119 

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) 

123 

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) 

129 

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 ) 

137 

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 ) 

155 

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 ) 

170 

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 ) 

186 

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 } 

200 

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") 

209 

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 ) 

220 

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 ) 

226 

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) 

244 

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 }