Coverage for cli/stacks.py: 81.50%

2539 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1""" 

2Stack management for GCO CLI. 

3 

4Provides commands for deploying, updating, and managing CDK stacks. 

5This is the largest CLI module (~1600 lines) because it orchestrates the 

6full deployment lifecycle including container runtime detection, CDK 

7bootstrapping, Lambda source synchronization, and parallel regional deploys. 

8 

9This module handles: 

10 - Container runtime detection (Docker, Finch, Podman) with automatic fallback 

11 - CDK bootstrap across all target regions (idempotent) 

12 - Lambda source synchronization (copies handler code + dependencies before synth) 

13 - CDK stack deployment with proper dependency ordering: 

14 1. Global stack (partition-wide state, plus Global Accelerator in `aws`) 

15 2. API Gateway stack (auth secret, Lambda proxy) 

16 3. Regional stacks in parallel (EKS, VPC, ALB per region) 

17 4. Monitoring stack (CloudWatch dashboards, alarms) 

18 - Parallel deployment of regional stacks via ThreadPoolExecutor 

19 - Stack destruction in reverse dependency order 

20 - FSx for Lustre enable/disable toggle 

21 - kubectl access configuration (EKS access entries + kubeconfig) 

22 

23Key Design Decisions: 

24 - Regional stacks deploy in parallel for speed; global/API/monitoring are sequential 

25 - Lambda build directories are synced before every deploy to avoid stale code 

26 - Container runtime is auto-detected; CDK_DOCKER env var overrides 

27 - All destructive operations require -y/--yes confirmation 

28 - Stack status is read from CloudFormation, not cached locally 

29 

30Environment Variables: 

31 CDK_DOCKER: Override container runtime (default: auto-detect Docker/Finch/Podman) 

32 AWS_REGION: Default region for single-region operations 

33""" 

34 

35from __future__ import annotations 

36 

37import errno 

38import hashlib 

39import importlib.util 

40import json 

41import logging 

42import math 

43import os 

44import shutil 

45import signal 

46import site 

47import stat 

48import subprocess 

49import sys 

50import tempfile 

51import time 

52import uuid 

53from collections.abc import Callable, Collection, Iterator, Mapping 

54from concurrent.futures import ThreadPoolExecutor, as_completed 

55from contextlib import ExitStack, contextmanager 

56from dataclasses import dataclass, field 

57from datetime import UTC, datetime 

58from functools import lru_cache 

59from pathlib import Path 

60from threading import Event, Lock, Thread, local 

61from typing import TYPE_CHECKING, Any, BinaryIO, TypedDict 

62 

63from botocore.exceptions import ClientError 

64 

65from gco.stacks.constants import ( 

66 known_cloudformation_regions, 

67 validated_deployment_partition, 

68 validated_regional_deployment_regions, 

69) 

70 

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

72# Generated at (UTC): 2026-07-18T01:03:40Z 

73# Flowchart(s) generated from this file: 

74# * ``StackManager.deploy_orchestrated`` -> ``diagrams/code_diagrams/cli/stacks.StackManager_deploy_orchestrated.html`` 

75# (PNG: ``diagrams/code_diagrams/cli/stacks.StackManager_deploy_orchestrated.png``) 

76# * ``StackManager.destroy_orchestrated`` -> ``diagrams/code_diagrams/cli/stacks.StackManager_destroy_orchestrated.html`` 

77# (PNG: ``diagrams/code_diagrams/cli/stacks.StackManager_destroy_orchestrated.png``) 

78# * ``StackManager._mirror_images_if_enabled`` -> ``diagrams/code_diagrams/cli/stacks.StackManager__mirror_images_if_enabled.html`` 

79# (PNG: ``diagrams/code_diagrams/cli/stacks.StackManager__mirror_images_if_enabled.png``) 

80# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

81# <pyflowchart-code-diagram> END 

82 

83 

84if TYPE_CHECKING: 

85 from .config import GCOConfig 

86 

87logger = logging.getLogger(__name__) 

88 

89# Python packages ``app.py`` imports at CDK synth time. They ship in the 

90# optional ``[cdk]`` extra (see pyproject.toml), NOT the base install, so a 

91# lightweight ``uvx`` / ``pip install`` of ``gco-cli`` that skips the extra 

92# cannot synthesize or deploy. ``StackManager._ensure_cdk_toolchain`` checks 

93# for these before invoking ``cdk`` so a missing toolchain is actionable. 

94_CDK_TOOLCHAIN_MODULES = ("aws_cdk", "cdk_nag") 

95_INFERENCE_STREAMING_PACKAGE_FILES = ("index.mjs", "package.json", "package-lock.json") 

96_KUBECTL_PACKAGE_INPUTS = ("handler.py", "requirements.txt", "manifests") 

97_LAMBDA_BUILD_MANIFEST = ".gco-build-manifest.json" 

98_LAMBDA_BUILD_MANIFEST_VERSION = 1 

99_LAMBDA_SOURCE_IGNORED_DIRECTORIES = frozenset({"__pycache__", ".mypy_cache", ".pytest_cache"}) 

100_LAMBDA_SOURCE_IGNORED_FILES = frozenset({".DS_Store"}) 

101_LAMBDA_SOURCE_COPY_IGNORE_PATTERNS = ( 

102 "__pycache__", 

103 ".mypy_cache", 

104 ".pytest_cache", 

105 ".DS_Store", 

106 "*.pyc", 

107 "*.pyo", 

108) 

109_ASSET_LOCK_RETRY_SECONDS = 0.05 

110# 15 minutes: comfortably above the longest legitimate hold (a cold publisher 

111# rebuild, minutes) while bounding the pathological one (an abandoned pytest 

112# session's session-long shared locks, indefinite). 

113_ASSET_LOCK_TIMEOUT_SECONDS_DEFAULT = 900.0 

114_CDK_ASSET_CONSUMER_MAX_ATTEMPTS = 3 

115_CLOUDFORMATION_DELETE_TIMEOUT_SECONDS = 7200.0 

116_CLOUDFORMATION_DELETE_POLL_SECONDS = 15.0 

117_CLOUDFORMATION_DELETE_HEARTBEAT_SECONDS = 60.0 

118_BOOTSTRAP_HEALTHY_STATUSES = frozenset({"CREATE_COMPLETE", "UPDATE_COMPLETE"}) 

119_LIVE_VALIDATION_PROVIDER_LOG_CONTEXT = "gco_live_validation_retain_provider_log_groups" 

120StackAuthorizationCallback = Callable[[str, str, str], None] 

121CleanupOutcomeCallback = Callable[[str, dict[str, Any]], None] 

122ChangeSetPreparedCallback = Callable[[str, str, str, str, str], None] 

123PreparedChangeSetAuthority = Mapping[str, Mapping[str, Mapping[str, str]]] 

124EcrRepositoryCreatedCallback = Callable[[str, Mapping[str, Any]], None] 

125 

126 

127class _StackOperationSafetyKwargs(TypedDict): 

128 """Type-preserving keyword bundle shared by strict deploy and destroy calls.""" 

129 

130 allow_bootstrap: bool 

131 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None 

132 expected_stack_ids: Mapping[str, str | None] | None 

133 prepared_change_sets: PreparedChangeSetAuthority | None 

134 authorize_stack: StackAuthorizationCallback | None 

135 strict_deployment_token: str | None 

136 on_change_set_prepared: ChangeSetPreparedCallback | None 

137 on_ecr_repository_created: EcrRepositoryCreatedCallback | None 

138 

139 

140@dataclass(frozen=True) 

141class _CdkAssetSpec: 

142 """One canonical generated asset consumed by the CDK application.""" 

143 

144 name: str 

145 source_directory: str 

146 build_directory: str 

147 source_inputs: tuple[str, ...] | None 

148 

149 def paths(self, project_root: Path) -> tuple[Path, Path]: 

150 lambda_dir = project_root / "lambda" 

151 return lambda_dir / self.source_directory, lambda_dir / self.build_directory 

152 

153 

154_KUBECTL_CDK_ASSET = _CdkAssetSpec( 

155 name="kubectl-applier-simple", 

156 source_directory="kubectl-applier-simple", 

157 build_directory="kubectl-applier-simple-build", 

158 source_inputs=_KUBECTL_PACKAGE_INPUTS, 

159) 

160_HELM_CDK_ASSET = _CdkAssetSpec( 

161 name="helm-installer", 

162 source_directory="helm-installer", 

163 build_directory="helm-installer-build", 

164 source_inputs=None, 

165) 

166_INFERENCE_STREAMING_CDK_ASSET = _CdkAssetSpec( 

167 name="inference-streaming-proxy", 

168 source_directory="inference-streaming-proxy", 

169 build_directory="inference-streaming-proxy-build", 

170 source_inputs=_INFERENCE_STREAMING_PACKAGE_FILES, 

171) 

172_CDK_ASSET_SPECS = ( 

173 _KUBECTL_CDK_ASSET, 

174 _HELM_CDK_ASSET, 

175 _INFERENCE_STREAMING_CDK_ASSET, 

176) 

177 

178 

179class _AssetThreadState(local): 

180 """Per-thread nesting state; each OS lock still spans the full process.""" 

181 

182 def __init__(self) -> None: 

183 self.held: dict[str, tuple[bool, int]] = {} 

184 self.active_consumers: dict[str, int] = {} 

185 

186 

187_asset_thread_state = _AssetThreadState() 

188 

189 

190def _asset_tree_paths(root: Path, source_inputs: tuple[str, ...] | None) -> Iterator[Path]: 

191 """Yield deterministic source or build-tree entries below ``root``.""" 

192 selected: set[Path] = set() 

193 if source_inputs is None: 

194 selected.update(root.rglob("*")) 

195 else: 

196 for relative_name in source_inputs: 

197 path = root / relative_name 

198 if not path.exists() and not path.is_symlink(): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 raise FileNotFoundError(path) 

200 selected.add(path) 

201 if path.is_dir() and not path.is_symlink(): 

202 selected.update(path.rglob("*")) 

203 yield from sorted(selected, key=lambda path: path.relative_to(root).as_posix()) 

204 

205 

206def _asset_tree_digest( 

207 root: Path, 

208 *, 

209 source_inputs: tuple[str, ...] | None = None, 

210) -> str | None: 

211 """Hash every deployable entry in a source selection or complete build tree. 

212 

213 The completion manifest and local cache files are excluded. Regular-file 

214 content, paths, modes, directory entries, and symlink targets are included 

215 so removing any installed transitive dependency invalidates the build. 

216 """ 

217 if not root.is_dir(): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 return None 

219 digest = hashlib.sha256() 

220 try: 

221 for path in _asset_tree_paths(root, source_inputs): 

222 relative = path.relative_to(root) 

223 if any(part in _LAMBDA_SOURCE_IGNORED_DIRECTORIES for part in relative.parts): 

224 continue 

225 if ( 

226 path.name in _LAMBDA_SOURCE_IGNORED_FILES 

227 or path.name == _LAMBDA_BUILD_MANIFEST 

228 or path.suffix in {".pyc", ".pyo"} 

229 ): 

230 continue 

231 

232 metadata = path.lstat() 

233 relative_bytes = relative.as_posix().encode("utf-8") 

234 digest.update(len(relative_bytes).to_bytes(8, "big")) 

235 digest.update(relative_bytes) 

236 digest.update(stat.S_IMODE(metadata.st_mode).to_bytes(4, "big")) 

237 

238 if path.is_symlink(): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 target = os.readlink(path).encode("utf-8") 

240 digest.update(b"L") 

241 digest.update(len(target).to_bytes(8, "big")) 

242 digest.update(target) 

243 elif path.is_dir(): 

244 digest.update(b"D") 

245 elif path.is_file(): 245 ↛ 253line 245 didn't jump to line 253 because the condition on line 245 was always true

246 file_digest = hashlib.sha256() 

247 with path.open("rb") as handle: 

248 for chunk in iter(lambda: handle.read(1024 * 1024), b""): 

249 file_digest.update(chunk) 

250 digest.update(b"F") 

251 digest.update(file_digest.digest()) 

252 else: 

253 return None 

254 except OSError, UnicodeError: 

255 return None 

256 return digest.hexdigest() 

257 

258 

259def _read_build_manifest(build_dir: Path) -> dict[str, Any] | None: 

260 try: 

261 value = json.loads((build_dir / _LAMBDA_BUILD_MANIFEST).read_text(encoding="utf-8")) 

262 except OSError, UnicodeError, json.JSONDecodeError: 

263 return None 

264 return value if isinstance(value, dict) else None 

265 

266 

267def _write_build_manifest(build_dir: Path, source_digest: str) -> None: 

268 """Write the completion marker only after the staged build is complete.""" 

269 build_digest = _asset_tree_digest(build_dir) 

270 if build_digest is None: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 raise RuntimeError(f"Unable to hash completed Lambda asset {build_dir.name}") 

272 manifest = { 

273 "schema_version": _LAMBDA_BUILD_MANIFEST_VERSION, 

274 "source_digest": source_digest, 

275 "build_digest": build_digest, 

276 } 

277 manifest_path = build_dir / _LAMBDA_BUILD_MANIFEST 

278 with manifest_path.open("x", encoding="utf-8") as handle: 

279 json.dump(manifest, handle, sort_keys=True, separators=(",", ":")) 

280 handle.write("\n") 

281 handle.flush() 

282 os.fsync(handle.fileno()) 

283 

284 

285def _asset_build_is_fresh_unlocked( 

286 source_dir: Path, 

287 build_dir: Path, 

288 *, 

289 source_inputs: tuple[str, ...] | None, 

290) -> bool: 

291 manifest = _read_build_manifest(build_dir) 

292 if manifest is None or manifest.get("schema_version") != _LAMBDA_BUILD_MANIFEST_VERSION: 

293 return False 

294 source_digest = _asset_tree_digest(source_dir, source_inputs=source_inputs) 

295 build_digest = _asset_tree_digest(build_dir) 

296 return ( 

297 source_digest is not None 

298 and build_digest is not None 

299 and manifest.get("source_digest") == source_digest 

300 and manifest.get("build_digest") == build_digest 

301 ) 

302 

303 

304def _thread_asset_locks() -> dict[str, tuple[bool, int]]: 

305 """Return locks held by the current thread for safe nested consumers.""" 

306 return _asset_thread_state.held 

307 

308 

309def _ensure_windows_lock_byte(lock_file: BinaryIO) -> None: 

310 """Ensure msvcrt has a real byte range to lock.""" 

311 lock_file.seek(0, os.SEEK_END) 

312 if lock_file.tell() == 0: 

313 lock_file.write(b"\0") 

314 lock_file.flush() 

315 lock_file.seek(0) 

316 

317 

318def _windows_lock_is_contended(exc: OSError) -> bool: 

319 return exc.errno in {errno.EACCES, errno.EAGAIN, errno.EDEADLK} or getattr( 

320 exc, 

321 "winerror", 

322 None, 

323 ) in {32, 33, 36} 

324 

325 

326def _asset_lock_timeout_seconds() -> float: 

327 """Bounded wait for a contended asset lock, env-tunable. 

328 

329 Contended acquisitions used to block forever with no diagnostics; a 

330 destroy was once observed frozen for 40 minutes inside ``flock`` because 

331 two abandoned pytest sessions still held their session-long shared locks. 

332 The bound turns that silence into a warning at contention time and an 

333 actionable error at the deadline. 

334 """ 

335 raw = os.environ.get("GCO_ASSET_LOCK_TIMEOUT_SECONDS", "") 

336 try: 

337 value = float(raw) 

338 except ValueError: 

339 return _ASSET_LOCK_TIMEOUT_SECONDS_DEFAULT 

340 if not math.isfinite(value) or value <= 0: 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 return _ASSET_LOCK_TIMEOUT_SECONDS_DEFAULT 

342 return value 

343 

344 

345def _warn_asset_lock_contended(lock_file: BinaryIO, *, exclusive: bool, timeout: float) -> None: 

346 lock_name = getattr(lock_file, "name", "<unknown>") 

347 mode = "exclusive" if exclusive else "shared" 

348 logger.warning( 

349 "Waiting up to %.0fs for the %s asset lock on %s — another process holds " 

350 "it (a pytest session holds shared locks for its whole run; a " 

351 "deploy/synth/destroy holds the exclusive lock while rebuilding). " 

352 "Find the holder with `lsof %s`; tune via GCO_ASSET_LOCK_TIMEOUT_SECONDS.", 

353 timeout, 

354 mode, 

355 lock_name, 

356 lock_name, 

357 ) 

358 

359 

360def _raise_asset_lock_timeout(lock_file: BinaryIO, *, timeout: float) -> None: 

361 lock_name = getattr(lock_file, "name", "<unknown>") 

362 raise TimeoutError( 

363 f"Timed out after {timeout:.0f}s waiting for the asset lock on {lock_name}. " 

364 "Another process still holds it — often an abandoned pytest session, which " 

365 f"keeps shared locks until it exits. Find it with `lsof {lock_name}`, stop " 

366 "it, and retry; raise GCO_ASSET_LOCK_TIMEOUT_SECONDS to wait longer." 

367 ) 

368 

369 

370def _acquire_asset_file_lock( 

371 lock_file: BinaryIO, 

372 *, 

373 exclusive: bool, 

374) -> None: 

375 """Acquire a platform-native interprocess lock, loudly and boundedly. 

376 

377 The first attempt is non-blocking. On contention a warning names the lock 

378 file and the likely holder class, then acquisition polls until the 

379 env-tunable deadline so a stuck holder produces an actionable error 

380 instead of an indefinite silent hang. 

381 """ 

382 if os.name == "nt": 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 import msvcrt 

384 

385 msvcrt_api: Any = msvcrt 

386 _ensure_windows_lock_byte(lock_file) 

387 warned = False 

388 deadline: float | None = None 

389 while True: 

390 lock_file.seek(0) 

391 try: 

392 # msvcrt exposes only exclusive byte-range locks. Serializing 

393 # Windows readers and writers preserves correctness while POSIX 

394 # keeps true shared-reader concurrency through flock below. 

395 msvcrt_api.locking(lock_file.fileno(), msvcrt_api.LK_NBLCK, 1) 

396 return 

397 except OSError as exc: 

398 if not _windows_lock_is_contended(exc): 

399 raise 

400 if not warned: 

401 timeout = _asset_lock_timeout_seconds() 

402 deadline = time.monotonic() + timeout 

403 _warn_asset_lock_contended(lock_file, exclusive=exclusive, timeout=timeout) 

404 warned = True 

405 assert deadline is not None 

406 if time.monotonic() >= deadline: 

407 _raise_asset_lock_timeout(lock_file, timeout=_asset_lock_timeout_seconds()) 

408 time.sleep(_ASSET_LOCK_RETRY_SECONDS) 

409 

410 import fcntl 

411 

412 operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH 

413 try: 

414 fcntl.flock(lock_file.fileno(), operation | fcntl.LOCK_NB) 

415 return 

416 except BlockingIOError: 

417 pass 

418 timeout = _asset_lock_timeout_seconds() 

419 deadline = time.monotonic() + timeout 

420 _warn_asset_lock_contended(lock_file, exclusive=exclusive, timeout=timeout) 

421 while True: 

422 try: 

423 fcntl.flock(lock_file.fileno(), operation | fcntl.LOCK_NB) 

424 return 

425 except BlockingIOError: 

426 if time.monotonic() >= deadline: 

427 _raise_asset_lock_timeout(lock_file, timeout=timeout) 

428 time.sleep(_ASSET_LOCK_RETRY_SECONDS) 

429 

430 

431def _release_asset_file_lock(lock_file: BinaryIO) -> None: 

432 """Release the matching platform-native interprocess lock.""" 

433 if os.name == "nt": 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 import msvcrt 

435 

436 msvcrt_api: Any = msvcrt 

437 lock_file.seek(0) 

438 msvcrt_api.locking(lock_file.fileno(), msvcrt_api.LK_UNLCK, 1) 

439 return 

440 

441 import fcntl 

442 

443 fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) 

444 

445 

446@contextmanager 

447def _lambda_asset_lock(build_dir: Path, *, exclusive: bool) -> Iterator[None]: 

448 """Serialize publishers and keep freshness reads off rename windows.""" 

449 build_dir.parent.mkdir(parents=True, exist_ok=True) 

450 lock_path = build_dir.with_name(f".{build_dir.name}.lock") 

451 lock_key = os.path.normcase(os.path.abspath(lock_path)) 

452 held = _thread_asset_locks() 

453 existing = held.get(lock_key) 

454 if existing is not None: 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true

455 held_exclusive, depth = existing 

456 if exclusive and not held_exclusive: 

457 raise RuntimeError(f"Cannot upgrade shared asset lock to exclusive: {lock_path}") 

458 held[lock_key] = (held_exclusive, depth + 1) 

459 try: 

460 yield 

461 finally: 

462 held[lock_key] = (held_exclusive, depth) 

463 return 

464 

465 with lock_path.open("a+b") as lock_file: 

466 _acquire_asset_file_lock(lock_file, exclusive=exclusive) 

467 held[lock_key] = (exclusive, 1) 

468 try: 

469 yield 

470 finally: 

471 held.pop(lock_key, None) 

472 _release_asset_file_lock(lock_file) 

473 

474 

475def _asset_build_is_fresh( 

476 source_dir: Path, 

477 build_dir: Path, 

478 *, 

479 source_inputs: tuple[str, ...] | None, 

480) -> bool: 

481 with _lambda_asset_lock(build_dir, exclusive=False): 

482 return _asset_build_is_fresh_unlocked( 

483 source_dir, 

484 build_dir, 

485 source_inputs=source_inputs, 

486 ) 

487 

488 

489def _remove_asset_tree(path: Path) -> None: 

490 if path.exists() or path.is_symlink(): 

491 _safe_rmtree(path) 

492 

493 

494def _recover_interrupted_asset_publish(build_dir: Path) -> None: 

495 """Restore a prior final tree and discard abandoned staging directories.""" 

496 staging_dirs = list(build_dir.parent.glob(f".{build_dir.name}.staging-*")) 

497 backup_dirs = list(build_dir.parent.glob(f".{build_dir.name}.backup-*")) 

498 

499 if not build_dir.exists() and backup_dirs: 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true

500 try: 

501 newest_backup = max(backup_dirs, key=lambda path: path.stat().st_mtime_ns) 

502 except OSError: 

503 newest_backup = backup_dirs[0] 

504 os.replace(newest_backup, build_dir) 

505 

506 for path in [*staging_dirs, *backup_dirs]: 506 ↛ 507line 506 didn't jump to line 507 because the loop on line 506 never started

507 _remove_asset_tree(path) 

508 

509 

510def _publish_staged_asset(staging_dir: Path, build_dir: Path) -> None: 

511 """Publish one complete staged tree with rollback to the previous final.""" 

512 backup_dir = build_dir.with_name(f".{build_dir.name}.backup-{uuid.uuid4().hex}") 

513 had_previous = build_dir.exists() 

514 if had_previous: 

515 os.replace(build_dir, backup_dir) 

516 try: 

517 os.replace(staging_dir, build_dir) 

518 except Exception: 

519 if had_previous and backup_dir.exists() and not build_dir.exists(): 

520 os.replace(backup_dir, build_dir) 

521 raise 

522 if backup_dir.exists(): 

523 _remove_asset_tree(backup_dir) 

524 

525 

526def _prepare_lambda_asset( 

527 source_dir: Path, 

528 build_dir: Path, 

529 *, 

530 source_inputs: tuple[str, ...] | None, 

531 display_name: str, 

532 builder: Callable[[Path], None], 

533) -> bool: 

534 """Build and atomically publish an asset when its completion proof is stale. 

535 

536 Freshness is checked under a *shared* lock first, so the common case — 

537 the asset is already source-current — never contends: concurrent pytest 

538 workers validate in parallel instead of serialising behind one writer, 

539 and a deploy/destroy against fresh assets never blocks on a pytest 

540 session's session-long shared locks. Only a genuinely stale asset 

541 escalates to the exclusive publisher lock, which re-checks freshness 

542 after acquisition (another publisher may have finished the same rebuild 

543 while this one waited). 

544 """ 

545 if _asset_build_is_fresh(source_dir, build_dir, source_inputs=source_inputs): 

546 return False 

547 with _lambda_asset_lock(build_dir, exclusive=True): 

548 _recover_interrupted_asset_publish(build_dir) 

549 source_digest = _asset_tree_digest(source_dir, source_inputs=source_inputs) 

550 if source_digest is None: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true

551 raise RuntimeError(f"{display_name} source inputs are incomplete or unreadable") 

552 if _asset_build_is_fresh_unlocked( 552 ↛ 557line 552 didn't jump to line 557 because the condition on line 552 was never true

553 source_dir, 

554 build_dir, 

555 source_inputs=source_inputs, 

556 ): 

557 return False 

558 

559 print(f" Building {display_name}...") 

560 staging_dir = Path( 

561 tempfile.mkdtemp(prefix=f".{build_dir.name}.staging-", dir=build_dir.parent) 

562 ) 

563 try: 

564 builder(staging_dir) 

565 if _asset_tree_digest(source_dir, source_inputs=source_inputs) != source_digest: 565 ↛ 566line 565 didn't jump to line 566 because the condition on line 565 was never true

566 raise RuntimeError(f"{display_name} sources changed while packaging") 

567 _write_build_manifest(staging_dir, source_digest) 

568 if not _asset_build_is_fresh_unlocked( 568 ↛ 573line 568 didn't jump to line 573 because the condition on line 568 was never true

569 source_dir, 

570 staging_dir, 

571 source_inputs=source_inputs, 

572 ): 

573 raise RuntimeError(f"{display_name} completion manifest verification failed") 

574 _publish_staged_asset(staging_dir, build_dir) 

575 finally: 

576 _remove_asset_tree(staging_dir) 

577 print(f" {display_name} built successfully") 

578 return True 

579 

580 

581def _atomic_copy_file(source: Path, target: Path) -> None: 

582 """Replace one checked-in Lambda source copy without exposing partial bytes.""" 

583 temporary = target.with_name(f".{target.name}.tmp-{uuid.uuid4().hex}") 

584 try: 

585 shutil.copy2(source, temporary) 

586 os.replace(temporary, target) 

587 finally: 

588 temporary.unlink(missing_ok=True) 

589 

590 

591def _atomic_write_bytes(target: Path, content: bytes, *, mode: int | None = None) -> None: 

592 """Atomically restore exact bytes without exposing a partial configuration.""" 

593 temporary = target.with_name(f".{target.name}.tmp-{uuid.uuid4().hex}") 

594 try: 

595 temporary.write_bytes(content) 

596 if mode is not None: 596 ↛ 598line 596 didn't jump to line 598 because the condition on line 596 was always true

597 os.chmod(temporary, mode) 

598 os.replace(temporary, target) 

599 finally: 

600 temporary.unlink(missing_ok=True) 

601 

602 

603@lru_cache(maxsize=1) 

604def _known_cloudformation_regions() -> frozenset[str]: 

605 """Return every AWS SDK-known Region that exposes CloudFormation.""" 

606 return known_cloudformation_regions() 

607 

608 

609class CdkToolchainError(RuntimeError): 

610 """The CDK Python toolchain (``aws-cdk-lib`` / ``cdk-nag``) is not 

611 importable in the environment that will run ``cdk``. 

612 

613 Raised before shelling out to ``cdk`` so operators get a clear install 

614 hint instead of the cryptic ``ImportError: cannot import name 'App' from 

615 'aws_cdk'`` that the ``python3 app.py`` synth subprocess would otherwise 

616 emit from a base (extra-less) install. 

617 """ 

618 

619 

620@dataclass 

621class StackInfo: 

622 """Information about a CDK stack.""" 

623 

624 name: str 

625 status: str 

626 region: str 

627 created_time: datetime | None = None 

628 updated_time: datetime | None = None 

629 outputs: dict[str, str] = field(default_factory=dict) 

630 tags: dict[str, str] = field(default_factory=dict) 

631 

632 def to_dict(self) -> dict[str, Any]: 

633 return { 

634 "name": self.name, 

635 "status": self.status, 

636 "region": self.region, 

637 "created_time": self.created_time.isoformat() if self.created_time else None, 

638 "updated_time": self.updated_time.isoformat() if self.updated_time else None, 

639 "outputs": self.outputs, 

640 "tags": self.tags, 

641 } 

642 

643 

644def _safe_rmtree(path: Path) -> None: 

645 """Remove a directory tree, handling broken symlinks on macOS. 

646 

647 shutil.rmtree can fail with ``OSError: [Errno 66] Directory not empty`` 

648 on macOS when pip-installed packages (e.g. botocore) contain broken 

649 symlinks or extended-attribute resource forks. 

650 

651 Falls back to ``rm -rf`` via subprocess, but only after validating the 

652 path is a real directory under the project tree to avoid accidents. 

653 """ 

654 resolved = path.resolve() 

655 

656 # Safety: refuse to remove anything that isn't clearly a final, staging, 

657 # or rollback Lambda build artifact inside the project tree. 

658 artifact_name = resolved.name 

659 is_final = artifact_name.endswith("-build") 

660 is_ephemeral = artifact_name.startswith(".") and ( 

661 ".staging-" in artifact_name or ".backup-" in artifact_name 

662 ) 

663 if "lambda" not in resolved.parts or not (is_final or is_ephemeral): 

664 raise ValueError(f"Refusing to remove unexpected path: {resolved}") 

665 

666 try: 

667 shutil.rmtree(str(resolved)) 

668 except OSError: 

669 subprocess.run(["rm", "-rf", "--", str(resolved)], check=True) 

670 

671 

672# Container runtime detection lives in cli/_container_runtime.py so it can 

673# be shared between StackManager (CDK asset bundling) and ImageManager 

674# (gco images build/push). The uncached probe is imported from there; 

675# this module keeps its own small cache so existing tests that reset 

676# ``cli.stacks._container_runtime_cache`` continue to work without 

677# touching the new module's cache. 

678from cli._container_runtime import ( # noqa: E402 

679 _detect_container_runtime_uncached, 

680) 

681 

682# Cached result for container runtime detection (None = not yet checked) 

683_container_runtime_cache: str | None = None 

684_container_runtime_checked: bool = False 

685 

686 

687def _detect_container_runtime() -> str | None: 

688 """ 

689 Detect available container runtime for CDK asset bundling. 

690 

691 Thin caching wrapper around the shared 

692 ``cli._container_runtime._detect_container_runtime_uncached`` probe. 

693 The cache state is held on this module so tests that patch or reset 

694 ``cli.stacks._container_runtime_cache`` keep working unchanged. 

695 """ 

696 global _container_runtime_cache, _container_runtime_checked 

697 if _container_runtime_checked: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 return _container_runtime_cache 

699 

700 _container_runtime_cache = _detect_container_runtime_uncached() 

701 _container_runtime_checked = True 

702 return _container_runtime_cache 

703 

704 

705def prepare_cdk_assets(project_root: str | Path) -> None: 

706 """Prepare every ignored Lambda asset consumed by the CDK application. 

707 

708 This is the shared entry point for build-only callers. CDK consumers must 

709 use :func:`cdk_asset_consumer` so the resulting paths remain immutable 

710 until app construction and synthesis finish. 

711 """ 

712 manager = StackManager.__new__(StackManager) 

713 manager.project_root = Path(project_root) 

714 manager._ensure_lambda_build() 

715 

716 

717def _thread_asset_consumers() -> dict[str, int]: 

718 return _asset_thread_state.active_consumers 

719 

720 

721@contextmanager 

722def cdk_asset_consumer(project_root: str | Path) -> Iterator[None]: 

723 """Hold source-current generated assets stable through CDK synthesis. 

724 

725 Preparation runs before any shared locks are acquired. The complete set of 

726 canonical paths is then locked in deterministic order and every completion 

727 manifest is revalidated while publishers are excluded. A stale observation 

728 releases all locks and retries preparation; repeated source churn fails 

729 closed instead of exposing CDK to a missing or mixed-version tree. 

730 """ 

731 root = Path(project_root) 

732 root_key = os.path.normcase(os.path.abspath(root)) 

733 active = _thread_asset_consumers() 

734 if root_key in active: 

735 active[root_key] += 1 

736 try: 

737 yield 

738 finally: 

739 active[root_key] -= 1 

740 return 

741 

742 stale_assets: list[str] = [] 

743 # Attempt 0 validates under shared locks without preparing anything: when 

744 # every asset is already source-current (always true in CI, where the 

745 # composite build action runs first, and true locally on any second run) 

746 # the consumer takes no exclusive lock and does one hash pass. Concurrent 

747 # consumers — xdist workers — therefore proceed in parallel instead of 

748 # serialising behind the publisher lock. Later attempts keep the original 

749 # prepare-then-revalidate budget for genuinely stale trees. 

750 for attempt in range(_CDK_ASSET_CONSUMER_MAX_ATTEMPTS + 1): 750 ↛ 787line 750 didn't jump to line 787 because the loop on line 750 didn't complete

751 if attempt: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true

752 prepare_cdk_assets(root) 

753 resolved_assets = [] 

754 for spec in _CDK_ASSET_SPECS: 

755 source_dir, build_dir = spec.paths(root) 

756 # Include a source-backed path even during the publisher's 

757 # final-to-backup rename gap, when the canonical build is absent. 

758 if source_dir.exists() or build_dir.exists(): 

759 resolved_assets.append((spec, source_dir, build_dir)) 

760 

761 with ExitStack() as locks: 

762 for _spec, _source_dir, build_dir in sorted( 

763 resolved_assets, 

764 key=lambda item: str(item[2]), 

765 ): 

766 locks.enter_context(_lambda_asset_lock(build_dir, exclusive=False)) 

767 

768 stale_assets = [ 

769 spec.name 

770 for spec, source_dir, build_dir in resolved_assets 

771 if not _asset_build_is_fresh_unlocked( 

772 source_dir, 

773 build_dir, 

774 source_inputs=spec.source_inputs, 

775 ) 

776 ] 

777 if stale_assets: 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true

778 continue 

779 

780 active[root_key] = 1 

781 try: 

782 yield 

783 finally: 

784 active.pop(root_key, None) 

785 return 

786 

787 names = ", ".join(stale_assets) or "unknown assets" 

788 raise RuntimeError( 

789 "Generated CDK assets changed repeatedly while acquiring consumer locks: " 

790 f"{names}. Stop concurrent source edits and retry." 

791 ) 

792 

793 

794class StackManager: 

795 """Manages CDK stack operations.""" 

796 

797 def __init__(self, config: GCOConfig, project_root: Path | None = None): 

798 self.config = config 

799 self.project_root = project_root or self._find_project_root() 

800 # Resolve CDK only when a CDK-backed operation runs. CloudFormation-only 

801 # status/output commands must not require a local Node/CDK installation. 

802 self._cdk_path: str | None = None 

803 self._active_cdk_processes: dict[int, Any] = {} 

804 self._active_cdk_lock = Lock() 

805 self._cdk_cancel_event = Event() 

806 

807 def _find_project_root(self) -> Path: 

808 """Find the project root by looking for cdk.json.""" 

809 current = Path.cwd() 

810 for parent in [current] + list(current.parents): 

811 if (parent / "cdk.json").exists(): 

812 return parent 

813 return current 

814 

815 def _find_cdk(self) -> str: 

816 """Find the dependency-locked CDK executable when available.""" 

817 # Prefer the repository's locked tool when ``npm ci`` has populated it. 

818 local_cdk = self.project_root / "node_modules" / ".bin" / "cdk" 

819 if local_cdk.is_file(): 819 ↛ 820line 819 didn't jump to line 820 because the condition on line 819 was never true

820 return str(local_cdk) 

821 

822 # Fall back to PATH for installed distributions that do not include 

823 # the repository's root npm graph. 

824 try: 

825 result = subprocess.run(["which", "cdk"], capture_output=True, text=True, check=True) 

826 return result.stdout.strip() 

827 except subprocess.CalledProcessError: 

828 pass 

829 

830 # Check common global-install locations. 

831 for path in ["/usr/local/bin/cdk", "~/.npm-global/bin/cdk"]: 

832 expanded = os.path.expanduser(path) 

833 if os.path.exists(expanded): 

834 return expanded 

835 

836 raise CdkToolchainError( 

837 "AWS CDK CLI is not installed. Run " 

838 "'npm ci --ignore-scripts --no-audit --no-fund' at the project root " 

839 "to install the dependency-locked CLI." 

840 ) 

841 

842 @staticmethod 

843 def _kubectl_build_is_fresh(source_dir: Path, build_dir: Path) -> bool: 

844 """Return whether the kubectl build has a valid full-tree completion proof.""" 

845 return _asset_build_is_fresh( 

846 source_dir, 

847 build_dir, 

848 source_inputs=_KUBECTL_CDK_ASSET.source_inputs, 

849 ) 

850 

851 @staticmethod 

852 def _helm_build_is_fresh(source_dir: Path, build_dir: Path) -> bool: 

853 """Return whether the Helm build has a valid full-tree completion proof.""" 

854 return _asset_build_is_fresh( 

855 source_dir, 

856 build_dir, 

857 source_inputs=_HELM_CDK_ASSET.source_inputs, 

858 ) 

859 

860 @staticmethod 

861 def _inference_streaming_build_is_fresh(source_dir: Path, build_dir: Path) -> bool: 

862 """Return whether the Node build has a valid full-tree completion proof.""" 

863 return _asset_build_is_fresh( 

864 source_dir, 

865 build_dir, 

866 source_inputs=_INFERENCE_STREAMING_CDK_ASSET.source_inputs, 

867 ) 

868 

869 def _ensure_lambda_build(self) -> None: 

870 """Atomically prepare every generated Lambda asset when source-stale. 

871 

872 Every builder takes its per-asset interprocess lock, repairs an 

873 interrupted publish, and rechecks freshness before doing installation 

874 work. Concurrent app evaluations therefore either reuse one complete 

875 final tree or publish another complete tree; they never share a 

876 directory while pip/npm/copy operations are mutating it. 

877 """ 

878 for spec, builder in ( 

879 (_KUBECTL_CDK_ASSET, self._build_kubectl_lambda), 

880 (_HELM_CDK_ASSET, self._build_helm_installer_lambda), 

881 (_INFERENCE_STREAMING_CDK_ASSET, self._build_inference_streaming_proxy_lambda), 

882 ): 

883 source_dir, _build_dir = spec.paths(self.project_root) 

884 if source_dir.exists(): 

885 builder() 

886 

887 def _check_and_fix_stuck_stack( 

888 self, 

889 stack_name: str, 

890 *, 

891 expected_stack_id: str | None = None, 

892 authorize_stack: StackAuthorizationCallback | None = None, 

893 strict_ownership: bool = False, 

894 ) -> None: 

895 """Delete a stuck stack only after revalidating its immutable identity.""" 

896 import boto3 

897 

898 region = self._get_deploy_region(stack_name) 

899 if not region: 

900 if strict_ownership: 900 ↛ 901line 900 didn't jump to line 901 because the condition on line 900 was never true

901 raise RuntimeError(f"Could not resolve deploy Region for {stack_name}") 

902 return 

903 

904 cfn = boto3.client("cloudformation", region_name=region) 

905 try: 

906 response = cfn.describe_stacks(StackName=stack_name) 

907 except ClientError as exc: 

908 error = exc.response.get("Error", {}) 

909 if ( 

910 error.get("Code") == "ValidationError" 

911 and "does not exist" in str(error.get("Message", "")).lower() 

912 ): 

913 return 

914 if strict_ownership: 

915 raise 

916 logger.debug("Stack pre-check for %s failed: %s", stack_name, exc) 

917 return 

918 except Exception as exc: 

919 if strict_ownership: 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true

920 raise 

921 logger.debug("Stack pre-check for %s failed: %s", stack_name, exc) 

922 return 

923 

924 stacks = response.get("Stacks", []) 

925 if len(stacks) != 1: 925 ↛ 926line 925 didn't jump to line 926 because the condition on line 925 was never true

926 raise RuntimeError(f"CloudFormation returned an invalid identity for {stack_name}") 

927 stack = stacks[0] 

928 stack_id = str(stack.get("StackId") or "") 

929 if stack.get("StackName") != stack_name or not stack_id: 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true

930 raise RuntimeError(f"CloudFormation returned an invalid identity for {stack_name}") 

931 if strict_ownership and expected_stack_id is None: 

932 raise RuntimeError( 

933 f"Refusing to adopt uncheckpointed stack {region}:{stack_name} ({stack_id})" 

934 ) 

935 if expected_stack_id is not None and stack_id != expected_stack_id: 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true

936 raise RuntimeError( 

937 f"Stack identity changed for {region}:{stack_name}; expected {expected_stack_id}, " 

938 f"found {stack_id}" 

939 ) 

940 

941 stuck_states = { 

942 "REVIEW_IN_PROGRESS", 

943 "ROLLBACK_COMPLETE", 

944 "ROLLBACK_FAILED", 

945 "CREATE_FAILED", 

946 "DELETE_FAILED", 

947 } 

948 status = str(stack.get("StackStatus") or "") 

949 if status not in stuck_states: 

950 return 

951 if authorize_stack is not None: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true

952 authorize_stack(stack_name, region, stack_id) 

953 

954 print(f" Stack {stack_name} is in {status} state, cleaning up...") 

955 cfn.delete_stack(StackName=stack_id) 

956 waiter = cfn.get_waiter("stack_delete_complete") 

957 waiter.wait(StackName=stack_id, WaiterConfig={"Delay": 10, "MaxAttempts": 60}) 

958 print(f" Stack {stack_name} cleaned up, will recreate on deploy") 

959 

960 def _diagnose_deploy_failure(self, stack_name: str) -> None: 

961 """Fetch CloudFormation events after a failed deploy and print diagnostics. 

962 

963 Gives users actionable information instead of just the CDK error message. 

964 """ 

965 import boto3 

966 

967 region = self._get_deploy_region(stack_name) 

968 if not region: 

969 return 

970 

971 try: 

972 cfn = boto3.client("cloudformation", region_name=region) 

973 

974 # Get recent events 

975 response = cfn.describe_stack_events(StackName=stack_name) 

976 events = response.get("StackEvents", []) 

977 

978 # Filter to failed events 

979 failed = [ 

980 e 

981 for e in events[:20] 

982 if "FAILED" in e.get("ResourceStatus", "") 

983 or "ROLLBACK" in e.get("ResourceStatus", "") 

984 ] 

985 

986 if failed: 

987 print(f"\n CloudFormation failure details for {stack_name}:") 

988 for event in failed[:5]: 

989 resource = event.get("LogicalResourceId", "unknown") 

990 status = event.get("ResourceStatus", "unknown") 

991 reason = event.get("ResourceStatusReason", "no reason given") 

992 print(f" {resource}: {status}") 

993 print(f" {reason}") 

994 

995 # Check stack status for actionable advice 

996 try: 

997 stack_resp = cfn.describe_stacks(StackName=stack_name) 

998 status = stack_resp["Stacks"][0]["StackStatus"] 

999 

1000 advice = { 

1001 "REVIEW_IN_PROGRESS": ( 

1002 "Stack is stuck in REVIEW_IN_PROGRESS. " 

1003 "Run: aws cloudformation delete-stack " 

1004 f"--stack-name {stack_name} --region {region}" 

1005 ), 

1006 "ROLLBACK_COMPLETE": ( 

1007 "Stack rolled back. Delete it and retry: " 

1008 f"aws cloudformation delete-stack " 

1009 f"--stack-name {stack_name} --region {region}" 

1010 ), 

1011 "ROLLBACK_FAILED": ( 

1012 "Stack rollback failed. Delete with --retain: " 

1013 f"aws cloudformation delete-stack " 

1014 f"--stack-name {stack_name} --region {region}" 

1015 ), 

1016 "UPDATE_ROLLBACK_COMPLETE": ( 

1017 "Update rolled back but stack is stable. " 

1018 "Check the events above and retry the deploy." 

1019 ), 

1020 } 

1021 

1022 if status in advice: 1022 ↛ exitline 1022 didn't return from function '_diagnose_deploy_failure' because the condition on line 1022 was always true

1023 print(f"\n Suggested fix: {advice[status]}") 

1024 

1025 except Exception as e: 

1026 logger.debug("Failed to parse stack events: %s", e) 

1027 

1028 except Exception as e: 

1029 logger.debug("Failed to diagnose deploy failure for %s: %s", stack_name, e) 

1030 # Best effort — don't fail the deploy further 

1031 

1032 def _sync_lambda_sources(self) -> None: 

1033 """Atomically synchronize canonical shared files before asset ensures. 

1034 

1035 Checked-in copies keep raw CDK evaluation deterministic. Deploy updates 

1036 those copies before generated assets are checked, and never mutates a 

1037 generated final build tree in place. 

1038 """ 

1039 if getattr(self, "_lambda_sources_synced", False): 

1040 return 

1041 

1042 lambda_dir = self.project_root / "lambda" 

1043 shared_source_targets = { 

1044 lambda_dir / "proxy-shared" / "proxy_utils.py": [ 

1045 lambda_dir / "api-gateway-proxy" / "proxy_utils.py", 

1046 lambda_dir / "regional-api-proxy" / "proxy_utils.py", 

1047 ], 

1048 lambda_dir / "tls-shared" / "backend_tls.py": [ 

1049 lambda_dir / "proxy-shared" / "backend_tls.py", 

1050 lambda_dir / "api-gateway-proxy" / "backend_tls.py", 

1051 lambda_dir / "regional-api-proxy" / "backend_tls.py", 

1052 ], 

1053 } 

1054 for shared_source, targets in shared_source_targets.items(): 

1055 if not shared_source.exists(): 

1056 continue 

1057 for target in targets: 

1058 if target.parent.exists(): 1058 ↛ 1057line 1058 didn't jump to line 1057 because the condition on line 1058 was always true

1059 _atomic_copy_file(shared_source, target) 

1060 self._lambda_sources_synced = True 

1061 

1062 def _rebuild_lambda_packages(self) -> None: 

1063 """Compatibility wrapper for a source-current atomic asset ensure.""" 

1064 if getattr(self, "_lambda_packages_rebuilt", False): 

1065 return 

1066 self._ensure_lambda_build() 

1067 self._lambda_packages_rebuilt = True 

1068 

1069 def _build_lambda_packages(self) -> None: 

1070 """Source-check and atomically publish all generated Lambda packages.""" 

1071 self._build_kubectl_lambda() 

1072 self._build_helm_installer_lambda() 

1073 self._build_inference_streaming_proxy_lambda() 

1074 

1075 def _build_kubectl_lambda(self) -> None: 

1076 """Build the kubectl-applier-simple Lambda package.""" 

1077 source_dir, build_dir = _KUBECTL_CDK_ASSET.paths(self.project_root) 

1078 requirements = source_dir / "requirements.txt" 

1079 if not source_dir.is_dir() or not requirements.is_file(): 

1080 return 

1081 

1082 def build(staging_dir: Path) -> None: 

1083 shutil.copy2(source_dir / "handler.py", staging_dir / "handler.py") 

1084 shutil.copy2(requirements, staging_dir / "requirements.txt") 

1085 shutil.copytree(source_dir / "manifests", staging_dir / "manifests") 

1086 result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - static pip arguments and project-owned paths 

1087 [ 

1088 sys.executable, 

1089 "-m", 

1090 "pip", 

1091 "install", 

1092 "-r", 

1093 str(requirements), 

1094 "-t", 

1095 str(staging_dir), 

1096 "--upgrade", 

1097 "--platform", 

1098 "manylinux2014_x86_64", 

1099 "--only-binary=:all:", 

1100 "--quiet", 

1101 ], 

1102 capture_output=True, 

1103 text=True, 

1104 ) 

1105 if result.returncode != 0: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true

1106 raise RuntimeError( 

1107 "kubectl Lambda dependency installation failed: " + result.stderr[:200] 

1108 ) 

1109 

1110 _prepare_lambda_asset( 

1111 source_dir, 

1112 build_dir, 

1113 source_inputs=_KUBECTL_CDK_ASSET.source_inputs, 

1114 display_name="kubectl-applier-simple Lambda package", 

1115 builder=build, 

1116 ) 

1117 

1118 def _build_helm_installer_lambda(self) -> None: 

1119 """Build the complete helm-installer Lambda Docker context.""" 

1120 source_dir, build_dir = _HELM_CDK_ASSET.paths(self.project_root) 

1121 if not source_dir.is_dir(): 

1122 return 

1123 

1124 def build(staging_dir: Path) -> None: 

1125 shutil.copytree( 

1126 source_dir, 

1127 staging_dir, 

1128 ignore=shutil.ignore_patterns(*_LAMBDA_SOURCE_COPY_IGNORE_PATTERNS), 

1129 dirs_exist_ok=True, 

1130 ) 

1131 

1132 _prepare_lambda_asset( 

1133 source_dir, 

1134 build_dir, 

1135 source_inputs=_HELM_CDK_ASSET.source_inputs, 

1136 display_name="helm-installer Lambda package", 

1137 builder=build, 

1138 ) 

1139 

1140 def _build_inference_streaming_proxy_lambda(self) -> None: 

1141 """Build the Node.js streaming Lambda with its pinned AWS SDK clients.""" 

1142 source_dir, build_dir = _INFERENCE_STREAMING_CDK_ASSET.paths(self.project_root) 

1143 if not source_dir.is_dir(): 

1144 return 

1145 

1146 package_files = _INFERENCE_STREAMING_CDK_ASSET.source_inputs 

1147 assert package_files is not None 

1148 missing = [name for name in package_files if not (source_dir / name).is_file()] 

1149 if missing: 1149 ↛ 1150line 1149 didn't jump to line 1150 because the condition on line 1149 was never true

1150 raise RuntimeError( 

1151 "Inference streaming Lambda package is incomplete; missing: " + ", ".join(missing) 

1152 ) 

1153 try: 

1154 package_manager = str( 

1155 json.loads((source_dir / "package.json").read_text(encoding="utf-8")).get( 

1156 "packageManager", "" 

1157 ) 

1158 ) 

1159 except (OSError, UnicodeError, json.JSONDecodeError) as exc: 

1160 raise RuntimeError("Unable to read the inference streaming Lambda npm pin") from exc 

1161 required_npm = package_manager.removeprefix("npm@") 

1162 version_parts = required_npm.split(".") 

1163 if ( 

1164 not package_manager.startswith("npm@") 

1165 or len(version_parts) != 3 

1166 or any(not part.isdigit() for part in version_parts) 

1167 ): 

1168 raise RuntimeError( 

1169 "Inference streaming Lambda packageManager must pin an exact npm version" 

1170 ) 

1171 

1172 def build(staging_dir: Path) -> None: 

1173 npm = shutil.which("npm") 

1174 if npm is None: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true

1175 raise RuntimeError( 

1176 f"npm {required_npm} is required to package the inference streaming Lambda; " 

1177 "install the Node.js version pinned in .nvmrc" 

1178 ) 

1179 try: 

1180 version_result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - resolved executable and project-owned cwd 

1181 [npm, "--version"], 

1182 cwd=source_dir, 

1183 capture_output=True, 

1184 text=True, 

1185 timeout=30, 

1186 ) 

1187 except (OSError, subprocess.TimeoutExpired) as exc: 

1188 raise RuntimeError("Unable to verify the npm packaging version") from exc 

1189 actual_npm = version_result.stdout.strip() 

1190 if version_result.returncode != 0 or actual_npm != required_npm: 

1191 found = actual_npm or "unavailable" 

1192 raise RuntimeError( 

1193 f"npm {required_npm} is required to package the inference streaming Lambda; " 

1194 f"found {found}. Run: npm install --global npm@{required_npm}" 

1195 ) 

1196 

1197 for name in package_files: 

1198 shutil.copy2(source_dir / name, staging_dir / name) 

1199 result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - resolved npm path, static arguments, and project-owned cwd 

1200 [ 

1201 npm, 

1202 "ci", 

1203 "--omit=dev", 

1204 "--ignore-scripts", 

1205 "--no-audit", 

1206 "--no-fund", 

1207 ], 

1208 cwd=staging_dir, 

1209 capture_output=True, 

1210 text=True, 

1211 ) 

1212 if result.returncode != 0: 

1213 raise RuntimeError( 

1214 "Failed to install pinned inference streaming Lambda dependencies: " 

1215 + result.stderr[:500] 

1216 ) 

1217 

1218 _prepare_lambda_asset( 

1219 source_dir, 

1220 build_dir, 

1221 source_inputs=package_files, 

1222 display_name="inference-streaming-proxy Lambda package", 

1223 builder=build, 

1224 ) 

1225 

1226 def _get_python_path(self) -> str: 

1227 """ 

1228 Get PYTHONPATH that includes the current Python's site-packages. 

1229 

1230 This is critical for pipx installations where CDK runs `python3 app.py` 

1231 using the system Python, which doesn't have aws_cdk installed. 

1232 By setting PYTHONPATH, we ensure CDK's subprocess can find our modules. 

1233 """ 

1234 # Get all site-packages directories from the current Python 

1235 site_packages = site.getsitepackages() 

1236 

1237 # Also include user site-packages if available 

1238 user_site = site.getusersitepackages() 

1239 if user_site and os.path.isdir(user_site): 1239 ↛ 1240line 1239 didn't jump to line 1240 because the condition on line 1239 was never true

1240 site_packages.append(user_site) 

1241 

1242 # Include the directory containing the current module (for editable installs) 

1243 current_module_dir = Path(__file__).parent.parent 

1244 if current_module_dir.exists(): 1244 ↛ 1248line 1244 didn't jump to line 1248 because the condition on line 1244 was always true

1245 site_packages.append(str(current_module_dir)) 

1246 

1247 # Combine with existing PYTHONPATH if any 

1248 existing_path = os.environ.get("PYTHONPATH", "") 

1249 all_paths = site_packages + ([existing_path] if existing_path else []) 

1250 

1251 return os.pathsep.join(all_paths) 

1252 

1253 def _ensure_cdk_toolchain(self) -> None: 

1254 """Preflight the CDK Python toolchain before invoking ``cdk``. 

1255 

1256 Infra operations run ``python3 app.py`` (via the Node ``cdk`` CLI), 

1257 which imports ``aws_cdk`` and ``cdk_nag``. Those ship in the optional 

1258 ``[cdk]`` extra — a base ``uvx`` / ``pip`` install of ``gco-cli`` does 

1259 not include them, so the synth subprocess fails with a cryptic 

1260 ``ImportError: cannot import name 'App' from 'aws_cdk'``. Detect the 

1261 missing toolchain up front and raise :class:`CdkToolchainError` with an 

1262 actionable install hint instead. 

1263 """ 

1264 missing = [m for m in _CDK_TOOLCHAIN_MODULES if importlib.util.find_spec(m) is None] 

1265 if not missing: 

1266 return 

1267 raise CdkToolchainError( 

1268 "CDK toolchain not available: cannot import " 

1269 + ", ".join(missing) 

1270 + ".\nInfrastructure operations (deploy / synth / diff / list / destroy / " 

1271 "bootstrap) need the CDK Python packages installed in the SAME " 

1272 "environment as the `gco` CLI, plus a repository checkout providing " 

1273 "`app.py` and `cdk.json`.\n" 

1274 "Install the `[cdk]` extra one of these ways:\n" 

1275 ' - uv: uv tool install "gco-cli[cdk] @ ' 

1276 'git+https://github.com/awslabs/global-capacity-orchestrator-on-aws.git@<tag>"\n' 

1277 ' - pip: pip install -e ".[cdk,mcp]" (from a clone)\n' 

1278 " - or use the dev container (see QUICKSTART.md), which bundles the " 

1279 "full toolchain.\n" 

1280 "See gco_mcp/README.md (Setup) for the deploy-capable configuration." 

1281 ) 

1282 

1283 @staticmethod 

1284 def _terminate_cdk_process(process: Any) -> None: 

1285 """Terminate one complete CDK process tree with a bounded grace period.""" 

1286 if process.poll() is not None: 1286 ↛ 1287line 1286 didn't jump to line 1287 because the condition on line 1286 was never true

1287 return 

1288 

1289 if os.name == "nt": 

1290 taskkill = shutil.which("taskkill.exe") or shutil.which("taskkill") 

1291 

1292 def terminate_tree(*, force: bool) -> bool: 

1293 if taskkill is None: 1293 ↛ 1294line 1293 didn't jump to line 1294 because the condition on line 1293 was never true

1294 return False 

1295 command = [taskkill, "/PID", str(process.pid), "/T"] 

1296 if force: 

1297 command.append("/F") 

1298 try: 

1299 result = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - resolved Windows system utility and numeric child PID 

1300 command, 

1301 capture_output=True, 

1302 text=True, 

1303 check=False, 

1304 timeout=30, 

1305 creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), 

1306 ) 

1307 except OSError, subprocess.TimeoutExpired: 

1308 return False 

1309 return result.returncode == 0 

1310 

1311 terminate_tree(force=False) 

1312 try: 

1313 process.wait(timeout=30) 

1314 except OSError, subprocess.TimeoutExpired: 

1315 terminate_tree(force=True) 

1316 if process.poll() is None: 1316 ↛ 1317line 1316 didn't jump to line 1317 because the condition on line 1316 was never true

1317 process.kill() 

1318 process.wait() 

1319 return 

1320 

1321 try: 

1322 os.killpg(process.pid, signal.SIGTERM) 

1323 process.wait(timeout=30) 

1324 except OSError, subprocess.TimeoutExpired: 

1325 if process.poll() is None: 1325 ↛ exitline 1325 didn't return from function '_terminate_cdk_process' because the condition on line 1325 was always true

1326 try: 

1327 os.killpg(process.pid, signal.SIGKILL) 

1328 finally: 

1329 process.wait() 

1330 

1331 def cancel_active_cdk_processes(self) -> None: 

1332 """Prevent new CDK work and terminate every process group currently registered.""" 

1333 self._cdk_cancel_event.set() 

1334 with self._active_cdk_lock: 

1335 processes = list(self._active_cdk_processes.values()) 

1336 for process in processes: 

1337 self._terminate_cdk_process(process) 

1338 

1339 def _run_cdk( 

1340 self, 

1341 command: list[str], 

1342 capture_output: bool = False, 

1343 env: dict[str, str] | None = None, 

1344 timeout: float | None = None, 

1345 ) -> subprocess.CompletedProcess[str]: 

1346 """Run a CDK command. 

1347 

1348 Args: 

1349 command: CDK subcommand argv (e.g. ``["destroy", "gco-us-east-1", "--force"]``). 

1350 capture_output: Capture stdout / stderr instead of streaming. 

1351 env: Extra env vars merged onto the parent process environment. 

1352 timeout: Wall-clock timeout in seconds. ``None`` (default) waits 

1353 forever — preserving the old behaviour for ``synth`` / ``list``. 

1354 When set, on timeout we send SIGTERM, give the CDK process up 

1355 to 30 seconds to exit cleanly, then SIGKILL, and finally 

1356 re-raise ``subprocess.TimeoutExpired`` so callers can decide 

1357 how to handle a hung subprocess. ``deploy()`` and ``destroy()`` 

1358 pass a per-stack budget so a wedged ``cdk destroy`` (e.g. its 

1359 post-delete polling loop hanging after CloudFormation has 

1360 already finished) can't block the orchestrator forever. 

1361 """ 

1362 # Fail fast with an actionable message when the CDK Python toolchain 

1363 # isn't importable (e.g. a base uvx/pip install without the [cdk] 

1364 # extra), instead of letting the ``python3 app.py`` subprocess surface 

1365 # a cryptic ImportError. 

1366 self._ensure_cdk_toolchain() 

1367 

1368 # These commands all evaluate app.py, including list and destroy. The 

1369 # stack graph references ignored generated Lambda assets, so prepare 

1370 # them centrally rather than relying on individual command wrappers. 

1371 if command and command[0] in {"deploy", "destroy", "diff", "list", "synth"}: 1371 ↛ 1374line 1371 didn't jump to line 1374 because the condition on line 1371 was always true

1372 self._ensure_lambda_build() 

1373 

1374 full_env = os.environ.copy() 

1375 

1376 # Inject PYTHONPATH so CDK's python3 subprocess can find aws_cdk 

1377 # This is essential for pipx installations 

1378 full_env["PYTHONPATH"] = self._get_python_path() 

1379 

1380 if env: 

1381 full_env.update(env) 

1382 

1383 cdk_path = self._cdk_path 

1384 if cdk_path is None: 1384 ↛ 1385line 1384 didn't jump to line 1385 because the condition on line 1384 was never true

1385 cdk_path = self._find_cdk() 

1386 self._cdk_path = cdk_path 

1387 cdk_cmd = [cdk_path, *command] 

1388 

1389 if self._cdk_cancel_event.is_set(): 1389 ↛ 1390line 1389 didn't jump to line 1390 because the condition on line 1389 was never true

1390 raise RuntimeError("CDK operation cancelled before process start") 

1391 popen_kwargs: dict[str, Any] = { 

1392 "cwd": self.project_root, 

1393 "stdout": subprocess.PIPE if capture_output else None, 

1394 "stderr": subprocess.PIPE if capture_output else None, 

1395 "text": True, 

1396 "env": full_env, 

1397 "start_new_session": os.name == "posix", 

1398 } 

1399 if os.name == "nt": 

1400 popen_kwargs["creationflags"] = getattr( 

1401 subprocess, 

1402 "CREATE_NEW_PROCESS_GROUP", 

1403 0, 

1404 ) 

1405 process = subprocess.Popen( # nosemgrep: dangerous-subprocess-use-audit - static CDK argv, no shell 

1406 cdk_cmd, 

1407 **popen_kwargs, 

1408 ) 

1409 with self._active_cdk_lock: 

1410 self._active_cdk_processes[process.pid] = process 

1411 if self._cdk_cancel_event.is_set(): 1411 ↛ 1412line 1411 didn't jump to line 1412 because the condition on line 1411 was never true

1412 self._terminate_cdk_process(process) 

1413 with self._active_cdk_lock: 

1414 self._active_cdk_processes.pop(process.pid, None) 

1415 raise RuntimeError("CDK operation cancelled during process start") 

1416 

1417 try: 

1418 stdout, stderr = process.communicate(timeout=timeout) 

1419 except subprocess.TimeoutExpired as exc: 

1420 self._terminate_cdk_process(process) 

1421 logger.warning( 

1422 "cdk command timed out after %ss: %s", 

1423 timeout, 

1424 " ".join(cdk_cmd), 

1425 ) 

1426 raise subprocess.TimeoutExpired( 

1427 cdk_cmd, 

1428 exc.timeout, 

1429 output=exc.output, 

1430 stderr=exc.stderr, 

1431 ) from exc 

1432 except BaseException: 

1433 self._terminate_cdk_process(process) 

1434 raise 

1435 finally: 

1436 with self._active_cdk_lock: 

1437 self._active_cdk_processes.pop(process.pid, None) 

1438 return subprocess.CompletedProcess( 

1439 cdk_cmd, 

1440 process.returncode, 

1441 stdout=stdout or "", 

1442 stderr=stderr or "", 

1443 ) 

1444 

1445 def list_stacks(self) -> list[str]: 

1446 """List all available CDK stacks.""" 

1447 result = self._run_cdk(["list"], capture_output=True) 

1448 if result.returncode != 0: 

1449 raise RuntimeError(f"Failed to list stacks: {result.stderr}") 

1450 return [s.strip() for s in result.stdout.strip().split("\n") if s.strip()] 

1451 

1452 def synth(self, stack_name: str | None = None, quiet: bool = True) -> str: 

1453 """Synthesize CloudFormation templates from source-current assets.""" 

1454 self._ensure_lambda_build() 

1455 cmd = ["synth"] 

1456 if stack_name: 1456 ↛ 1458line 1456 didn't jump to line 1458 because the condition on line 1456 was always true

1457 cmd.append(stack_name) 

1458 if quiet: 1458 ↛ 1461line 1458 didn't jump to line 1461 because the condition on line 1458 was always true

1459 cmd.append("--quiet") 

1460 

1461 result = self._run_cdk(cmd, capture_output=True) 

1462 if result.returncode != 0: 

1463 raise RuntimeError(f"CDK synth failed: {result.stderr}") 

1464 return str(result.stdout) 

1465 

1466 def diff(self, stack_name: str | None = None) -> str: 

1467 """Show diff between deployed and source-current local stacks.""" 

1468 self._ensure_lambda_build() 

1469 cmd = ["diff", "--no-color"] 

1470 if stack_name: 1470 ↛ 1473line 1470 didn't jump to line 1473 because the condition on line 1470 was always true

1471 cmd.append(stack_name) 

1472 

1473 result = self._run_cdk(cmd, capture_output=True) 

1474 # diff returns non-zero if there are differences, which is expected 

1475 return str(result.stdout or result.stderr) 

1476 

1477 def deploy( 

1478 self, 

1479 stack_name: str | None = None, 

1480 require_approval: bool = True, 

1481 all_stacks: bool = False, 

1482 outputs_file: str | None = None, 

1483 parameters: dict[str, str] | None = None, 

1484 tags: dict[str, str] | None = None, 

1485 progress: str = "events", 

1486 output_dir: str | None = None, 

1487 exclusively: bool = False, 

1488 allow_bootstrap: bool = True, 

1489 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None = None, 

1490 expected_stack_ids: Mapping[str, str | None] | None = None, 

1491 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

1492 authorize_stack: StackAuthorizationCallback | None = None, 

1493 strict_deployment_token: str | None = None, 

1494 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

1495 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

1496 ) -> bool: 

1497 """Deploy CDK stacks. 

1498 

1499 Args: 

1500 stack_name: Name of the stack to deploy 

1501 require_approval: Whether to require approval for changes 

1502 all_stacks: Deploy all stacks 

1503 outputs_file: File to write outputs to 

1504 parameters: CDK parameters 

1505 tags: Tags to apply to stacks 

1506 progress: Progress display type 

1507 output_dir: Custom CDK output directory (for parallel deployments) 

1508 exclusively: Pass ``--exclusively`` to CDK so only the named 

1509 stack is evaluated, not its transitive dependencies. Used by 

1510 ``deploy_orchestrated`` once earlier phases have already 

1511 deployed the globals — re-synthesizing them every phase 

1512 forces custom resources (notably KubectlApplyManifests) 

1513 to re-run each time, adding minutes per phase for no 

1514 actual change. 

1515 """ 

1516 # Synchronize canonical checked-in copies first, then source-check and 

1517 # atomically publish only stale generated assets. A deploy must never 

1518 # destructively rebuild a fresh tree while another CDK process may be 

1519 # fingerprinting it. 

1520 self._sync_lambda_sources() 

1521 self._ensure_lambda_build() 

1522 

1523 strict_deployment = ( 

1524 strict_deployment_token is not None or on_change_set_prepared is not None 

1525 ) 

1526 expected_stack_id: str | None = None 

1527 prepared_change_set_records: Mapping[str, Mapping[str, str]] = {} 

1528 change_set_name: str | None = None 

1529 if strict_deployment: 

1530 if not stack_name or all_stacks: 1530 ↛ 1531line 1530 didn't jump to line 1531 because the condition on line 1530 was never true

1531 raise RuntimeError("Strict deployment requires exactly one named stack") 

1532 if not strict_deployment_token or on_change_set_prepared is None: 1532 ↛ 1533line 1532 didn't jump to line 1533 because the condition on line 1532 was never true

1533 raise RuntimeError( 

1534 "Strict deployment requires both a run token and a prepared-change-set callback" 

1535 ) 

1536 if allow_bootstrap: 1536 ↛ 1537line 1536 didn't jump to line 1537 because the condition on line 1536 was never true

1537 raise RuntimeError("Strict deployment cannot auto-bootstrap a Region") 

1538 if authorize_stack is None: 1538 ↛ 1539line 1538 didn't jump to line 1539 because the condition on line 1538 was never true

1539 raise RuntimeError("Strict deployment requires an exact stack authorizer") 

1540 if expected_stack_ids is None or stack_name not in expected_stack_ids: 1540 ↛ 1541line 1540 didn't jump to line 1541 because the condition on line 1540 was never true

1541 raise RuntimeError( 

1542 f"Strict deployment lacks authoritative target state for {stack_name}" 

1543 ) 

1544 expected_stack_id = expected_stack_ids[stack_name] 

1545 if prepared_change_sets is None or stack_name not in prepared_change_sets: 1545 ↛ 1546line 1545 didn't jump to line 1546 because the condition on line 1545 was never true

1546 raise RuntimeError( 

1547 f"Strict deployment lacks prepared change-set history for {stack_name}" 

1548 ) 

1549 prepared_change_set_records = prepared_change_sets[stack_name] 

1550 change_set_name = self._strict_change_set_name( 

1551 stack_name, 

1552 strict_deployment_token, 

1553 ) 

1554 

1555 # Validate bootstrap identity before any AWS mutation. In strict mode 

1556 # this also revalidates the expected stack ARN (or authoritative 

1557 # absence) before image mirroring or change-set preparation. 

1558 if stack_name: 

1559 region = self._get_deploy_region(stack_name) 

1560 if not region: 1560 ↛ 1561line 1560 didn't jump to line 1561 because the condition on line 1560 was never true

1561 raise RuntimeError(f"Could not resolve deploy Region for {stack_name}") 

1562 if allow_bootstrap: 

1563 if not self.ensure_bootstrapped(region): 

1564 raise RuntimeError( 

1565 f"Region {region} could not be bootstrapped. " 

1566 "Run 'gco stacks bootstrap --region " 

1567 f"{region}' manually to diagnose." 

1568 ) 

1569 else: 

1570 expected_bootstrap = (bootstrap_stacks or {}).get(region) 

1571 if expected_bootstrap is None: 1571 ↛ 1572line 1571 didn't jump to line 1572 because the condition on line 1571 was never true

1572 raise RuntimeError( 

1573 f"Strict deployment lacks a checkpointed CDKToolkit identity for {region}" 

1574 ) 

1575 self._validate_bootstrap_stack(region, expected_bootstrap) 

1576 if strict_deployment: 

1577 target = self._describe_stack_target( 

1578 stack_name, 

1579 expected_stack_id=expected_stack_id, 

1580 require_expected_identity=True, 

1581 ) 

1582 if expected_stack_id is not None and target is None: 1582 ↛ 1583line 1582 didn't jump to line 1583 because the condition on line 1582 was never true

1583 raise RuntimeError( 

1584 f"Checkpointed stack {expected_stack_id} is absent; refusing recreation" 

1585 ) 

1586 assert change_set_name is not None 

1587 self._preflight_strict_change_set( 

1588 stack_name=stack_name, 

1589 change_set_name=change_set_name, 

1590 expected_stack_id=expected_stack_id, 

1591 prepared_change_sets=prepared_change_set_records, 

1592 ) 

1593 

1594 # Name-based stuck-stack recovery is intentionally disabled for strict 

1595 # deployments. A prepared change set must establish CREATE-vs-UPDATE 

1596 # authority without deleting or adopting anything by name. 

1597 if stack_name and not strict_deployment: 

1598 self._check_and_fix_stuck_stack( 

1599 stack_name, 

1600 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

1601 authorize_stack=authorize_stack, 

1602 strict_ownership=not allow_bootstrap, 

1603 ) 

1604 

1605 # Ensure container runtime is available for building images 

1606 runtime = _detect_container_runtime() 

1607 if not runtime: 

1608 raise RuntimeError( 

1609 "No container runtime found. Please install Docker, Finch, or Podman.\n" 

1610 " - Docker: https://docs.docker.com/get-docker/\n" 

1611 " - Finch: brew install finch && finch vm init\n" 

1612 " - Podman: https://podman.io/getting-started/installation" 

1613 ) 

1614 

1615 # Mirror third-party images into ECR only after strict bootstrap and 

1616 # target checks. Repository creation acknowledgements are persisted 

1617 # synchronously by the live-validation callback before any image copy. 

1618 self._mirror_images_if_enabled( 

1619 stack_name=stack_name, 

1620 all_stacks=all_stacks, 

1621 repository_tags=tags, 

1622 on_repository_created=on_ecr_repository_created, 

1623 ) 

1624 

1625 cmd = ["deploy"] 

1626 

1627 if all_stacks: 

1628 cmd.append("--all") 

1629 elif stack_name: 1629 ↛ 1637line 1629 didn't jump to line 1637 because the condition on line 1629 was always true

1630 cmd.append(stack_name) 

1631 

1632 # --exclusively tells CDK to deploy *only* the named stack, not its 

1633 # transitive dependencies. deploy_orchestrated sets this once the 

1634 # earlier phases (global, api-gateway) are already in place so that 

1635 # the regional and monitoring phases don't re-synthesize and 

1636 # re-evaluate globals on every pass. 

1637 if exclusively and stack_name and not all_stacks: 

1638 cmd.append("--exclusively") 

1639 

1640 if strict_deployment: 

1641 assert change_set_name is not None 

1642 cmd.extend( 

1643 [ 

1644 "--method", 

1645 "prepare-change-set", 

1646 "--change-set-name", 

1647 change_set_name, 

1648 "--context", 

1649 f"{_LIVE_VALIDATION_PROVIDER_LOG_CONTEXT}=true", 

1650 ] 

1651 ) 

1652 

1653 if not require_approval: 

1654 cmd.extend(["--require-approval", "never"]) 

1655 

1656 if outputs_file: 

1657 cmd.extend(["--outputs-file", outputs_file]) 

1658 

1659 if parameters: 

1660 for key, value in parameters.items(): 

1661 cmd.extend(["--parameters", f"{key}={value}"]) 

1662 

1663 if tags: 

1664 for key, value in tags.items(): 

1665 cmd.extend(["--tags", f"{key}={value}"]) 

1666 

1667 cmd.extend(["--progress", progress]) 

1668 

1669 # Use custom output directory for parallel deployments 

1670 if output_dir: 

1671 cmd.extend(["--output", output_dir]) 

1672 

1673 # Set CDK_DOCKER env var if not already set 

1674 env = {"CDK_DOCKER": runtime} if not os.environ.get("CDK_DOCKER") else None 

1675 

1676 # Per-stack wall-clock cap so a wedged ``cdk deploy`` (e.g. an 

1677 # IAM eventual-consistency wait that never completes) can't block 

1678 # the orchestrator forever. Default 60 minutes — long enough for 

1679 # a fresh EKS cluster cold start. Override via 

1680 # GCO_CDK_DEPLOY_TIMEOUT_SECONDS. 

1681 timeout_s = float(os.environ.get("GCO_CDK_DEPLOY_TIMEOUT_SECONDS", "3600")) 

1682 

1683 # Timestamp (UTC) marking the start of this deploy attempt. The failure 

1684 # reconciliation below uses it to tell a *fresh* CloudFormation 

1685 # completion (cdk's client-side polling gave up just after CFN finished 

1686 # — a real success) apart from a *stale* terminal state left by a 

1687 # previous deploy (cdk failed before touching CloudFormation — a real 

1688 # failure that must not be masked). 

1689 deploy_start = datetime.now(UTC) 

1690 

1691 try: 

1692 result = self._run_cdk(cmd, env=env, timeout=timeout_s) 

1693 success = result.returncode == 0 

1694 except subprocess.TimeoutExpired: 

1695 print( 

1696 f" cdk deploy timed out after {timeout_s}s for " 

1697 f"{stack_name or 'all stacks'}; verifying CloudFormation state..." 

1698 ) 

1699 success = False 

1700 

1701 if self._cdk_cancel_event.is_set(): 1701 ↛ 1702line 1701 didn't jump to line 1702 because the condition on line 1701 was never true

1702 raise RuntimeError("CDK deployment cancelled before AWS-side reconciliation") 

1703 

1704 if strict_deployment: 

1705 assert stack_name is not None 

1706 assert change_set_name is not None 

1707 assert on_change_set_prepared is not None 

1708 try: 

1709 success = self._execute_prepared_change_set( 

1710 stack_name=stack_name, 

1711 change_set_name=change_set_name, 

1712 expected_stack_id=expected_stack_id, 

1713 expected_tags=tags, 

1714 prepared_change_sets=prepared_change_set_records, 

1715 preparation_succeeded=success, 

1716 authorize_stack=authorize_stack, 

1717 on_change_set_prepared=on_change_set_prepared, 

1718 allow_noop=success, 

1719 timeout=timeout_s, 

1720 ) 

1721 except Exception: 

1722 self._diagnose_deploy_failure(stack_name) 

1723 raise 

1724 if not success: 1724 ↛ 1725line 1724 didn't jump to line 1725 because the condition on line 1724 was never true

1725 self._diagnose_deploy_failure(stack_name) 

1726 

1727 if success and "analytics" in stack_name: 1727 ↛ 1728line 1727 didn't jump to line 1728 because the condition on line 1727 was never true

1728 api_gateway_stack = f"{self.config.project_name}-api-gateway" 

1729 print(f" Updating {api_gateway_stack} with analytics routes...") 

1730 success = self.deploy( 

1731 stack_name=api_gateway_stack, 

1732 require_approval=require_approval, 

1733 outputs_file=outputs_file, 

1734 parameters=parameters, 

1735 tags=tags, 

1736 progress=progress, 

1737 exclusively=True, 

1738 allow_bootstrap=allow_bootstrap, 

1739 bootstrap_stacks=bootstrap_stacks, 

1740 expected_stack_ids=expected_stack_ids, 

1741 prepared_change_sets=prepared_change_sets, 

1742 authorize_stack=authorize_stack, 

1743 strict_deployment_token=(f"{strict_deployment_token}-analytics-routes"), 

1744 on_change_set_prepared=on_change_set_prepared, 

1745 on_ecr_repository_created=on_ecr_repository_created, 

1746 ) 

1747 return success 

1748 

1749 # Reconcile a cdk failure/timeout against CloudFormation. cdk's 

1750 # client-side polling can give up (a transient ``read EADDRNOTAVAIL`` 

1751 # socket error, or our wall-clock timeout) while CloudFormation keeps 

1752 # working server-side, so a non-zero exit does not always mean the 

1753 # deploy failed. The trick is to reconcile without masking a *real* 

1754 # failure by mistaking a stale terminal state for a fresh success. 

1755 if stack_name and not all_stacks and not success: 

1756 cfn_status = self._get_stack_status(stack_name) 

1757 if cfn_status is not None and cfn_status.endswith("_IN_PROGRESS"): 

1758 # CloudFormation is still mid-operation — observing that is 

1759 # itself proof it ran an operation for this attempt. Wait for it 

1760 # to settle and accept a terminal COMPLETE as a genuine success. 

1761 print( 

1762 f" cdk exited non-zero but {stack_name} is {cfn_status} in " 

1763 "CloudFormation; waiting for the operation to settle..." 

1764 ) 

1765 settled_status = self._wait_for_stack_settle(stack_name) 

1766 if settled_status in ("CREATE_COMPLETE", "UPDATE_COMPLETE"): 

1767 print( 

1768 f" cdk reported a non-zero exit but {stack_name} settled " 

1769 f"to {settled_status} in CloudFormation — treating as " 

1770 "success." 

1771 ) 

1772 success = True 

1773 elif cfn_status in ("CREATE_COMPLETE", "UPDATE_COMPLETE"): 

1774 # The stack is already terminal and CloudFormation is not 

1775 # mid-flight. Two very different situations look identical on 

1776 # status alone; only the stack's last-operation time tells them 

1777 # apart: 

1778 # * cdk's polling gave up just *after* CloudFormation finished 

1779 # this attempt's operation — a genuine success whose 

1780 # last-update time is newer than when we started. 

1781 # * cdk failed *before* it ever touched CloudFormation (a 

1782 # synth error, a cloud-assembly schema mismatch, an 

1783 # asset/image build failure); the stack is merely sitting in 

1784 # a *previous* deploy's COMPLETE state, whose last-update 

1785 # time predates this attempt. Masking this is the 

1786 # false-success bug this guards against. 

1787 last_op = self._get_stack_last_update_time(stack_name) 

1788 if last_op is not None and last_op >= deploy_start: 

1789 print( 

1790 f" cdk reported a non-zero exit but {stack_name} shows a " 

1791 f"fresh {cfn_status} in CloudFormation — treating as " 

1792 "success." 

1793 ) 

1794 success = True 

1795 else: 

1796 print( 

1797 f" cdk failed and {stack_name} is {cfn_status}, but no " 

1798 "new CloudFormation operation ran for this attempt — cdk " 

1799 "failed before touching CloudFormation. Treating as a " 

1800 "failed deploy." 

1801 ) 

1802 

1803 # Conversely, when cdk reports success, confirm CloudFormation actually 

1804 # landed in a terminal success state. A zero cdk exit can still mask a 

1805 # stack that silently rolled back (e.g. UPDATE_ROLLBACK_COMPLETE) or is 

1806 # otherwise not in a healthy COMPLETE state — verifying the AWS-side 

1807 # truth keeps deploy() from reporting a rolled-back stack as deployed. 

1808 # A None status (lookup failed / transient) leaves cdk's verdict intact; 

1809 # we only override on a *known* non-success state. No-op deploys stay in 

1810 # CREATE_COMPLETE/UPDATE_COMPLETE, so this never false-fails them — we 

1811 # deliberately don't require LastUpdatedTime to advance. 

1812 if success and stack_name and not all_stacks: 

1813 cfn_status = self._get_stack_status(stack_name) 

1814 if cfn_status is not None and cfn_status not in ( 

1815 "CREATE_COMPLETE", 

1816 "UPDATE_COMPLETE", 

1817 ): 

1818 print( 

1819 f" cdk reported success but {stack_name} is in {cfn_status} " 

1820 f"in CloudFormation — treating as a failed deploy." 

1821 ) 

1822 success = False 

1823 

1824 if not success and stack_name: 

1825 self._diagnose_deploy_failure(stack_name) 

1826 

1827 # After deploying gco-analytics, automatically redeploy 

1828 # gco-api-gateway to wire in the /studio/* routes (the API gateway 

1829 # imports the Cognito pool ARN and presigned-URL Lambda ARN from 

1830 # the analytics stack). 

1831 if success and stack_name and "analytics" in stack_name and not all_stacks: 

1832 api_gateway_stack = f"{self.config.project_name}-api-gateway" 

1833 print(f" Updating {api_gateway_stack} with analytics routes...") 

1834 success = self.deploy( 

1835 stack_name=api_gateway_stack, 

1836 require_approval=require_approval, 

1837 outputs_file=outputs_file, 

1838 parameters=parameters, 

1839 tags=tags, 

1840 progress=progress, 

1841 exclusively=True, 

1842 allow_bootstrap=allow_bootstrap, 

1843 bootstrap_stacks=bootstrap_stacks, 

1844 expected_stack_ids=expected_stack_ids, 

1845 authorize_stack=authorize_stack, 

1846 on_ecr_repository_created=on_ecr_repository_created, 

1847 ) 

1848 

1849 return success 

1850 

1851 def destroy( 

1852 self, 

1853 stack_name: str | None = None, 

1854 all_stacks: bool = False, 

1855 force: bool = False, 

1856 output_dir: str | None = None, 

1857 expected_stack_id: str | None = None, 

1858 expected_stack_ids: Mapping[str, str | None] | None = None, 

1859 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

1860 authorize_stack: StackAuthorizationCallback | None = None, 

1861 allow_bootstrap: bool = True, 

1862 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None = None, 

1863 strict_deployment_token: str | None = None, 

1864 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

1865 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

1866 ) -> bool: 

1867 """Destroy stacks while restoring any temporary config mutation exactly.""" 

1868 config_path: Path | None = None 

1869 original_bytes: bytes | None = None 

1870 original_mode: int | None = None 

1871 if stack_name and not all_stacks and "analytics" in stack_name: 

1872 config_path = _find_cdk_json() 

1873 if config_path is None: 1873 ↛ 1874line 1873 didn't jump to line 1874 because the condition on line 1873 was never true

1874 raise RuntimeError("cdk.json not found before analytics destroy") 

1875 original_bytes = config_path.read_bytes() 

1876 original_mode = stat.S_IMODE(config_path.stat().st_mode) 

1877 try: 

1878 return self._destroy( 

1879 stack_name=stack_name, 

1880 all_stacks=all_stacks, 

1881 force=force, 

1882 output_dir=output_dir, 

1883 expected_stack_id=expected_stack_id, 

1884 expected_stack_ids=expected_stack_ids, 

1885 prepared_change_sets=prepared_change_sets, 

1886 authorize_stack=authorize_stack, 

1887 allow_bootstrap=allow_bootstrap, 

1888 bootstrap_stacks=bootstrap_stacks, 

1889 strict_deployment_token=strict_deployment_token, 

1890 on_change_set_prepared=on_change_set_prepared, 

1891 on_ecr_repository_created=on_ecr_repository_created, 

1892 ) 

1893 finally: 

1894 if config_path is not None and original_bytes is not None: 

1895 _atomic_write_bytes(config_path, original_bytes, mode=original_mode) 

1896 

1897 def _destroy( 

1898 self, 

1899 stack_name: str | None = None, 

1900 all_stacks: bool = False, 

1901 force: bool = False, 

1902 output_dir: str | None = None, 

1903 expected_stack_id: str | None = None, 

1904 expected_stack_ids: Mapping[str, str | None] | None = None, 

1905 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

1906 authorize_stack: StackAuthorizationCallback | None = None, 

1907 allow_bootstrap: bool = True, 

1908 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None = None, 

1909 strict_deployment_token: str | None = None, 

1910 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

1911 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

1912 ) -> bool: 

1913 """Destroy CDK stacks. 

1914 

1915 If the target stack exists in CloudFormation but isn't in the CDK 

1916 app (e.g. because a toggle was disabled), temporarily enables the 

1917 toggle so CDK can synthesize and destroy the stack properly. This 

1918 ensures custom resource cleanup handlers (like the analytics 

1919 cleanup Lambda) fire during deletion. 

1920 

1921 Args: 

1922 stack_name: Name of the stack to destroy 

1923 all_stacks: Destroy all stacks 

1924 force: Skip confirmation prompts 

1925 output_dir: Custom CDK output directory (for parallel deployments) 

1926 """ 

1927 if all_stacks and (expected_stack_id is not None or expected_stack_ids is not None): 

1928 raise RuntimeError("Identity-fenced teardown cannot use all_stacks=True") 

1929 if stack_name is None and (expected_stack_id is not None or expected_stack_ids is not None): 1929 ↛ 1930line 1929 didn't jump to line 1930 because the condition on line 1929 was never true

1930 raise RuntimeError("Identity-fenced teardown requires exactly one named stack") 

1931 

1932 strict_identity = expected_stack_id is not None or expected_stack_ids is not None 

1933 if stack_name is not None and expected_stack_ids is not None: 

1934 if stack_name not in expected_stack_ids: 1934 ↛ 1935line 1934 didn't jump to line 1935 because the condition on line 1934 was never true

1935 raise RuntimeError( 

1936 f"Strict teardown lacks authoritative target state for {stack_name}" 

1937 ) 

1938 mapped_stack_id = expected_stack_ids[stack_name] 

1939 if expected_stack_id is not None and expected_stack_id != mapped_stack_id: 1939 ↛ 1940line 1939 didn't jump to line 1940 because the condition on line 1939 was never true

1940 raise RuntimeError(f"Conflicting expected stack identities for {stack_name}") 

1941 expected_stack_id = mapped_stack_id 

1942 if strict_identity and authorize_stack is None: 1942 ↛ 1943line 1942 didn't jump to line 1943 because the condition on line 1942 was never true

1943 raise RuntimeError("Identity-fenced teardown requires an exact stack authorizer") 

1944 

1945 # Image-registry pre-destroy guards. Only fires for the global 

1946 # stack (where the registry lives) and only when the operator 

1947 # has explicitly chosen ``removal_policy: "destroy"``. The 

1948 # default ``retain`` posture is a no-op here. See 

1949 # ``_image_registry_destroy_preflight`` for the exact rules. 

1950 if ( 

1951 stack_name is not None 

1952 and stack_name.endswith("-global") 

1953 and not all_stacks 

1954 and not self._image_registry_destroy_preflight(force=force) 

1955 ): 

1956 return False 

1957 

1958 # Strict callers never enter CDK's name-based destroy or toggle-based 

1959 # recovery paths. Analytics may first require one strict prepared 

1960 # change set on the exact API stack to remove cross-stack imports. 

1961 if strict_identity and stack_name and not all_stacks: 

1962 if self._cdk_cancel_event.is_set(): 1962 ↛ 1963line 1962 didn't jump to line 1963 because the condition on line 1962 was never true

1963 raise RuntimeError(f"Strict teardown cancelled before deleting {stack_name}") 

1964 if "analytics" in stack_name: 

1965 if expected_stack_ids is None: 

1966 raise RuntimeError( 

1967 "Identity-fenced analytics teardown requires the complete expected " 

1968 "stack identity map" 

1969 ) 

1970 safe_to_destroy = self._remove_api_gateway_analytics_dependency( 

1971 allow_bootstrap=allow_bootstrap, 

1972 bootstrap_stacks=bootstrap_stacks, 

1973 expected_stack_ids=expected_stack_ids, 

1974 prepared_change_sets=prepared_change_sets, 

1975 authorize_stack=authorize_stack, 

1976 strict_deployment_token=( 

1977 f"{strict_deployment_token}-drop-analytics-routes" 

1978 if strict_deployment_token is not None 

1979 else None 

1980 ), 

1981 on_change_set_prepared=on_change_set_prepared, 

1982 on_ecr_repository_created=on_ecr_repository_created, 

1983 ) 

1984 if not safe_to_destroy: 1984 ↛ 1985line 1984 didn't jump to line 1985 because the condition on line 1984 was never true

1985 return False 

1986 return self._cloudformation_delete_stack( 

1987 stack_name, 

1988 expected_stack_id=expected_stack_id, 

1989 authorize_stack=authorize_stack, 

1990 require_expected_identity=True, 

1991 ) 

1992 

1993 # A regional API bridge disappears from the CDK app when its Region is 

1994 # removed from configuration. Only this exact project-scoped shape with 

1995 # an SDK-known CloudFormation Region may bypass CDK; configured bridges, 

1996 # arbitrary suffixes, and every other stack keep the normal CDK path. 

1997 if stack_name and not all_stacks: 

1998 orphan_region = self._get_orphan_regional_api_region(stack_name) 

1999 if orphan_region is not None: 

2000 if not self._stack_exists_in_cloudformation(stack_name): 2000 ↛ 2001line 2000 didn't jump to line 2001 because the condition on line 2000 was never true

2001 return True 

2002 print( 

2003 f" {stack_name} is absent from the configured CDK app; " 

2004 f"deleting it directly in {orphan_region}..." 

2005 ) 

2006 return self._cloudformation_delete_stack( 

2007 stack_name, 

2008 expected_stack_id=expected_stack_id, 

2009 authorize_stack=authorize_stack, 

2010 ) 

2011 

2012 # If destroying a specific stack that exists in CloudFormation but 

2013 # might not be in the CDK app, temporarily enable its toggle. 

2014 toggle_restored = False 

2015 if ( 

2016 stack_name 

2017 and not all_stacks 

2018 and "analytics" in stack_name 

2019 and self._stack_exists_in_cloudformation(stack_name) 

2020 ): 

2021 toggle_restored = self._ensure_analytics_enabled_for_destroy() 

2022 

2023 # The analytics stack exports values (e.g. Cognito pool ARN) that 

2024 # gco-api-gateway imports. CloudFormation blocks deletion of stacks 

2025 # with consumed exports. To break the dependency, redeploy the API 

2026 # gateway with analytics disabled first, then destroy analytics. 

2027 if stack_name and not all_stacks and "analytics" in stack_name: 

2028 safe_to_destroy = self._remove_api_gateway_analytics_dependency( 

2029 allow_bootstrap=allow_bootstrap, 

2030 bootstrap_stacks=bootstrap_stacks, 

2031 expected_stack_ids=expected_stack_ids, 

2032 prepared_change_sets=prepared_change_sets, 

2033 authorize_stack=authorize_stack, 

2034 strict_deployment_token=strict_deployment_token, 

2035 on_change_set_prepared=on_change_set_prepared, 

2036 on_ecr_repository_created=on_ecr_repository_created, 

2037 ) 

2038 if not safe_to_destroy: 

2039 # Restore analytics toggle before bailing out. 

2040 if toggle_restored: 2040 ↛ 2041line 2040 didn't jump to line 2041 because the condition on line 2040 was never true

2041 self._restore_analytics_disabled() 

2042 project = self.config.project_name 

2043 print( 

2044 f" Aborting {project}-analytics destroy: {project}-api-gateway " 

2045 "still imports analytics exports. Fix the API gateway and retry." 

2046 ) 

2047 return False 

2048 

2049 # Non-strict callers may still use CDK's name-based path. Strict calls 

2050 # returned above after exact-ARN deletion. 

2051 cmd = ["destroy"] 

2052 

2053 if all_stacks: 

2054 cmd.append("--all") 

2055 elif stack_name: 2055 ↛ 2063line 2055 didn't jump to line 2063 because the condition on line 2055 was always true

2056 cmd.append(stack_name) 

2057 # --exclusively prevents CDK from cascading the destroy to 

2058 # dependent stacks (e.g. destroying gco-analytics should not 

2059 # also destroy gco-api-gateway just because it references the 

2060 # presigned-URL Lambda ARN). 

2061 cmd.append("--exclusively") 

2062 

2063 if force: 

2064 cmd.append("--force") 

2065 

2066 if output_dir: 

2067 cmd.extend(["--output", output_dir]) 

2068 

2069 # Per-stack wall-clock cap so a wedged ``cdk destroy`` (its 

2070 # post-delete polling loop hanging after CloudFormation has 

2071 # already finished) can't block the orchestrator forever. Default 

2072 # 90 minutes: a healthy EKS regional teardown has been observed 

2073 # needing ~60 (the VPC Lambda ENI detach alone can serialise for 

2074 # 20+ while CloudFormation keeps making progress), and the prior 

2075 # 45-minute cap killed the poller mid-delete — the AWS-side 

2076 # reconciliation below recovered, but the timeout should mark a 

2077 # wedged CDK, not a normal teardown. Override via 

2078 # GCO_CDK_DESTROY_TIMEOUT_SECONDS. 

2079 timeout_s = float(os.environ.get("GCO_CDK_DESTROY_TIMEOUT_SECONDS", "5400")) 

2080 

2081 try: 

2082 result = self._run_cdk(cmd, timeout=timeout_s) 

2083 cdk_succeeded = result.returncode == 0 

2084 except subprocess.TimeoutExpired: 

2085 # CDK hung. Verify the AWS-side state below — if the stack 

2086 # is gone in CloudFormation, the destroy actually succeeded 

2087 # and the timeout was just CDK's polling loop wedged. 

2088 print( 

2089 f" cdk destroy timed out after {timeout_s}s; verifying " 

2090 f"CloudFormation state for {stack_name}..." 

2091 ) 

2092 cdk_succeeded = False 

2093 

2094 if self._cdk_cancel_event.is_set(): 2094 ↛ 2095line 2094 didn't jump to line 2095 because the condition on line 2094 was never true

2095 raise RuntimeError("CDK teardown cancelled before AWS-side reconciliation") 

2096 

2097 # Restore the toggle if we changed it 

2098 if toggle_restored: 

2099 self._restore_analytics_disabled() 

2100 

2101 # Reconcile against CloudFormation. A local CDK timeout/failure is not 

2102 # an AWS failure when the delete operation is still healthy. Once AWS 

2103 # reports DELETE_IN_PROGRESS, wait for bounded server-side convergence 

2104 # instead of letting the orchestrator advance into dependent stacks. 

2105 if stack_name and not all_stacks: 

2106 still_present = self._stack_exists_in_cloudformation(stack_name) 

2107 if not still_present: 

2108 if not cdk_succeeded: 

2109 print( 

2110 f" cdk reported a non-zero exit but {stack_name} is " 

2111 "already deleted in CloudFormation — treating as success." 

2112 ) 

2113 return True 

2114 

2115 status = self._get_stack_status(stack_name, expected_stack_id) 

2116 if status == "DELETE_IN_PROGRESS": 

2117 return self._wait_for_stack_delete_convergence( 

2118 stack_name, 

2119 initial_status=status, 

2120 ) 

2121 if status == "DELETE_FAILED": 

2122 self._print_stack_delete_heartbeat( 

2123 stack_name, 

2124 status, 

2125 expected_stack_id, 

2126 ) 

2127 print(f" {stack_name} reached DELETE_FAILED; refusing to continue teardown.") 

2128 return False 

2129 

2130 # A zero CDK exit with a still-present, non-deleting stack is a rare 

2131 # client-side false success. Start deletion directly and then use 

2132 # the same bounded convergence loop. A non-zero exit in any other 

2133 # state means CDK failed before it started a delete operation. 

2134 if cdk_succeeded: 

2135 return self._cloudformation_delete_stack(stack_name) 

2136 print( 

2137 f" cdk failed and {stack_name} is still {status or 'in an unknown state'}; " 

2138 "no active CloudFormation delete operation was confirmed." 

2139 ) 

2140 return False 

2141 

2142 return cdk_succeeded 

2143 

2144 # ------------------------------------------------------------------ 

2145 # Image registry pre-destroy guards 

2146 # ------------------------------------------------------------------ 

2147 def _read_images_config(self) -> dict[str, Any]: 

2148 """Read the ``images`` block from cdk.json with defaults applied. 

2149 

2150 Mirrors the parser in ``gco/stacks/global_stack.py`` so the CLI 

2151 can reason about the same fields without importing the CDK 

2152 module (which pulls aws_cdk and the full constructs surface). 

2153 Defaults stay aligned with the global-stack parser; any value 

2154 that fails validation (e.g. an unexpected ``removal_policy``) 

2155 is silently coerced to ``"retain"`` here so the CLI never blocks 

2156 on a typo — the actual deploy-time validation is the global 

2157 stack's responsibility. 

2158 """ 

2159 import json 

2160 

2161 cdk_json_path = _find_cdk_json() 

2162 if not cdk_json_path: 

2163 return { 

2164 "removal_policy": "retain", 

2165 "empty_on_delete": False, 

2166 } 

2167 try: 

2168 with open(cdk_json_path, encoding="utf-8") as f: 

2169 ctx = json.load(f).get("context", {}) or {} 

2170 except (OSError, json.JSONDecodeError) as exc: 

2171 logger.debug("Failed to read cdk.json for images config: %s", exc) 

2172 return {"removal_policy": "retain", "empty_on_delete": False} 

2173 

2174 raw = ctx.get("images") or {} 

2175 removal_policy = str(raw.get("removal_policy", "retain")).strip().lower() 

2176 if removal_policy not in ("retain", "destroy"): 

2177 removal_policy = "retain" 

2178 return { 

2179 "removal_policy": removal_policy, 

2180 "empty_on_delete": bool(raw.get("empty_on_delete", False)), 

2181 } 

2182 

2183 def _build_image_registry_inventory(self) -> dict[str, Any]: 

2184 """Aggregate repo / tag / size / reference counts for the registry. 

2185 

2186 Returns a dict shape suitable for printing to the operator. Best 

2187 effort: a missing ImageManager dependency or an AWS error 

2188 produces a partially-populated dict rather than raising. 

2189 """ 

2190 inventory: dict[str, Any] = { 

2191 "repo_count": 0, 

2192 "tag_count": 0, 

2193 "total_bytes": 0, 

2194 "endpoint_refs": 0, 

2195 "job_refs": 0, 

2196 } 

2197 try: 

2198 from cli.images import ImageManager 

2199 except Exception as exc: # noqa: BLE001 

2200 logger.debug("ImageManager import failed during preflight: %s", exc) 

2201 return inventory 

2202 

2203 try: 

2204 manager = ImageManager(config=self.config) 

2205 repos = manager.list_repos() 

2206 inventory["repo_count"] = len(repos) 

2207 # Repos this deployment owns live under ``<project_name>/`` (#139). 

2208 repo_prefix = f"{self.config.project_name}/" 

2209 for repo in repos: 

2210 repo_name = repo.get("name", "") 

2211 if not repo_name.startswith(repo_prefix): 

2212 continue 

2213 short = repo_name.removeprefix(repo_prefix) 

2214 try: 

2215 tags = manager.list_tags(short) 

2216 except Exception as exc: # noqa: BLE001 

2217 logger.debug("list_tags failed for %s: %s", repo_name, exc) 

2218 continue 

2219 inventory["tag_count"] += len(tags) 

2220 for row in tags: 

2221 size = row.get("size_bytes") 

2222 if isinstance(size, int): 2222 ↛ 2220line 2222 didn't jump to line 2220 because the condition on line 2222 was always true

2223 inventory["total_bytes"] += size 

2224 try: 

2225 inventory["endpoint_refs"] = len(manager._collect_inference_image_refs()) 

2226 except Exception as exc: # noqa: BLE001 

2227 logger.debug("inference ref collection failed: %s", exc) 

2228 try: 

2229 inventory["job_refs"] = len(manager._collect_recent_job_image_refs()) 

2230 except Exception as exc: # noqa: BLE001 

2231 logger.debug("job ref collection failed: %s", exc) 

2232 except Exception as exc: # noqa: BLE001 

2233 logger.debug("Image registry inventory failed: %s", exc) 

2234 return inventory 

2235 

2236 def _image_registry_destroy_preflight(self, *, force: bool) -> bool: 

2237 """Validate the image-registry destroy posture before invoking CFN. 

2238 

2239 Two rules: 

2240 

2241 1. ``removal_policy: "destroy"`` AND ``empty_on_delete: false`` 

2242 → refuse with the literal helpful-error message pointing 

2243 the operator at ``gco images cleanup --all`` or at flipping 

2244 ``empty_on_delete: true``. 

2245 

2246 2. ``removal_policy: "destroy"`` AND ``empty_on_delete: true`` 

2247 → print the inventory summary first. On a TTY the operator 

2248 is also prompted for confirmation; non-TTY runs proceed 

2249 (the operator presumably passed ``-y`` or is automating). 

2250 

2251 Returns True when the destroy may proceed, False when it has 

2252 been refused or declined. 

2253 """ 

2254 cfg = self._read_images_config() 

2255 if cfg["removal_policy"] != "destroy": 

2256 return True 

2257 

2258 if not cfg["empty_on_delete"]: 

2259 print( 

2260 f"Repos under {self.config.project_name}/* are not empty and " 

2261 "empty_on_delete is false. Run 'gco images cleanup --all' " 

2262 "first, or set images.empty_on_delete: true in cdk.json." 

2263 ) 

2264 return False 

2265 

2266 inventory = self._build_image_registry_inventory() 

2267 gib = inventory["total_bytes"] / (1024**3) if inventory["total_bytes"] else 0.0 

2268 print("Image registry inventory before destroy:") 

2269 print(f" repos: {inventory['repo_count']}") 

2270 print(f" tags: {inventory['tag_count']}") 

2271 print(f" total size: {gib:.2f} GiB") 

2272 print(f" referencing endpoints: {inventory['endpoint_refs']}") 

2273 print(f" recent job refs: {inventory['job_refs']}") 

2274 

2275 # Already confirmed via -y, or non-interactive — proceed. 

2276 if force or not sys.stdin.isatty(): 

2277 return True 

2278 

2279 try: 

2280 response = input( 

2281 f"Destroy {self.config.project_name}-global and delete every " 

2282 f"{self.config.project_name}/* repo? [y/N]: " 

2283 ) 

2284 except EOFError, KeyboardInterrupt: 

2285 print("Aborted.") 

2286 return False 

2287 if response.strip().lower() not in ("y", "yes"): 

2288 print("Aborted.") 

2289 return False 

2290 return True 

2291 

2292 @staticmethod 

2293 def _stack_missing(exc: ClientError) -> bool: 

2294 error = exc.response.get("Error", {}) 

2295 return bool( 

2296 error.get("Code") == "ValidationError" 

2297 and "does not exist" in str(error.get("Message", "")).lower() 

2298 ) 

2299 

2300 @staticmethod 

2301 def _change_set_missing(exc: ClientError) -> bool: 

2302 """Return whether CloudFormation authoritatively reports an absent change set.""" 

2303 return bool(exc.response.get("Error", {}).get("Code") == "ChangeSetNotFound") 

2304 

2305 def _describe_stack_target( 

2306 self, 

2307 stack_name: str, 

2308 *, 

2309 expected_stack_id: str | None = None, 

2310 require_expected_identity: bool = False, 

2311 ) -> tuple[str, Any, dict[str, Any]] | None: 

2312 """Resolve live/absent/tombstone/replacement state without name adoption.""" 

2313 import boto3 

2314 

2315 region = self._get_destroy_region(stack_name) 

2316 cfn = boto3.client("cloudformation", region_name=region) 

2317 

2318 def describe(identifier: str) -> dict[str, Any] | None: 

2319 try: 

2320 response = cfn.describe_stacks(StackName=identifier) 

2321 except ClientError as exc: 

2322 if self._stack_missing(exc): 

2323 return None 

2324 raise 

2325 stacks = response.get("Stacks", []) 

2326 if len(stacks) != 1: 2326 ↛ 2327line 2326 didn't jump to line 2327 because the condition on line 2326 was never true

2327 raise RuntimeError(f"CloudFormation returned an invalid identity for {stack_name}") 

2328 stack = stacks[0] 

2329 if not isinstance(stack, dict): 2329 ↛ 2330line 2329 didn't jump to line 2330 because the condition on line 2329 was never true

2330 raise RuntimeError(f"CloudFormation returned an invalid identity for {stack_name}") 

2331 stack_id = str(stack.get("StackId") or "") 

2332 if stack.get("StackName") != stack_name or not stack_id: 2332 ↛ 2333line 2332 didn't jump to line 2333 because the condition on line 2332 was never true

2333 raise RuntimeError(f"CloudFormation returned an invalid identity for {stack_name}") 

2334 return stack 

2335 

2336 exact = describe(expected_stack_id) if expected_stack_id else None 

2337 if exact is not None and str(exact.get("StackStatus") or "") != "DELETE_COMPLETE": 

2338 if str(exact.get("StackId") or "") != expected_stack_id: 2338 ↛ 2339line 2338 didn't jump to line 2339 because the condition on line 2338 was never true

2339 raise RuntimeError(f"Stack identity changed for {region}:{stack_name}") 

2340 return region, cfn, exact 

2341 

2342 by_name = describe(stack_name) 

2343 if by_name is None or str(by_name.get("StackStatus") or "") == "DELETE_COMPLETE": 

2344 return None 

2345 actual_id = str(by_name.get("StackId") or "") 

2346 if expected_stack_id is not None and actual_id != expected_stack_id: 

2347 raise RuntimeError( 

2348 f"Checkpointed stack {expected_stack_id} is absent or deleted but same-name " 

2349 f"replacement {actual_id} exists; refusing adoption" 

2350 ) 

2351 if expected_stack_id is None and require_expected_identity: 

2352 raise RuntimeError( 

2353 f"Refusing name-authorized access to uncheckpointed stack " 

2354 f"{region}:{stack_name} ({actual_id})" 

2355 ) 

2356 return region, cfn, by_name 

2357 

2358 def _stack_exists_in_cloudformation( 

2359 self, 

2360 stack_name: str, 

2361 expected_stack_id: str | None = None, 

2362 *, 

2363 require_expected_identity: bool = False, 

2364 ) -> bool: 

2365 """Return whether the exact live target exists, rejecting replacements.""" 

2366 target = self._describe_stack_target( 

2367 stack_name, 

2368 expected_stack_id=expected_stack_id, 

2369 require_expected_identity=require_expected_identity, 

2370 ) 

2371 return target is not None 

2372 

2373 def _get_stack_status( 

2374 self, 

2375 stack_name: str, 

2376 stack_identifier: str | None = None, 

2377 ) -> str | None: 

2378 """Return the live CloudFormation status of ``stack_name`` or None. 

2379 

2380 Used by ``deploy()`` to reconcile against AWS-side state when ``cdk 

2381 deploy`` returns a non-zero exit code or times out — if the stack 

2382 actually finished CREATE_COMPLETE or UPDATE_COMPLETE on the AWS 

2383 side, the deploy succeeded regardless of what cdk reported. 

2384 Returns None when the stack does not exist or the lookup itself 

2385 fails (network blip, perms, etc.) so callers can treat the 

2386 unknown case as 'cdk's verdict stands'. 

2387 """ 

2388 import boto3 

2389 

2390 try: 

2391 region = self._get_destroy_region(stack_name) 

2392 cfn = boto3.client("cloudformation", region_name=region) 

2393 resp = cfn.describe_stacks(StackName=stack_identifier or stack_name) 

2394 return str(resp["Stacks"][0]["StackStatus"]) 

2395 except Exception: 

2396 return None 

2397 

2398 def _get_stack_last_update_time(self, stack_name: str) -> datetime | None: 

2399 """Return the UTC time of ``stack_name``'s most recent CloudFormation 

2400 operation, or None if the stack is absent or the lookup fails. 

2401 

2402 Uses ``LastUpdatedTime`` when the stack has been updated at least once, 

2403 falling back to ``CreationTime`` for a stack that has only ever been 

2404 created. ``deploy()`` compares this against the moment the deploy 

2405 attempt started to decide whether a cdk failure/timeout that leaves the 

2406 stack ``*_COMPLETE`` reflects a *fresh* operation (cdk's polling merely 

2407 gave up early — success) or a *stale* one left by a previous deploy 

2408 (cdk failed before touching CloudFormation — a real failure). A None 

2409 return keeps the conservative 'cdk's failure stands' verdict. 

2410 """ 

2411 import boto3 

2412 

2413 try: 

2414 region = self._get_destroy_region(stack_name) 

2415 cfn = boto3.client("cloudformation", region_name=region) 

2416 resp = cfn.describe_stacks(StackName=stack_name) 

2417 stack = resp["Stacks"][0] 

2418 last_op = stack.get("LastUpdatedTime") or stack.get("CreationTime") 

2419 return last_op if isinstance(last_op, datetime) else None 

2420 except Exception: 

2421 return None 

2422 

2423 def _wait_for_stack_settle( 

2424 self, 

2425 stack_name: str, 

2426 timeout: float | None = None, 

2427 stack_identifier: str | None = None, 

2428 ) -> str | None: 

2429 """Poll CloudFormation until ``stack_name`` leaves a ``*_IN_PROGRESS`` state. 

2430 

2431 When ``cdk deploy`` dies on a transient client-side error (e.g. a 

2432 ``read EADDRNOTAVAIL`` socket failure) the CloudFormation operation it 

2433 started usually keeps running server-side. ``deploy()`` reconciles 

2434 against CloudFormation, but a single status read taken the instant cdk 

2435 exits can catch the stack mid-flight (``CREATE_IN_PROGRESS``) and give 

2436 up only seconds before it would have reached ``CREATE_COMPLETE``. This 

2437 helper waits out the in-progress window so the reconcile judges the 

2438 *terminal* state instead of a transient one. 

2439 

2440 Returns the terminal status string, the last status seen on timeout, or 

2441 ``None`` if the status could not be read (so callers treat the unknown 

2442 case as 'cdk's verdict stands'). 

2443 """ 

2444 import time 

2445 

2446 if timeout is None: 

2447 timeout = float(os.environ.get("GCO_CDK_SETTLE_TIMEOUT_SECONDS", "1200")) 

2448 deadline = time.monotonic() + timeout 

2449 status = self._get_stack_status(stack_name, stack_identifier) 

2450 while status is not None and status.endswith("_IN_PROGRESS"): 

2451 if self._cdk_cancel_event.is_set(): 2451 ↛ 2452line 2451 didn't jump to line 2452 because the condition on line 2451 was never true

2452 return status 

2453 if time.monotonic() >= deadline: 

2454 break 

2455 time.sleep(15.0) 

2456 status = self._get_stack_status(stack_name, stack_identifier) 

2457 return status 

2458 

2459 def _get_latest_stack_event( 

2460 self, 

2461 stack_name: str, 

2462 stack_identifier: str | None = None, 

2463 ) -> dict[str, Any] | None: 

2464 """Return the newest CloudFormation event for delete heartbeats.""" 

2465 import boto3 

2466 

2467 try: 

2468 region = self._get_destroy_region(stack_name) 

2469 cfn = boto3.client("cloudformation", region_name=region) 

2470 events = cfn.describe_stack_events(StackName=stack_identifier or stack_name).get( 

2471 "StackEvents", [] 

2472 ) 

2473 return events[0] if events else None 

2474 except Exception: 

2475 logger.debug("Could not read delete events for %s", stack_name, exc_info=True) 

2476 return None 

2477 

2478 def _print_stack_delete_heartbeat( 

2479 self, 

2480 stack_name: str, 

2481 status: str | None, 

2482 stack_identifier: str | None = None, 

2483 ) -> None: 

2484 """Print the latest AWS-side state while a long delete converges.""" 

2485 event = self._get_latest_stack_event(stack_name, stack_identifier) 

2486 if not event: 2486 ↛ 2487line 2486 didn't jump to line 2487 because the condition on line 2486 was never true

2487 print(f" {stack_name}: CloudFormation status {status or 'unknown'}") 

2488 return 

2489 

2490 timestamp = event.get("Timestamp") 

2491 timestamp_text = ( 

2492 timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp or "") 

2493 ) 

2494 logical_id = str(event.get("LogicalResourceId") or stack_name) 

2495 resource_status = str(event.get("ResourceStatus") or status or "unknown") 

2496 reason = " ".join(str(event.get("ResourceStatusReason") or "").split()) 

2497 if len(reason) > 400: 2497 ↛ 2498line 2497 didn't jump to line 2498 because the condition on line 2497 was never true

2498 reason = reason[:397] + "..." 

2499 suffix = f"{reason}" if reason else "" 

2500 print( 

2501 f" {stack_name}: {status or 'unknown'}; latest event " 

2502 f"{timestamp_text} {logical_id} {resource_status}{suffix}" 

2503 ) 

2504 

2505 def _wait_for_stack_delete_convergence( 

2506 self, 

2507 stack_name: str, 

2508 *, 

2509 timeout: float | None = None, 

2510 poll_interval: float = _CLOUDFORMATION_DELETE_POLL_SECONDS, 

2511 heartbeat_interval: float = _CLOUDFORMATION_DELETE_HEARTBEAT_SECONDS, 

2512 initial_status: str = "DELETE_IN_PROGRESS", 

2513 expected_stack_id: str | None = None, 

2514 require_expected_identity: bool = False, 

2515 ) -> bool: 

2516 """Wait for an AWS-side stack delete to finish without trusting CDK polling. 

2517 

2518 The caller must already have evidence that a delete operation started. 

2519 Transient status-read failures are tolerated after that proof, but a 

2520 terminal ``DELETE_FAILED`` or the overall deadline fails closed. 

2521 """ 

2522 if timeout is None: 2522 ↛ 2523line 2522 didn't jump to line 2523 because the condition on line 2522 was never true

2523 try: 

2524 timeout = float( 

2525 os.environ.get( 

2526 "GCO_CLOUDFORMATION_DELETE_TIMEOUT_SECONDS", 

2527 str(_CLOUDFORMATION_DELETE_TIMEOUT_SECONDS), 

2528 ) 

2529 ) 

2530 except ValueError: 

2531 timeout = _CLOUDFORMATION_DELETE_TIMEOUT_SECONDS 

2532 if not math.isfinite(timeout) or timeout <= 0: 

2533 raise ValueError("CloudFormation delete timeout must be positive and finite") 

2534 if ( 

2535 not math.isfinite(poll_interval) 

2536 or not math.isfinite(heartbeat_interval) 

2537 or poll_interval <= 0 

2538 or heartbeat_interval <= 0 

2539 ): 

2540 raise ValueError("CloudFormation delete polling intervals must be positive and finite") 

2541 

2542 deadline = time.monotonic() + timeout 

2543 next_heartbeat = time.monotonic() 

2544 status: str | None = initial_status 

2545 last_printed_status: str | None = None 

2546 

2547 while True: 

2548 if self._cdk_cancel_event.is_set(): 2548 ↛ 2549line 2548 didn't jump to line 2549 because the condition on line 2548 was never true

2549 logger.warning("CloudFormation delete wait cancelled for %s", stack_name) 

2550 return False 

2551 try: 

2552 if not self._stack_exists_in_cloudformation( 2552 ↛ 2557line 2552 didn't jump to line 2557 because the condition on line 2552 was never true

2553 stack_name, 

2554 expected_stack_id=expected_stack_id, 

2555 require_expected_identity=require_expected_identity, 

2556 ): 

2557 print(f" {stack_name} is absent from CloudFormation.") 

2558 return True 

2559 except RuntimeError: 

2560 raise 

2561 except Exception: 

2562 logger.debug( 

2563 "CloudFormation presence check failed for %s", 

2564 stack_name, 

2565 exc_info=True, 

2566 ) 

2567 

2568 now = time.monotonic() 

2569 if status == "DELETE_COMPLETE": 2569 ↛ 2570line 2569 didn't jump to line 2570 because the condition on line 2569 was never true

2570 return True 

2571 if status == "DELETE_FAILED": 2571 ↛ 2572line 2571 didn't jump to line 2572 because the condition on line 2571 was never true

2572 self._print_stack_delete_heartbeat( 

2573 stack_name, 

2574 status, 

2575 expected_stack_id, 

2576 ) 

2577 return False 

2578 if status not in (None, "DELETE_IN_PROGRESS"): 2578 ↛ 2579line 2578 didn't jump to line 2579 because the condition on line 2578 was never true

2579 self._print_stack_delete_heartbeat( 

2580 stack_name, 

2581 status, 

2582 expected_stack_id, 

2583 ) 

2584 print( 

2585 f" {stack_name} left DELETE_IN_PROGRESS without being deleted; " 

2586 "refusing to continue teardown." 

2587 ) 

2588 return False 

2589 if now >= deadline: 2589 ↛ 2600line 2589 didn't jump to line 2600 because the condition on line 2589 was always true

2590 self._print_stack_delete_heartbeat( 

2591 stack_name, 

2592 status, 

2593 expected_stack_id, 

2594 ) 

2595 print( 

2596 f" Timed out after {timeout:.0f}s waiting for {stack_name} " 

2597 "to disappear from CloudFormation." 

2598 ) 

2599 return False 

2600 if status != last_printed_status or now >= next_heartbeat: 

2601 self._print_stack_delete_heartbeat( 

2602 stack_name, 

2603 status, 

2604 expected_stack_id, 

2605 ) 

2606 last_printed_status = status 

2607 next_heartbeat = now + heartbeat_interval 

2608 

2609 time.sleep(min(poll_interval, max(0.0, deadline - now))) 

2610 status = self._get_stack_status(stack_name, expected_stack_id) 

2611 

2612 def _cloudformation_delete_stack( 

2613 self, 

2614 stack_name: str, 

2615 *, 

2616 expected_stack_id: str | None = None, 

2617 authorize_stack: StackAuthorizationCallback | None = None, 

2618 require_expected_identity: bool = False, 

2619 ) -> bool: 

2620 """Delete an immediately revalidated stack by immutable ARN.""" 

2621 if self._cdk_cancel_event.is_set(): 2621 ↛ 2622line 2621 didn't jump to line 2622 because the condition on line 2621 was never true

2622 raise RuntimeError(f"CloudFormation deletion cancelled before {stack_name}") 

2623 target = self._describe_stack_target( 

2624 stack_name, 

2625 expected_stack_id=expected_stack_id, 

2626 require_expected_identity=require_expected_identity, 

2627 ) 

2628 if target is None: 2628 ↛ 2629line 2628 didn't jump to line 2629 because the condition on line 2628 was never true

2629 return True 

2630 region, cfn, stack = target 

2631 stack_id = str(stack["StackId"]) 

2632 status = str(stack.get("StackStatus") or "") 

2633 if authorize_stack is not None: 

2634 authorize_stack(stack_name, region, stack_id) 

2635 if status == "DELETE_IN_PROGRESS": 2635 ↛ 2636line 2635 didn't jump to line 2636 because the condition on line 2635 was never true

2636 return self._wait_for_stack_delete_convergence( 

2637 stack_name, 

2638 initial_status=status, 

2639 expected_stack_id=stack_id, 

2640 require_expected_identity=require_expected_identity, 

2641 ) 

2642 try: 

2643 cfn.delete_stack(StackName=stack_id) 

2644 except Exception: 

2645 logger.debug("Direct CloudFormation delete failed for %s", stack_id, exc_info=True) 

2646 return False 

2647 return self._wait_for_stack_delete_convergence( 

2648 stack_name, 

2649 expected_stack_id=stack_id, 

2650 require_expected_identity=require_expected_identity, 

2651 ) 

2652 

2653 def _validated_regional_api_region(self, stack_name: str) -> str | None: 

2654 """Return an exact project bridge's SDK-known CloudFormation Region.""" 

2655 bridge_prefix = f"{self.config.project_name}-regional-api-" 

2656 if not stack_name.startswith(bridge_prefix): 

2657 return None 

2658 

2659 region = stack_name[len(bridge_prefix) :] 

2660 if not region: 2660 ↛ 2661line 2660 didn't jump to line 2661 because the condition on line 2660 was never true

2661 return None 

2662 try: 

2663 return region if region in _known_cloudformation_regions() else None 

2664 except Exception: 

2665 logger.debug( 

2666 "Could not validate regional API bridge Region for %s", 

2667 stack_name, 

2668 exc_info=True, 

2669 ) 

2670 return None 

2671 

2672 def _configured_regional_api_regions( 

2673 self, 

2674 ) -> tuple[frozenset[str], str] | None: 

2675 """Read valid root regions and their partition for orphan deletion. 

2676 

2677 Returning ``None`` means the configuration could not prove anything: 

2678 missing, unreadable, malformed, wrong-project, incomplete, empty, and 

2679 duplicate Region configurations all fail closed under the same contract 

2680 used by :class:`ConfigLoader`. 

2681 """ 

2682 path = self.project_root / "cdk.json" 

2683 try: 

2684 data = json.loads(path.read_text(encoding="utf-8")) 

2685 if not isinstance(data, dict): 2685 ↛ 2686line 2685 didn't jump to line 2686 because the condition on line 2685 was never true

2686 return None 

2687 context = data.get("context") 

2688 if not isinstance(context, dict): 2688 ↛ 2689line 2688 didn't jump to line 2689 because the condition on line 2688 was never true

2689 return None 

2690 configured_project = context.get("project_name") 

2691 if ( 

2692 not isinstance(configured_project, str) 

2693 or not configured_project 

2694 or configured_project != self.config.project_name 

2695 ): 

2696 return None 

2697 deployment_regions = context.get("deployment_regions") 

2698 if not isinstance(deployment_regions, dict): 2698 ↛ 2699line 2698 didn't jump to line 2699 because the condition on line 2698 was never true

2699 return None 

2700 

2701 known_regions = _known_cloudformation_regions() 

2702 for key in ("global", "api_gateway", "monitoring"): 

2703 region = deployment_regions.get(key) 

2704 if not isinstance(region, str) or region not in known_regions: 

2705 return None 

2706 

2707 try: 

2708 regional = validated_regional_deployment_regions( 

2709 deployment_regions.get("regional"), 

2710 known_regions=known_regions, 

2711 ) 

2712 deployment_partition = validated_deployment_partition( 

2713 ( 

2714 deployment_regions["global"], 

2715 deployment_regions["api_gateway"], 

2716 deployment_regions["monitoring"], 

2717 *regional, 

2718 ) 

2719 ) 

2720 except RuntimeError, ValueError: 

2721 return None 

2722 return frozenset(regional), deployment_partition 

2723 except OSError, UnicodeError, json.JSONDecodeError, TypeError: 

2724 logger.debug( 

2725 "Could not read authoritative regional configuration from %s", 

2726 path, 

2727 exc_info=True, 

2728 ) 

2729 return None 

2730 

2731 def _get_orphan_regional_api_region(self, stack_name: str) -> str | None: 

2732 """Return a bridge Region only when valid root config proves it absent. 

2733 

2734 This result authorizes bypassing CDK and deleting a stack directly via 

2735 CloudFormation. Merely failing a normal configuration lookup can never 

2736 be interpreted as proof that the stack is orphaned. 

2737 """ 

2738 region = self._validated_regional_api_region(stack_name) 

2739 if region is None: 

2740 return None 

2741 configured = self._configured_regional_api_regions() 

2742 if configured is None: 

2743 return None 

2744 configured_regions, deployment_partition = configured 

2745 if region in configured_regions: 

2746 return None 

2747 try: 

2748 candidate_partition = validated_deployment_partition((region,)) 

2749 except RuntimeError, ValueError: 

2750 return None 

2751 if candidate_partition != deployment_partition: 

2752 return None 

2753 return region 

2754 

2755 def _get_destroy_region(self, stack_name: str) -> str: 

2756 """Determine a configured or cryptographically bounded destroy Region. 

2757 

2758 Deploy resolution intentionally requires bridge Regions to remain in 

2759 ``cdk.json``. A removed bridge may still resolve through the orphan 

2760 path, but that path validates the exact project-scoped name, SDK-known 

2761 CloudFormation Region, authoritative root configuration, and matching 

2762 AWS partition. Reconciliation must reuse that same proof rather than 

2763 trusting a bridge-shaped suffix independently. 

2764 """ 

2765 try: 

2766 region = self._get_deploy_region(stack_name) 

2767 except Exception: 

2768 logger.debug( 

2769 "Configured deploy Region lookup failed for %s; checking orphan shape", 

2770 stack_name, 

2771 exc_info=True, 

2772 ) 

2773 else: 

2774 if region: 

2775 return region 

2776 

2777 orphan_region = self._get_orphan_regional_api_region(stack_name) 

2778 return orphan_region or self.config.api_gateway_region 

2779 

2780 def _ensure_analytics_enabled_for_destroy(self) -> bool: 

2781 """Temporarily enable analytics so CDK includes the stack for destroy.""" 

2782 try: 

2783 current = get_analytics_config() 

2784 if not current.get("enabled"): 

2785 update_analytics_config({"enabled": True}) 

2786 return True 

2787 except Exception as exc: 

2788 logger.debug( 

2789 "Failed to enable analytics toggle for destroy: %s", 

2790 exc, 

2791 exc_info=True, 

2792 ) 

2793 return False 

2794 

2795 def _restore_analytics_disabled(self) -> None: 

2796 """Restore analytics toggle to disabled after destroy.""" 

2797 try: 

2798 update_analytics_config({"enabled": False}) 

2799 except Exception as exc: 

2800 logger.warning( 

2801 "Failed to restore analytics toggle to disabled after destroy: %s", 

2802 exc, 

2803 exc_info=True, 

2804 ) 

2805 

2806 def _remove_api_gateway_analytics_dependency( 

2807 self, 

2808 *, 

2809 allow_bootstrap: bool = True, 

2810 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None = None, 

2811 expected_stack_ids: Mapping[str, str | None] | None = None, 

2812 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

2813 authorize_stack: StackAuthorizationCallback | None = None, 

2814 strict_deployment_token: str | None = None, 

2815 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

2816 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

2817 ) -> bool: 

2818 """Redeploy gco-api-gateway with analytics disabled to drop cross-stack imports. 

2819 

2820 The analytics stack exports values (Cognito pool ARN, presigned-URL 

2821 Lambda ARN) that gco-api-gateway imports for the /studio/* routes. 

2822 CloudFormation blocks deletion of stacks with consumed exports. By 

2823 disabling analytics and redeploying the API gateway, the /studio/* 

2824 routes are removed and the imports are dropped, unblocking the 

2825 analytics stack deletion. 

2826 

2827 Returns: 

2828 True if the analytics stack is safe to destroy (either because 

2829 no consumer remains or because the redeploy successfully 

2830 dropped the imports). False if a consumer of the analytics 

2831 exports still exists and the analytics destroy will fail. 

2832 """ 

2833 api_gateway_stack = f"{self.config.project_name}-api-gateway" 

2834 analytics_stack = f"{self.config.project_name}-analytics" 

2835 

2836 strict_identity = expected_stack_ids is not None 

2837 if strict_identity: 

2838 assert expected_stack_ids is not None 

2839 if api_gateway_stack not in expected_stack_ids: 2839 ↛ 2840line 2839 didn't jump to line 2840 because the condition on line 2839 was never true

2840 raise RuntimeError( 

2841 f"Strict teardown lacks authoritative target state for {api_gateway_stack}" 

2842 ) 

2843 api_gateway_expected_id = expected_stack_ids[api_gateway_stack] 

2844 else: 

2845 api_gateway_expected_id = None 

2846 

2847 # Fast path: if the api-gateway stack doesn't exist (or has already 

2848 # been deleted/rolled-back into a non-consuming state), there's 

2849 # nothing importing the analytics exports. Skip the redeploy entirely. 

2850 if not self._stack_exists_in_cloudformation( 

2851 api_gateway_stack, 

2852 expected_stack_id=api_gateway_expected_id, 

2853 require_expected_identity=strict_identity, 

2854 ): 

2855 logger.info( 

2856 "%s does not exist in CloudFormation; skipping redeploy before analytics destroy.", 

2857 api_gateway_stack, 

2858 ) 

2859 return True 

2860 

2861 # Second fast path: if the deployed api-gateway isn't actually 

2862 # importing anything from the analytics stack, we don't need to 

2863 # touch it. This happens when analytics was never fully wired up. 

2864 if not self._api_gateway_imports_from_analytics(): 

2865 logger.info( 

2866 "%s does not import any %s exports; skipping redeploy before analytics destroy.", 

2867 api_gateway_stack, 

2868 analytics_stack, 

2869 ) 

2870 return True 

2871 

2872 if strict_identity and ( 2872 ↛ 2875line 2872 didn't jump to line 2875 because the condition on line 2872 was never true

2873 not strict_deployment_token or on_change_set_prepared is None or authorize_stack is None 

2874 ): 

2875 raise RuntimeError( 

2876 "Strict analytics teardown cannot remove API imports without " 

2877 "prepared-change-set authority" 

2878 ) 

2879 

2880 try: 

2881 # Temporarily disable analytics so CDK drops the /studio/* routes. 

2882 current = get_analytics_config() 

2883 was_enabled = current.get("enabled", False) 

2884 if was_enabled: 

2885 update_analytics_config({"enabled": False}) 

2886 

2887 print(f" Updating {api_gateway_stack} to remove analytics routes...") 

2888 import tempfile 

2889 

2890 with tempfile.TemporaryDirectory() as tmp_out: 

2891 success = self.deploy( 

2892 stack_name=api_gateway_stack, 

2893 require_approval=False, 

2894 exclusively=True, 

2895 output_dir=tmp_out, 

2896 allow_bootstrap=allow_bootstrap, 

2897 bootstrap_stacks=bootstrap_stacks, 

2898 expected_stack_ids=expected_stack_ids, 

2899 prepared_change_sets=prepared_change_sets, 

2900 authorize_stack=authorize_stack, 

2901 strict_deployment_token=strict_deployment_token, 

2902 on_change_set_prepared=on_change_set_prepared, 

2903 on_ecr_repository_created=on_ecr_repository_created, 

2904 ) 

2905 

2906 # Re-enable analytics so CDK can synthesize the analytics stack 

2907 # for the destroy operation (custom resources need to fire). 

2908 if was_enabled: 

2909 update_analytics_config({"enabled": True}) 

2910 

2911 if not success: 

2912 # The redeploy failed. That's only a real problem if the 

2913 # api-gateway still imports analytics exports. Recheck: 

2914 # the auto-cleanup of ROLLBACK_COMPLETE stacks may have 

2915 # deleted the consumer entirely, in which case the destroy 

2916 # can still proceed. 

2917 if not self._api_gateway_imports_from_analytics(): 

2918 logger.info( 

2919 "%s redeploy failed, but the stack no longer imports " 

2920 "analytics exports (likely deleted during cleanup). " 

2921 "Analytics destroy can proceed.", 

2922 api_gateway_stack, 

2923 ) 

2924 return True 

2925 logger.error( 

2926 "Failed to redeploy %s to drop analytics imports, and the " 

2927 "stack still consumes analytics exports. Destroying %s will " 

2928 "fail with 'Export ... cannot be deleted as it is in use'. " 

2929 "Fix %s first (see events above) and retry.", 

2930 api_gateway_stack, 

2931 analytics_stack, 

2932 api_gateway_stack, 

2933 ) 

2934 return False 

2935 

2936 return True 

2937 except Exception as exc: 

2938 logger.warning( 

2939 "Failed to remove API gateway analytics dependency: %s", 

2940 exc, 

2941 exc_info=True, 

2942 ) 

2943 # On unexpected exceptions, recheck whether imports remain. 

2944 # Be permissive only if we can confirm the destroy is safe. 

2945 try: 

2946 return not self._api_gateway_imports_from_analytics() 

2947 except Exception: 

2948 return False 

2949 

2950 def _api_gateway_imports_from_analytics(self) -> bool: 

2951 """Return True if gco-api-gateway imports any exports from gco-analytics. 

2952 

2953 Uses CloudFormation's ``list_exports`` + ``list_imports`` to detect 

2954 cross-stack references at runtime. This is more reliable than 

2955 inspecting the CDK app because it reflects what's actually 

2956 deployed. 

2957 """ 

2958 import boto3 

2959 

2960 analytics_stack = f"{self.config.project_name}-analytics" 

2961 api_gateway_stack = f"{self.config.project_name}-api-gateway" 

2962 

2963 region = self._get_deploy_region(analytics_stack) 

2964 if not region: 

2965 return False 

2966 

2967 try: 

2968 cfn = boto3.client("cloudformation", region_name=region) 

2969 # Collect every export whose owning stack is the analytics stack. 

2970 analytics_exports: list[str] = [] 

2971 paginator = cfn.get_paginator("list_exports") 

2972 for page in paginator.paginate(): 

2973 for export in page.get("Exports", []): 

2974 owner = export.get("ExportingStackId", "") 

2975 # ExportingStackId is a full ARN; match by stack name. 

2976 if f":stack/{analytics_stack}/" in owner: 

2977 analytics_exports.append(export["Name"]) 

2978 

2979 if not analytics_exports: 

2980 return False 

2981 

2982 # For each export, check whether the api-gateway stack is 

2983 # listed as an importer. ``list_imports`` returns the stack 

2984 # names that currently import the given export. 

2985 import_paginator = cfn.get_paginator("list_imports") 

2986 for export_name in analytics_exports: 

2987 try: 

2988 for page in import_paginator.paginate(ExportName=export_name): 

2989 for importer in page.get("Imports", []): 

2990 if importer == api_gateway_stack: 2990 ↛ 2989line 2990 didn't jump to line 2989 because the condition on line 2990 was always true

2991 return True 

2992 except Exception as exc: 

2993 # ``list_imports`` raises when an export has zero 

2994 # consumers — treat that as "not imported" and move on. 

2995 logger.debug( 

2996 "list_imports(%s) failed (likely no consumers): %s", 

2997 export_name, 

2998 exc, 

2999 ) 

3000 return False 

3001 except Exception as exc: 

3002 logger.debug( 

3003 "Failed to check analytics imports for %s: %s", 

3004 api_gateway_stack, 

3005 exc, 

3006 exc_info=True, 

3007 ) 

3008 # On failure to check, err on the side of attempting the 

3009 # redeploy so we don't skip necessary cleanup. 

3010 return True 

3011 

3012 def bootstrap( 

3013 self, 

3014 account: str | None = None, 

3015 region: str | None = None, 

3016 ) -> bool: 

3017 """Bootstrap CDK in an AWS account/region.""" 

3018 cmd = ["bootstrap"] 

3019 

3020 if account and region: 

3021 cmd.append(f"aws://{account}/{region}") 

3022 elif region: 3022 ↛ 3025line 3022 didn't jump to line 3025 because the condition on line 3022 was always true

3023 cmd.append(f"aws://unknown-account/{region}") 

3024 

3025 result = self._run_cdk(cmd) 

3026 return result.returncode == 0 

3027 

3028 def is_bootstrapped(self, region: str) -> bool: 

3029 """Check if CDK has been bootstrapped in a region. 

3030 

3031 Looks for the CDKToolkit CloudFormation stack which is created 

3032 by ``cdk bootstrap``. Result is cached per region for the lifetime 

3033 of this StackManager instance. 

3034 """ 

3035 if not hasattr(self, "_bootstrap_cache"): 3035 ↛ 3038line 3035 didn't jump to line 3038 because the condition on line 3035 was always true

3036 self._bootstrap_cache: dict[str, bool] = {} 

3037 

3038 if region in self._bootstrap_cache: 3038 ↛ 3039line 3038 didn't jump to line 3039 because the condition on line 3038 was never true

3039 return self._bootstrap_cache[region] 

3040 

3041 import boto3 

3042 

3043 cf = boto3.client("cloudformation", region_name=region) 

3044 try: 

3045 response = cf.describe_stacks(StackName="CDKToolkit") 

3046 stacks = response.get("Stacks", []) 

3047 if stacks: 

3048 status = stacks[0].get("StackStatus", "") 

3049 # Any non-deleted state counts as bootstrapped 

3050 result = "DELETE" not in status 

3051 self._bootstrap_cache[region] = result 

3052 return result 

3053 except ClientError: 

3054 pass # Stack doesn't exist — not bootstrapped 

3055 except Exception as e: 

3056 logger.debug("Failed to check CDK bootstrap in %s: %s", region, e) 

3057 

3058 self._bootstrap_cache[region] = False 

3059 return False 

3060 

3061 def _validate_bootstrap_stack( 

3062 self, 

3063 region: str, 

3064 expected: Mapping[str, str], 

3065 ) -> None: 

3066 """Require the exact preflighted CDKToolkit ARN and healthy status.""" 

3067 import boto3 

3068 

3069 expected_id = str(expected.get("stack_id") or "") 

3070 expected_status = str(expected.get("status") or "") 

3071 if not expected_id or expected_status not in _BOOTSTRAP_HEALTHY_STATUSES: 3071 ↛ 3072line 3071 didn't jump to line 3072 because the condition on line 3071 was never true

3072 raise RuntimeError(f"Invalid checkpointed CDKToolkit identity for {region}") 

3073 cfn = boto3.client("cloudformation", region_name=region) 

3074 try: 

3075 response = cfn.describe_stacks(StackName=expected_id) 

3076 except Exception as exc: 

3077 raise RuntimeError( 

3078 f"Could not revalidate checkpointed CDKToolkit {expected_id} in {region}" 

3079 ) from exc 

3080 stacks = response.get("Stacks", []) 

3081 if len(stacks) != 1: 3081 ↛ 3082line 3081 didn't jump to line 3082 because the condition on line 3081 was never true

3082 raise RuntimeError(f"CDKToolkit {expected_id} returned an invalid identity") 

3083 stack = stacks[0] 

3084 actual_id = str(stack.get("StackId") or "") 

3085 actual_status = str(stack.get("StackStatus") or "") 

3086 if stack.get("StackName") != "CDKToolkit" or actual_id != expected_id: 

3087 raise RuntimeError(f"CDKToolkit identity changed in {region}") 

3088 if actual_status != expected_status or actual_status not in _BOOTSTRAP_HEALTHY_STATUSES: 

3089 raise RuntimeError( 

3090 f"CDKToolkit {expected_id} status changed from {expected_status} " 

3091 f"to {actual_status or 'unknown'}" 

3092 ) 

3093 

3094 @staticmethod 

3095 def _strict_change_set_name(stack_name: str, token: str) -> str: 

3096 """Return one deterministic, run-scoped CloudFormation change-set name.""" 

3097 safe_token = "".join( 

3098 character if character.isascii() and character.isalnum() else "-" for character in token 

3099 ) 

3100 safe_token = "-".join(part for part in safe_token.split("-") if part) 

3101 digest = hashlib.sha256(f"{token}:{stack_name}".encode()).hexdigest()[:16] 

3102 namespace = "gco" 

3103 max_token_length = 128 - len(namespace) - len(digest) - 2 

3104 safe_token = (safe_token or "live-validation")[:max_token_length] 

3105 return f"{namespace}-{safe_token}-{digest}" 

3106 

3107 def _preflight_strict_change_set( 

3108 self, 

3109 *, 

3110 stack_name: str, 

3111 change_set_name: str, 

3112 expected_stack_id: str | None, 

3113 prepared_change_sets: Mapping[str, Mapping[str, str]], 

3114 ) -> None: 

3115 """Reject an existing deterministic change set without checkpoint authority.""" 

3116 import boto3 

3117 

3118 region = self._get_deploy_region(stack_name) 

3119 if not region: 3119 ↛ 3120line 3119 didn't jump to line 3120 because the condition on line 3119 was never true

3120 raise RuntimeError(f"Could not resolve deploy Region for {stack_name}") 

3121 cfn = boto3.client("cloudformation", region_name=region) 

3122 try: 

3123 change_set = cfn.describe_change_set( 

3124 ChangeSetName=change_set_name, 

3125 StackName=stack_name, 

3126 ) 

3127 except ClientError as exc: 

3128 if self._change_set_missing(exc): 

3129 return 

3130 # DescribeChangeSet reports a stack-style ValidationError when both 

3131 # the deterministic change set and its fresh target stack are absent. 

3132 # The target was authoritatively checked immediately above; only an 

3133 # empty create history can safely interpret this as "not prepared". 

3134 if expected_stack_id is None and not prepared_change_sets and self._stack_missing(exc): 

3135 return 

3136 raise RuntimeError( 

3137 f"Could not preflight strict change set {change_set_name} for {stack_name}" 

3138 ) from exc 

3139 

3140 change_set_id = str(change_set.get("ChangeSetId") or "") 

3141 observed_change_set_name = str(change_set.get("ChangeSetName") or "") 

3142 stack_id = str(change_set.get("StackId") or "") 

3143 if not change_set_id or not stack_id or observed_change_set_name != change_set_name: 3143 ↛ 3144line 3143 didn't jump to line 3144 because the condition on line 3143 was never true

3144 raise RuntimeError( 

3145 f"Existing strict change set {change_set_name} omitted immutable identities" 

3146 ) 

3147 self._validate_strict_change_set_arns( 

3148 stack_name=stack_name, 

3149 change_set_name=change_set_name, 

3150 stack_id=stack_id, 

3151 change_set_id=change_set_id, 

3152 region=region, 

3153 ) 

3154 prepared_record = prepared_change_sets.get(change_set_id) 

3155 if prepared_record is None: 

3156 raise RuntimeError( 

3157 f"Existing strict change set {change_set_id} lacks checkpoint authority" 

3158 ) 

3159 recorded_change_set_id = str(prepared_record.get("change_set_id") or "") 

3160 recorded_stack_id = str(prepared_record.get("stack_id") or "") 

3161 recorded_type = str(prepared_record.get("change_set_type") or "") 

3162 if ( 3162 ↛ 3169line 3162 didn't jump to line 3169 because the condition on line 3162 was never true

3163 expected_stack_id is None 

3164 or stack_id != expected_stack_id 

3165 or recorded_change_set_id != change_set_id 

3166 or recorded_stack_id != stack_id 

3167 or recorded_type not in {"CREATE", "UPDATE"} 

3168 ): 

3169 raise RuntimeError(f"Existing strict change-set authority changed for {stack_name}") 

3170 

3171 @staticmethod 

3172 def _validate_strict_change_set_arns( 

3173 *, 

3174 stack_name: str, 

3175 change_set_name: str, 

3176 stack_id: str, 

3177 change_set_id: str, 

3178 region: str, 

3179 ) -> None: 

3180 """Require both prepared identities to be exact, related CloudFormation ARNs.""" 

3181 

3182 def split_arn(identifier: str, label: str) -> tuple[str, str, str, str]: 

3183 parts = identifier.split(":", 5) 

3184 if ( 

3185 len(parts) != 6 

3186 or parts[0] != "arn" 

3187 or not (parts[1] == "aws" or parts[1].startswith("aws-")) 

3188 or parts[2] != "cloudformation" 

3189 or parts[3] != region 

3190 or not parts[4] 

3191 or not parts[5] 

3192 ): 

3193 raise RuntimeError(f"Strict {label} has an invalid CloudFormation ARN") 

3194 return parts[1], parts[3], parts[4], parts[5] 

3195 

3196 stack_partition, _stack_region, stack_account, stack_resource = split_arn( 

3197 stack_id, 

3198 "stack identity", 

3199 ) 

3200 stack_prefix = f"stack/{stack_name}/" 

3201 if not stack_resource.startswith(stack_prefix) or not stack_resource.removeprefix( 3201 ↛ 3204line 3201 didn't jump to line 3204 because the condition on line 3201 was never true

3202 stack_prefix 

3203 ): 

3204 raise RuntimeError( 

3205 f"Strict stack identity {stack_id} does not name expected stack {stack_name}" 

3206 ) 

3207 

3208 change_partition, _change_region, change_account, change_resource = split_arn( 

3209 change_set_id, 

3210 "change-set identity", 

3211 ) 

3212 change_prefix = f"changeSet/{change_set_name}/" 

3213 if not change_resource.startswith(change_prefix) or not change_resource.removeprefix( 3213 ↛ 3216line 3213 didn't jump to line 3216 because the condition on line 3213 was never true

3214 change_prefix 

3215 ): 

3216 raise RuntimeError( 

3217 f"Strict change-set identity {change_set_id} does not name {change_set_name}" 

3218 ) 

3219 if change_partition != stack_partition or change_account != stack_account: 

3220 raise RuntimeError( 

3221 "Strict stack and change-set identities belong to different AWS authorities" 

3222 ) 

3223 

3224 def _execute_prepared_change_set( 

3225 self, 

3226 *, 

3227 stack_name: str, 

3228 change_set_name: str, 

3229 expected_stack_id: str | None, 

3230 expected_tags: Mapping[str, str] | None, 

3231 prepared_change_sets: Mapping[str, Mapping[str, str]], 

3232 preparation_succeeded: bool, 

3233 authorize_stack: StackAuthorizationCallback | None, 

3234 on_change_set_prepared: ChangeSetPreparedCallback, 

3235 allow_noop: bool, 

3236 timeout: float, 

3237 ) -> bool: 

3238 """Validate, checkpoint, and execute only the deterministic CDK change set.""" 

3239 import boto3 

3240 

3241 region = self._get_deploy_region(stack_name) 

3242 if not region: 3242 ↛ 3243line 3242 didn't jump to line 3243 because the condition on line 3242 was never true

3243 raise RuntimeError(f"Could not resolve deploy Region for {stack_name}") 

3244 cfn = boto3.client("cloudformation", region_name=region) 

3245 try: 

3246 change_set = cfn.describe_change_set( 

3247 ChangeSetName=change_set_name, 

3248 StackName=stack_name, 

3249 ) 

3250 except ClientError as exc: 

3251 if not self._change_set_missing(exc): 3251 ↛ 3252line 3251 didn't jump to line 3252 because the condition on line 3251 was never true

3252 raise RuntimeError( 

3253 f"Could not inspect strict change set {change_set_name} for {stack_name}" 

3254 ) from exc 

3255 if allow_noop and expected_stack_id: 3255 ↛ 3271line 3255 didn't jump to line 3271 because the condition on line 3255 was always true

3256 target = self._describe_stack_target( 

3257 stack_name, 

3258 expected_stack_id=expected_stack_id, 

3259 require_expected_identity=True, 

3260 ) 

3261 if target is not None: 3261 ↛ 3271line 3261 didn't jump to line 3271 because the condition on line 3261 was always true

3262 stack = target[2] 

3263 status = str(stack.get("StackStatus") or "") 

3264 if status in _BOOTSTRAP_HEALTHY_STATUSES: 3264 ↛ 3271line 3264 didn't jump to line 3271 because the condition on line 3264 was always true

3265 if authorize_stack is None: 3265 ↛ 3266line 3265 didn't jump to line 3266 because the condition on line 3265 was never true

3266 raise RuntimeError( 

3267 f"Strict no-op for {stack_name} lacks exact authorization" 

3268 ) from exc 

3269 authorize_stack(stack_name, region, expected_stack_id) 

3270 return True 

3271 raise RuntimeError( 

3272 f"CDK did not create the strict change set {change_set_name} for {stack_name}" 

3273 ) from exc 

3274 

3275 change_set_id = str(change_set.get("ChangeSetId") or "") 

3276 observed_change_set_name = str(change_set.get("ChangeSetName") or "") 

3277 stack_id = str(change_set.get("StackId") or "") 

3278 status = str(change_set.get("Status") or "") 

3279 execution_status = str(change_set.get("ExecutionStatus") or "") 

3280 if not change_set_id or not stack_id: 3280 ↛ 3281line 3280 didn't jump to line 3281 because the condition on line 3280 was never true

3281 raise RuntimeError(f"Strict change set {change_set_name} omitted immutable identities") 

3282 if observed_change_set_name != change_set_name: 3282 ↛ 3283line 3282 didn't jump to line 3283 because the condition on line 3282 was never true

3283 raise RuntimeError( 

3284 f"Strict change set identity changed from {change_set_name} " 

3285 f"to {observed_change_set_name or 'unknown'}" 

3286 ) 

3287 self._validate_strict_change_set_arns( 

3288 stack_name=stack_name, 

3289 change_set_name=change_set_name, 

3290 stack_id=stack_id, 

3291 change_set_id=change_set_id, 

3292 region=region, 

3293 ) 

3294 prepared_record = prepared_change_sets.get(change_set_id) 

3295 if prepared_record is None: 

3296 # DescribeChangeSet does not expose ChangeSetType. For a newly 

3297 # prepared change set, the pre-CDK exact target state is the only 

3298 # authoritative source: absence means CREATE; an exact stack means 

3299 # UPDATE. Resumes use the persisted per-change-set record below. 

3300 change_set_type = "CREATE" if expected_stack_id is None else "UPDATE" 

3301 else: 

3302 recorded_change_set_id = str(prepared_record.get("change_set_id") or "") 

3303 recorded_stack_id = str(prepared_record.get("stack_id") or "") 

3304 change_set_type = str(prepared_record.get("change_set_type") or "") 

3305 if recorded_change_set_id != change_set_id or recorded_stack_id != stack_id: 3305 ↛ 3306line 3305 didn't jump to line 3306 because the condition on line 3305 was never true

3306 raise RuntimeError( 

3307 f"Persisted strict change-set authority changed for {stack_name}" 

3308 ) 

3309 if change_set_type not in {"CREATE", "UPDATE"}: 3309 ↛ 3310line 3309 didn't jump to line 3310 because the condition on line 3309 was never true

3310 raise RuntimeError( 

3311 f"Persisted strict change set for {stack_name} has invalid type " 

3312 f"{change_set_type or 'unknown'}" 

3313 ) 

3314 if change_set_type == "UPDATE" and expected_stack_id is None: 3314 ↛ 3315line 3314 didn't jump to line 3315 because the condition on line 3314 was never true

3315 raise RuntimeError( 

3316 f"Strict change set for absent {stack_name} unexpectedly performs UPDATE" 

3317 ) 

3318 if expected_stack_id is not None and stack_id != expected_stack_id: 3318 ↛ 3319line 3318 didn't jump to line 3319 because the condition on line 3318 was never true

3319 raise RuntimeError( 

3320 f"Strict change set targets replacement {stack_id}; expected {expected_stack_id}" 

3321 ) 

3322 observed_tags = { 

3323 str(tag.get("Key")): str(tag.get("Value")) 

3324 for tag in change_set.get("Tags", []) 

3325 if tag.get("Key") is not None 

3326 } 

3327 for key, value in (expected_tags or {}).items(): 

3328 if observed_tags.get(str(key)) != str(value): 

3329 raise RuntimeError(f"Strict change set {change_set_id} omitted required tag {key}") 

3330 

3331 status_reason = " ".join(str(change_set.get("StatusReason") or "").split()).lower() 

3332 empty_change_set = ( 

3333 "submitted information didn't contain changes" in status_reason 

3334 or "no updates are to be performed" in status_reason 

3335 ) 

3336 if ( 

3337 status == "FAILED" 

3338 and empty_change_set 

3339 and (allow_noop or prepared_record is not None) 

3340 and expected_stack_id 

3341 ): 

3342 if stack_id != expected_stack_id: 3342 ↛ 3343line 3342 didn't jump to line 3343 because the condition on line 3342 was never true

3343 raise RuntimeError( 

3344 f"Empty strict change set targets {stack_id}; expected {expected_stack_id}" 

3345 ) 

3346 target = self._describe_stack_target( 

3347 stack_name, 

3348 expected_stack_id=expected_stack_id, 

3349 require_expected_identity=True, 

3350 ) 

3351 if target is None or str(target[2].get("StackStatus") or "") not in ( 3351 ↛ 3354line 3351 didn't jump to line 3354 because the condition on line 3351 was never true

3352 _BOOTSTRAP_HEALTHY_STATUSES 

3353 ): 

3354 raise RuntimeError( 

3355 f"Empty strict change set {change_set_id} has no healthy exact stack" 

3356 ) 

3357 if authorize_stack is None: 3357 ↛ 3358line 3357 didn't jump to line 3358 because the condition on line 3357 was never true

3358 raise RuntimeError(f"Strict no-op for {stack_name} lacks exact authorization") 

3359 authorize_stack(stack_name, region, expected_stack_id) 

3360 on_change_set_prepared( 

3361 stack_name, 

3362 region, 

3363 stack_id, 

3364 change_set_id, 

3365 change_set_type, 

3366 ) 

3367 return True 

3368 if status != "CREATE_COMPLETE" or execution_status not in { 3368 ↛ 3372line 3368 didn't jump to line 3372 because the condition on line 3368 was never true

3369 "AVAILABLE", 

3370 "EXECUTE_COMPLETE", 

3371 }: 

3372 raise RuntimeError( 

3373 f"Strict change set {change_set_id} is {status}/{execution_status}, not usable" 

3374 ) 

3375 

3376 if execution_status == "AVAILABLE" and ( 

3377 prepared_record is None and not preparation_succeeded 

3378 ): 

3379 raise RuntimeError( 

3380 f"Strict change set {change_set_id} was not produced by this preparation" 

3381 ) 

3382 if execution_status == "EXECUTE_COMPLETE" and ( 

3383 prepared_record is None or expected_stack_id is None 

3384 ): 

3385 raise RuntimeError( 

3386 f"Executed strict change set {change_set_id} lacks prior checkpoint authority" 

3387 ) 

3388 

3389 if expected_stack_id is not None: 

3390 if authorize_stack is None: 3390 ↛ 3391line 3390 didn't jump to line 3391 because the condition on line 3390 was never true

3391 raise RuntimeError(f"Strict change set for {stack_name} lacks exact authorization") 

3392 authorize_stack(stack_name, region, stack_id) 

3393 

3394 if execution_status == "EXECUTE_COMPLETE": 

3395 target = self._describe_stack_target( 

3396 stack_name, 

3397 expected_stack_id=stack_id, 

3398 require_expected_identity=True, 

3399 ) 

3400 if target is None or str(target[2].get("StackStatus") or "") not in ( 3400 ↛ 3403line 3400 didn't jump to line 3403 because the condition on line 3400 was never true

3401 _BOOTSTRAP_HEALTHY_STATUSES 

3402 ): 

3403 raise RuntimeError( 

3404 f"Executed strict change set {change_set_id} has no healthy exact stack" 

3405 ) 

3406 elif change_set_type == "CREATE": 

3407 target = self._describe_stack_target( 

3408 stack_name, 

3409 expected_stack_id=stack_id, 

3410 require_expected_identity=True, 

3411 ) 

3412 if target is None or str(target[2].get("StackStatus") or "") != "REVIEW_IN_PROGRESS": 

3413 raise RuntimeError( 

3414 f"Prepared CREATE change set {change_set_id} has no exact review stack" 

3415 ) 

3416 

3417 on_change_set_prepared( 

3418 stack_name, 

3419 region, 

3420 stack_id, 

3421 change_set_id, 

3422 change_set_type, 

3423 ) 

3424 if execution_status == "EXECUTE_COMPLETE": 

3425 return True 

3426 if self._cdk_cancel_event.is_set(): 

3427 raise RuntimeError( 

3428 f"Strict change set {change_set_id} was checkpointed but execution was cancelled" 

3429 ) 

3430 

3431 cfn.execute_change_set(ChangeSetName=change_set_id) 

3432 settled = self._wait_for_stack_settle( 

3433 stack_name, 

3434 timeout=timeout, 

3435 stack_identifier=stack_id, 

3436 ) 

3437 if settled not in _BOOTSTRAP_HEALTHY_STATUSES: 3437 ↛ 3438line 3437 didn't jump to line 3438 because the condition on line 3437 was never true

3438 logger.error( 

3439 "Strict change set %s for %s settled as %s", 

3440 change_set_id, 

3441 stack_name, 

3442 settled or "unknown", 

3443 ) 

3444 return False 

3445 return True 

3446 

3447 def ensure_bootstrapped(self, region: str) -> bool: 

3448 """Ensure a region is CDK-bootstrapped, auto-bootstrapping if needed. 

3449 

3450 Returns True if the region is (or was successfully) bootstrapped. 

3451 """ 

3452 if self.is_bootstrapped(region): 

3453 return True 

3454 

3455 print(f"ℹ Region {region} is not CDK-bootstrapped. Bootstrapping now...") 

3456 success = self.bootstrap(region=region) 

3457 if success: 

3458 # Update cache so we don't re-check this region 

3459 if not hasattr(self, "_bootstrap_cache"): 3459 ↛ 3461line 3459 didn't jump to line 3461 because the condition on line 3459 was always true

3460 self._bootstrap_cache = {} 

3461 self._bootstrap_cache[region] = True 

3462 print(f"✓ CDK bootstrapped in {region}") 

3463 else: 

3464 print(f"✗ Failed to bootstrap CDK in {region}") 

3465 return success 

3466 

3467 def _get_deploy_region(self, stack_name: str) -> str | None: 

3468 """Determine the target AWS region for a given stack name.""" 

3469 from .config import _load_cdk_json 

3470 

3471 cdk_regions = _load_cdk_json() 

3472 

3473 # Named stacks are classified by suffix and regional stacks by the 

3474 # ``<project>-`` prefix (#139) so a non-``gco`` deployment resolves 

3475 # regions for its own ``<project>-*`` stacks — otherwise the image 

3476 # mirror (which calls this to pick a regional stack's region) would 

3477 # silently no-op. For the default ``gco`` behaviour is unchanged. 

3478 region: str | None 

3479 if stack_name.endswith("-global"): 

3480 region = cdk_regions.get("global") or self.config.global_region 

3481 return region 

3482 if stack_name.endswith("-api-gateway"): 

3483 region = cdk_regions.get("api_gateway") or self.config.api_gateway_region 

3484 return region 

3485 if stack_name.endswith("-monitoring"): 

3486 region = cdk_regions.get("monitoring") or self.config.monitoring_region 

3487 return region 

3488 if stack_name.endswith("-analytics"): 

3489 # The analytics stack shares the API gateway region so the 

3490 # presigned-URL Lambda can hook into the existing /studio/* 

3491 # routes on the same API Gateway. 

3492 region = cdk_regions.get("api_gateway") or self.config.api_gateway_region 

3493 return region 

3494 

3495 # Regional API bridges use ``<project>-regional-api-<region>``. Resolve 

3496 # this exact shape before generic regional stacks; otherwise the generic 

3497 # project-prefix branch returns the malformed ``regional-api-<region>``. 

3498 # Requiring a configured deployment region also prevents bridge-shaped 

3499 # typos from being treated as valid AWS regions. 

3500 bridge_prefix = f"{self.config.project_name}-regional-api-" 

3501 if stack_name.startswith(bridge_prefix): 

3502 region = stack_name[len(bridge_prefix) :] 

3503 configured_regions = {str(item) for item in (cdk_regions.get("regional") or [])} 

3504 return region if region in configured_regions else None 

3505 

3506 # Base regional stacks: {project}-{region}. The region is whatever 

3507 # follows the project prefix (regions contain hyphens, so we strip 

3508 # the known prefix rather than guess a split point). 

3509 prefix = f"{self.config.project_name}-" 

3510 if stack_name.startswith(prefix): 

3511 return stack_name[len(prefix) :] 

3512 

3513 return None 

3514 

3515 def _mirror_target_regions(self, stack_name: str | None, all_stacks: bool) -> list[str]: 

3516 """Regional regions to auto-mirror images for on this deploy. 

3517 

3518 Only regional stacks (``gco-<region>``) run a Helm install that needs the 

3519 mirror; the named global / api-gateway / monitoring / analytics stacks do 

3520 not. For ``--all`` the regional regions come straight from cdk.json 

3521 (``deployment_regions.regional``) so no synth is required. Returns a 

3522 de-duplicated, order-stable list. 

3523 """ 

3524 # Derive the prefix from project_name (#139) so a non-``gco`` 

3525 # deployment's regional stacks (``<project>-<region>``) are still 

3526 # recognised — otherwise the mirror would silently no-op and the 

3527 # regional Volcano Helm install would have no images to pull. 

3528 prefix = f"{self.config.project_name}-" 

3529 named = { 

3530 f"{prefix}global", 

3531 f"{prefix}api-gateway", 

3532 f"{prefix}monitoring", 

3533 f"{prefix}analytics", 

3534 } 

3535 if all_stacks: 

3536 from .config import _load_cdk_json 

3537 

3538 regional = _load_cdk_json().get("regional") or [] 

3539 return list(dict.fromkeys(str(r) for r in regional)) 

3540 

3541 # Bridge stacks contain no regional Helm consumers and must not trigger 

3542 # image mirroring. Match the exact project-scoped prefix so a project 

3543 # name containing ``regional-api`` remains unambiguous. 

3544 bridge_prefix = f"{self.config.project_name}-regional-api-" 

3545 if stack_name and stack_name.startswith(bridge_prefix): 

3546 return [] 

3547 

3548 if stack_name and stack_name.startswith(prefix) and stack_name not in named: 

3549 region = self._get_deploy_region(stack_name) 

3550 return [region] if region else [] 

3551 return [] 

3552 

3553 def _mirror_images_if_enabled( 

3554 self, 

3555 stack_name: str | None, 

3556 all_stacks: bool, 

3557 repository_tags: Mapping[str, str] | None = None, 

3558 on_repository_created: EcrRepositoryCreatedCallback | None = None, 

3559 ) -> None: 

3560 """Mirror third-party images into ECR before a regional stack deploys. 

3561 

3562 No-op unless ``volcano_image_mirror.enabled`` is set in cdk.json. Mirrors 

3563 every relevant regional region (see :meth:`_mirror_target_regions`); the 

3564 copy is idempotent and skips images already present, so a fresh deploy 

3565 seeds the mirror automatically and repeat deploys cost only a few ECR 

3566 describe calls. Raises **before** any CDK call if an enabled mirror fails, 

3567 so a deploy never points a consumer (e.g. Volcano's ``image_registry``) 

3568 at images that aren't in ECR yet. 

3569 """ 

3570 from . import _image_mirror as image_mirror 

3571 

3572 cfg = image_mirror.read_mirror_config() 

3573 if not cfg["enabled"]: 

3574 return 

3575 

3576 regions = self._mirror_target_regions(stack_name, all_stacks) 

3577 for region in regions: 

3578 print(f"Mirroring third-party images into ECR for {region} ...") 

3579 try: 

3580 image_mirror.mirror_images( 

3581 region, 

3582 ecr_namespace=cfg["ecr_namespace"], 

3583 skip_existing=True, 

3584 repository_tags=repository_tags, 

3585 on_repository_created=on_repository_created, 

3586 ) 

3587 except Exception as exc: # noqa: BLE001 - surface a clear, actionable failure 

3588 raise RuntimeError( 

3589 f"Image mirror failed for region {region}: {exc}\n" 

3590 "volcano_image_mirror is enabled but the images could not be " 

3591 "mirrored into ECR. Fix the cause (container runtime / network / " 

3592 "credentials) or run " 

3593 f"'gco images mirror --region {region}' manually, " 

3594 "then retry. Aborting before CDK so the deploy never points a " 

3595 "consumer at images that aren't in ECR." 

3596 ) from exc 

3597 

3598 def get_outputs(self, stack_name: str, region: str) -> dict[str, str]: 

3599 """Get stack outputs from CloudFormation.""" 

3600 import boto3 

3601 

3602 cf = boto3.client("cloudformation", region_name=region) 

3603 try: 

3604 response = cf.describe_stacks(StackName=stack_name) 

3605 if response["Stacks"]: 

3606 stack = response["Stacks"][0] 

3607 outputs: dict[str, str] = {} 

3608 for output in stack.get("Outputs", []): 

3609 outputs[str(output["OutputKey"])] = str(output["OutputValue"]) 

3610 return outputs 

3611 except Exception as e: 

3612 logger.debug("Failed to get outputs for %s in %s: %s", stack_name, region, e) 

3613 return {} 

3614 

3615 def get_stack_status(self, stack_name: str, region: str) -> StackInfo | None: 

3616 """Get detailed stack status from CloudFormation.""" 

3617 import boto3 

3618 

3619 cf = boto3.client("cloudformation", region_name=region) 

3620 try: 

3621 response = cf.describe_stacks(StackName=stack_name) 

3622 if response["Stacks"]: 

3623 stack = response["Stacks"][0] 

3624 return StackInfo( 

3625 name=stack["StackName"], 

3626 status=stack["StackStatus"], 

3627 region=region, 

3628 created_time=stack.get("CreationTime"), 

3629 updated_time=stack.get("LastUpdatedTime"), 

3630 outputs={o["OutputKey"]: o["OutputValue"] for o in stack.get("Outputs", [])}, 

3631 tags={t["Key"]: t["Value"] for t in stack.get("Tags", [])}, 

3632 ) 

3633 except Exception as e: 

3634 logger.debug("Failed to get stack status for %s in %s: %s", stack_name, region, e) 

3635 return None 

3636 

3637 def deploy_orchestrated( 

3638 self, 

3639 require_approval: bool = True, 

3640 outputs_file: str | None = None, 

3641 parameters: dict[str, str] | None = None, 

3642 tags: dict[str, str] | None = None, 

3643 progress: str = "events", 

3644 on_stack_start: Callable[[str], None] | None = None, 

3645 on_stack_complete: Callable[[str, bool], None] | None = None, 

3646 parallel: bool = False, 

3647 max_workers: int = 4, 

3648 allow_bootstrap: bool = True, 

3649 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None = None, 

3650 expected_stack_ids: Mapping[str, str | None] | None = None, 

3651 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

3652 authorize_stack: StackAuthorizationCallback | None = None, 

3653 strict_deployment_token: str | None = None, 

3654 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

3655 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

3656 ) -> tuple[bool, list[str], list[str]]: 

3657 """ 

3658 Deploy all stacks in the correct order. 

3659 

3660 Deploys global stacks first, then base regional stacks, regional API 

3661 bridges, and finally monitoring. Parallelism never crosses a dependency 

3662 level. 

3663 

3664 Args: 

3665 require_approval: Whether to require approval for changes 

3666 outputs_file: File to write outputs to 

3667 parameters: CDK parameters 

3668 tags: Tags to apply to stacks 

3669 progress: Progress display type 

3670 on_stack_start: Callback(stack_name) called when starting a stack 

3671 on_stack_complete: Callback(stack_name, success) called when stack completes 

3672 parallel: Deploy regional stacks in parallel 

3673 max_workers: Maximum number of parallel deployments (default: 4) 

3674 

3675 Returns: 

3676 Tuple of (overall_success, successful_stacks, failed_stacks) 

3677 """ 

3678 stacks = self.list_stacks() 

3679 stack_names = set(stacks) 

3680 project_name = self.config.project_name 

3681 ordered_stacks = get_stack_deployment_order(stacks, project_name=project_name) 

3682 

3683 strict_deployment = ( 

3684 strict_deployment_token is not None or on_change_set_prepared is not None 

3685 ) 

3686 if strict_deployment: 

3687 if not strict_deployment_token or on_change_set_prepared is None: 3687 ↛ 3688line 3687 didn't jump to line 3688 because the condition on line 3687 was never true

3688 raise RuntimeError( 

3689 "Strict deployment requires both a run token and a prepared-change-set callback" 

3690 ) 

3691 if allow_bootstrap: 3691 ↛ 3692line 3691 didn't jump to line 3692 because the condition on line 3691 was never true

3692 raise RuntimeError("Strict orchestrated deployment cannot auto-bootstrap") 

3693 if authorize_stack is None: 3693 ↛ 3694line 3693 didn't jump to line 3694 because the condition on line 3693 was never true

3694 raise RuntimeError("Strict orchestrated deployment requires an exact authorizer") 

3695 if expected_stack_ids is None: 3695 ↛ 3696line 3695 didn't jump to line 3696 because the condition on line 3695 was never true

3696 raise RuntimeError("Strict orchestrated deployment lacks target identities") 

3697 if prepared_change_sets is None: 3697 ↛ 3698line 3697 didn't jump to line 3698 because the condition on line 3697 was never true

3698 raise RuntimeError("Strict orchestrated deployment lacks change-set history") 

3699 missing = sorted(set(stacks) - set(expected_stack_ids)) 

3700 unexpected = sorted(set(expected_stack_ids) - set(stacks)) 

3701 if missing or unexpected: 3701 ↛ 3702line 3701 didn't jump to line 3702 because the condition on line 3701 was never true

3702 raise RuntimeError( 

3703 "Strict deployment target map does not match the CDK graph; " 

3704 f"missing={missing}, unexpected={unexpected}" 

3705 ) 

3706 missing_history = sorted(set(stacks) - set(prepared_change_sets)) 

3707 unexpected_history = sorted(set(prepared_change_sets) - set(stacks)) 

3708 if missing_history or unexpected_history: 3708 ↛ 3709line 3708 didn't jump to line 3709 because the condition on line 3708 was never true

3709 raise RuntimeError( 

3710 "Strict change-set history does not match the CDK graph; " 

3711 f"missing={missing_history}, unexpected={unexpected_history}" 

3712 ) 

3713 

3714 # Validate every toolkit and every expected stack before the first 

3715 # repository copy, stuck-stack recovery, or CloudFormation mutation. 

3716 validated_regions: set[str] = set() 

3717 for target_name in ordered_stacks: 3717 ↛ 3749line 3717 didn't jump to line 3749 because the loop on line 3717 didn't complete

3718 region = self._get_deploy_region(target_name) 

3719 if not region: 3719 ↛ 3720line 3719 didn't jump to line 3720 because the condition on line 3719 was never true

3720 raise RuntimeError(f"Could not resolve deploy Region for {target_name}") 

3721 if region not in validated_regions: 

3722 expected_bootstrap = (bootstrap_stacks or {}).get(region) 

3723 if expected_bootstrap is None: 3723 ↛ 3724line 3723 didn't jump to line 3724 because the condition on line 3723 was never true

3724 raise RuntimeError( 

3725 f"Strict deployment lacks a checkpointed CDKToolkit identity for {region}" 

3726 ) 

3727 self._validate_bootstrap_stack(region, expected_bootstrap) 

3728 validated_regions.add(region) 

3729 

3730 expected_id = expected_stack_ids[target_name] 

3731 target = self._describe_stack_target( 

3732 target_name, 

3733 expected_stack_id=expected_id, 

3734 require_expected_identity=True, 

3735 ) 

3736 if expected_id is not None and target is None: 3736 ↛ 3737line 3736 didn't jump to line 3737 because the condition on line 3736 was never true

3737 raise RuntimeError( 

3738 f"Checkpointed stack {expected_id} is absent; refusing recreation" 

3739 ) 

3740 if target is not None: 3740 ↛ 3717line 3740 didn't jump to line 3717 because the condition on line 3740 was always true

3741 authorize_stack(target_name, region, str(target[2]["StackId"])) 

3742 

3743 # Separate stacks into four dependency levels by suffix/marker so 

3744 # ordering is independent of project_name (#139): 

3745 # 1. Pre-regional global stacks (<project>-global, <project>-api-gateway) 

3746 # 2. Base regional stacks (<project>-<region>, parallel-safe) 

3747 # 3. Regional API bridges (<project>-regional-api-<region>, depend on base) 

3748 # 4. Monitoring (depends on regional stacks) 

3749 pre_regional_stacks = [s for s in ordered_stacks if s.endswith(("-global", "-api-gateway"))] 

3750 regional_api_stacks = [ 

3751 s 

3752 for s in ordered_stacks 

3753 if _is_regional_api_bridge_stack( 

3754 s, 

3755 project_name=project_name, 

3756 stack_names=stack_names, 

3757 ) 

3758 ] 

3759 regional_stacks = [ 

3760 s 

3761 for s in ordered_stacks 

3762 if not s.endswith(("-global", "-api-gateway", "-monitoring")) 

3763 and not _is_regional_api_bridge_stack( 

3764 s, 

3765 project_name=project_name, 

3766 stack_names=stack_names, 

3767 ) 

3768 ] 

3769 post_regional_stacks = [s for s in ordered_stacks if s.endswith("-monitoring")] 

3770 

3771 successful: list[str] = [] 

3772 failed: list[str] = [] 

3773 deployment_safety: _StackOperationSafetyKwargs = { 

3774 "allow_bootstrap": allow_bootstrap, 

3775 "bootstrap_stacks": bootstrap_stacks, 

3776 "expected_stack_ids": expected_stack_ids, 

3777 "prepared_change_sets": prepared_change_sets, 

3778 "authorize_stack": authorize_stack, 

3779 "strict_deployment_token": strict_deployment_token, 

3780 "on_change_set_prepared": on_change_set_prepared, 

3781 "on_ecr_repository_created": on_ecr_repository_created, 

3782 } 

3783 

3784 # Phase 1: Deploy pre-regional global stacks sequentially 

3785 for stack_name in pre_regional_stacks: 

3786 if on_stack_start: 

3787 on_stack_start(stack_name) 

3788 

3789 success = self.deploy( 

3790 stack_name=stack_name, 

3791 require_approval=require_approval, 

3792 outputs_file=outputs_file, 

3793 parameters=parameters, 

3794 tags=tags, 

3795 progress=progress, 

3796 **deployment_safety, 

3797 ) 

3798 

3799 if success: 

3800 successful.append(stack_name) 

3801 else: 

3802 failed.append(stack_name) 

3803 

3804 if on_stack_complete: 

3805 on_stack_complete(stack_name, success) 

3806 

3807 # Stop on failure to prevent cascading issues 

3808 if not success: 

3809 return False, successful, failed 

3810 

3811 # Phase 2: Deploy regional stacks (parallel or sequential) 

3812 # All regional stacks pass --exclusively: globals are already deployed 

3813 # in Phase 1, so CDK doesn't need to re-evaluate them. Skipping that 

3814 # re-evaluation avoids re-running custom resources (notably 

3815 # KubectlApplyManifests) on the global stacks every time a regional 

3816 # stack is deployed — that would otherwise re-apply manifests and 

3817 # rollout-restart controllers for no actual change. 

3818 if regional_stacks: 3818 ↛ 3877line 3818 didn't jump to line 3877 because the condition on line 3818 was always true

3819 if parallel and len(regional_stacks) > 1: 

3820 # Parallel deployment of regional stacks 

3821 successful_regional, failed_regional = self._deploy_stacks_parallel( 

3822 stacks=regional_stacks, 

3823 require_approval=require_approval, 

3824 outputs_file=outputs_file, 

3825 parameters=parameters, 

3826 tags=tags, 

3827 progress=progress, 

3828 on_stack_start=on_stack_start, 

3829 on_stack_complete=on_stack_complete, 

3830 max_workers=max_workers, 

3831 allow_bootstrap=allow_bootstrap, 

3832 bootstrap_stacks=bootstrap_stacks, 

3833 expected_stack_ids=expected_stack_ids, 

3834 prepared_change_sets=prepared_change_sets, 

3835 authorize_stack=authorize_stack, 

3836 strict_deployment_token=strict_deployment_token, 

3837 on_change_set_prepared=on_change_set_prepared, 

3838 on_ecr_repository_created=on_ecr_repository_created, 

3839 ) 

3840 successful.extend(successful_regional) 

3841 failed.extend(failed_regional) 

3842 

3843 # Stop if any regional stack failed 

3844 if failed_regional: 

3845 return False, successful, failed 

3846 else: 

3847 # Sequential deployment 

3848 for stack_name in regional_stacks: 

3849 if on_stack_start: 

3850 on_stack_start(stack_name) 

3851 

3852 success = self.deploy( 

3853 stack_name=stack_name, 

3854 require_approval=require_approval, 

3855 outputs_file=outputs_file, 

3856 parameters=parameters, 

3857 tags=tags, 

3858 progress=progress, 

3859 exclusively=True, 

3860 **deployment_safety, 

3861 ) 

3862 

3863 if success: 3863 ↛ 3866line 3863 didn't jump to line 3866 because the condition on line 3863 was always true

3864 successful.append(stack_name) 

3865 else: 

3866 failed.append(stack_name) 

3867 

3868 if on_stack_complete: 

3869 on_stack_complete(stack_name, success) 

3870 

3871 # Stop on failure 

3872 if not success: 3872 ↛ 3873line 3872 didn't jump to line 3873 because the condition on line 3872 was never true

3873 return False, successful, failed 

3874 

3875 # Phase 3: Deploy regional API bridges only after every base regional 

3876 # stack is complete. Bridges within this level remain parallel-safe. 

3877 if regional_api_stacks: 

3878 if parallel and len(regional_api_stacks) > 1: 

3879 successful_api, failed_api = self._deploy_stacks_parallel( 

3880 stacks=regional_api_stacks, 

3881 require_approval=require_approval, 

3882 outputs_file=outputs_file, 

3883 parameters=parameters, 

3884 tags=tags, 

3885 progress=progress, 

3886 on_stack_start=on_stack_start, 

3887 on_stack_complete=on_stack_complete, 

3888 max_workers=max_workers, 

3889 allow_bootstrap=allow_bootstrap, 

3890 bootstrap_stacks=bootstrap_stacks, 

3891 expected_stack_ids=expected_stack_ids, 

3892 prepared_change_sets=prepared_change_sets, 

3893 authorize_stack=authorize_stack, 

3894 strict_deployment_token=strict_deployment_token, 

3895 on_change_set_prepared=on_change_set_prepared, 

3896 on_ecr_repository_created=on_ecr_repository_created, 

3897 ) 

3898 successful.extend(successful_api) 

3899 failed.extend(failed_api) 

3900 if failed_api: 3900 ↛ 3901line 3900 didn't jump to line 3901 because the condition on line 3900 was never true

3901 return False, successful, failed 

3902 else: 

3903 for stack_name in regional_api_stacks: 

3904 if on_stack_start: 3904 ↛ 3905line 3904 didn't jump to line 3905 because the condition on line 3904 was never true

3905 on_stack_start(stack_name) 

3906 

3907 success = self.deploy( 

3908 stack_name=stack_name, 

3909 require_approval=require_approval, 

3910 outputs_file=outputs_file, 

3911 parameters=parameters, 

3912 tags=tags, 

3913 progress=progress, 

3914 exclusively=True, 

3915 **deployment_safety, 

3916 ) 

3917 if success: 3917 ↛ 3920line 3917 didn't jump to line 3920 because the condition on line 3917 was always true

3918 successful.append(stack_name) 

3919 else: 

3920 failed.append(stack_name) 

3921 if on_stack_complete: 3921 ↛ 3922line 3921 didn't jump to line 3922 because the condition on line 3921 was never true

3922 on_stack_complete(stack_name, success) 

3923 if not success: 3923 ↛ 3924line 3923 didn't jump to line 3924 because the condition on line 3923 was never true

3924 return False, successful, failed 

3925 

3926 # Phase 4: Deploy post-regional stacks (monitoring) sequentially. 

3927 # Same rationale as Phase 2: every upstream stack is already 

3928 # deployed, so --exclusively prevents a redundant pass over 

3929 # global/api-gateway/regional. 

3930 for stack_name in post_regional_stacks: 

3931 if on_stack_start: 3931 ↛ 3932line 3931 didn't jump to line 3932 because the condition on line 3931 was never true

3932 on_stack_start(stack_name) 

3933 

3934 success = self.deploy( 

3935 stack_name=stack_name, 

3936 require_approval=require_approval, 

3937 outputs_file=outputs_file, 

3938 parameters=parameters, 

3939 tags=tags, 

3940 progress=progress, 

3941 exclusively=True, 

3942 **deployment_safety, 

3943 ) 

3944 

3945 if success: 3945 ↛ 3948line 3945 didn't jump to line 3948 because the condition on line 3945 was always true

3946 successful.append(stack_name) 

3947 else: 

3948 failed.append(stack_name) 

3949 

3950 if on_stack_complete: 3950 ↛ 3951line 3950 didn't jump to line 3951 because the condition on line 3950 was never true

3951 on_stack_complete(stack_name, success) 

3952 

3953 if not success: 3953 ↛ 3954line 3953 didn't jump to line 3954 because the condition on line 3953 was never true

3954 return False, successful, failed 

3955 

3956 return len(failed) == 0, successful, failed 

3957 

3958 def _deploy_stacks_parallel( 

3959 self, 

3960 stacks: list[str], 

3961 require_approval: bool, 

3962 outputs_file: str | None, 

3963 parameters: dict[str, str] | None, 

3964 tags: dict[str, str] | None, 

3965 progress: str, 

3966 on_stack_start: Callable[[str], None] | None, 

3967 on_stack_complete: Callable[[str, bool], None] | None, 

3968 max_workers: int, 

3969 allow_bootstrap: bool, 

3970 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None, 

3971 expected_stack_ids: Mapping[str, str | None] | None, 

3972 prepared_change_sets: PreparedChangeSetAuthority | None, 

3973 authorize_stack: StackAuthorizationCallback | None, 

3974 strict_deployment_token: str | None = None, 

3975 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

3976 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

3977 ) -> tuple[list[str], list[str]]: 

3978 """Deploy multiple stacks in parallel using separate CDK output directories.""" 

3979 import tempfile 

3980 

3981 successful: list[str] = [] 

3982 failed: list[str] = [] 

3983 lock = Lock() 

3984 

3985 def deploy_single(stack_name: str) -> tuple[str, bool]: 

3986 # Use a unique output directory in /tmp for each parallel deployment 

3987 # This avoids CDK copying cdk.out.* directories into assets 

3988 output_dir = tempfile.mkdtemp(prefix=f"cdk-{stack_name}-") 

3989 try: 

3990 if on_stack_start: 3990 ↛ 3991line 3990 didn't jump to line 3991 because the condition on line 3990 was never true

3991 with lock: 

3992 on_stack_start(stack_name) 

3993 

3994 success = self.deploy( 

3995 stack_name=stack_name, 

3996 require_approval=require_approval, 

3997 outputs_file=outputs_file, 

3998 parameters=parameters, 

3999 tags=tags, 

4000 progress=progress, 

4001 output_dir=output_dir, 

4002 exclusively=True, 

4003 allow_bootstrap=allow_bootstrap, 

4004 bootstrap_stacks=bootstrap_stacks, 

4005 expected_stack_ids=expected_stack_ids, 

4006 prepared_change_sets=prepared_change_sets, 

4007 authorize_stack=authorize_stack, 

4008 strict_deployment_token=strict_deployment_token, 

4009 on_change_set_prepared=on_change_set_prepared, 

4010 on_ecr_repository_created=on_ecr_repository_created, 

4011 ) 

4012 return stack_name, success 

4013 finally: 

4014 try: 

4015 import shutil 

4016 

4017 if os.path.exists(output_dir): 

4018 shutil.rmtree(output_dir) 

4019 except Exception as e: 

4020 logger.debug("Cleanup of %s failed: %s", output_dir, e) 

4021 

4022 self._cdk_cancel_event.clear() 

4023 futures: dict[Any, str] = {} 

4024 executor = ThreadPoolExecutor(max_workers=max_workers) 

4025 try: 

4026 futures = {executor.submit(deploy_single, stack): stack for stack in stacks} 

4027 

4028 for future in as_completed(futures): 

4029 stack_name, success = future.result() 

4030 

4031 with lock: 

4032 if success: 

4033 successful.append(stack_name) 

4034 else: 

4035 failed.append(stack_name) 

4036 

4037 if on_stack_complete: 4037 ↛ 4038line 4037 didn't jump to line 4038 because the condition on line 4037 was never true

4038 on_stack_complete(stack_name, success) 

4039 except BaseException: 

4040 # Terminate registered process groups before waiting for executor 

4041 # shutdown; the context-manager form waits first and can deadlock an 

4042 # interrupted orchestration behind a still-running CDK worker. 

4043 self.cancel_active_cdk_processes() 

4044 for future in futures: 

4045 future.cancel() 

4046 executor.shutdown(wait=True, cancel_futures=True) 

4047 raise 

4048 else: 

4049 executor.shutdown(wait=True) 

4050 finally: 

4051 self._cdk_cancel_event.clear() 

4052 

4053 return successful, failed 

4054 

4055 def _resolve_strict_teardown_resources( 

4056 self, 

4057 *, 

4058 stacks: Collection[str], 

4059 regional_stacks: Collection[str], 

4060 expected_stack_ids: Mapping[str, str | None], 

4061 authorize_stack: StackAuthorizationCallback, 

4062 ) -> dict[str, dict[str, str]]: 

4063 """Authorize every live stack, then resolve helper IDs from exact stack ARNs.""" 

4064 import boto3 

4065 

4066 live_targets: dict[str, tuple[str, Any, dict[str, Any]]] = {} 

4067 for stack_name in stacks: 

4068 expected_stack_id = expected_stack_ids[stack_name] 

4069 target = self._describe_stack_target( 

4070 stack_name, 

4071 expected_stack_id=expected_stack_id, 

4072 require_expected_identity=True, 

4073 ) 

4074 if target is None: 4074 ↛ 4075line 4074 didn't jump to line 4075 because the condition on line 4074 was never true

4075 continue 

4076 region, _cloudformation, stack = target 

4077 stack_id = str(stack["StackId"]) 

4078 authorize_stack(stack_name, region, stack_id) 

4079 live_targets[stack_name] = target 

4080 

4081 project_name = self.config.project_name 

4082 base_regional_stacks: list[str] = [] 

4083 for stack_name in regional_stacks: 

4084 deploy_region = self._get_deploy_region(stack_name) 

4085 if deploy_region and stack_name == f"{project_name}-{deploy_region}": 4085 ↛ 4083line 4085 didn't jump to line 4083 because the condition on line 4085 was always true

4086 base_regional_stacks.append(stack_name) 

4087 

4088 resolved: dict[str, dict[str, str]] = {} 

4089 for stack_name in base_regional_stacks: 

4090 target = live_targets.get(stack_name) 

4091 if target is None: 4091 ↛ 4092line 4091 didn't jump to line 4092 because the condition on line 4091 was never true

4092 continue 

4093 region, cloudformation, stack = target 

4094 stack_id = str(stack["StackId"]) 

4095 summaries: list[dict[str, Any]] = [] 

4096 paginator = cloudformation.get_paginator("list_stack_resources") 

4097 for page in paginator.paginate(StackName=stack_id): 

4098 summaries.extend(page.get("StackResourceSummaries", [])) 

4099 

4100 vpc_ids = { 

4101 str(item["PhysicalResourceId"]) 

4102 for item in summaries 

4103 if item.get("ResourceType") == "AWS::EC2::VPC" and item.get("PhysicalResourceId") 

4104 } 

4105 cluster_names = { 

4106 str(item["PhysicalResourceId"]) 

4107 for item in summaries 

4108 if item.get("ResourceType") == "AWS::EKS::Cluster" 

4109 and item.get("PhysicalResourceId") 

4110 } 

4111 if len(vpc_ids) > 1 or len(cluster_names) > 1: 4111 ↛ 4112line 4111 didn't jump to line 4112 because the condition on line 4111 was never true

4112 raise RuntimeError(f"Exact stack {stack_id} returned ambiguous VPC/EKS resources") 

4113 

4114 details = { 

4115 "stack_name": stack_name, 

4116 "stack_id": stack_id, 

4117 "region": region, 

4118 } 

4119 vpc_id = next(iter(vpc_ids), "") 

4120 cluster_name = next(iter(cluster_names), "") 

4121 if vpc_id: 4121 ↛ 4123line 4121 didn't jump to line 4123 because the condition on line 4121 was always true

4122 details["vpc_id"] = vpc_id 

4123 if cluster_name: 4123 ↛ 4174line 4123 didn't jump to line 4174 because the condition on line 4123 was always true

4124 details["cluster_name"] = cluster_name 

4125 cluster: dict[str, Any] | None 

4126 try: 

4127 cluster = boto3.client("eks", region_name=region).describe_cluster( 

4128 name=cluster_name 

4129 )["cluster"] 

4130 except ClientError as exc: 

4131 if exc.response.get("Error", {}).get("Code") == "ResourceNotFoundException": 

4132 cluster = None 

4133 else: 

4134 raise 

4135 if cluster is not None: 4135 ↛ 4152line 4135 didn't jump to line 4152 because the condition on line 4135 was always true

4136 if str(cluster.get("name") or "") != cluster_name: 4136 ↛ 4137line 4136 didn't jump to line 4137 because the condition on line 4136 was never true

4137 raise RuntimeError( 

4138 f"EKS returned a changed identity for {region}:{cluster_name}" 

4139 ) 

4140 networking = cluster.get("resourcesVpcConfig") or {} 

4141 cluster_vpc_id = str(networking.get("vpcId") or "") 

4142 security_group_id = str(networking.get("clusterSecurityGroupId") or "") 

4143 if vpc_id and cluster_vpc_id != vpc_id: 4143 ↛ 4144line 4143 didn't jump to line 4144 because the condition on line 4143 was never true

4144 raise RuntimeError( 

4145 f"EKS cluster {cluster_name} no longer belongs to exact VPC {vpc_id}" 

4146 ) 

4147 if not security_group_id: 4147 ↛ 4148line 4147 didn't jump to line 4148 because the condition on line 4147 was never true

4148 raise RuntimeError( 

4149 f"EKS cluster {cluster_name} omitted its security-group identity" 

4150 ) 

4151 details["cluster_security_group_id"] = security_group_id 

4152 elif vpc_id: 

4153 # On teardown resume the cluster may already be gone while 

4154 # its managed SG remains. Resolve the SG ID inside the exact 

4155 # stack VPC using the exact cluster physical ID. 

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

4157 groups = ec2.describe_security_groups( 

4158 Filters=[ 

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

4160 { 

4161 "Name": "tag:aws:eks:cluster-name", 

4162 "Values": [cluster_name], 

4163 }, 

4164 ] 

4165 ).get("SecurityGroups", []) 

4166 group_ids = {str(group["GroupId"]) for group in groups if group.get("GroupId")} 

4167 if len(group_ids) > 1: 

4168 raise RuntimeError( 

4169 f"Exact VPC {vpc_id} has ambiguous EKS security groups for " 

4170 f"{cluster_name}" 

4171 ) 

4172 if group_ids: 

4173 details["cluster_security_group_id"] = next(iter(group_ids)) 

4174 resolved[stack_name] = details 

4175 return resolved 

4176 

4177 def _destroy_phase_remaining_stacks( 

4178 self, 

4179 phase_name: str, 

4180 stacks: Collection[str], 

4181 expected_stack_ids: Mapping[str, str | None] | None = None, 

4182 ) -> list[str]: 

4183 """Return stacks still present after a dependency phase. 

4184 

4185 A lookup error is treated as present: advancing when absence cannot be 

4186 proven is less safe than stopping for an operator retry. 

4187 """ 

4188 remaining: list[str] = [] 

4189 for stack_name in stacks: 

4190 try: 

4191 present = self._stack_exists_in_cloudformation( 

4192 stack_name, 

4193 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

4194 require_expected_identity=expected_stack_ids is not None, 

4195 ) 

4196 except Exception: 

4197 logger.exception( 

4198 "Could not verify %s absence after %s", 

4199 stack_name, 

4200 phase_name, 

4201 ) 

4202 present = True 

4203 if present: 

4204 remaining.append(stack_name) 

4205 if remaining: 

4206 print( 

4207 f" {phase_name} barrier blocked: stack absence was not confirmed for " 

4208 + ", ".join(remaining) 

4209 ) 

4210 return remaining 

4211 

4212 def destroy_orchestrated( 

4213 self, 

4214 force: bool = False, 

4215 on_stack_start: Callable[[str], None] | None = None, 

4216 on_stack_complete: Callable[[str, bool], None] | None = None, 

4217 parallel: bool = False, 

4218 max_workers: int = 4, 

4219 expected_stack_ids: Mapping[str, str | None] | None = None, 

4220 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

4221 authorize_stack: StackAuthorizationCallback | None = None, 

4222 allow_bootstrap: bool = True, 

4223 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None = None, 

4224 on_cleanup_complete: CleanupOutcomeCallback | None = None, 

4225 strict_deployment_token: str | None = None, 

4226 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

4227 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

4228 ) -> tuple[bool, list[str], list[str]]: 

4229 """Destroy stacks in dependency order with optional exact-ARN authority.""" 

4230 app_stacks = self.list_stacks() 

4231 strict_identity = expected_stack_ids is not None 

4232 if strict_identity: 

4233 assert expected_stack_ids is not None 

4234 missing = sorted(set(app_stacks) - set(expected_stack_ids)) 

4235 if missing: 

4236 raise RuntimeError( 

4237 f"Strict teardown target map is incomplete before cleanup; missing={missing}" 

4238 ) 

4239 if authorize_stack is None: 4239 ↛ 4240line 4239 didn't jump to line 4240 because the condition on line 4239 was never true

4240 raise RuntimeError("Strict teardown requires an exact stack authorizer") 

4241 invalid = sorted( 

4242 name 

4243 for name, stack_id in expected_stack_ids.items() 

4244 if stack_id is not None and not str(stack_id).startswith("arn:") 

4245 ) 

4246 if invalid: 4246 ↛ 4247line 4246 didn't jump to line 4247 because the condition on line 4246 was never true

4247 raise RuntimeError( 

4248 f"Strict teardown has invalid stack identities for: {', '.join(invalid)}" 

4249 ) 

4250 

4251 strict_prepared_deployment = ( 

4252 strict_deployment_token is not None or on_change_set_prepared is not None 

4253 ) 

4254 if strict_prepared_deployment: 

4255 if not strict_deployment_token or on_change_set_prepared is None: 4255 ↛ 4256line 4255 didn't jump to line 4256 because the condition on line 4255 was never true

4256 raise RuntimeError( 

4257 "Strict teardown dependency deployment requires both a run token " 

4258 "and a prepared-change-set callback" 

4259 ) 

4260 if prepared_change_sets is None: 4260 ↛ 4261line 4260 didn't jump to line 4261 because the condition on line 4260 was never true

4261 raise RuntimeError("Strict teardown lacks prepared change-set history") 

4262 expected_history_keys = set(expected_stack_ids or {}) 

4263 if set(prepared_change_sets) != expected_history_keys: 4263 ↛ 4264line 4263 didn't jump to line 4264 because the condition on line 4263 was never true

4264 raise RuntimeError( 

4265 "Strict teardown change-set history does not match target identities" 

4266 ) 

4267 

4268 stacks = list(app_stacks) 

4269 if expected_stack_ids is not None: 

4270 for stack_name in expected_stack_ids: 

4271 if stack_name not in stacks: 4271 ↛ 4272line 4271 didn't jump to line 4272 because the condition on line 4271 was never true

4272 stacks.append(stack_name) 

4273 project_name = self.config.project_name 

4274 ( 

4275 post_regional_stacks, 

4276 regional_api_stacks, 

4277 regional_stacks, 

4278 pre_regional_stacks, 

4279 ) = _get_stack_destroy_phases(stacks, project_name=project_name) 

4280 

4281 strict_resources: dict[str, dict[str, str]] = {} 

4282 if strict_identity: 

4283 assert expected_stack_ids is not None 

4284 assert authorize_stack is not None 

4285 strict_resources = self._resolve_strict_teardown_resources( 

4286 stacks=stacks, 

4287 regional_stacks=regional_stacks, 

4288 expected_stack_ids=expected_stack_ids, 

4289 authorize_stack=authorize_stack, 

4290 ) 

4291 

4292 destroy_safety: _StackOperationSafetyKwargs = { 

4293 "expected_stack_ids": expected_stack_ids, 

4294 "prepared_change_sets": prepared_change_sets, 

4295 "authorize_stack": authorize_stack, 

4296 "allow_bootstrap": allow_bootstrap, 

4297 "bootstrap_stacks": bootstrap_stacks, 

4298 "strict_deployment_token": strict_deployment_token, 

4299 "on_change_set_prepared": on_change_set_prepared, 

4300 "on_ecr_repository_created": on_ecr_repository_created, 

4301 } 

4302 

4303 def record_cleanup(name: str, details: dict[str, Any]) -> None: 

4304 if on_cleanup_complete is not None: 

4305 on_cleanup_complete(name, details) 

4306 

4307 if not self._image_registry_destroy_preflight(force=force): 

4308 return False, [], list(stacks) 

4309 

4310 bastion_targets = { 

4311 name: details for name, details in strict_resources.items() if details.get("vpc_id") 

4312 } 

4313 bastions = self.cleanup_orphaned_bastions( 

4314 stacks, 

4315 parallel=parallel, 

4316 resource_targets=bastion_targets if strict_identity else None, 

4317 ) 

4318 record_cleanup("bastions", {"terminated_instances": bastions}) 

4319 

4320 # Non-strict teardowns also retire the bastion's standing IAM 

4321 # role/profile and, below, the implicit log groups CloudFormation 

4322 # never modeled. Strict (live-validation) teardowns skip both: the 

4323 # harness owns fenced log-group deletion and audits IAM itself. 

4324 if not strict_identity: 

4325 record_cleanup("bastion-iam", self._cleanup_bastion_iam()) 

4326 

4327 global_stack_name = f"{project_name}-global" 

4328 backup = self._cleanup_backup_vault( 

4329 expected_stack_id=(expected_stack_ids or {}).get(global_stack_name), 

4330 authorize_stack=authorize_stack, 

4331 require_expected_identity=strict_identity, 

4332 ) 

4333 record_cleanup("backup-vault", backup) 

4334 if strict_identity and backup.get("errors"): 4334 ↛ 4335line 4334 didn't jump to line 4335 because the condition on line 4334 was never true

4335 raise RuntimeError( 

4336 "Strict backup-vault cleanup failed before stack deletion: " 

4337 + json.dumps(backup["errors"], sort_keys=True) 

4338 ) 

4339 

4340 successful: list[str] = [] 

4341 failed: list[str] = [] 

4342 

4343 # Capture implicit log-group names while the source stacks still 

4344 # exist; the exact derived names are deleted by ``finish`` below 

4345 # once their stacks are gone. Strict teardowns collect nothing — 

4346 # the live-validation harness owns fenced log-group deletion. 

4347 implicit_log_groups: dict[str, dict[str, Any]] = {} 

4348 if not strict_identity: 

4349 implicit_log_groups = self._collect_implicit_log_groups(stacks) 

4350 

4351 def finish(overall: bool) -> tuple[bool, list[str], list[str]]: 

4352 """Funnel every exit through the implicit log-group sweep. 

4353 

4354 Called at each return point so a partially failed teardown 

4355 still cleans up the stacks that DID delete. New exit paths 

4356 must return through here as well. 

4357 """ 

4358 if implicit_log_groups: 

4359 record_cleanup( 

4360 "implicit-log-groups", 

4361 self._cleanup_implicit_log_groups(implicit_log_groups, successful), 

4362 ) 

4363 return overall, successful, failed 

4364 

4365 for stack_name in post_regional_stacks: 

4366 if on_stack_start: 4366 ↛ 4367line 4366 didn't jump to line 4367 because the condition on line 4366 was never true

4367 on_stack_start(stack_name) 

4368 success = self.destroy( 

4369 stack_name=stack_name, 

4370 force=force, 

4371 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

4372 **destroy_safety, 

4373 ) 

4374 (successful if success else failed).append(stack_name) 

4375 if on_stack_complete: 4375 ↛ 4376line 4375 didn't jump to line 4376 because the condition on line 4375 was never true

4376 on_stack_complete(stack_name, success) 

4377 

4378 phase_remaining = self._destroy_phase_remaining_stacks( 

4379 "post-regional", 

4380 post_regional_stacks, 

4381 expected_stack_ids, 

4382 ) 

4383 for stack_name in phase_remaining: 4383 ↛ 4384line 4383 didn't jump to line 4384 because the loop on line 4383 never started

4384 if stack_name not in failed: 

4385 failed.append(stack_name) 

4386 if any(stack in failed for stack in post_regional_stacks) or phase_remaining: 4386 ↛ 4387line 4386 didn't jump to line 4387 because the condition on line 4386 was never true

4387 return finish(False) 

4388 

4389 if regional_api_stacks: 

4390 if parallel and len(regional_api_stacks) > 1: 

4391 successful_api, failed_api = self._destroy_stacks_parallel( 

4392 stacks=regional_api_stacks, 

4393 force=force, 

4394 on_stack_start=on_stack_start, 

4395 on_stack_complete=on_stack_complete, 

4396 max_workers=max_workers, 

4397 expected_stack_ids=expected_stack_ids, 

4398 authorize_stack=authorize_stack, 

4399 allow_bootstrap=allow_bootstrap, 

4400 bootstrap_stacks=bootstrap_stacks, 

4401 prepared_change_sets=prepared_change_sets, 

4402 strict_deployment_token=strict_deployment_token, 

4403 on_change_set_prepared=on_change_set_prepared, 

4404 on_ecr_repository_created=on_ecr_repository_created, 

4405 ) 

4406 successful.extend(successful_api) 

4407 failed.extend(failed_api) 

4408 else: 

4409 for stack_name in regional_api_stacks: 

4410 if on_stack_start: 4410 ↛ 4411line 4410 didn't jump to line 4411 because the condition on line 4410 was never true

4411 on_stack_start(stack_name) 

4412 success = self.destroy( 

4413 stack_name=stack_name, 

4414 force=force, 

4415 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

4416 **destroy_safety, 

4417 ) 

4418 (successful if success else failed).append(stack_name) 

4419 if on_stack_complete: 4419 ↛ 4420line 4419 didn't jump to line 4420 because the condition on line 4419 was never true

4420 on_stack_complete(stack_name, success) 

4421 phase_remaining = self._destroy_phase_remaining_stacks( 

4422 "regional API bridge", 

4423 regional_api_stacks, 

4424 expected_stack_ids, 

4425 ) 

4426 for stack_name in phase_remaining: 4426 ↛ 4427line 4426 didn't jump to line 4427 because the loop on line 4426 never started

4427 if stack_name not in failed: 

4428 failed.append(stack_name) 

4429 if any(stack in failed for stack in regional_api_stacks) or phase_remaining: 4429 ↛ 4430line 4429 didn't jump to line 4430 because the condition on line 4429 was never true

4430 return finish(False) 

4431 

4432 watchdog_stops: dict[str, Event] = {} 

4433 watchdog_threads: dict[str, Thread] = {} 

4434 watchdog_targets = ( 

4435 [ 

4436 name 

4437 for name in regional_stacks 

4438 if strict_resources.get(name, {}).get("cluster_security_group_id") 

4439 ] 

4440 if strict_identity 

4441 else list(regional_stacks) 

4442 ) 

4443 try: 

4444 for stack_name in watchdog_targets: 

4445 details = strict_resources.get(stack_name, {}) 

4446 stop_event = Event() 

4447 watchdog_stops[stack_name] = stop_event 

4448 watchdog_threads[stack_name] = self._start_eks_sg_watchdog( 

4449 stack_name, 

4450 stop_event, 

4451 region=details.get("region"), 

4452 security_group_id=details.get("cluster_security_group_id"), 

4453 vpc_id=details.get("vpc_id"), 

4454 ) 

4455 

4456 if regional_stacks: 4456 ↛ 4489line 4456 didn't jump to line 4489 because the condition on line 4456 was always true

4457 if parallel and len(regional_stacks) > 1: 

4458 successful_regional, failed_regional = self._destroy_stacks_parallel( 

4459 stacks=regional_stacks, 

4460 force=force, 

4461 on_stack_start=on_stack_start, 

4462 on_stack_complete=on_stack_complete, 

4463 max_workers=max_workers, 

4464 expected_stack_ids=expected_stack_ids, 

4465 authorize_stack=authorize_stack, 

4466 allow_bootstrap=allow_bootstrap, 

4467 bootstrap_stacks=bootstrap_stacks, 

4468 prepared_change_sets=prepared_change_sets, 

4469 strict_deployment_token=strict_deployment_token, 

4470 on_change_set_prepared=on_change_set_prepared, 

4471 on_ecr_repository_created=on_ecr_repository_created, 

4472 ) 

4473 successful.extend(successful_regional) 

4474 failed.extend(failed_regional) 

4475 else: 

4476 for stack_name in regional_stacks: 

4477 if on_stack_start: 

4478 on_stack_start(stack_name) 

4479 success = self.destroy( 

4480 stack_name=stack_name, 

4481 force=force, 

4482 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

4483 **destroy_safety, 

4484 ) 

4485 (successful if success else failed).append(stack_name) 

4486 if on_stack_complete: 

4487 on_stack_complete(stack_name, success) 

4488 finally: 

4489 for stop_event in watchdog_stops.values(): 

4490 stop_event.set() 

4491 for stack_name, thread in watchdog_threads.items(): 

4492 try: 

4493 thread.join(timeout=5) 

4494 except Exception as exc: 

4495 logger.exception("Could not join teardown watchdog for %s", stack_name) 

4496 record_cleanup( 

4497 "eks-security-group", 

4498 {"stack": stack_name, "errors": [f"{type(exc).__name__}: {exc}"]}, 

4499 ) 

4500 if strict_identity and stack_name not in failed: 

4501 failed.append(stack_name) 

4502 continue 

4503 details = strict_resources.get(stack_name, {}) 

4504 if strict_identity and thread.is_alive(): 4504 ↛ 4505line 4504 didn't jump to line 4505 because the condition on line 4504 was never true

4505 outcome = { 

4506 "stack": stack_name, 

4507 "errors": ["watchdog thread did not stop"], 

4508 } 

4509 else: 

4510 outcome = self._cleanup_eks_security_groups( 

4511 stack_name, 

4512 region=details.get("region"), 

4513 security_group_id=details.get("cluster_security_group_id"), 

4514 vpc_id=details.get("vpc_id"), 

4515 ) 

4516 record_cleanup("eks-security-group", outcome) 

4517 if ( 

4518 strict_identity 

4519 and (outcome.get("errors") or outcome.get("blocked_by_enis")) 

4520 and stack_name not in failed 

4521 ): 

4522 failed.append(stack_name) 

4523 

4524 phase_remaining = self._destroy_phase_remaining_stacks( 

4525 "regional", 

4526 regional_stacks, 

4527 expected_stack_ids, 

4528 ) 

4529 for stack_name in phase_remaining: 

4530 if stack_name not in failed: 

4531 failed.append(stack_name) 

4532 if any(stack in failed for stack in regional_stacks) or phase_remaining: 

4533 return finish(False) 

4534 

4535 for stack_name in pre_regional_stacks: 

4536 if on_stack_start: 

4537 on_stack_start(stack_name) 

4538 success = self.destroy( 

4539 stack_name=stack_name, 

4540 force=force, 

4541 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

4542 **destroy_safety, 

4543 ) 

4544 (successful if success else failed).append(stack_name) 

4545 if on_stack_complete: 

4546 on_stack_complete(stack_name, success) 

4547 

4548 phase_remaining = self._destroy_phase_remaining_stacks( 

4549 "pre-regional global", 

4550 [stack_name], 

4551 expected_stack_ids, 

4552 ) 

4553 for remaining_stack in phase_remaining: 4553 ↛ 4554line 4553 didn't jump to line 4554 because the loop on line 4553 never started

4554 if remaining_stack not in failed: 

4555 failed.append(remaining_stack) 

4556 if not success or phase_remaining: 

4557 return finish(False) 

4558 

4559 return finish(len(failed) == 0) 

4560 

4561 def _destroy_stacks_parallel( 

4562 self, 

4563 stacks: list[str], 

4564 force: bool, 

4565 on_stack_start: Callable[[str], None] | None, 

4566 on_stack_complete: Callable[[str, bool], None] | None, 

4567 max_workers: int, 

4568 expected_stack_ids: Mapping[str, str | None] | None, 

4569 authorize_stack: StackAuthorizationCallback | None, 

4570 allow_bootstrap: bool, 

4571 bootstrap_stacks: Mapping[str, Mapping[str, str]] | None, 

4572 prepared_change_sets: PreparedChangeSetAuthority | None, 

4573 strict_deployment_token: str | None = None, 

4574 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

4575 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

4576 ) -> tuple[list[str], list[str]]: 

4577 """Destroy multiple stacks in parallel using separate CDK output directories.""" 

4578 import tempfile 

4579 

4580 successful: list[str] = [] 

4581 failed: list[str] = [] 

4582 lock = Lock() 

4583 

4584 def destroy_single(stack_name: str) -> tuple[str, bool]: 

4585 # Use a unique output directory in /tmp for each parallel destruction 

4586 output_dir = tempfile.mkdtemp(prefix=f"cdk-{stack_name}-") 

4587 try: 

4588 if on_stack_start: 4588 ↛ 4589line 4588 didn't jump to line 4589 because the condition on line 4588 was never true

4589 with lock: 

4590 on_stack_start(stack_name) 

4591 

4592 success = self.destroy( 

4593 stack_name=stack_name, 

4594 force=force, 

4595 output_dir=output_dir, 

4596 expected_stack_id=(expected_stack_ids or {}).get(stack_name), 

4597 expected_stack_ids=expected_stack_ids, 

4598 authorize_stack=authorize_stack, 

4599 allow_bootstrap=allow_bootstrap, 

4600 bootstrap_stacks=bootstrap_stacks, 

4601 prepared_change_sets=prepared_change_sets, 

4602 strict_deployment_token=strict_deployment_token, 

4603 on_change_set_prepared=on_change_set_prepared, 

4604 on_ecr_repository_created=on_ecr_repository_created, 

4605 ) 

4606 return stack_name, success 

4607 finally: 

4608 try: 

4609 import shutil 

4610 

4611 if os.path.exists(output_dir): 

4612 shutil.rmtree(output_dir) 

4613 except Exception as e: 

4614 logger.debug("Cleanup of %s failed: %s", output_dir, e) 

4615 

4616 self._cdk_cancel_event.clear() 

4617 futures: dict[Any, str] = {} 

4618 executor = ThreadPoolExecutor(max_workers=max_workers) 

4619 try: 

4620 futures = {executor.submit(destroy_single, stack): stack for stack in stacks} 

4621 

4622 for future in as_completed(futures): 

4623 stack_name, success = future.result() 

4624 

4625 with lock: 

4626 if success: 

4627 successful.append(stack_name) 

4628 else: 

4629 failed.append(stack_name) 

4630 

4631 if on_stack_complete: 4631 ↛ 4632line 4631 didn't jump to line 4632 because the condition on line 4631 was never true

4632 on_stack_complete(stack_name, success) 

4633 except BaseException: 

4634 self.cancel_active_cdk_processes() 

4635 for future in futures: 

4636 future.cancel() 

4637 executor.shutdown(wait=True, cancel_futures=True) 

4638 raise 

4639 else: 

4640 executor.shutdown(wait=True) 

4641 finally: 

4642 self._cdk_cancel_event.clear() 

4643 

4644 return successful, failed 

4645 

4646 def _cleanup_backup_vault( 

4647 self, 

4648 *, 

4649 expected_stack_id: str | None = None, 

4650 authorize_stack: StackAuthorizationCallback | None = None, 

4651 require_expected_identity: bool = False, 

4652 ) -> dict[str, Any]: 

4653 """Delete points only from the exact stack resource's physical vault.""" 

4654 import boto3 

4655 

4656 global_region = self.config.global_region 

4657 global_stack_name = f"{self.config.project_name}-global" 

4658 result: dict[str, Any] = { 

4659 "stack_name": global_stack_name, 

4660 "stack_id": expected_stack_id, 

4661 "status": "not-needed", 

4662 "deleted_recovery_points": 0, 

4663 "errors": [], 

4664 } 

4665 

4666 try: 

4667 target = self._describe_stack_target( 

4668 global_stack_name, 

4669 expected_stack_id=expected_stack_id, 

4670 require_expected_identity=require_expected_identity, 

4671 ) 

4672 if target is None: 4672 ↛ 4673line 4672 didn't jump to line 4673 because the condition on line 4672 was never true

4673 result["status"] = "stack-absent" 

4674 return result 

4675 region, cloudformation, stack = target 

4676 stack_id = str(stack["StackId"]) 

4677 result["stack_id"] = stack_id 

4678 if region != global_region: 4678 ↛ 4679line 4678 didn't jump to line 4679 because the condition on line 4678 was never true

4679 raise RuntimeError(f"Global stack resolved to {region}, expected {global_region}") 

4680 if authorize_stack is not None: 4680 ↛ 4681line 4680 didn't jump to line 4681 because the condition on line 4680 was never true

4681 authorize_stack(global_stack_name, region, stack_id) 

4682 

4683 resources: list[dict[str, Any]] = [] 

4684 for page in cloudformation.get_paginator("list_stack_resources").paginate( 

4685 StackName=stack_id 

4686 ): 

4687 resources.extend( 

4688 resource 

4689 for resource in page.get("StackResourceSummaries", []) 

4690 if resource.get("ResourceType") == "AWS::Backup::BackupVault" 

4691 and resource.get("PhysicalResourceId") 

4692 ) 

4693 if not resources: 

4694 result["status"] = "vault-resource-absent" 

4695 return result 

4696 if len(resources) != 1: 4696 ↛ 4697line 4696 didn't jump to line 4697 because the condition on line 4696 was never true

4697 raise RuntimeError( 

4698 f"Expected one AWS::Backup::BackupVault in {stack_id}; found {len(resources)}" 

4699 ) 

4700 

4701 resource = resources[0] 

4702 physical_id = str(resource["PhysicalResourceId"]) 

4703 if physical_id.startswith("arn:"): 4703 ↛ 4709line 4703 didn't jump to line 4709 because the condition on line 4703 was always true

4704 parts = physical_id.split(":", 5) 

4705 if len(parts) != 6 or not parts[5].startswith("backup-vault:"): 4705 ↛ 4706line 4705 didn't jump to line 4706 because the condition on line 4705 was never true

4706 raise RuntimeError(f"Invalid backup vault physical ARN: {physical_id}") 

4707 vault_name = parts[5].removeprefix("backup-vault:") 

4708 else: 

4709 vault_name = physical_id 

4710 if not vault_name: 4710 ↛ 4711line 4710 didn't jump to line 4711 because the condition on line 4710 was never true

4711 raise RuntimeError("CloudFormation returned an empty backup vault physical ID") 

4712 

4713 backup_client = boto3.client("backup", region_name=global_region) 

4714 described_vault = backup_client.describe_backup_vault(BackupVaultName=vault_name) 

4715 vault_arn = str(described_vault.get("BackupVaultArn") or "") 

4716 arn_parts = vault_arn.split(":", 5) 

4717 if ( 4717 ↛ 4723line 4717 didn't jump to line 4723 because the condition on line 4717 was never true

4718 len(arn_parts) != 6 

4719 or arn_parts[2] != "backup" 

4720 or arn_parts[3] != global_region 

4721 or arn_parts[5] != f"backup-vault:{vault_name}" 

4722 ): 

4723 raise RuntimeError( 

4724 "AWS Backup identity does not match the CloudFormation physical resource" 

4725 ) 

4726 if physical_id.startswith("arn:") and physical_id != vault_arn: 4726 ↛ 4727line 4726 didn't jump to line 4727 because the condition on line 4726 was never true

4727 raise RuntimeError("Backup vault ARN changed after CloudFormation resolution") 

4728 

4729 result.update( 

4730 { 

4731 "status": "inspected", 

4732 "logical_id": str(resource.get("LogicalResourceId") or ""), 

4733 "physical_id": physical_id, 

4734 "vault_name": vault_name, 

4735 "vault_arn": vault_arn, 

4736 } 

4737 ) 

4738 paginator = backup_client.get_paginator("list_recovery_points_by_backup_vault") 

4739 for page in paginator.paginate(BackupVaultName=vault_name): 

4740 for recovery_point in page.get("RecoveryPoints", []): 

4741 recovery_point_arn = recovery_point.get("RecoveryPointArn") 

4742 if not recovery_point_arn: 4742 ↛ 4743line 4742 didn't jump to line 4743 because the condition on line 4742 was never true

4743 continue 

4744 try: 

4745 backup_client.delete_recovery_point( 

4746 BackupVaultName=vault_name, 

4747 RecoveryPointArn=recovery_point_arn, 

4748 ) 

4749 result["deleted_recovery_points"] += 1 

4750 except Exception as exc: 

4751 result["errors"].append( 

4752 { 

4753 "recovery_point_arn": str(recovery_point_arn), 

4754 "error": f"{type(exc).__name__}: {exc}", 

4755 } 

4756 ) 

4757 if result["deleted_recovery_points"]: 

4758 print( 

4759 f" Cleaned up {result['deleted_recovery_points']} backup recovery " 

4760 f"points from {vault_name}" 

4761 ) 

4762 result["status"] = "completed" if not result["errors"] else "partial" 

4763 except Exception as exc: 

4764 result["status"] = "failed" 

4765 result["errors"].append({"error": f"{type(exc).__name__}: {exc}"}) 

4766 print(f" Warning: Backup vault cleanup failed (non-fatal): {exc}") 

4767 return result 

4768 

4769 def cleanup_orphaned_bastions( 

4770 self, 

4771 stacks: list[str] | None = None, 

4772 *, 

4773 parallel: bool = True, 

4774 resource_targets: Mapping[str, Mapping[str, str]] | None = None, 

4775 ) -> int: 

4776 """Terminate CLI bastions, using exact stack VPC IDs in strict mode.""" 

4777 if stacks is None: 4777 ↛ 4778line 4777 didn't jump to line 4778 because the condition on line 4777 was never true

4778 stacks = self.list_stacks() 

4779 if resource_targets is not None: 4779 ↛ 4780line 4779 didn't jump to line 4780 because the condition on line 4779 was never true

4780 regional_stacks = [name for name in stacks if name in resource_targets] 

4781 else: 

4782 regional_stacks = [ 

4783 stack 

4784 for stack in stacks 

4785 if not stack.endswith(("-global", "-api-gateway", "-monitoring", "-analytics")) 

4786 ] 

4787 

4788 def cleanup_one(stack_name: str) -> int: 

4789 details = (resource_targets or {}).get(stack_name, {}) 

4790 return self._cleanup_orphaned_bastions( 

4791 stack_name, 

4792 region=details.get("region"), 

4793 vpc_id=details.get("vpc_id"), 

4794 fail_closed=resource_targets is not None, 

4795 ) 

4796 

4797 if not regional_stacks: 4797 ↛ 4798line 4797 didn't jump to line 4798 because the condition on line 4797 was never true

4798 return 0 

4799 if len(regional_stacks) == 1 or not parallel: 

4800 terminated = sum(cleanup_one(stack_name) for stack_name in regional_stacks) 

4801 else: 

4802 # Each region has independent EC2 waiters. Run them concurrently so 

4803 # one slow termination does not add its full timeout to every other 

4804 # region before CloudFormation can start deleting stacks. 

4805 with ThreadPoolExecutor(max_workers=min(4, len(regional_stacks))) as executor: 

4806 terminated = sum(executor.map(cleanup_one, regional_stacks)) 

4807 if terminated: 

4808 print( 

4809 f" Requested termination for {terminated} orphaned ephemeral " 

4810 "SSM bastion(s) before stack deletion." 

4811 ) 

4812 return terminated 

4813 

4814 def _cleanup_orphaned_bastions( 

4815 self, 

4816 stack_name: str, 

4817 *, 

4818 region: str | None = None, 

4819 vpc_id: str | None = None, 

4820 fail_closed: bool = False, 

4821 ) -> int: 

4822 """Terminate tagged bastions only inside a resolved stack VPC.""" 

4823 import boto3 

4824 

4825 from .ephemeral_bastion import ( 

4826 BASTION_PURPOSE, 

4827 TAG_EPHEMERAL_KEY, 

4828 TAG_PROJECT_KEY, 

4829 TAG_PURPOSE_KEY, 

4830 bastion_instance_name, 

4831 ) 

4832 

4833 region = region or self._get_deploy_region(stack_name) 

4834 if not region: 

4835 if fail_closed: 4835 ↛ 4836line 4835 didn't jump to line 4836 because the condition on line 4835 was never true

4836 raise RuntimeError(f"Strict bastion cleanup lacks a Region for {stack_name}") 

4837 return 0 

4838 

4839 project_name = str(self.config.project_name) 

4840 expected_name = bastion_instance_name(project_name) 

4841 try: 

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

4843 if vpc_id: 4843 ↛ 4844line 4843 didn't jump to line 4844 because the condition on line 4843 was never true

4844 vpcs = [{"VpcId": vpc_id}] 

4845 else: 

4846 vpcs = ec2.describe_vpcs( 

4847 Filters=[ 

4848 { 

4849 "Name": "tag:aws:cloudformation:stack-name", 

4850 "Values": [stack_name], 

4851 } 

4852 ] 

4853 ).get("Vpcs", []) 

4854 except Exception as exc: 

4855 if fail_closed: 4855 ↛ 4856line 4855 didn't jump to line 4856 because the condition on line 4855 was never true

4856 raise RuntimeError( 

4857 f"Strict bastion cleanup could not inspect {stack_name}" 

4858 ) from exc 

4859 print(f" Warning: Bastion cleanup could not inspect {stack_name}: {exc}") 

4860 return 0 

4861 

4862 instance_ids: list[str] = [] 

4863 eni_ids: list[str] = [] 

4864 for vpc in vpcs: 

4865 candidate_vpc_id = str(vpc.get("VpcId") or "") 

4866 if not candidate_vpc_id: 4866 ↛ 4867line 4866 didn't jump to line 4867 because the condition on line 4866 was never true

4867 if fail_closed: 

4868 raise RuntimeError(f"Strict bastion cleanup has no VPC ID for {stack_name}") 

4869 continue 

4870 try: 

4871 reservations = ec2.describe_instances( 

4872 Filters=[ 

4873 {"Name": "vpc-id", "Values": [candidate_vpc_id]}, 

4874 {"Name": f"tag:{TAG_EPHEMERAL_KEY}", "Values": ["true"]}, 

4875 {"Name": f"tag:{TAG_PURPOSE_KEY}", "Values": [BASTION_PURPOSE]}, 

4876 { 

4877 "Name": "instance-state-name", 

4878 "Values": [ 

4879 "pending", 

4880 "running", 

4881 "stopping", 

4882 "stopped", 

4883 "shutting-down", 

4884 ], 

4885 }, 

4886 ] 

4887 ).get("Reservations", []) 

4888 except Exception as exc: 

4889 if fail_closed: 

4890 raise RuntimeError( 

4891 f"Strict bastion lookup failed in {stack_name} ({candidate_vpc_id})" 

4892 ) from exc 

4893 logger.warning( 

4894 "Bastion lookup failed in %s (%s): %s", 

4895 stack_name, 

4896 candidate_vpc_id, 

4897 exc, 

4898 ) 

4899 continue 

4900 

4901 for reservation in reservations: 

4902 for instance in reservation.get("Instances", []): 

4903 tags = { 

4904 str(tag.get("Key")): str(tag.get("Value")) 

4905 for tag in instance.get("Tags", []) 

4906 if tag.get("Key") is not None 

4907 } 

4908 tagged_project = tags.get(TAG_PROJECT_KEY) 

4909 if tagged_project != project_name and not ( 

4910 tagged_project is None and tags.get("Name") == expected_name 

4911 ): 

4912 continue 

4913 instance_id = instance.get("InstanceId") 

4914 if instance_id: 4914 ↛ 4916line 4914 didn't jump to line 4916 because the condition on line 4914 was always true

4915 instance_ids.append(str(instance_id)) 

4916 for interface in instance.get("NetworkInterfaces", []): 

4917 attachment = interface.get("Attachment") or {} 

4918 if not ( 

4919 attachment.get("DeviceIndex") == 0 

4920 and attachment.get("DeleteOnTermination") is True 

4921 ): 

4922 continue 

4923 eni_id = interface.get("NetworkInterfaceId") 

4924 if eni_id: 4924 ↛ 4916line 4924 didn't jump to line 4916 because the condition on line 4924 was always true

4925 eni_ids.append(str(eni_id)) 

4926 

4927 instance_ids = list(dict.fromkeys(instance_ids)) 

4928 eni_ids = list(dict.fromkeys(eni_ids)) 

4929 if not instance_ids: 

4930 return 0 

4931 

4932 try: 

4933 ec2.terminate_instances(InstanceIds=instance_ids) 

4934 except Exception as exc: 

4935 if fail_closed: 

4936 raise RuntimeError(f"Strict bastion termination failed in {stack_name}") from exc 

4937 print(f" Warning: Failed to terminate ephemeral bastion(s) in {stack_name}: {exc}") 

4938 return 0 

4939 

4940 print( 

4941 f" Terminating {len(instance_ids)} ephemeral SSM bastion(s) in " 

4942 f"{stack_name}: {', '.join(instance_ids)}" 

4943 ) 

4944 try: 

4945 ec2.get_waiter("instance_terminated").wait( 

4946 InstanceIds=instance_ids, 

4947 WaiterConfig={"Delay": 5, "MaxAttempts": 60}, 

4948 ) 

4949 except Exception as exc: 

4950 if fail_closed: 

4951 raise RuntimeError( 

4952 f"Strict bastion termination did not converge in {stack_name}" 

4953 ) from exc 

4954 logger.warning("Timed out waiting for bastion termination in %s: %s", stack_name, exc) 

4955 

4956 remaining_enis = self._wait_for_bastion_network_interfaces(ec2, eni_ids) 

4957 if remaining_enis: 4957 ↛ 4958line 4957 didn't jump to line 4958 because the condition on line 4957 was never true

4958 message = ( 

4959 f"{len(remaining_enis)} bastion network interface(s) in {stack_name} " 

4960 f"have not released: {', '.join(sorted(remaining_enis))}" 

4961 ) 

4962 if fail_closed: 

4963 raise RuntimeError(message) 

4964 print(f" Warning: {message}. The destroy retry will check again.") 

4965 return len(instance_ids) 

4966 

4967 @staticmethod 

4968 def _wait_for_bastion_network_interfaces( 

4969 ec2: Any, 

4970 eni_ids: list[str], 

4971 *, 

4972 timeout_seconds: float = 120.0, 

4973 poll_interval_seconds: float = 2.0, 

4974 ) -> set[str]: 

4975 """Wait for terminated bastion ENIs, deleting detached leftovers. 

4976 

4977 EC2 normally deletes a primary ENI with its instance. If it becomes 

4978 detached instead, it is safe to delete here because its owning instance 

4979 was selected by the project/VPC bastion filters and termination has 

4980 already been requested. 

4981 """ 

4982 import time as _time 

4983 

4984 remaining = set(eni_ids) 

4985 deadline = _time.monotonic() + timeout_seconds 

4986 while remaining: 4986 ↛ 5021line 4986 didn't jump to line 5021 because the condition on line 4986 was always true

4987 for eni_id in tuple(remaining): 

4988 try: 

4989 response = ec2.describe_network_interfaces(NetworkInterfaceIds=[eni_id]) 

4990 except ClientError as exc: 

4991 code = exc.response.get("Error", {}).get("Code") 

4992 if code == "InvalidNetworkInterfaceID.NotFound": 4992 ↛ 4995line 4992 didn't jump to line 4995 because the condition on line 4992 was always true

4993 remaining.discard(eni_id) 

4994 continue 

4995 logger.warning("Could not inspect bastion ENI %s: %s", eni_id, exc) 

4996 return remaining 

4997 except Exception as exc: # noqa: BLE001 - cleanup is best-effort 

4998 logger.warning("Could not inspect bastion ENI %s: %s", eni_id, exc) 

4999 return remaining 

5000 

5001 interfaces = response.get("NetworkInterfaces", []) 

5002 if not interfaces: 5002 ↛ 5003line 5002 didn't jump to line 5003 because the condition on line 5002 was never true

5003 remaining.discard(eni_id) 

5004 continue 

5005 if interfaces[0].get("Status") == "available": 

5006 try: 

5007 ec2.delete_network_interface(NetworkInterfaceId=eni_id) 

5008 remaining.discard(eni_id) 

5009 except ClientError as exc: 

5010 code = exc.response.get("Error", {}).get("Code") 

5011 if code == "InvalidNetworkInterfaceID.NotFound": 

5012 remaining.discard(eni_id) 

5013 else: 

5014 logger.debug("Delete of bastion ENI %s failed: %s", eni_id, exc) 

5015 except Exception as exc: # noqa: BLE001 - retry until timeout 

5016 logger.debug("Delete of bastion ENI %s failed: %s", eni_id, exc) 

5017 

5018 if not remaining or _time.monotonic() >= deadline: 5018 ↛ 5020line 5018 didn't jump to line 5020 because the condition on line 5018 was always true

5019 break 

5020 _time.sleep(poll_interval_seconds) 

5021 return remaining 

5022 

5023 # ------------------------------------------------------------------ 

5024 # Implicit log-group + bastion IAM cleanup (non-strict destroy only) 

5025 # ------------------------------------------------------------------ 

5026 # 

5027 # CloudFormation only deletes the log groups it modeled. Lambda default 

5028 # groups (``/aws/lambda/<function>``), the EKS control-plane group 

5029 # (``/aws/eks/<cluster>/cluster``), and the Container Insights groups 

5030 # (``/aws/containerinsights/<cluster>/…``) are created out-of-band by 

5031 # the services themselves, so ``destroy-all`` used to report success 

5032 # while leaving them behind — a real teardown orphaned 22 of them plus 

5033 # the ephemeral-bastion IAM role/profile, which then failed the live 

5034 # release validation's clean-account baseline gate. 

5035 # 

5036 # The cleanup below deletes ONLY exact names derived from the project's 

5037 # own stack resources, captured while the stacks still exist, and only 

5038 # for stacks whose deletion actually succeeded. It never runs in strict 

5039 # (live-validation) teardowns: the harness checkpoints, tags, and 

5040 # fences its own log-group generations and must remain the single 

5041 # owner of that deletion authority. 

5042 

5043 # The service-side patterns implicit log groups follow. An explicit 

5044 # ``AWS::Logs::LogGroup`` resource is deliberately absent here — 

5045 # CloudFormation owns those directly. 

5046 _EKS_CONTAINER_INSIGHTS_SUFFIXES = ("application", "dataplane", "host", "performance") 

5047 

5048 @staticmethod 

5049 def _implicit_log_group_names(resource_type: str, physical_id: str) -> tuple[str, ...]: 

5050 """Exact implicit log-group names a stack resource creates out-of-band.""" 

5051 if resource_type == "AWS::Lambda::Function": 

5052 return (f"/aws/lambda/{physical_id}",) 

5053 if resource_type == "AWS::EKS::Cluster": 

5054 return ( 

5055 f"/aws/eks/{physical_id}/cluster", 

5056 *( 

5057 f"/aws/containerinsights/{physical_id}/{suffix}" 

5058 for suffix in StackManager._EKS_CONTAINER_INSIGHTS_SUFFIXES 

5059 ), 

5060 ) 

5061 return () 

5062 

5063 def _collect_implicit_log_groups(self, stacks: Collection[str]) -> dict[str, dict[str, Any]]: 

5064 """Derive per-stack implicit log-group names while the stacks are live. 

5065 

5066 Best-effort: a stack that cannot be described or listed is skipped 

5067 with a warning — collection must never block the destroy itself. 

5068 """ 

5069 collected: dict[str, dict[str, Any]] = {} 

5070 for stack_name in stacks: 

5071 try: 

5072 target = self._describe_stack_target(stack_name) 

5073 if target is None: 

5074 continue 

5075 region, cloudformation, stack = target 

5076 names: list[str] = [] 

5077 paginator = cloudformation.get_paginator("list_stack_resources") 

5078 for page in paginator.paginate(StackName=str(stack["StackId"])): 

5079 for item in page.get("StackResourceSummaries", []): 

5080 resource_type = str(item.get("ResourceType") or "") 

5081 physical_id = str(item.get("PhysicalResourceId") or "") 

5082 if not physical_id: 

5083 continue 

5084 names.extend(self._implicit_log_group_names(resource_type, physical_id)) 

5085 if names: 5085 ↛ 5070line 5085 didn't jump to line 5070 because the condition on line 5085 was always true

5086 collected[stack_name] = {"region": region, "log_groups": sorted(set(names))} 

5087 except Exception as exc: # noqa: BLE001 - best-effort collection 

5088 logger.warning("Could not derive implicit log groups for %s: %s", stack_name, exc) 

5089 return collected 

5090 

5091 def _cleanup_implicit_log_groups( 

5092 self, 

5093 collected: Mapping[str, Mapping[str, Any]], 

5094 successful_stacks: Collection[str], 

5095 ) -> dict[str, Any]: 

5096 """Delete the exact derived log groups of successfully destroyed stacks. 

5097 

5098 A missing group is normal (a Lambda that never logged, or a custom 

5099 ``LoggingConfig`` pointing elsewhere) and is recorded, not retried. 

5100 Every error is recorded and swallowed: cleanup never converts a 

5101 successful destroy into a failure. 

5102 """ 

5103 import boto3 

5104 

5105 outcome: dict[str, Any] = {"deleted": [], "missing": [], "errors": []} 

5106 clients: dict[str, Any] = {} 

5107 for stack_name in sorted(successful_stacks): 

5108 details = collected.get(stack_name) 

5109 if not details: 5109 ↛ 5110line 5109 didn't jump to line 5110 because the condition on line 5109 was never true

5110 continue 

5111 region = str(details.get("region") or "") 

5112 for name in details.get("log_groups", []): 

5113 try: 

5114 client = clients.get(region) 

5115 if client is None: 

5116 client = boto3.client("logs", region_name=region) 

5117 clients[region] = client 

5118 client.delete_log_group(logGroupName=name) 

5119 outcome["deleted"].append(f"{region}:{name}") 

5120 except ClientError as exc: 

5121 code = str(exc.response.get("Error", {}).get("Code") or "") 

5122 if code == "ResourceNotFoundException": 

5123 outcome["missing"].append(f"{region}:{name}") 

5124 else: 

5125 outcome["errors"].append(f"{region}:{name}: {code}") 

5126 except Exception as exc: # noqa: BLE001 - best-effort cleanup 

5127 outcome["errors"].append(f"{region}:{name}: {type(exc).__name__}: {exc}") 

5128 if outcome["deleted"]: 

5129 print( 

5130 f" Deleted {len(outcome['deleted'])} implicit CloudWatch log group(s) " 

5131 "left behind by Lambda/EKS/Container Insights." 

5132 ) 

5133 for failure in outcome["errors"]: 

5134 logger.warning("Implicit log-group cleanup failed for %s", failure) 

5135 return outcome 

5136 

5137 def _cleanup_bastion_iam(self) -> dict[str, Any]: 

5138 """Best-effort teardown of the ephemeral-bastion IAM role + profile. 

5139 

5140 ``destroy_ephemeral_bastion`` already attempts this when a tunnel 

5141 closes normally, but a killed process leaves the pair behind (they 

5142 cost nothing, yet fail any clean-account audit). Deletion is by the 

5143 exact project-scoped names from the bastion naming contract; a 

5144 ``NoSuchEntity`` response simply means there was nothing to clean. 

5145 """ 

5146 from .ephemeral_bastion import ( 

5147 _run_aws, 

5148 bastion_profile_name, 

5149 bastion_role_name, 

5150 build_iam_teardown_commands, 

5151 ) 

5152 

5153 outcome: dict[str, Any] = { 

5154 "completed_steps": 0, 

5155 "absent_steps": 0, 

5156 "errors": [], 

5157 } 

5158 try: 

5159 role_name = bastion_role_name(self.config.project_name) 

5160 profile_name = bastion_profile_name(self.config.project_name) 

5161 outcome["role"] = role_name 

5162 outcome["profile"] = profile_name 

5163 steps = build_iam_teardown_commands( 

5164 role_name, 

5165 profile_name, 

5166 self.config.global_region, 

5167 ) 

5168 except Exception as exc: # noqa: BLE001 - best-effort cleanup 

5169 outcome["errors"].append(f"{type(exc).__name__}: {exc}") 

5170 return outcome 

5171 for step in steps: 

5172 try: 

5173 _run_aws(step) 

5174 outcome["completed_steps"] += 1 

5175 except RuntimeError as exc: 

5176 if "NoSuchEntity" in str(exc): 

5177 outcome["absent_steps"] += 1 

5178 continue 

5179 outcome["errors"].append(f"{' '.join(step[:3])}: {exc}") 

5180 if outcome["completed_steps"] and not outcome["errors"]: 

5181 print(" Removed the ephemeral-bastion IAM role and instance profile.") 

5182 for failure in outcome["errors"]: 

5183 logger.warning("Bastion IAM teardown step failed: %s", failure) 

5184 return outcome 

5185 

5186 def cleanup_eks_security_groups(self) -> None: 

5187 """Clean up EKS-managed security groups across all regional stacks. 

5188 

5189 Called between destroy retries to remove orphaned security groups 

5190 that block VPC deletion. 

5191 """ 

5192 stacks = self.list_stacks() 

5193 # Regional stacks are everything that isn't a named global stack; 

5194 # classify by suffix so this works for any project_name (#139). 

5195 regional_stacks = [ 

5196 s for s in stacks if not s.endswith(("-global", "-api-gateway", "-monitoring")) 

5197 ] 

5198 for stack_name in regional_stacks: 

5199 self._cleanup_eks_security_groups(stack_name) 

5200 

5201 def cleanup_orphaned_network_interfaces(self) -> None: 

5202 """Report and clear resources that can block VPC deletion, across all 

5203 regional stacks. Run between destroy retries. 

5204 

5205 Generalizes ``cleanup_eks_security_groups`` (which force-deletes the 

5206 ``eks-cluster-sg-*`` security group + its ENIs that EKS leaves behind) 

5207 with a broader sweep: for each regional stack's VPC it enumerates every 

5208 remaining network interface, categorizes them (Global Accelerator / ELB 

5209 / EKS / other), deletes the ones that are safe to remove (detached and 

5210 not service-managed), and prints a friendly summary of what it found and 

5211 what the next retry is waiting on. Service-managed ENIs (Global 

5212 Accelerator, ELB) are released asynchronously by AWS once the endpoint / 

5213 load balancer is gone, so we report them rather than fight them. 

5214 """ 

5215 stacks = self.list_stacks() 

5216 regional_stacks = [ 

5217 s for s in stacks if not s.endswith(("-global", "-api-gateway", "-monitoring")) 

5218 ] 

5219 for stack_name in regional_stacks: 

5220 # Existing behaviour first: clear the EKS cluster SG + its ENIs. 

5221 self._cleanup_eks_security_groups(stack_name) 

5222 # Then report (and safely clear) anything else lingering in the VPC. 

5223 summary = self._summarize_orphaned_enis(stack_name) 

5224 self._print_orphaned_eni_summary(stack_name, summary) 

5225 

5226 @staticmethod 

5227 def _classify_orphaned_eni(eni: dict[str, Any]) -> str: 

5228 """Bucket a network interface by which AWS service owns it. 

5229 

5230 Uses ``InterfaceType`` first (authoritative for Global Accelerator and 

5231 the load-balancer types) and falls back to the human ``Description`` 

5232 string for the EKS / ELB cases that present as a plain ``interface``. 

5233 Returns one of ``global_accelerator`` / ``elb`` / ``eks`` / ``other``. 

5234 """ 

5235 itype = str(eni.get("InterfaceType") or "").lower() 

5236 desc = str(eni.get("Description") or "").lower() 

5237 if ( 

5238 itype == "global_accelerator_managed" 

5239 or "global_accelerator" in desc 

5240 or "global accelerator" in desc 

5241 ): 

5242 return "global_accelerator" 

5243 if itype in ("load_balancer", "network_load_balancer") or desc.startswith("elb "): 

5244 return "elb" 

5245 if "eks" in desc or "k8s" in desc or "kubernetes" in desc: 

5246 return "eks" 

5247 return "other" 

5248 

5249 def _summarize_orphaned_enis(self, stack_name: str) -> dict[str, int]: 

5250 """Inspect the stack's VPC(s) for lingering ENIs, categorize them, and 

5251 best-effort delete the ones that are safe to remove. 

5252 

5253 "Safe to remove" means ``Status == "available"`` (detached) and not 

5254 ``RequesterManaged`` (i.e. not owned by a service like GA / ELB, which 

5255 rejects manual deletion and releases the ENI on its own schedule). 

5256 

5257 Returns a dict of counts: per-category totals plus ``deleted`` and 

5258 ``vpcs``. Wholly best-effort — any AWS error degrades to the counts 

5259 gathered so far rather than raising into the destroy flow. 

5260 """ 

5261 import boto3 

5262 

5263 region = stack_name.replace(f"{self.config.project_name}-", "", 1) 

5264 summary: dict[str, int] = { 

5265 "global_accelerator": 0, 

5266 "elb": 0, 

5267 "eks": 0, 

5268 "other": 0, 

5269 "deleted": 0, 

5270 "vpcs": 0, 

5271 } 

5272 try: 

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

5274 vpcs = ec2.describe_vpcs( 

5275 Filters=[{"Name": "tag:aws:cloudformation:stack-name", "Values": [stack_name]}] 

5276 ).get("Vpcs", []) 

5277 except Exception as e: # noqa: BLE001 

5278 logger.debug("ENI sweep: VPC lookup failed for %s: %s", stack_name, e) 

5279 return summary 

5280 

5281 for vpc in vpcs: 

5282 summary["vpcs"] += 1 

5283 vpc_id = vpc.get("VpcId") 

5284 try: 

5285 enis = ec2.describe_network_interfaces( 

5286 Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] 

5287 ).get("NetworkInterfaces", []) 

5288 except Exception as e: # noqa: BLE001 

5289 logger.debug("ENI sweep: describe ENIs failed for %s: %s", vpc_id, e) 

5290 continue 

5291 

5292 for eni in enis: 

5293 summary[self._classify_orphaned_eni(eni)] += 1 

5294 detached = eni.get("Status") == "available" 

5295 service_managed = bool(eni.get("RequesterManaged", False)) 

5296 if detached and not service_managed: 

5297 eni_id = eni.get("NetworkInterfaceId") 

5298 try: 

5299 ec2.delete_network_interface(NetworkInterfaceId=eni_id) 

5300 summary["deleted"] += 1 

5301 logger.debug("ENI sweep: deleted detached ENI %s in %s", eni_id, vpc_id) 

5302 except Exception as e: # noqa: BLE001 

5303 logger.debug("ENI sweep: delete of %s failed: %s", eni_id, e) 

5304 return summary 

5305 

5306 @staticmethod 

5307 def _print_orphaned_eni_summary(stack_name: str, summary: dict[str, int]) -> None: 

5308 """Print a friendly summary of what the ENI sweep found and handled.""" 

5309 categories = ( 

5310 ("global_accelerator", "Global Accelerator-managed"), 

5311 ("elb", "ELB-managed"), 

5312 ("eks", "EKS-managed"), 

5313 ("other", "other"), 

5314 ) 

5315 total = sum(summary.get(key, 0) for key, _ in categories) 

5316 if total == 0: 

5317 return 

5318 breakdown = ", ".join( 

5319 f"{summary[key]} {label}" for key, label in categories if summary.get(key) 

5320 ) 

5321 print(f" {stack_name}: {total} network interface(s) still in the VPC ({breakdown}).") 

5322 if summary.get("deleted"): 5322 ↛ 5324line 5322 didn't jump to line 5324 because the condition on line 5322 was always true

5323 print(f" Removed {summary['deleted']} detached interface(s).") 

5324 remaining = total - summary.get("deleted", 0) 

5325 if remaining > 0: 

5326 print( 

5327 f" {remaining} still held by AWS — Global Accelerator / ELB release these " 

5328 "asynchronously once the endpoint and load balancer are gone; the next retry " 

5329 "proceeds once they drain." 

5330 ) 

5331 

5332 def _cleanup_eks_security_groups( 

5333 self, 

5334 stack_name: str, 

5335 *, 

5336 region: str | None = None, 

5337 security_group_id: str | None = None, 

5338 vpc_id: str | None = None, 

5339 ) -> dict[str, Any]: 

5340 """Delete empty EKS SGs, optionally by one exact preauthorized ID.""" 

5341 import boto3 

5342 

5343 project_name = self.config.project_name 

5344 region = region or stack_name.replace(f"{project_name}-", "", 1) 

5345 cluster_name = stack_name 

5346 outcome: dict[str, Any] = { 

5347 "stack": stack_name, 

5348 "region": region, 

5349 "security_group_id": security_group_id, 

5350 "inspected": 0, 

5351 "deleted": [], 

5352 "blocked_by_enis": [], 

5353 "errors": [], 

5354 } 

5355 

5356 try: 

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

5358 try: 

5359 if security_group_id: 5359 ↛ 5360line 5359 didn't jump to line 5360 because the condition on line 5359 was never true

5360 response = ec2.describe_security_groups(GroupIds=[security_group_id]) 

5361 else: 

5362 response = ec2.describe_security_groups( 

5363 Filters=[ 

5364 { 

5365 "Name": "group-name", 

5366 "Values": [f"eks-cluster-sg-{cluster_name}-*"], 

5367 } 

5368 ] 

5369 ) 

5370 except ClientError as exc: 

5371 if ( 

5372 security_group_id 

5373 and exc.response.get("Error", {}).get("Code") == "InvalidGroup.NotFound" 

5374 ): 

5375 outcome["absent"] = True 

5376 return outcome 

5377 raise 

5378 

5379 for security_group in response.get("SecurityGroups", []): 

5380 outcome["inspected"] += 1 

5381 group_id = str(security_group["GroupId"]) 

5382 group_name = str(security_group.get("GroupName", "")) 

5383 if security_group_id and group_id != security_group_id: 5383 ↛ 5384line 5383 didn't jump to line 5384 because the condition on line 5383 was never true

5384 raise RuntimeError( 

5385 f"EC2 returned changed security-group identity for {security_group_id}" 

5386 ) 

5387 if vpc_id and str(security_group.get("VpcId") or "") != vpc_id: 5387 ↛ 5388line 5387 didn't jump to line 5388 because the condition on line 5387 was never true

5388 raise RuntimeError( 

5389 f"Security group {group_id} no longer belongs to exact VPC {vpc_id}" 

5390 ) 

5391 interfaces = ec2.describe_network_interfaces( 

5392 Filters=[{"Name": "group-id", "Values": [group_id]}] 

5393 ).get("NetworkInterfaces", []) 

5394 if interfaces: 

5395 outcome["blocked_by_enis"].append( 

5396 { 

5397 "group_id": group_id, 

5398 "group_name": group_name, 

5399 "network_interface_ids": sorted( 

5400 str(interface.get("NetworkInterfaceId") or "") 

5401 for interface in interfaces 

5402 if interface.get("NetworkInterfaceId") 

5403 ), 

5404 } 

5405 ) 

5406 logger.debug( 

5407 "Waiting for AWS to release %d EKS-managed ENI(s) from %s", 

5408 len(interfaces), 

5409 group_name, 

5410 ) 

5411 continue 

5412 try: 

5413 ec2.delete_security_group(GroupId=group_id) 

5414 outcome["deleted"].append({"group_id": group_id, "group_name": group_name}) 

5415 print(f" Cleaned up empty EKS security group: {group_name} ({group_id})") 

5416 except ClientError as exc: 

5417 if exc.response.get("Error", {}).get("Code") == "InvalidGroup.NotFound": 

5418 outcome["absent"] = True 

5419 continue 

5420 outcome["errors"].append( 

5421 {"group_id": group_id, "error": f"{type(exc).__name__}: {exc}"} 

5422 ) 

5423 except Exception as exc: 

5424 outcome["errors"].append( 

5425 {"group_id": group_id, "error": f"{type(exc).__name__}: {exc}"} 

5426 ) 

5427 except Exception as exc: 

5428 outcome["errors"].append({"error": f"{type(exc).__name__}: {exc}"}) 

5429 logger.debug("EKS security group cleanup for %s failed: %s", stack_name, exc) 

5430 return outcome 

5431 

5432 def _start_eks_sg_watchdog( 

5433 self, 

5434 stack_name: str, 

5435 stop_event: Event, 

5436 *, 

5437 region: str | None = None, 

5438 security_group_id: str | None = None, 

5439 vpc_id: str | None = None, 

5440 ) -> Thread: 

5441 """Start a background thread that polls for orphaned EKS security groups. 

5442 

5443 EKS creates an ``eks-cluster-sg-<cluster-name>-*`` security group that 

5444 is owned by the EKS service (not CloudFormation). The watchdog observes 

5445 it throughout regional teardown and removes it only after AWS has 

5446 released every attached ENI. Service-managed interfaces are never 

5447 detached or deleted by the CLI. 

5448 

5449 The thread exits when ``stop_event`` is set by the orchestrator at 

5450 the end of the regional phase. 

5451 """ 

5452 

5453 def _watchdog() -> None: 

5454 while not stop_event.is_set(): 

5455 try: 

5456 self._cleanup_eks_security_groups( 

5457 stack_name, 

5458 region=region, 

5459 security_group_id=security_group_id, 

5460 vpc_id=vpc_id, 

5461 ) 

5462 except Exception as e: 

5463 logger.debug( 

5464 "EKS SG watchdog tick for %s failed (non-fatal): %s", 

5465 stack_name, 

5466 e, 

5467 ) 

5468 # ``wait`` returns immediately when the event is set, so this 

5469 # doubles as the sleep-and-shutdown-check in one call. 

5470 stop_event.wait(timeout=30) 

5471 

5472 thread = Thread( 

5473 target=_watchdog, 

5474 name=f"eks-sg-watchdog-{stack_name}", 

5475 daemon=True, 

5476 ) 

5477 thread.start() 

5478 return thread 

5479 

5480 

5481def get_stack_manager(config: GCOConfig) -> StackManager: 

5482 """Factory function to get a StackManager instance.""" 

5483 return StackManager(config) 

5484 

5485 

5486def _is_regional_api_bridge_stack( 

5487 stack: str, 

5488 *, 

5489 project_name: str, 

5490 stack_names: Collection[str], 

5491) -> bool: 

5492 """Return whether ``stack`` is a configured per-Region API bridge. 

5493 

5494 A bare ``"-regional-api-"`` substring is ambiguous because it is valid 

5495 inside ``project_name``. Match the exact project-scoped bridge prefix and 

5496 require the corresponding configured ``<project>-<region>`` base stack. 

5497 """ 

5498 bridge_prefix = f"{project_name}-regional-api-" 

5499 if not stack.startswith(bridge_prefix): 

5500 return False 

5501 region = stack.removeprefix(bridge_prefix) 

5502 return bool(region) and f"{project_name}-{region}" in stack_names 

5503 

5504 

5505def _get_stack_destroy_phases( 

5506 stacks: list[str], 

5507 *, 

5508 project_name: str, 

5509) -> tuple[list[str], list[str], list[str], list[str]]: 

5510 """Classify and order the exact phases used by orchestrated destroy. 

5511 

5512 Returns monitoring, regional API bridge, base regional, and pre-regional 

5513 global phases. The public preview helper and the execution path both flatten 

5514 this result, so custom project names and bridge dependencies cannot drift. 

5515 """ 

5516 stack_names = set(stacks) 

5517 monitoring_stacks = sorted( 

5518 (stack for stack in stacks if stack.endswith("-monitoring")), 

5519 reverse=True, 

5520 ) 

5521 regional_api_stacks = sorted( 

5522 ( 

5523 stack 

5524 for stack in stacks 

5525 if _is_regional_api_bridge_stack( 

5526 stack, 

5527 project_name=project_name, 

5528 stack_names=stack_names, 

5529 ) 

5530 ), 

5531 reverse=True, 

5532 ) 

5533 regional_stacks = sorted( 

5534 ( 

5535 stack 

5536 for stack in stacks 

5537 if not stack.endswith(("-global", "-api-gateway", "-monitoring")) 

5538 and not _is_regional_api_bridge_stack( 

5539 stack, 

5540 project_name=project_name, 

5541 stack_names=stack_names, 

5542 ) 

5543 ), 

5544 reverse=True, 

5545 ) 

5546 pre_regional_stacks = sorted( 

5547 (stack for stack in stacks if stack.endswith(("-global", "-api-gateway"))), 

5548 key=lambda stack: ( 

5549 1 if stack.endswith("-api-gateway") else (2 if stack.endswith("-global") else 0) 

5550 ), 

5551 ) 

5552 return ( 

5553 monitoring_stacks, 

5554 regional_api_stacks, 

5555 regional_stacks, 

5556 pre_regional_stacks, 

5557 ) 

5558 

5559 

5560def get_stack_deployment_order( 

5561 stacks: list[str], 

5562 *, 

5563 project_name: str = "gco", 

5564) -> list[str]: 

5565 """ 

5566 Get the correct deployment order for stacks. 

5567 

5568 Order: global stacks first, then regional stacks. 

5569 Global stacks: <project>-global, <project>-api-gateway, 

5570 <project>-analytics, <project>-monitoring 

5571 Regional stacks: <project>-{region} (e.g., gco-us-east-1) 

5572 

5573 Named stacks are classified by suffix so ordering is independent of 

5574 ``project_name`` (#139): a non-``gco`` deployment (``acme-global`` …) 

5575 orders identically. Regional stacks are ``<project>-<region>`` and match 

5576 no named suffix, so they fall through to the regional bucket. 

5577 """ 

5578 stack_names = set(stacks) 

5579 global_stacks = [] 

5580 regional_stacks = [] 

5581 regional_api_stacks = [] 

5582 

5583 # Named (non-regional) stack priority by suffix (lower = deploy first). 

5584 suffix_priority = { 

5585 "-global": 1, 

5586 "-api-gateway": 2, 

5587 "-analytics": 2.5, 

5588 "-monitoring": 3, 

5589 } 

5590 

5591 def _named_priority(stack: str) -> float | None: 

5592 for suffix, prio in suffix_priority.items(): 

5593 if stack.endswith(suffix): 

5594 return prio 

5595 return None 

5596 

5597 for stack in stacks: 

5598 priority = _named_priority(stack) 

5599 if priority is not None: 

5600 global_stacks.append((priority, stack)) 

5601 elif _is_regional_api_bridge_stack( 

5602 stack, 

5603 project_name=project_name, 

5604 stack_names=stack_names, 

5605 ): 

5606 regional_api_stacks.append(stack) 

5607 else: 

5608 regional_stacks.append(stack) 

5609 

5610 # Keep bridge dependencies after every base regional stack. The 

5611 # orchestrated lifecycle further separates monitoring into its own phase. 

5612 global_stacks.sort(key=lambda x: x[0]) 

5613 regional_stacks.sort() 

5614 regional_api_stacks.sort() 

5615 

5616 return [s[1] for s in global_stacks] + regional_stacks + regional_api_stacks 

5617 

5618 

5619def get_stack_destroy_order( 

5620 stacks: list[str], 

5621 *, 

5622 project_name: str = "gco", 

5623) -> list[str]: 

5624 """Return the exact project-aware order used by orchestrated destroy.""" 

5625 phases = _get_stack_destroy_phases(stacks, project_name=project_name) 

5626 return [stack for phase in phases for stack in phase] 

5627 

5628 

5629# ============================================================================= 

5630# Feature toggle helpers 

5631# ============================================================================= 

5632 

5633_FSX_DEFAULTS: dict[str, Any] = { 

5634 "enabled": False, 

5635 "storage_capacity_gib": 1200, 

5636 "deployment_type": "SCRATCH_2", 

5637 "per_unit_storage_throughput": 200, 

5638 "data_compression_type": "LZ4", 

5639 "import_path": None, 

5640 "export_path": None, 

5641 "auto_import_policy": "NEW_CHANGED_DELETED", 

5642} 

5643 

5644 

5645def _find_cdk_json() -> Path | None: 

5646 """Find cdk.json in current or parent directories.""" 

5647 current = Path.cwd() 

5648 for parent in [current] + list(current.parents): 

5649 cdk_path = parent / "cdk.json" 

5650 if cdk_path.exists(): 

5651 return cdk_path 

5652 return None 

5653 

5654 

5655def get_fsx_config(region: str | None = None) -> dict[str, Any]: 

5656 """Get current FSx for Lustre configuration from cdk.json. 

5657 

5658 Args: 

5659 region: Optional region to get config for. If provided, checks for 

5660 region-specific overrides first. 

5661 

5662 Returns: 

5663 FSx configuration dictionary 

5664 """ 

5665 return _get_feature_config("fsx_lustre", _FSX_DEFAULTS, region) 

5666 

5667 

5668def update_fsx_config(settings: dict[str, Any], region: str | None = None) -> None: 

5669 """Update FSx for Lustre configuration in cdk.json. 

5670 

5671 Args: 

5672 settings: FSx settings to update 

5673 region: Optional region for region-specific config. If None, updates global config. 

5674 """ 

5675 _update_feature_config("fsx_lustre", settings, _FSX_DEFAULTS, region) 

5676 

5677 

5678# ============================================================================= 

5679# Generic feature toggle helpers (used by FSx, Valkey, Aurora, and future features) 

5680# ============================================================================= 

5681 

5682 

5683def _get_feature_config( 

5684 feature_key: str, 

5685 default_config: dict[str, Any], 

5686 region: str | None = None, 

5687) -> dict[str, Any]: 

5688 """Get configuration for a toggleable feature from cdk.json. 

5689 

5690 Args: 

5691 feature_key: The cdk.json context key (e.g. "valkey", "aurora_pgvector"). 

5692 default_config: Default configuration values when the key is missing. 

5693 region: Optional region for region-specific overrides. 

5694 

5695 Returns: 

5696 Merged configuration dictionary. 

5697 """ 

5698 cdk_json_path = _find_cdk_json() 

5699 if not cdk_json_path: 

5700 raise RuntimeError("cdk.json not found") 

5701 

5702 import json 

5703 

5704 with open(cdk_json_path, encoding="utf-8") as f: 

5705 cdk_config = json.load(f) 

5706 

5707 global_config = cdk_config.get("context", {}).get(feature_key, default_config) 

5708 

5709 if region: 

5710 region_key = f"{feature_key}_regions" 

5711 region_overrides = cdk_config.get("context", {}).get(region_key, {}) 

5712 if region in region_overrides: 

5713 merged = {**global_config, **region_overrides[region]} 

5714 merged["region"] = region 

5715 merged["is_region_specific"] = True 

5716 return merged 

5717 

5718 result = {**default_config, **global_config} 

5719 result["is_region_specific"] = False 

5720 return result 

5721 

5722 

5723def _update_feature_config( 

5724 feature_key: str, 

5725 settings: dict[str, Any], 

5726 default_config: dict[str, Any], 

5727 region: str | None = None, 

5728) -> None: 

5729 """Update configuration for a toggleable feature in cdk.json. 

5730 

5731 Args: 

5732 feature_key: The cdk.json context key (e.g. "valkey", "aurora_pgvector"). 

5733 settings: Settings to update. 

5734 default_config: Default configuration values when the key is missing. 

5735 region: Optional region for region-specific config. 

5736 """ 

5737 cdk_json_path = _find_cdk_json() 

5738 if not cdk_json_path: 

5739 raise RuntimeError("cdk.json not found") 

5740 

5741 import json 

5742 

5743 with open(cdk_json_path, encoding="utf-8") as f: 

5744 cdk_config = json.load(f) 

5745 

5746 if "context" not in cdk_config: 

5747 cdk_config["context"] = {} 

5748 

5749 if region: 

5750 region_key = f"{feature_key}_regions" 

5751 if region_key not in cdk_config["context"]: 5751 ↛ 5753line 5751 didn't jump to line 5753 because the condition on line 5751 was always true

5752 cdk_config["context"][region_key] = {} 

5753 if region not in cdk_config["context"][region_key]: 5753 ↛ 5755line 5753 didn't jump to line 5755 because the condition on line 5753 was always true

5754 cdk_config["context"][region_key][region] = {} 

5755 for key, value in settings.items(): 

5756 if value is not None or key == "enabled": 5756 ↛ 5755line 5756 didn't jump to line 5755 because the condition on line 5756 was always true

5757 cdk_config["context"][region_key][region][key] = value 

5758 else: 

5759 if feature_key not in cdk_config["context"]: 

5760 cdk_config["context"][feature_key] = {**default_config} 

5761 for key, value in settings.items(): 

5762 if value is not None or key == "enabled": 

5763 cdk_config["context"][feature_key][key] = value 

5764 

5765 serialized = json.dumps(cdk_config, indent=2).encode("utf-8") 

5766 _atomic_write_bytes( 

5767 cdk_json_path, 

5768 serialized, 

5769 mode=stat.S_IMODE(cdk_json_path.stat().st_mode), 

5770 ) 

5771 

5772 

5773# ============================================================================= 

5774# Valkey configuration 

5775# ============================================================================= 

5776 

5777_VALKEY_DEFAULTS: dict[str, Any] = { 

5778 "enabled": False, 

5779 "max_data_storage_gb": 5, 

5780 "max_ecpu_per_second": 5000, 

5781 "snapshot_retention_limit": 1, 

5782} 

5783 

5784 

5785def get_valkey_config(region: str | None = None) -> dict[str, Any]: 

5786 """Get current Valkey Serverless configuration from cdk.json.""" 

5787 return _get_feature_config("valkey", _VALKEY_DEFAULTS, region) 

5788 

5789 

5790def update_valkey_config(settings: dict[str, Any], region: str | None = None) -> None: 

5791 """Update Valkey Serverless configuration in cdk.json.""" 

5792 _update_feature_config("valkey", settings, _VALKEY_DEFAULTS, region) 

5793 

5794 

5795# ============================================================================= 

5796# Aurora pgvector configuration 

5797# ============================================================================= 

5798 

5799_AURORA_DEFAULTS: dict[str, Any] = { 

5800 "enabled": False, 

5801 "min_acu": 0, 

5802 "max_acu": 16, 

5803 "backup_retention_days": 7, 

5804 "deletion_protection": False, 

5805} 

5806 

5807 

5808def get_aurora_config(region: str | None = None) -> dict[str, Any]: 

5809 """Get current Aurora pgvector configuration from cdk.json.""" 

5810 return _get_feature_config("aurora_pgvector", _AURORA_DEFAULTS, region) 

5811 

5812 

5813def update_aurora_config(settings: dict[str, Any], region: str | None = None) -> None: 

5814 """Update Aurora pgvector configuration in cdk.json.""" 

5815 _update_feature_config("aurora_pgvector", settings, _AURORA_DEFAULTS, region) 

5816 

5817 

5818# ============================================================================= 

5819# Analytics environment configuration 

5820# ============================================================================= 

5821 

5822_ANALYTICS_DEFAULTS: dict[str, Any] = { 

5823 "enabled": False, 

5824 "hyperpod": {"enabled": False}, 

5825 "canvas": {"enabled": False}, 

5826 "cognito": {"domain_prefix": None, "removal_policy": "destroy"}, 

5827 "efs": {"removal_policy": "destroy"}, 

5828 "studio": {"user_profile_name_prefix": None}, 

5829} 

5830 

5831 

5832def get_analytics_config() -> dict[str, Any]: 

5833 """Get the analytics environment configuration from cdk.json. 

5834 

5835 The analytics stack is single-region by construction (lives in the 

5836 api-gateway region), so this helper does not accept a region argument. 

5837 Returned dict is the defaults merged with any operator overrides from 

5838 the ``context.analytics_environment`` block. 

5839 """ 

5840 return _get_feature_config("analytics_environment", _ANALYTICS_DEFAULTS) 

5841 

5842 

5843def update_analytics_config(settings: dict[str, Any]) -> None: 

5844 """Update the analytics environment configuration in cdk.json. 

5845 

5846 Mirrors ``update_valkey_config`` / ``update_aurora_config``. Nested 

5847 keys under ``analytics_environment`` (``hyperpod``, ``canvas``, 

5848 ``cognito``, ``efs``, ``studio``) are merged one level deep rather 

5849 than replaced wholesale — ``enable --hyperpod`` must not clobber 

5850 ``cognito.removal_policy``. 

5851 """ 

5852 _update_feature_config("analytics_environment", settings, _ANALYTICS_DEFAULTS) 

5853 

5854 

5855# ============================================================================= 

5856# Cluster observability configuration 

5857# ============================================================================= 

5858 

5859# Mirrors the on-by-default cdk.json cluster_observability block. Unlike the 

5860# other feature toggles this one defaults to enabled=True: a stock deploy 

5861# installs kube-prometheus-stack on every regional cluster and operators opt 

5862# out. The CDK side reads/validates the same block via 

5863# ConfigLoader.get_cluster_observability_config. 

5864_CLUSTER_OBSERVABILITY_DEFAULTS: dict[str, Any] = { 

5865 "enabled": True, 

5866 "grafana": { 

5867 "persistence_size": "10Gi", 

5868 "admin_user": "admin", 

5869 "admin_password_rotation_schedule": "0 4 1 * *", 

5870 }, 

5871 "prometheus": {"persistence_size": "50Gi", "retention": "15d"}, 

5872 "alertmanager": {"enabled": True, "persistence_size": "5Gi"}, 

5873} 

5874 

5875 

5876def get_cluster_observability_config() -> dict[str, Any]: 

5877 """Get the cluster observability configuration from cdk.json. 

5878 

5879 Observability is per-region (installed on every regional cluster) but the 

5880 toggle itself is global, so this takes no region argument. Returns the 

5881 defaults merged with any operator overrides from the 

5882 ``context.cluster_observability`` block. 

5883 """ 

5884 return _get_feature_config("cluster_observability", _CLUSTER_OBSERVABILITY_DEFAULTS) 

5885 

5886 

5887def update_cluster_observability_config(settings: dict[str, Any]) -> None: 

5888 """Update the cluster observability toggle in cdk.json. 

5889 

5890 ``gco monitoring enable`` / ``disable`` pass ``{"enabled": True/False}``; 

5891 the grafana/prometheus/alertmanager sub-blocks are left untouched so an 

5892 operator's sizing/retention/rotation overrides survive a disable/enable 

5893 cycle. 

5894 """ 

5895 _update_feature_config("cluster_observability", settings, _CLUSTER_OBSERVABILITY_DEFAULTS)