Coverage for cli / stacks.py: 100.00%

3105 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1""" 

2Stack management for GCO CLI. 

3 

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

5It orchestrates the full deployment lifecycle, including container runtime 

6detection, CDK bootstrapping, Lambda source synchronization, and parallel 

7regional 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, nullcontext 

56from dataclasses import dataclass, field 

57from datetime import UTC, datetime 

58from functools import lru_cache 

59from pathlib import Path 

60from threading import Event, Lock, RLock, Thread, local 

61from typing import TYPE_CHECKING, Any, BinaryIO, Literal, TypedDict 

62 

63from botocore.exceptions import ClientError 

64from click import Abort 

65 

66from gco.lambda_shared_sources import LAMBDA_SHARED_SOURCE_TARGETS 

67from gco.stacks.constants import ( 

68 CONTROL_PLANE_ONLY_CONTEXT_KEY, 

69 LAMBDA_SOURCE_ASSET_EXCLUDE, 

70 known_cloudformation_regions, 

71 validated_deployment_partition, 

72 validated_regional_deployment_regions, 

73) 

74 

75from .output import confirm, interactive_echo 

76 

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

78# Generated at (UTC): 2026-10-23T11:52:58Z 

79# Generated from Git commit: 6c1b25d9046fec3666f29c38a10fbefe6b833c00 

80# Flowchart(s) generated from this file: 

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

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

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

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

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

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

87# * ``StackManager._execute_prepared_change_set`` -> ``diagrams/code_diagrams/cli/stacks.StackManager__execute_prepared_change_set.html`` 

88# (PNG: ``diagrams/code_diagrams/cli/stacks.StackManager__execute_prepared_change_set.png``) 

89# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

90# <pyflowchart-code-diagram> END 

91 

92 

93if TYPE_CHECKING: 

94 from .config import GCOConfig 

95 

96logger = logging.getLogger(__name__) 

97 

98# Every writer that replaces cdk.json must participate in the same transaction 

99# lock. The process-local RLock handles threads and nested feature updates; the 

100# advisory process lock uses a stable directory descriptor on POSIX and a 

101# persistent sidecar file on Windows, so it survives ``os.replace`` of the 

102# configuration inode. 

103_CONFIG_LOCK_FILENAME = ".gco-config.lock" 

104_CONFIG_THREAD_LOCKS: dict[Path, Any] = {} 

105_CONFIG_THREAD_LOCKS_GUARD = Lock() 

106_CONFIG_LOCK_STATE = local() 

107 

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

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

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

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

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

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

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

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

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

117_LAMBDA_BUILD_MANIFEST_VERSION = 1 

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

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

120#: The caches the stacks leave out of every zipped Lambda source directory, 

121#: left out of the CLI's own builds the same way. 

122_LAMBDA_SOURCE_COPY_IGNORE_PATTERNS = LAMBDA_SOURCE_ASSET_EXCLUDE 

123_FILE_LOCK_RETRY_SECONDS = 0.05 

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

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

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

127_ASSET_LOCK_TIMEOUT_SECONDS_DEFAULT = 900.0 

128_CONFIG_LOCK_TIMEOUT_SECONDS_DEFAULT = 900.0 

129_FileLockPurpose = Literal["asset", "configuration"] 

130_CDK_ASSET_CONSUMER_MAX_ATTEMPTS = 3 

131_CLOUDFORMATION_DELETE_TIMEOUT_SECONDS = 7200.0 

132_CLOUDFORMATION_DELETE_POLL_SECONDS = 15.0 

133_CLOUDFORMATION_DELETE_HEARTBEAT_SECONDS = 60.0 

134_CLOUDFORMATION_SETTLE_UNKNOWN_TIMEOUT_SECONDS = 60.0 

135_CLOUDFORMATION_SETTLE_UNKNOWN_POLL_SECONDS = 5.0 

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

137# CDK's ``prepare-change-set`` mode can return before a fresh CREATE change set 

138# is visible through CloudFormation's read path. Poll only that authoritative 

139# fresh-create absence window; every access, identity, tag, and ownership error 

140# still fails immediately. Sixteen reads at two-second intervals bound the 

141# eventual-consistency allowance to 30 seconds after the first attempt. 

142_STRICT_CHANGE_SET_INSPECTION_ATTEMPTS = 16 

143_STRICT_CHANGE_SET_INSPECTION_RETRY_SECONDS = 2.0 

144_LIVE_VALIDATION_PROVIDER_LOG_CONTEXT = "gco_live_validation_retain_provider_log_groups" 

145 

146# LAMBDA_SHARED_SOURCE_TARGETS is imported from the dependency-light inventory 

147# shared by deploy packaging, diagram reconciliation, and commit-time guards. 

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

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

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

151PreparedChangeSetAuthority = Mapping[str, Mapping[str, Mapping[str, str]]] 

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

153 

154 

155class _StackOperationSafetyKwargs(TypedDict): 

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

157 

158 allow_bootstrap: bool 

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

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

161 prepared_change_sets: PreparedChangeSetAuthority | None 

162 authorize_stack: StackAuthorizationCallback | None 

163 strict_deployment_token: str | None 

164 on_change_set_prepared: ChangeSetPreparedCallback | None 

165 on_ecr_repository_created: EcrRepositoryCreatedCallback | None 

166 

167 

168@dataclass(frozen=True) 

169class _CdkAssetSpec: 

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

171 

172 name: str 

173 source_directory: str 

174 build_directory: str 

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

176 

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

178 lambda_dir = project_root / "lambda" 

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

180 

181 

182_KUBECTL_CDK_ASSET = _CdkAssetSpec( 

183 name="kubectl-applier-simple", 

184 source_directory="kubectl-applier-simple", 

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

186 source_inputs=_KUBECTL_PACKAGE_INPUTS, 

187) 

188_HELM_CDK_ASSET = _CdkAssetSpec( 

189 name="helm-installer", 

190 source_directory="helm-installer", 

191 build_directory="helm-installer-build", 

192 source_inputs=None, 

193) 

194_INFERENCE_STREAMING_CDK_ASSET = _CdkAssetSpec( 

195 name="inference-streaming-proxy", 

196 source_directory="inference-streaming-proxy", 

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

198 source_inputs=_INFERENCE_STREAMING_PACKAGE_FILES, 

199) 

200_CDK_ASSET_SPECS = ( 

201 _KUBECTL_CDK_ASSET, 

202 _HELM_CDK_ASSET, 

203 _INFERENCE_STREAMING_CDK_ASSET, 

204) 

205 

206 

207class _AssetThreadState(local): 

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

209 

210 def __init__(self) -> None: 

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

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

213 

214 

215_asset_thread_state = _AssetThreadState() 

216 

217 

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

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

220 selected: set[Path] = set() 

221 if source_inputs is None: 

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

223 else: 

224 for relative_name in source_inputs: 

225 path = root / relative_name 

226 if not path.exists() and not path.is_symlink(): 

227 raise FileNotFoundError(path) 

228 selected.add(path) 

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

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

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

232 

233 

234def _asset_tree_digest( 

235 root: Path, 

236 *, 

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

238) -> str | None: 

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

240 

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

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

243 so removing any installed transitive dependency invalidates the build. 

244 """ 

245 if not root.is_dir(): 

246 return None 

247 digest = hashlib.sha256() 

248 try: 

249 for path in _asset_tree_paths(root, source_inputs): 

250 relative = path.relative_to(root) 

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

252 continue 

253 if ( 

254 path.name in _LAMBDA_SOURCE_IGNORED_FILES 

255 or path.name == _LAMBDA_BUILD_MANIFEST 

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

257 ): 

258 continue 

259 

260 metadata = path.lstat() 

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

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

263 digest.update(relative_bytes) 

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

265 

266 if path.is_symlink(): 

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

268 digest.update(b"L") 

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

270 digest.update(target) 

271 elif path.is_dir(): 

272 digest.update(b"D") 

273 elif path.is_file(): 

274 file_digest = hashlib.sha256() 

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

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

277 file_digest.update(chunk) 

278 digest.update(b"F") 

279 digest.update(file_digest.digest()) 

280 else: 

281 return None 

282 except OSError, UnicodeError: 

283 return None 

284 return digest.hexdigest() 

285 

286 

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

288 try: 

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

290 except OSError, UnicodeError, json.JSONDecodeError: 

291 return None 

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

293 

294 

295#: The modes every published asset entry carries. Lambda reads a zip entry as 

296#: an unprivileged user, and a Dockerfile COPY keeps the build context's modes 

297#: in an image whose later steps run as a non-root user, so an entry that only 

298#: the deploying user can read deploys cleanly and fails at run time. Git 

299#: records exactly these two file modes, so a build made under the default 

300#: umask already has them and its digest (which hashes modes) does not change. 

301_PUBLISHED_DIRECTORY_MODE = 0o755 

302_PUBLISHED_EXECUTABLE_MODE = 0o755 

303_PUBLISHED_FILE_MODE = 0o644 

304 

305 

306def _modes_gate_reads() -> bool: 

307 """Whether POSIX mode bits decide who may read a file (they do not on Windows).""" 

308 return os.name != "nt" 

309 

310 

311def _published_mode(metadata: os.stat_result) -> int | None: 

312 """Return the mode a published entry must have, or ``None`` to keep its own. 

313 

314 Symlinks keep theirs: nothing reads a link's own mode, and ``os.chmod`` 

315 would follow the link to its target. 

316 """ 

317 if stat.S_ISDIR(metadata.st_mode): 

318 return _PUBLISHED_DIRECTORY_MODE 

319 if stat.S_ISREG(metadata.st_mode): 

320 return _PUBLISHED_EXECUTABLE_MODE if metadata.st_mode & 0o111 else _PUBLISHED_FILE_MODE 

321 return None 

322 

323 

324def _hidden_from_other_users(metadata: os.stat_result) -> bool: 

325 """A file others cannot read, or a directory others cannot list and enter.""" 

326 if stat.S_ISDIR(metadata.st_mode): 

327 return metadata.st_mode & 0o005 != 0o005 

328 if stat.S_ISREG(metadata.st_mode): 

329 return not metadata.st_mode & 0o004 

330 return False 

331 

332 

333def _publish_mode(path: Path) -> bool: 

334 """Give one asset entry its published mode; return whether it had to change.""" 

335 metadata = path.lstat() 

336 wanted = _published_mode(metadata) 

337 if wanted is None or stat.S_IMODE(metadata.st_mode) == wanted: 

338 return False 

339 # World-readable on purpose: the Lambda runtime and an image's non-root 

340 # user must read every file and list and enter every directory. 

341 # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions 

342 os.chmod(path, wanted) 

343 return True 

344 

345 

346def _normalize_asset_modes(root: Path) -> bool: 

347 """Give a staged asset tree its publishable modes, whatever the umask was. 

348 

349 ``pip install -t`` and ``npm ci`` create files under the process umask, and 

350 ``shutil.copy2`` / ``copytree`` copy the checkout's own modes, so a build 

351 made under ``umask 077`` (or from a checkout made under it) would publish 

352 owner-only files. CDK's asset hash ignores modes, so such a zip would also 

353 be reused from the bootstrap bucket by later deploys. This runs before the 

354 completion manifest is written, so the build digest records the final 

355 modes. ``root`` may also be a single file; a symlink is left as it is. 

356 Windows modes do not gate reads, so it is a no-op there. 

357 

358 Returns: 

359 Whether any entry's mode had to change. 

360 """ 

361 if not _modes_gate_reads(): 

362 return False 

363 changed = _publish_mode(root) 

364 if root.is_symlink() or not root.is_dir(): 

365 return changed 

366 for directory, directories, files in os.walk(root): 

367 for name in (*directories, *files): 

368 changed = _publish_mode(Path(directory, name)) or changed 

369 return changed 

370 

371 

372def _asset_tree_is_world_readable(root: Path) -> bool: 

373 """Whether every entry below ``root`` is readable (and searchable) by every user. 

374 

375 A build published before modes were normalized can hold owner-only 

376 entries that its own manifest still matches; failing freshness on it makes 

377 the next deploy rebuild (and so re-upload) it. ``root`` itself is not 

378 judged: no asset carries its mode, and the staging directory it was 

379 renamed from was created owner-only by ``tempfile.mkdtemp``. 

380 """ 

381 if not _modes_gate_reads(): 

382 return True 

383 try: 

384 for directory, directories, files in os.walk(root): 

385 for name in (*directories, *files): 

386 if _hidden_from_other_users(Path(directory, name).lstat()): 

387 return False 

388 except OSError: 

389 return False 

390 return True 

391 

392 

393#: Where cdk-assets keeps the zip it packages from a staged directory, as 

394#: ``<asset manifest directory>/.cache/<asset id>.zip``. It publishes that zip, 

395#: without reading the staged copy again, whenever the bootstrap bucket lacks 

396#: the object. 

397_CDK_ASSET_ZIP_CACHE = ".cache" 

398 

399 

400def _cloud_assembly_asset_manifests(assembly: Path) -> list[Path]: 

401 """Every asset manifest a cloud assembly lists, its nested assemblies' included.""" 

402 manifests: list[Path] = [] 

403 pending = [assembly] 

404 while pending: 

405 directory = pending.pop() 

406 try: 

407 document = json.loads((directory / "manifest.json").read_text(encoding="utf-8")) 

408 except OSError, ValueError: 

409 continue 

410 for artifact in (document.get("artifacts") or {}).values(): 

411 properties = artifact.get("properties") or {} 

412 if artifact.get("type") == "cdk:asset-manifest" and properties.get("file"): 

413 manifests.append(directory / properties["file"]) 

414 elif artifact.get("type") == "cdk:cloud-assembly" and properties.get("directoryName"): 

415 pending.append(directory / properties["directoryName"]) 

416 return manifests 

417 

418 

419def _published_asset_copies(assembly: Path) -> dict[Path, set[Path]]: 

420 """Map each staged copy a cloud assembly publishes to the zips cached from it. 

421 

422 CDK stages every asset at the top of the assembly as ``asset.<hash>`` 

423 (a directory, or a file for a single-file asset), nested assemblies 

424 included; the stack templates are file assets too, but nothing stages 

425 them. Only the copies the assembly's asset manifests name are returned: 

426 CDK never removes a copy that a later synth stops using. 

427 """ 

428 top = Path(os.path.normpath(assembly)) 

429 copies: dict[Path, set[Path]] = {} 

430 for manifest_path in _cloud_assembly_asset_manifests(assembly): 

431 try: 

432 manifest = json.loads(manifest_path.read_text(encoding="utf-8")) 

433 except OSError, ValueError: 

434 continue 

435 work_dir = manifest_path.parent 

436 # (path relative to the manifest, id of the zip cdk-assets caches from it) 

437 sources: list[tuple[Any, str | None]] = [] 

438 for asset_id, asset in (manifest.get("files") or {}).items(): 

439 source = asset.get("source") or {} 

440 zipped = source.get("packaging") == "zip" 

441 sources.append((source.get("path"), asset_id if zipped else None)) 

442 for asset in (manifest.get("dockerImages") or {}).values(): 

443 sources.append(((asset.get("source") or {}).get("directory"), None)) 

444 for relative, zip_id in sources: 

445 if not isinstance(relative, str): 

446 continue 

447 copy = Path(os.path.normpath(work_dir / relative)) 

448 if copy.parent != top or not copy.name.startswith("asset."): 

449 continue 

450 zips = copies.setdefault(copy, set()) 

451 if zip_id is not None: 

452 zips.add(work_dir / _CDK_ASSET_ZIP_CACHE / f"{zip_id}.zip") 

453 return copies 

454 

455 

456def normalize_staged_asset_modes(assembly_directory: str | Path) -> list[str]: 

457 """Give every asset copy a cloud assembly publishes git's modes. 

458 

459 CDK copies each asset into the cloud assembly before the CDK CLI zips, 

460 builds or uploads it. The copy's files keep the checkout's modes, but CDK 

461 creates its directories under the process umask, and an image's ``COPY`` 

462 keeps them: from a shell running ``umask 077``, the service images could 

463 not import their own entry modules as their non-root users and failed the 

464 distroless runtime smoke. CDK also reuses a staged copy for every later 

465 synth of the same content (the hash ignores modes), so an owner-only copy 

466 outlives the umask that made it, and so does any zip cdk-assets packaged 

467 from it. ``app.py`` runs this as soon as synthesis finishes, so the copies 

468 the CLI then publishes have git's modes however and whenever they were 

469 staged, even by a ``cdk`` command run directly; a cached zip of a copy this 

470 had to correct is removed so the next publish packages the corrected copy. 

471 Templates and manifests keep the caller's modes, since no asset carries 

472 them. Windows modes do not gate reads, so it is a no-op there. 

473 

474 Returns: 

475 The names of the staged copies whose modes were corrected, sorted. 

476 """ 

477 if not _modes_gate_reads(): 

478 return [] 

479 corrected: list[str] = [] 

480 for copy, zips in sorted(_published_asset_copies(Path(assembly_directory)).items()): 

481 if not (copy.exists() or copy.is_symlink()): 

482 continue 

483 if _normalize_asset_modes(copy): 

484 corrected.append(copy.name) 

485 for packaged in zips: 

486 packaged.unlink(missing_ok=True) 

487 return corrected 

488 

489 

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

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

492 build_digest = _asset_tree_digest(build_dir) 

493 if build_digest is None: 

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

495 manifest = { 

496 "schema_version": _LAMBDA_BUILD_MANIFEST_VERSION, 

497 "source_digest": source_digest, 

498 "build_digest": build_digest, 

499 } 

500 manifest_path = build_dir / _LAMBDA_BUILD_MANIFEST 

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

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

503 handle.write("\n") 

504 handle.flush() 

505 os.fsync(handle.fileno()) 

506 # The manifest ships inside the asset too and is created under the umask. 

507 # It is not part of the build digest, so its mode can be set afterwards. 

508 if _modes_gate_reads(): 

509 os.chmod(manifest_path, _PUBLISHED_FILE_MODE) 

510 

511 

512def _asset_build_is_fresh_unlocked( 

513 source_dir: Path, 

514 build_dir: Path, 

515 *, 

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

517) -> bool: 

518 manifest = _read_build_manifest(build_dir) 

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

520 return False 

521 source_digest = _asset_tree_digest(source_dir, source_inputs=source_inputs) 

522 build_digest = _asset_tree_digest(build_dir) 

523 return ( 

524 source_digest is not None 

525 and build_digest is not None 

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

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

528 and _asset_tree_is_world_readable(build_dir) 

529 ) 

530 

531 

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

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

534 return _asset_thread_state.held 

535 

536 

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

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

539 lock_file.seek(0, os.SEEK_END) 

540 if lock_file.tell() == 0: 

541 lock_file.write(b"\0") 

542 lock_file.flush() 

543 lock_file.seek(0) 

544 

545 

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

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

548 exc, 

549 "winerror", 

550 None, 

551 ) in {32, 33, 36} 

552 

553 

554def _file_lock_timeout_seconds(purpose: _FileLockPurpose) -> float: 

555 """Return the bounded wait for one class of interprocess lock.""" 

556 if purpose == "asset": 

557 env_name = "GCO_ASSET_LOCK_TIMEOUT_SECONDS" 

558 default = _ASSET_LOCK_TIMEOUT_SECONDS_DEFAULT 

559 else: 

560 env_name = "GCO_CONFIG_LOCK_TIMEOUT_SECONDS" 

561 default = _CONFIG_LOCK_TIMEOUT_SECONDS_DEFAULT 

562 

563 raw = os.environ.get(env_name, "") 

564 try: 

565 value = float(raw) 

566 except ValueError: 

567 return default 

568 if not math.isfinite(value) or value <= 0: 

569 return default 

570 return value 

571 

572 

573def _warn_file_lock_contended( 

574 lock_name: object, 

575 *, 

576 exclusive: bool, 

577 timeout: float, 

578 purpose: _FileLockPurpose, 

579) -> None: 

580 if purpose == "configuration": 

581 logger.warning( 

582 "Waiting up to %.0fs for the configuration lock on %s — another CLI " 

583 "or MCP process is updating cdk.json. Wait for it to finish; tune via " 

584 "GCO_CONFIG_LOCK_TIMEOUT_SECONDS.", 

585 timeout, 

586 lock_name, 

587 ) 

588 return 

589 

590 mode = "exclusive" if exclusive else "shared" 

591 logger.warning( 

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

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

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

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

596 timeout, 

597 mode, 

598 lock_name, 

599 lock_name, 

600 ) 

601 

602 

603def _raise_file_lock_timeout( 

604 lock_name: object, 

605 *, 

606 timeout: float, 

607 purpose: _FileLockPurpose, 

608) -> None: 

609 if purpose == "configuration": 

610 raise TimeoutError( 

611 f"Timed out after {timeout:.0f}s waiting for the configuration lock on " 

612 f"{lock_name}. Another CLI or MCP process is updating cdk.json. Wait " 

613 "for it to finish and retry; raise GCO_CONFIG_LOCK_TIMEOUT_SECONDS " 

614 "to wait longer." 

615 ) 

616 

617 raise TimeoutError( 

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

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

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

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

622 ) 

623 

624 

625def _posix_lock_is_contended(exc: OSError) -> bool: 

626 return isinstance(exc, BlockingIOError) or exc.errno in {errno.EACCES, errno.EAGAIN} 

627 

628 

629def _acquire_posix_flock( 

630 lock_fd: int, 

631 *, 

632 lock_name: object, 

633 exclusive: bool, 

634 purpose: _FileLockPurpose, 

635) -> None: 

636 """Acquire one POSIX flock with the shared warning and timeout contract.""" 

637 import fcntl 

638 

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

640 try: 

641 fcntl.flock(lock_fd, operation | fcntl.LOCK_NB) 

642 return 

643 except OSError as exc: 

644 if not _posix_lock_is_contended(exc): 

645 raise 

646 

647 timeout = _file_lock_timeout_seconds(purpose) 

648 deadline = time.monotonic() + timeout 

649 _warn_file_lock_contended( 

650 lock_name, 

651 exclusive=exclusive, 

652 timeout=timeout, 

653 purpose=purpose, 

654 ) 

655 while True: 

656 try: 

657 fcntl.flock(lock_fd, operation | fcntl.LOCK_NB) 

658 return 

659 except OSError as exc: 

660 if not _posix_lock_is_contended(exc): 

661 raise 

662 if time.monotonic() >= deadline: 

663 _raise_file_lock_timeout( 

664 lock_name, 

665 timeout=timeout, 

666 purpose=purpose, 

667 ) 

668 time.sleep(_FILE_LOCK_RETRY_SECONDS) 

669 

670 

671def _acquire_file_lock( 

672 lock_file: BinaryIO, 

673 *, 

674 exclusive: bool, 

675 purpose: _FileLockPurpose, 

676) -> None: 

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

678 

679 The first attempt is non-blocking. On contention a purpose-specific warning 

680 names the lock file, then acquisition polls until the env-tunable deadline 

681 so a stuck holder produces an actionable error instead of an indefinite 

682 silent hang. 

683 """ 

684 if os.name == "nt": 

685 import msvcrt 

686 

687 msvcrt_api: Any = msvcrt 

688 _ensure_windows_lock_byte(lock_file) 

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

690 warned = False 

691 deadline: float | None = None 

692 timeout: float | None = None 

693 while True: 

694 lock_file.seek(0) 

695 try: 

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

697 # Windows readers and writers preserves correctness while POSIX 

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

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

700 return 

701 except OSError as exc: 

702 if not _windows_lock_is_contended(exc): 

703 raise 

704 if not warned: 

705 timeout = _file_lock_timeout_seconds(purpose) 

706 deadline = time.monotonic() + timeout 

707 _warn_file_lock_contended( 

708 lock_name, 

709 exclusive=exclusive, 

710 timeout=timeout, 

711 purpose=purpose, 

712 ) 

713 warned = True 

714 assert deadline is not None and timeout is not None 

715 if time.monotonic() >= deadline: 

716 _raise_file_lock_timeout( 

717 lock_name, 

718 timeout=timeout, 

719 purpose=purpose, 

720 ) 

721 time.sleep(_FILE_LOCK_RETRY_SECONDS) 

722 

723 _acquire_posix_flock( 

724 lock_file.fileno(), 

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

726 exclusive=exclusive, 

727 purpose=purpose, 

728 ) 

729 

730 

731def _release_file_lock(lock_file: BinaryIO) -> None: 

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

733 if os.name == "nt": 

734 import msvcrt 

735 

736 msvcrt_api: Any = msvcrt 

737 lock_file.seek(0) 

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

739 return 

740 

741 import fcntl 

742 

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

744 

745 

746@contextmanager 

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

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

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

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

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

752 held = _thread_asset_locks() 

753 existing = held.get(lock_key) 

754 if existing is not None: 

755 held_exclusive, depth = existing 

756 if exclusive and not held_exclusive: 

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

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

759 try: 

760 yield 

761 finally: 

762 held[lock_key] = (held_exclusive, depth) 

763 return 

764 

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

766 _acquire_file_lock(lock_file, exclusive=exclusive, purpose="asset") 

767 held[lock_key] = (exclusive, 1) 

768 try: 

769 yield 

770 finally: 

771 held.pop(lock_key, None) 

772 _release_file_lock(lock_file) 

773 

774 

775def _asset_build_is_fresh( 

776 source_dir: Path, 

777 build_dir: Path, 

778 *, 

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

780) -> bool: 

781 with _lambda_asset_lock(build_dir, exclusive=False): 

782 return _asset_build_is_fresh_unlocked( 

783 source_dir, 

784 build_dir, 

785 source_inputs=source_inputs, 

786 ) 

787 

788 

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

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

791 _safe_rmtree(path) 

792 

793 

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

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

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

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

798 

799 if not build_dir.exists() and backup_dirs: 

800 try: 

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

802 except OSError: 

803 newest_backup = backup_dirs[0] 

804 os.replace(newest_backup, build_dir) 

805 

806 for path in [*staging_dirs, *backup_dirs]: 

807 _remove_asset_tree(path) 

808 

809 

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

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

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

813 had_previous = build_dir.exists() 

814 if had_previous: 

815 os.replace(build_dir, backup_dir) 

816 try: 

817 os.replace(staging_dir, build_dir) 

818 except Exception: 

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

820 os.replace(backup_dir, build_dir) 

821 raise 

822 if backup_dir.exists(): 

823 _remove_asset_tree(backup_dir) 

824 

825 

826def _prepare_lambda_asset( 

827 source_dir: Path, 

828 build_dir: Path, 

829 *, 

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

831 display_name: str, 

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

833) -> bool: 

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

835 

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

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

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

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

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

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

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

843 while this one waited). 

844 """ 

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

846 return False 

847 with _lambda_asset_lock(build_dir, exclusive=True): 

848 _recover_interrupted_asset_publish(build_dir) 

849 source_digest = _asset_tree_digest(source_dir, source_inputs=source_inputs) 

850 if source_digest is None: 

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

852 if _asset_build_is_fresh_unlocked( 

853 source_dir, 

854 build_dir, 

855 source_inputs=source_inputs, 

856 ): 

857 return False 

858 

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

860 staging_dir = Path( 

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

862 ) 

863 try: 

864 builder(staging_dir) 

865 _normalize_asset_modes(staging_dir) 

866 if _asset_tree_digest(source_dir, source_inputs=source_inputs) != source_digest: 

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

868 _write_build_manifest(staging_dir, source_digest) 

869 if not _asset_build_is_fresh_unlocked( 

870 source_dir, 

871 staging_dir, 

872 source_inputs=source_inputs, 

873 ): 

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

875 _publish_staged_asset(staging_dir, build_dir) 

876 finally: 

877 _remove_asset_tree(staging_dir) 

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

879 return True 

880 

881 

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

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

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

885 try: 

886 shutil.copy2(source, temporary) 

887 os.replace(temporary, target) 

888 finally: 

889 temporary.unlink(missing_ok=True) 

890 

891 

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

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

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

895 try: 

896 temporary.write_bytes(content) 

897 if mode is not None: 

898 os.chmod(temporary, mode) 

899 os.replace(temporary, target) 

900 finally: 

901 temporary.unlink(missing_ok=True) 

902 

903 

904class ConfigMutationLockError(RuntimeError): 

905 """The shared cdk.json transaction lock could not be acquired.""" 

906 

907 

908@contextmanager 

909def _config_process_lock(lock_key: Path) -> Iterator[None]: 

910 """Hold the platform-native process lock for one config directory.""" 

911 if os.name == "nt": 

912 # Windows cannot open a directory for ``msvcrt.locking``. A persistent 

913 # sidecar in that directory gives every CLI/MCP process the same stable 

914 # inode even while cdk.json itself is atomically replaced. 

915 lock_path = lock_key / _CONFIG_LOCK_FILENAME 

916 lock_file: BinaryIO | None = None 

917 try: 

918 lock_file = lock_path.open("a+b") 

919 _acquire_file_lock(lock_file, exclusive=True, purpose="configuration") 

920 except OSError as exc: 

921 if lock_file is not None: 

922 lock_file.close() 

923 raise ConfigMutationLockError( 

924 f"could not lock configuration directory {lock_key}: {exc}" 

925 ) from exc 

926 

927 try: 

928 yield 

929 finally: 

930 assert lock_file is not None 

931 try: 

932 _release_file_lock(lock_file) 

933 finally: 

934 lock_file.close() 

935 return 

936 

937 # Keep the POSIX directory lock: unlike a lock on cdk.json, the descriptor 

938 # continues to identify the same object when an atomic writer replaces the 

939 # configuration file. 

940 import fcntl 

941 

942 flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) 

943 flags |= getattr(os, "O_DIRECTORY", 0) 

944 lock_fd: int | None = None 

945 try: 

946 lock_fd = os.open(lock_key, flags) 

947 _acquire_posix_flock( 

948 lock_fd, 

949 lock_name=str(lock_key), 

950 exclusive=True, 

951 purpose="configuration", 

952 ) 

953 except OSError as exc: 

954 if lock_fd is not None: 

955 os.close(lock_fd) 

956 raise ConfigMutationLockError( 

957 f"could not lock configuration directory {lock_key}: {exc}" 

958 ) from exc 

959 

960 try: 

961 yield 

962 finally: 

963 assert lock_fd is not None 

964 try: 

965 fcntl.flock(lock_fd, fcntl.LOCK_UN) 

966 finally: 

967 os.close(lock_fd) 

968 

969 

970@contextmanager 

971def _config_mutation_lock(path: Path) -> Iterator[None]: 

972 """Serialize a complete read/modify/replace transaction for ``path``. 

973 

974 POSIX locks the stable directory descriptor; Windows locks a persistent 

975 sidecar in that directory. A thread-local held-set makes this context 

976 reentrant, which is required by analytics teardown: it holds the 

977 transaction across its temporary mutation and nested feature-toggle writes. 

978 """ 

979 lock_key = path.parent.resolve() 

980 with _CONFIG_THREAD_LOCKS_GUARD: 

981 thread_lock = _CONFIG_THREAD_LOCKS.setdefault(lock_key, RLock()) 

982 

983 with thread_lock: 

984 held_directories = getattr(_CONFIG_LOCK_STATE, "held_directories", None) 

985 if held_directories is None: 

986 held_directories = set() 

987 _CONFIG_LOCK_STATE.held_directories = held_directories 

988 if lock_key in held_directories: 

989 yield 

990 return 

991 

992 with _config_process_lock(lock_key): 

993 held_directories.add(lock_key) 

994 try: 

995 yield 

996 finally: 

997 held_directories.discard(lock_key) 

998 

999 

1000@lru_cache(maxsize=1) 

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

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

1003 return known_cloudformation_regions() 

1004 

1005 

1006class CdkToolchainError(RuntimeError): 

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

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

1009 

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

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

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

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

1014 """ 

1015 

1016 

1017class AssetPermissionError(RuntimeError): 

1018 """Deployable sources in the checkout are not readable by every user.""" 

1019 

1020 

1021#: The checkout paths CDK packages exactly as they sit on disk, relative to 

1022#: the project root: every plain Lambda directory under ``lambda/`` (zipped as 

1023#: is, or a Docker build context) and the service images' build context, 

1024#: which the root ``.dockerignore`` narrows to ``gco/``, ``cli/``, 

1025#: ``dockerfiles/``, ``pyproject.toml`` and ``requirements-lock.txt`` and the 

1026#: image assets narrow further (the regional stack excludes ``cli/**``, 

1027#: ``gco/stacks/**`` and ``dockerfiles/README.md``). 

1028_ASSET_SOURCE_ROOTS = ("lambda", "gco", "dockerfiles", "pyproject.toml", "requirements-lock.txt") 

1029#: Paths below those roots that never reach an asset as they are. 

1030_ASSET_SOURCE_EXCLUDED = frozenset({"gco/stacks", "dockerfiles/README.md"}) 

1031#: Lambda sources CDK never packages directly: only their generated builds 

1032#: are assets, and ``_normalize_asset_modes`` gives those their modes. 

1033_CLI_BUILT_LAMBDA_SOURCES = frozenset({"kubectl-applier-simple", "inference-streaming-proxy"}) 

1034_ASSET_SOURCE_IGNORED_DIRECTORIES = _LAMBDA_SOURCE_IGNORED_DIRECTORIES | {"node_modules"} 

1035_ASSET_SOURCE_REPORT_LIMIT = 10 

1036 

1037 

1038def _skip_asset_source_directory(parent: str, name: str) -> bool: 

1039 if name in _ASSET_SOURCE_IGNORED_DIRECTORIES or f"{parent}/{name}" in _ASSET_SOURCE_EXCLUDED: 

1040 return True 

1041 # Generated builds with their lock, staging and backup siblings, and the 

1042 # CLI-built sources: the CLI normalizes what it publishes from them. 

1043 return parent == "lambda" and ( 

1044 name.startswith(".") or name.endswith("-build") or name in _CLI_BUILT_LAMBDA_SOURCES 

1045 ) 

1046 

1047 

1048def _skip_asset_source_file(parent: str, name: str) -> bool: 

1049 if name in _LAMBDA_SOURCE_IGNORED_FILES or name.endswith((".pyc", ".pyo")): 

1050 return True 

1051 # Files directly under lambda/ (its README and the asset locks) are in no asset. 

1052 return parent == "lambda" or f"{parent}/{name}" in _ASSET_SOURCE_EXCLUDED 

1053 

1054 

1055def unreadable_asset_sources(project_root: str | Path) -> list[str]: 

1056 """Return the deployable checkout paths other users cannot read, sorted. 

1057 

1058 Covers :data:`_ASSET_SOURCE_ROOTS`: files must be world-readable and 

1059 directories world-readable and searchable. Symlinks are not judged by 

1060 their own mode. Paths are relative to ``project_root``, POSIX-style. 

1061 """ 

1062 root = Path(project_root) 

1063 offenders: list[str] = [] 

1064 

1065 def check(path: Path) -> None: 

1066 try: 

1067 metadata = path.lstat() 

1068 except FileNotFoundError: 

1069 return # removed while walking (an editor's scratch file): nothing to package 

1070 except OSError: 

1071 offenders.append(path.relative_to(root).as_posix()) 

1072 return 

1073 if _hidden_from_other_users(metadata): 

1074 offenders.append(path.relative_to(root).as_posix()) 

1075 

1076 for name in _ASSET_SOURCE_ROOTS: 

1077 top = root / name 

1078 if not top.exists(): 

1079 continue 

1080 check(top) 

1081 if top.is_symlink() or not top.is_dir(): 

1082 continue 

1083 for directory, directories, files in os.walk(top): 

1084 current = Path(directory) 

1085 parent = current.relative_to(root).as_posix() 

1086 directories[:] = sorted( 

1087 entry for entry in directories if not _skip_asset_source_directory(parent, entry) 

1088 ) 

1089 for entry in directories: 

1090 check(current / entry) 

1091 for entry in sorted(files): 

1092 if not _skip_asset_source_file(parent, entry): 

1093 check(current / entry) 

1094 return sorted(offenders) 

1095 

1096 

1097def check_asset_sources_readable(project_root: str | Path) -> None: 

1098 """Refuse to deploy from a checkout whose deployable sources are owner-only. 

1099 

1100 CDK zips plain Lambda directories and builds the service images from the 

1101 checkout exactly as it is on disk, and its asset hash ignores file modes. 

1102 A checkout made under a restrictive umask therefore uploads Lambda code 

1103 the Lambda runtime cannot read (the function deploys and then fails when 

1104 invoked), fails the service image builds once they run as their non-root 

1105 user, and leaves those unreadable zips in the bootstrap bucket for later 

1106 deploys to reuse. Runs before anything is packaged. Windows modes do not 

1107 gate reads, so there is nothing to check there. 

1108 

1109 Raises: 

1110 AssetPermissionError: naming the offending paths and the fix. 

1111 """ 

1112 if not _modes_gate_reads(): 

1113 return 

1114 offenders = unreadable_asset_sources(project_root) 

1115 if not offenders: 

1116 return 

1117 shown = ", ".join(offenders[:_ASSET_SOURCE_REPORT_LIMIT]) 

1118 if len(offenders) > _ASSET_SOURCE_REPORT_LIMIT: 

1119 shown += f" (and {len(offenders) - _ASSET_SOURCE_REPORT_LIMIT} more)" 

1120 raise AssetPermissionError( 

1121 f"{len(offenders)} deployable path(s) in the checkout are not readable by every " 

1122 f"user: {shown}. CDK packages them as they are, so Lambda could not read the " 

1123 "uploaded code and the service image builds would fail; a checkout made under a " 

1124 "restrictive umask (such as 077) does this. Make files world-readable and " 

1125 "directories world-searchable from the repository root, then deploy again: " 

1126 f"chmod -R a+rX {' '.join(_ASSET_SOURCE_ROOTS)}" 

1127 ) 

1128 

1129 

1130@dataclass 

1131class StackInfo: 

1132 """Information about a CDK stack.""" 

1133 

1134 name: str 

1135 status: str 

1136 region: str 

1137 created_time: datetime | None = None 

1138 updated_time: datetime | None = None 

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

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

1141 

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

1143 return { 

1144 "name": self.name, 

1145 "status": self.status, 

1146 "region": self.region, 

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

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

1149 "outputs": self.outputs, 

1150 "tags": self.tags, 

1151 } 

1152 

1153 

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

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

1156 

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

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

1159 symlinks or extended-attribute resource forks. 

1160 

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

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

1163 """ 

1164 resolved = path.resolve() 

1165 

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

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

1168 artifact_name = resolved.name 

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

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

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

1172 ) 

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

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

1175 

1176 try: 

1177 shutil.rmtree(str(resolved)) 

1178 except OSError: 

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

1180 

1181 

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

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

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

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

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

1187# touching the new module's cache. 

1188from cli._container_runtime import ( # noqa: E402 

1189 _detect_container_runtime_uncached, 

1190) 

1191 

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

1193_container_runtime_cache: str | None = None 

1194_container_runtime_checked: bool = False 

1195 

1196 

1197def _detect_container_runtime() -> str | None: 

1198 """ 

1199 Detect available container runtime for CDK asset bundling. 

1200 

1201 Thin caching wrapper around the shared 

1202 ``cli._container_runtime._detect_container_runtime_uncached`` probe. 

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

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

1205 """ 

1206 global _container_runtime_cache, _container_runtime_checked 

1207 if _container_runtime_checked: 

1208 return _container_runtime_cache 

1209 

1210 _container_runtime_cache = _detect_container_runtime_uncached() 

1211 _container_runtime_checked = True 

1212 return _container_runtime_cache 

1213 

1214 

1215#: Environment marker ``Dockerfile.dev`` sets in the ``gco-dev`` image that 

1216#: ``scripts/setup-dev-alias.sh`` builds and its ``gco`` shell function runs. 

1217DEV_CONTAINER_ENV_VAR = "GCO_DEV_CONTAINER" 

1218 

1219#: Runtime npm prefix ``Dockerfile.dev`` has set since v5.4.0. It identifies a 

1220#: ``gco-dev`` image built before :data:`DEV_CONTAINER_ENV_VAR` existed, which 

1221#: is exactly the stale image a toolchain-pin error is most likely to come from. 

1222DEV_CONTAINER_NPM_PREFIX = "/root/.npm-global" 

1223 

1224 

1225def _running_in_dev_container() -> bool: 

1226 """Return whether this CLI runs inside the ``gco-dev`` container. 

1227 

1228 The shell function from ``scripts/setup-dev-alias.sh`` runs the CLI in the 

1229 ``gco-dev`` image, so the toolchain a pin check sees (npm, the CDK CLI, 

1230 kubectl) is the image's rather than the host's. Installing a tool on the 

1231 host never changes it; rebuilding the image does. 

1232 """ 

1233 if os.environ.get(DEV_CONTAINER_ENV_VAR) == "1": 

1234 return True 

1235 return os.environ.get("NPM_CONFIG_PREFIX") == DEV_CONTAINER_NPM_PREFIX 

1236 

1237 

1238def _npm_pin_remedy(required_npm: str) -> str: 

1239 """Return how to get the npm the inference streaming Lambda pins.""" 

1240 if _running_in_dev_container(): 

1241 return ( 

1242 "This command runs in the gco-dev container, whose npm comes from the image, " 

1243 "so installing npm on the host does not change it. The image was built from a " 

1244 "different release than this checkout: rebuild it by running " 

1245 "./scripts/setup-dev-alias.sh from the repository root on the host, then rerun " 

1246 "the command." 

1247 ) 

1248 return f"Run: npm install --global npm@{required_npm}" 

1249 

1250 

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

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

1253 

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

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

1256 until app construction and synthesis finish. 

1257 """ 

1258 manager = StackManager.__new__(StackManager) 

1259 manager.project_root = Path(project_root) 

1260 manager._ensure_lambda_build() 

1261 

1262 

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

1264 return _asset_thread_state.active_consumers 

1265 

1266 

1267@contextmanager 

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

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

1270 

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

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

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

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

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

1276 """ 

1277 root = Path(project_root) 

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

1279 active = _thread_asset_consumers() 

1280 if root_key in active: 

1281 active[root_key] += 1 

1282 try: 

1283 yield 

1284 finally: 

1285 active[root_key] -= 1 

1286 return 

1287 

1288 stale_assets: list[str] = [] 

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

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

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

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

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

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

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

1296 for attempt in range(_CDK_ASSET_CONSUMER_MAX_ATTEMPTS + 1): 

1297 if attempt: 

1298 prepare_cdk_assets(root) 

1299 resolved_assets = [] 

1300 for spec in _CDK_ASSET_SPECS: 

1301 source_dir, build_dir = spec.paths(root) 

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

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

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

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

1306 

1307 with ExitStack() as locks: 

1308 for _spec, _source_dir, build_dir in sorted( 

1309 resolved_assets, 

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

1311 ): 

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

1313 

1314 stale_assets = [ 

1315 spec.name 

1316 for spec, source_dir, build_dir in resolved_assets 

1317 if not _asset_build_is_fresh_unlocked( 

1318 source_dir, 

1319 build_dir, 

1320 source_inputs=spec.source_inputs, 

1321 ) 

1322 ] 

1323 if stale_assets: 

1324 continue 

1325 

1326 active[root_key] = 1 

1327 try: 

1328 yield 

1329 finally: 

1330 active.pop(root_key, None) 

1331 return 

1332 

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

1334 raise RuntimeError( 

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

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

1337 ) 

1338 

1339 

1340class StackManager: 

1341 """Manages CDK stack operations.""" 

1342 

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

1344 self.config = config 

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

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

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

1348 self._cdk_path: str | None = None 

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

1350 self._active_cdk_lock = Lock() 

1351 self._cdk_cancel_event = Event() 

1352 # Extra `--context key=value` pairs appended to every app-evaluating 

1353 # CDK invocation (deploy/destroy/diff/list/synth). Set once via 

1354 # set_extra_cdk_context; used by the live release validation harness 

1355 # to force-enable optional Helm charts (helm_enabled_overrides) for a 

1356 # run without mutating the checked-out cdk.json. 

1357 self._extra_cdk_context: dict[str, str] = {} 

1358 

1359 def set_extra_cdk_context(self, context: Mapping[str, str]) -> None: 

1360 """Register `--context` pairs for every subsequent CDK invocation. 

1361 

1362 Keys and values must be plain strings without shell metacharacters' 

1363 risk (argv is passed as a list, never a shell string); a key that is 

1364 empty or contains ``=`` is refused because it could not round-trip 

1365 through the CDK CLI's ``key=value`` form unambiguously. 

1366 """ 

1367 validated: dict[str, str] = {} 

1368 for key, value in context.items(): 

1369 if not key or "=" in key: 

1370 raise ValueError(f"Invalid CDK context key: {key!r}") 

1371 validated[str(key)] = str(value) 

1372 self._extra_cdk_context = validated 

1373 

1374 def _find_project_root(self) -> Path: 

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

1376 current = Path.cwd() 

1377 for parent in [current, *current.parents]: 

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

1379 return parent 

1380 return current 

1381 

1382 def _find_cdk(self) -> str: 

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

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

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

1386 if local_cdk.is_file(): 

1387 return str(local_cdk) 

1388 

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

1390 # the repository's root npm graph. 

1391 try: 

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

1393 return result.stdout.strip() 

1394 except subprocess.CalledProcessError: 

1395 pass 

1396 

1397 # Check common global-install locations. 

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

1399 expanded = os.path.expanduser(path) 

1400 if os.path.exists(expanded): 

1401 return expanded 

1402 

1403 raise CdkToolchainError( 

1404 "AWS CDK CLI is not installed. Run " 

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

1406 "to install the dependency-locked CLI." 

1407 ) 

1408 

1409 @staticmethod 

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

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

1412 return _asset_build_is_fresh( 

1413 source_dir, 

1414 build_dir, 

1415 source_inputs=_KUBECTL_CDK_ASSET.source_inputs, 

1416 ) 

1417 

1418 @staticmethod 

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

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

1421 return _asset_build_is_fresh( 

1422 source_dir, 

1423 build_dir, 

1424 source_inputs=_HELM_CDK_ASSET.source_inputs, 

1425 ) 

1426 

1427 @staticmethod 

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

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

1430 return _asset_build_is_fresh( 

1431 source_dir, 

1432 build_dir, 

1433 source_inputs=_INFERENCE_STREAMING_CDK_ASSET.source_inputs, 

1434 ) 

1435 

1436 def _ensure_lambda_build(self) -> None: 

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

1438 

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

1440 interrupted publish, and rechecks freshness before doing installation 

1441 work. Concurrent app evaluations therefore either reuse one complete 

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

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

1444 """ 

1445 for spec, builder in ( 

1446 (_KUBECTL_CDK_ASSET, self._build_kubectl_lambda), 

1447 (_HELM_CDK_ASSET, self._build_helm_installer_lambda), 

1448 (_INFERENCE_STREAMING_CDK_ASSET, self._build_inference_streaming_proxy_lambda), 

1449 ): 

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

1451 if source_dir.exists(): 

1452 builder() 

1453 

1454 def _check_and_fix_stuck_stack( 

1455 self, 

1456 stack_name: str, 

1457 *, 

1458 expected_stack_id: str | None = None, 

1459 authorize_stack: StackAuthorizationCallback | None = None, 

1460 strict_ownership: bool = False, 

1461 ) -> None: 

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

1463 import boto3 

1464 

1465 region = self._get_deploy_region(stack_name) 

1466 if not region: 

1467 if strict_ownership: 

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

1469 return 

1470 

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

1472 try: 

1473 response = cfn.describe_stacks(StackName=stack_name) 

1474 except ClientError as exc: 

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

1476 if ( 

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

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

1479 ): 

1480 return 

1481 if strict_ownership: 

1482 raise 

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

1484 return 

1485 except Exception as exc: 

1486 if strict_ownership: 

1487 raise 

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

1489 return 

1490 

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

1492 if len(stacks) != 1: 

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

1494 stack = stacks[0] 

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

1496 if stack.get("StackName") != stack_name or not stack_id: 

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

1498 if strict_ownership and expected_stack_id is None: 

1499 raise RuntimeError( 

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

1501 ) 

1502 if expected_stack_id is not None and stack_id != expected_stack_id: 

1503 raise RuntimeError( 

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

1505 f"found {stack_id}" 

1506 ) 

1507 

1508 stuck_states = { 

1509 "REVIEW_IN_PROGRESS", 

1510 "ROLLBACK_COMPLETE", 

1511 "ROLLBACK_FAILED", 

1512 "CREATE_FAILED", 

1513 "DELETE_FAILED", 

1514 } 

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

1516 if status not in stuck_states: 

1517 return 

1518 if authorize_stack is not None: 

1519 authorize_stack(stack_name, region, stack_id) 

1520 

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

1522 cfn.delete_stack(StackName=stack_id) 

1523 waiter = cfn.get_waiter("stack_delete_complete") 

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

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

1526 

1527 #: Resource statuses that carry the *cause* of a failed stack operation. 

1528 _ROOT_CAUSE_STATUSES = frozenset( 

1529 {"CREATE_FAILED", "UPDATE_FAILED", "DELETE_FAILED", "IMPORT_FAILED"} 

1530 ) 

1531 #: Reasons CloudFormation attaches to resources it merely abandoned because 

1532 #: a sibling failed first; they are noise next to the real failure. 

1533 _CASCADE_REASON_MARKERS = ("Resource creation cancelled", "Resource update cancelled") 

1534 #: Pagination ceiling for the operation-event walk (100 events per page). 

1535 _MAX_EVENT_PAGES = 10 

1536 #: How many root-cause events to print before truncating. 

1537 _MAX_DIAGNOSED_EVENTS = 5 

1538 

1539 @classmethod 

1540 def _collect_operation_events(cls, cfn: Any, stack_name: str) -> list[dict[str, Any]]: 

1541 """Return the newest-first events of the stack's most recent operation. 

1542 

1543 ``describe_stack_events`` pages newest first and a rolled-back create 

1544 of a large stack buries the one ``CREATE_FAILED`` that explains 

1545 everything under dozens of ``DELETE_COMPLETE`` rollback rows — the 

1546 first page alone is not enough. Walk pages until the stack-level 

1547 ``*_IN_PROGRESS`` event CloudFormation records as "User Initiated" 

1548 (the operation's start), bounded by ``_MAX_EVENT_PAGES``. 

1549 """ 

1550 collected: list[dict[str, Any]] = [] 

1551 token: str | None = None 

1552 for _ in range(cls._MAX_EVENT_PAGES): 

1553 kwargs: dict[str, Any] = {"StackName": stack_name} 

1554 if token: 

1555 kwargs["NextToken"] = token 

1556 response = cfn.describe_stack_events(**kwargs) 

1557 for event in response.get("StackEvents", []): 

1558 collected.append(event) 

1559 status = str(event.get("ResourceStatus") or "") 

1560 if ( 

1561 event.get("LogicalResourceId") == stack_name 

1562 and status.endswith("_IN_PROGRESS") 

1563 and not status.startswith("ROLLBACK") 

1564 and not status.startswith("UPDATE_ROLLBACK") 

1565 and "User Initiated" in str(event.get("ResourceStatusReason") or "") 

1566 ): 

1567 return collected 

1568 token_value = response.get("NextToken") 

1569 token = token_value if isinstance(token_value, str) and token_value else None 

1570 if not token: 

1571 break 

1572 return collected 

1573 

1574 @classmethod 

1575 def _summarize_failure_events( 

1576 cls, events: list[dict[str, Any]], stack_name: str 

1577 ) -> list[dict[str, Any]]: 

1578 """Pick the events worth showing: resource root causes, then the stack verdict. 

1579 

1580 Root causes are resource-level ``*_FAILED`` events whose reason is not 

1581 a cascade marker; they are printed oldest first so the first failure — 

1582 the one that triggered the rollback — leads. The stack-level 

1583 ``ROLLBACK_IN_PROGRESS``/``*_FAILED`` event follows because its reason 

1584 lists every failed logical id. When no root cause survives the filter 

1585 (the event window was exhausted), fall back to any failed/rollback 

1586 events so the operator still sees CloudFormation's own words. 

1587 """ 

1588 root_causes = [ 

1589 event 

1590 for event in reversed(events) 

1591 if str(event.get("ResourceStatus") or "") in cls._ROOT_CAUSE_STATUSES 

1592 and event.get("LogicalResourceId") != stack_name 

1593 and not any( 

1594 marker in str(event.get("ResourceStatusReason") or "") 

1595 for marker in cls._CASCADE_REASON_MARKERS 

1596 ) 

1597 ] 

1598 stack_verdicts = [ 

1599 event 

1600 for event in events 

1601 if event.get("LogicalResourceId") == stack_name 

1602 and ( 

1603 "ROLLBACK" in str(event.get("ResourceStatus") or "") 

1604 or "FAILED" in str(event.get("ResourceStatus") or "") 

1605 ) 

1606 and event.get("ResourceStatusReason") 

1607 ] 

1608 selected = root_causes[: cls._MAX_DIAGNOSED_EVENTS] + stack_verdicts[:1] 

1609 if selected: 

1610 return selected 

1611 return [ 

1612 event 

1613 for event in events 

1614 if "FAILED" in str(event.get("ResourceStatus") or "") 

1615 or "ROLLBACK" in str(event.get("ResourceStatus") or "") 

1616 ][: cls._MAX_DIAGNOSED_EVENTS] 

1617 

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

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

1620 

1621 Gives users actionable information instead of just the CDK error 

1622 message: the resource-level reason that actually failed (an S3 

1623 ``BucketAlreadyExists``, an IAM propagation error, ...) rather than 

1624 the stack's bare ``ROLLBACK_COMPLETE``. 

1625 """ 

1626 import boto3 

1627 

1628 region = self._get_deploy_region(stack_name) 

1629 if not region: 

1630 return 

1631 

1632 try: 

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

1634 

1635 events = self._collect_operation_events(cfn, stack_name) 

1636 failed = self._summarize_failure_events(events, stack_name) 

1637 

1638 if failed: 

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

1640 for event in failed: 

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

1642 resource_type = event.get("ResourceType") 

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

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

1645 label = f"{resource} ({resource_type})" if resource_type else str(resource) 

1646 print(f" {label}: {status}") 

1647 print(f" {reason}") 

1648 

1649 # Check stack status for actionable advice 

1650 try: 

1651 stack_resp = cfn.describe_stacks(StackName=stack_name) 

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

1653 

1654 advice = { 

1655 "REVIEW_IN_PROGRESS": ( 

1656 "Stack is stuck in REVIEW_IN_PROGRESS. " 

1657 "Run: aws cloudformation delete-stack " 

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

1659 ), 

1660 "ROLLBACK_COMPLETE": ( 

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

1662 f"aws cloudformation delete-stack " 

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

1664 ), 

1665 "ROLLBACK_FAILED": ( 

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

1667 f"aws cloudformation delete-stack " 

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

1669 ), 

1670 "UPDATE_ROLLBACK_COMPLETE": ( 

1671 "Update rolled back but stack is stable. " 

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

1673 ), 

1674 } 

1675 

1676 if status in advice: 

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

1678 

1679 except Exception as e: 

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

1681 

1682 except Exception as e: 

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

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

1685 

1686 def ensure_asset_sources_readable(self) -> None: 

1687 """Run :func:`check_asset_sources_readable` once per manager. 

1688 

1689 A multi-stack deploy calls ``deploy`` once per stack; the checkout's 

1690 modes are only walked on the first call. ``gco upgrade`` calls it before 

1691 its teardown, so a checkout that could not be redeployed stops the 

1692 upgrade before any stack is touched. 

1693 

1694 Raises: 

1695 AssetPermissionError: when a deployable source is owner-only. 

1696 """ 

1697 if getattr(self, "_asset_sources_checked", False): 

1698 return 

1699 check_asset_sources_readable(self.project_root) 

1700 self._asset_sources_checked = True 

1701 

1702 def _sync_lambda_sources(self) -> None: 

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

1704 

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

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

1707 generated final build tree in place. The source->targets mapping lives 

1708 in ``gco.lambda_shared_sources`` so deploy packaging, diagram 

1709 reconciliation, and commit-time identity tests consume one 

1710 dependency-light inventory. 

1711 """ 

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

1713 return 

1714 

1715 for source_rel, target_rels in LAMBDA_SHARED_SOURCE_TARGETS.items(): 

1716 shared_source = self.project_root / source_rel 

1717 if not shared_source.exists(): 

1718 continue 

1719 for target_rel in target_rels: 

1720 target = self.project_root / target_rel 

1721 if target.parent.exists(): 

1722 _atomic_copy_file(shared_source, target) 

1723 self._lambda_sources_synced = True 

1724 

1725 def _rebuild_lambda_packages(self) -> None: 

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

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

1728 return 

1729 self._ensure_lambda_build() 

1730 self._lambda_packages_rebuilt = True 

1731 

1732 def _build_lambda_packages(self) -> None: 

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

1734 self._build_kubectl_lambda() 

1735 self._build_helm_installer_lambda() 

1736 self._build_inference_streaming_proxy_lambda() 

1737 

1738 def _build_kubectl_lambda(self) -> None: 

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

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

1741 requirements = source_dir / "requirements.txt" 

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

1743 return 

1744 

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

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

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

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

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

1750 [ 

1751 sys.executable, 

1752 "-m", 

1753 "pip", 

1754 "install", 

1755 "-r", 

1756 str(requirements), 

1757 "-t", 

1758 str(staging_dir), 

1759 "--upgrade", 

1760 "--platform", 

1761 "manylinux2014_x86_64", 

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

1763 "--quiet", 

1764 ], 

1765 capture_output=True, 

1766 text=True, 

1767 check=False, 

1768 ) 

1769 if result.returncode != 0: 

1770 raise RuntimeError( 

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

1772 ) 

1773 

1774 _prepare_lambda_asset( 

1775 source_dir, 

1776 build_dir, 

1777 source_inputs=_KUBECTL_CDK_ASSET.source_inputs, 

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

1779 builder=build, 

1780 ) 

1781 

1782 def _build_helm_installer_lambda(self) -> None: 

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

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

1785 if not source_dir.is_dir(): 

1786 return 

1787 

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

1789 shutil.copytree( 

1790 source_dir, 

1791 staging_dir, 

1792 ignore=shutil.ignore_patterns(*_LAMBDA_SOURCE_COPY_IGNORE_PATTERNS), 

1793 dirs_exist_ok=True, 

1794 ) 

1795 

1796 _prepare_lambda_asset( 

1797 source_dir, 

1798 build_dir, 

1799 source_inputs=_HELM_CDK_ASSET.source_inputs, 

1800 display_name="helm-installer Lambda package", 

1801 builder=build, 

1802 ) 

1803 

1804 def _build_inference_streaming_proxy_lambda(self) -> None: 

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

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

1807 if not source_dir.is_dir(): 

1808 return 

1809 

1810 package_files = _INFERENCE_STREAMING_CDK_ASSET.source_inputs 

1811 assert package_files is not None 

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

1813 if missing: 

1814 raise RuntimeError( 

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

1816 ) 

1817 try: 

1818 package_manager = str( 

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

1820 "packageManager", "" 

1821 ) 

1822 ) 

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

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

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

1826 version_parts = required_npm.split(".") 

1827 if ( 

1828 not package_manager.startswith("npm@") 

1829 or len(version_parts) != 3 

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

1831 ): 

1832 raise RuntimeError( 

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

1834 ) 

1835 

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

1837 npm = shutil.which("npm") 

1838 if npm is None: 

1839 raise RuntimeError( 

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

1841 "install the Node.js version pinned in .nvmrc, or run gco from the dev " 

1842 "container (see QUICKSTART.md), which ships the pinned npm" 

1843 ) 

1844 try: 

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

1846 [npm, "--version"], 

1847 cwd=source_dir, 

1848 capture_output=True, 

1849 text=True, 

1850 timeout=30, 

1851 check=False, 

1852 ) 

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

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

1855 actual_npm = version_result.stdout.strip() 

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

1857 found = actual_npm or "unavailable" 

1858 raise RuntimeError( 

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

1860 f"found {found}. {_npm_pin_remedy(required_npm)}" 

1861 ) 

1862 

1863 for name in package_files: 

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

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

1866 [ 

1867 npm, 

1868 "ci", 

1869 "--omit=dev", 

1870 "--ignore-scripts", 

1871 "--no-audit", 

1872 "--no-fund", 

1873 ], 

1874 cwd=staging_dir, 

1875 capture_output=True, 

1876 text=True, 

1877 check=False, 

1878 ) 

1879 if result.returncode != 0: 

1880 raise RuntimeError( 

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

1882 + result.stderr[:500] 

1883 ) 

1884 

1885 _prepare_lambda_asset( 

1886 source_dir, 

1887 build_dir, 

1888 source_inputs=package_files, 

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

1890 builder=build, 

1891 ) 

1892 

1893 def _get_python_path(self) -> str: 

1894 """ 

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

1896 

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

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

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

1900 """ 

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

1902 site_packages = site.getsitepackages() 

1903 

1904 # Also include user site-packages if available 

1905 user_site = site.getusersitepackages() 

1906 if user_site and os.path.isdir(user_site): 

1907 site_packages.append(user_site) 

1908 

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

1910 current_module_dir = Path(__file__).parent.parent 

1911 if current_module_dir.exists(): 

1912 site_packages.append(str(current_module_dir)) 

1913 

1914 # Combine with existing PYTHONPATH if any 

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

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

1917 

1918 return os.pathsep.join(all_paths) 

1919 

1920 def _ensure_cdk_toolchain(self) -> None: 

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

1922 

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

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

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

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

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

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

1929 actionable install hint instead. 

1930 """ 

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

1932 if not missing: 

1933 return 

1934 raise CdkToolchainError( 

1935 "CDK toolchain not available: cannot import " 

1936 + ", ".join(missing) 

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

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

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

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

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

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

1943 'git+https://github.com/aws-solutions-library-samples/global-capacity-orchestrator-on-aws.git@<tag>"\n' 

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

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

1946 "full toolchain.\n" 

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

1948 ) 

1949 

1950 @staticmethod 

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

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

1953 if process.poll() is not None: 

1954 return 

1955 

1956 if os.name == "nt": 

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

1958 

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

1960 if taskkill is None: 

1961 return False 

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

1963 if force: 

1964 command.append("/F") 

1965 try: 

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

1967 command, 

1968 capture_output=True, 

1969 text=True, 

1970 check=False, 

1971 timeout=30, 

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

1973 ) 

1974 except OSError, subprocess.TimeoutExpired: 

1975 return False 

1976 return result.returncode == 0 

1977 

1978 terminate_tree(force=False) 

1979 try: 

1980 process.wait(timeout=30) 

1981 except OSError, subprocess.TimeoutExpired: 

1982 terminate_tree(force=True) 

1983 if process.poll() is None: 

1984 process.kill() 

1985 process.wait() 

1986 return 

1987 

1988 try: 

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

1990 process.wait(timeout=30) 

1991 except OSError, subprocess.TimeoutExpired: 

1992 if process.poll() is None: 

1993 try: 

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

1995 finally: 

1996 process.wait() 

1997 

1998 def cancel_active_cdk_processes(self) -> None: 

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

2000 self._cdk_cancel_event.set() 

2001 with self._active_cdk_lock: 

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

2003 for process in processes: 

2004 self._terminate_cdk_process(process) 

2005 

2006 def _run_cdk( 

2007 self, 

2008 command: list[str], 

2009 capture_output: bool = False, 

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

2011 timeout: float | None = None, 

2012 ) -> subprocess.CompletedProcess[str]: 

2013 """Run a CDK command. 

2014 

2015 Args: 

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

2017 capture_output: Capture stdout / stderr instead of streaming. 

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

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

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

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

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

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

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

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

2026 post-delete polling loop hanging after CloudFormation has 

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

2028 """ 

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

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

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

2032 # a cryptic ImportError. 

2033 self._ensure_cdk_toolchain() 

2034 

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

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

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

2038 if command and command[0] in {"deploy", "destroy", "diff", "list", "synth"}: 

2039 self._ensure_lambda_build() 

2040 # Apply registered context overrides uniformly to every 

2041 # app-evaluating command so deploy, destroy, and the stack listing 

2042 # all synthesize the same graph (see set_extra_cdk_context). 

2043 for key, value in sorted(self._extra_cdk_context.items()): 

2044 command = [*command, "--context", f"{key}={value}"] 

2045 

2046 full_env = os.environ.copy() 

2047 

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

2049 # This is essential for pipx installations 

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

2051 

2052 if env: 

2053 full_env.update(env) 

2054 

2055 cdk_path = self._cdk_path 

2056 if cdk_path is None: 

2057 cdk_path = self._find_cdk() 

2058 self._cdk_path = cdk_path 

2059 cdk_cmd = [cdk_path, *command] 

2060 

2061 if self._cdk_cancel_event.is_set(): 

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

2063 popen_kwargs: dict[str, Any] = { 

2064 "cwd": self.project_root, 

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

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

2067 "text": True, 

2068 "env": full_env, 

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

2070 } 

2071 if os.name == "nt": 

2072 popen_kwargs["creationflags"] = getattr( 

2073 subprocess, 

2074 "CREATE_NEW_PROCESS_GROUP", 

2075 0, 

2076 ) 

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

2078 cdk_cmd, 

2079 **popen_kwargs, 

2080 ) 

2081 with self._active_cdk_lock: 

2082 self._active_cdk_processes[process.pid] = process 

2083 if self._cdk_cancel_event.is_set(): 

2084 self._terminate_cdk_process(process) 

2085 with self._active_cdk_lock: 

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

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

2088 

2089 try: 

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

2091 except subprocess.TimeoutExpired as exc: 

2092 self._terminate_cdk_process(process) 

2093 logger.warning( 

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

2095 timeout, 

2096 " ".join(cdk_cmd), 

2097 ) 

2098 raise subprocess.TimeoutExpired( 

2099 cdk_cmd, 

2100 exc.timeout, 

2101 output=exc.output, 

2102 stderr=exc.stderr, 

2103 ) from exc 

2104 except BaseException: 

2105 self._terminate_cdk_process(process) 

2106 raise 

2107 finally: 

2108 with self._active_cdk_lock: 

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

2110 return subprocess.CompletedProcess( 

2111 cdk_cmd, 

2112 process.returncode, 

2113 stdout=stdout or "", 

2114 stderr=stderr or "", 

2115 ) 

2116 

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

2118 """List all available CDK stacks.""" 

2119 # stdout is parsed as one stack name per line, so it must carry 

2120 # nothing else. With CI set (every GitHub Actions runner sets it) the 

2121 # CDK CLI routes its own log lines to stdout instead of stderr, and a 

2122 # warning such as "current credentials could not be used to assume 

2123 # '...lookup-role...', but are for the right account. Proceeding 

2124 # anyway." then reads as a stack name. --no-ci keeps the logs on 

2125 # stderr whatever the environment says. 

2126 result = self._run_cdk(["list", "--no-ci"], capture_output=True) 

2127 if result.returncode != 0: 

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

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

2130 

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

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

2133 self._ensure_lambda_build() 

2134 cmd = ["synth"] 

2135 if stack_name: 

2136 cmd.append(stack_name) 

2137 if quiet: 

2138 cmd.append("--quiet") 

2139 

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

2141 if result.returncode != 0: 

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

2143 return str(result.stdout) 

2144 

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

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

2147 self._ensure_lambda_build() 

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

2149 if stack_name: 

2150 cmd.append(stack_name) 

2151 

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

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

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

2155 

2156 def deploy( 

2157 self, 

2158 stack_name: str | None = None, 

2159 require_approval: bool = True, 

2160 all_stacks: bool = False, 

2161 outputs_file: str | None = None, 

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

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

2164 progress: str = "events", 

2165 output_dir: str | None = None, 

2166 exclusively: bool = False, 

2167 allow_bootstrap: bool = True, 

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

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

2170 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

2171 authorize_stack: StackAuthorizationCallback | None = None, 

2172 strict_deployment_token: str | None = None, 

2173 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

2174 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

2175 ) -> bool: 

2176 """Deploy CDK stacks. 

2177 

2178 Args: 

2179 stack_name: Name of the stack to deploy 

2180 require_approval: Whether to require approval for changes 

2181 all_stacks: Deploy all stacks 

2182 outputs_file: File to write outputs to 

2183 parameters: CDK parameters 

2184 tags: Tags to apply to stacks 

2185 progress: Progress display type 

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

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

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

2189 ``deploy_orchestrated`` once earlier phases have already 

2190 deployed the globals — re-synthesizing them every phase 

2191 forces custom resources (notably KubectlApplyManifests) 

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

2193 actual change. 

2194 

2195 Raises: 

2196 AssetPermissionError: before anything is copied, packaged or 

2197 uploaded, when a deployable source is owner-only. 

2198 """ 

2199 # A destroy never runs this check itself: a teardown must not depend on 

2200 # the checkout's modes. The monitoring detach a keep-control-plane 

2201 # teardown starts with is a deploy, though, and uploads assets, so it 

2202 # does. 

2203 self.ensure_asset_sources_readable() 

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

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

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

2207 # fingerprinting it. 

2208 self._sync_lambda_sources() 

2209 self._ensure_lambda_build() 

2210 

2211 strict_deployment = ( 

2212 strict_deployment_token is not None or on_change_set_prepared is not None 

2213 ) 

2214 expected_stack_id: str | None = None 

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

2216 change_set_name: str | None = None 

2217 if strict_deployment: 

2218 if not stack_name or all_stacks: 

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

2220 if not strict_deployment_token or on_change_set_prepared is None: 

2221 raise RuntimeError( 

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

2223 ) 

2224 if allow_bootstrap: 

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

2226 if authorize_stack is None: 

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

2228 if expected_stack_ids is None or stack_name not in expected_stack_ids: 

2229 raise RuntimeError( 

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

2231 ) 

2232 expected_stack_id = expected_stack_ids[stack_name] 

2233 if prepared_change_sets is None or stack_name not in prepared_change_sets: 

2234 raise RuntimeError( 

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

2236 ) 

2237 prepared_change_set_records = prepared_change_sets[stack_name] 

2238 change_set_name = self._strict_change_set_name( 

2239 stack_name, 

2240 strict_deployment_token, 

2241 ) 

2242 

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

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

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

2246 if stack_name: 

2247 region = self._get_deploy_region(stack_name) 

2248 if not region: 

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

2250 if allow_bootstrap: 

2251 if not self.ensure_bootstrapped(region): 

2252 raise RuntimeError( 

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

2254 "Run 'gco stacks bootstrap --region " 

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

2256 ) 

2257 else: 

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

2259 if expected_bootstrap is None: 

2260 raise RuntimeError( 

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

2262 ) 

2263 self._validate_bootstrap_stack(region, expected_bootstrap) 

2264 if strict_deployment: 

2265 target = self._describe_stack_target( 

2266 stack_name, 

2267 expected_stack_id=expected_stack_id, 

2268 require_expected_identity=True, 

2269 ) 

2270 if expected_stack_id is not None and target is None: 

2271 raise RuntimeError( 

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

2273 ) 

2274 assert change_set_name is not None 

2275 self._preflight_strict_change_set( 

2276 stack_name=stack_name, 

2277 change_set_name=change_set_name, 

2278 expected_stack_id=expected_stack_id, 

2279 prepared_change_sets=prepared_change_set_records, 

2280 ) 

2281 

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

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

2284 # authority without deleting or adopting anything by name. 

2285 if stack_name and not strict_deployment: 

2286 self._check_and_fix_stuck_stack( 

2287 stack_name, 

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

2289 authorize_stack=authorize_stack, 

2290 strict_ownership=not allow_bootstrap, 

2291 ) 

2292 

2293 # Ensure container runtime is available for building images 

2294 runtime = _detect_container_runtime() 

2295 if not runtime: 

2296 from cli._container_runtime import container_runtime_error_message 

2297 

2298 raise RuntimeError(container_runtime_error_message()) 

2299 

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

2301 # target checks. Repository creation acknowledgements are persisted 

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

2303 self._mirror_images_if_enabled( 

2304 stack_name=stack_name, 

2305 all_stacks=all_stacks, 

2306 repository_tags=tags, 

2307 on_repository_created=on_ecr_repository_created, 

2308 ) 

2309 

2310 cmd = ["deploy"] 

2311 

2312 if all_stacks: 

2313 cmd.append("--all") 

2314 elif stack_name: 

2315 cmd.append(stack_name) 

2316 

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

2318 # transitive dependencies. deploy_orchestrated sets this once the 

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

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

2321 # re-evaluate globals on every pass. 

2322 if exclusively and stack_name and not all_stacks: 

2323 cmd.append("--exclusively") 

2324 

2325 if strict_deployment: 

2326 assert change_set_name is not None 

2327 cmd.extend( 

2328 [ 

2329 "--method", 

2330 "prepare-change-set", 

2331 "--change-set-name", 

2332 change_set_name, 

2333 "--context", 

2334 f"{_LIVE_VALIDATION_PROVIDER_LOG_CONTEXT}=true", 

2335 ] 

2336 ) 

2337 

2338 if not require_approval: 

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

2340 

2341 if outputs_file: 

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

2343 

2344 if parameters: 

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

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

2347 

2348 if tags: 

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

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

2351 

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

2353 

2354 # Use custom output directory for parallel deployments 

2355 if output_dir: 

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

2357 

2358 # Set CDK_DOCKER env var if not already set 

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

2360 

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

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

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

2364 # a fresh EKS cluster cold start. Override via 

2365 # GCO_CDK_DEPLOY_TIMEOUT_SECONDS. 

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

2367 

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

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

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

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

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

2373 # failure that must not be masked). 

2374 deploy_start = datetime.now(UTC) 

2375 

2376 try: 

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

2378 success = result.returncode == 0 

2379 except subprocess.TimeoutExpired: 

2380 print( 

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

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

2383 ) 

2384 success = False 

2385 

2386 if self._cdk_cancel_event.is_set(): 

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

2388 

2389 if strict_deployment: 

2390 assert stack_name is not None 

2391 assert change_set_name is not None 

2392 assert on_change_set_prepared is not None 

2393 try: 

2394 success = self._execute_prepared_change_set( 

2395 stack_name=stack_name, 

2396 change_set_name=change_set_name, 

2397 expected_stack_id=expected_stack_id, 

2398 expected_tags=tags, 

2399 prepared_change_sets=prepared_change_set_records, 

2400 preparation_succeeded=success, 

2401 authorize_stack=authorize_stack, 

2402 on_change_set_prepared=on_change_set_prepared, 

2403 allow_noop=success, 

2404 timeout=timeout_s, 

2405 ) 

2406 except Exception: 

2407 self._diagnose_deploy_failure(stack_name) 

2408 raise 

2409 if not success: 

2410 self._diagnose_deploy_failure(stack_name) 

2411 

2412 if success and "analytics" in stack_name: 

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

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

2415 success = self.deploy( 

2416 stack_name=api_gateway_stack, 

2417 require_approval=require_approval, 

2418 outputs_file=outputs_file, 

2419 parameters=parameters, 

2420 tags=tags, 

2421 progress=progress, 

2422 exclusively=True, 

2423 allow_bootstrap=allow_bootstrap, 

2424 bootstrap_stacks=bootstrap_stacks, 

2425 expected_stack_ids=expected_stack_ids, 

2426 prepared_change_sets=prepared_change_sets, 

2427 authorize_stack=authorize_stack, 

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

2429 on_change_set_prepared=on_change_set_prepared, 

2430 on_ecr_repository_created=on_ecr_repository_created, 

2431 ) 

2432 return success 

2433 

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

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

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

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

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

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

2440 if stack_name and not all_stacks and not success: 

2441 cfn_status = self._get_stack_status(stack_name) 

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

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

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

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

2446 print( 

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

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

2449 ) 

2450 settled_status = self._wait_for_stack_settle(stack_name) 

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

2452 print( 

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

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

2455 "success." 

2456 ) 

2457 success = True 

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

2459 # The stack is already terminal and CloudFormation is not 

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

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

2462 # apart: 

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

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

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

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

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

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

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

2470 # time predates this attempt. Masking this is the 

2471 # false-success bug this guards against. 

2472 last_op = self._get_stack_last_update_time(stack_name) 

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

2474 print( 

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

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

2477 "success." 

2478 ) 

2479 success = True 

2480 else: 

2481 print( 

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

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

2484 "failed before touching CloudFormation. Treating as a " 

2485 "failed deploy." 

2486 ) 

2487 

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

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

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

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

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

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

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

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

2496 # deliberately don't require LastUpdatedTime to advance. 

2497 if success and stack_name and not all_stacks: 

2498 cfn_status = self._get_stack_status(stack_name) 

2499 if cfn_status is not None and cfn_status not in ( 

2500 "CREATE_COMPLETE", 

2501 "UPDATE_COMPLETE", 

2502 ): 

2503 print( 

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

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

2506 ) 

2507 success = False 

2508 

2509 if not success and stack_name: 

2510 self._diagnose_deploy_failure(stack_name) 

2511 

2512 # After deploying gco-analytics, automatically redeploy 

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

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

2515 # the analytics stack). 

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

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

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

2519 success = self.deploy( 

2520 stack_name=api_gateway_stack, 

2521 require_approval=require_approval, 

2522 outputs_file=outputs_file, 

2523 parameters=parameters, 

2524 tags=tags, 

2525 progress=progress, 

2526 exclusively=True, 

2527 allow_bootstrap=allow_bootstrap, 

2528 bootstrap_stacks=bootstrap_stacks, 

2529 expected_stack_ids=expected_stack_ids, 

2530 authorize_stack=authorize_stack, 

2531 on_ecr_repository_created=on_ecr_repository_created, 

2532 ) 

2533 

2534 return success 

2535 

2536 def destroy( 

2537 self, 

2538 stack_name: str | None = None, 

2539 all_stacks: bool = False, 

2540 force: bool = False, 

2541 output_dir: str | None = None, 

2542 expected_stack_id: str | None = None, 

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

2544 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

2545 authorize_stack: StackAuthorizationCallback | None = None, 

2546 allow_bootstrap: bool = True, 

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

2548 strict_deployment_token: str | None = None, 

2549 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

2550 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

2551 ) -> bool: 

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

2553 config_path: Path | None = None 

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

2555 config_path = _find_cdk_json() 

2556 if config_path is None: 

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

2558 

2559 # Analytics teardown may temporarily enable a disabled stack in the CDK 

2560 # app. Hold the shared configuration transaction through restore so a 

2561 # concurrent CLI/MCP edit cannot be silently overwritten by the exact- 

2562 # bytes rollback in ``finally``. 

2563 lock_context = ( 

2564 _config_mutation_lock(config_path) if config_path is not None else nullcontext() 

2565 ) 

2566 with lock_context: 

2567 original_bytes: bytes | None = None 

2568 original_mode: int | None = None 

2569 if config_path is not None: 

2570 original_bytes = config_path.read_bytes() 

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

2572 try: 

2573 return self._destroy( 

2574 stack_name=stack_name, 

2575 all_stacks=all_stacks, 

2576 force=force, 

2577 output_dir=output_dir, 

2578 expected_stack_id=expected_stack_id, 

2579 expected_stack_ids=expected_stack_ids, 

2580 prepared_change_sets=prepared_change_sets, 

2581 authorize_stack=authorize_stack, 

2582 allow_bootstrap=allow_bootstrap, 

2583 bootstrap_stacks=bootstrap_stacks, 

2584 strict_deployment_token=strict_deployment_token, 

2585 on_change_set_prepared=on_change_set_prepared, 

2586 on_ecr_repository_created=on_ecr_repository_created, 

2587 ) 

2588 finally: 

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

2590 _atomic_write_bytes(config_path, original_bytes, mode=original_mode) 

2591 

2592 def _destroy( 

2593 self, 

2594 stack_name: str | None = None, 

2595 all_stacks: bool = False, 

2596 force: bool = False, 

2597 output_dir: str | None = None, 

2598 expected_stack_id: str | None = None, 

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

2600 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

2601 authorize_stack: StackAuthorizationCallback | None = None, 

2602 allow_bootstrap: bool = True, 

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

2604 strict_deployment_token: str | None = None, 

2605 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

2606 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

2607 ) -> bool: 

2608 """Destroy CDK stacks. 

2609 

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

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

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

2613 ensures custom resource cleanup handlers (like the analytics 

2614 cleanup Lambda) fire during deletion. 

2615 

2616 Args: 

2617 stack_name: Name of the stack to destroy 

2618 all_stacks: Destroy all stacks 

2619 force: Skip confirmation prompts 

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

2621 """ 

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

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

2624 if stack_name is None and (expected_stack_id is not None or expected_stack_ids is not None): 

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

2626 

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

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

2629 if stack_name not in expected_stack_ids: 

2630 raise RuntimeError( 

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

2632 ) 

2633 mapped_stack_id = expected_stack_ids[stack_name] 

2634 if expected_stack_id is not None and expected_stack_id != mapped_stack_id: 

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

2636 expected_stack_id = mapped_stack_id 

2637 if strict_identity and authorize_stack is None: 

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

2639 

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

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

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

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

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

2645 if ( 

2646 stack_name is not None 

2647 and stack_name.endswith("-global") 

2648 and not all_stacks 

2649 and not self._image_registry_destroy_preflight(force=force) 

2650 ): 

2651 return False 

2652 

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

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

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

2656 if strict_identity and stack_name and not all_stacks: 

2657 if self._cdk_cancel_event.is_set(): 

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

2659 if "analytics" in stack_name: 

2660 if expected_stack_ids is None: 

2661 raise RuntimeError( 

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

2663 "stack identity map" 

2664 ) 

2665 safe_to_destroy = self._remove_api_gateway_analytics_dependency( 

2666 allow_bootstrap=allow_bootstrap, 

2667 bootstrap_stacks=bootstrap_stacks, 

2668 expected_stack_ids=expected_stack_ids, 

2669 prepared_change_sets=prepared_change_sets, 

2670 authorize_stack=authorize_stack, 

2671 strict_deployment_token=( 

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

2673 if strict_deployment_token is not None 

2674 else None 

2675 ), 

2676 on_change_set_prepared=on_change_set_prepared, 

2677 on_ecr_repository_created=on_ecr_repository_created, 

2678 ) 

2679 if not safe_to_destroy: 

2680 return False 

2681 return self._cloudformation_delete_stack( 

2682 stack_name, 

2683 expected_stack_id=expected_stack_id, 

2684 authorize_stack=authorize_stack, 

2685 require_expected_identity=True, 

2686 ) 

2687 

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

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

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

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

2692 if stack_name and not all_stacks: 

2693 orphan_region = self._get_orphan_regional_api_region(stack_name) 

2694 if orphan_region is not None: 

2695 if not self._stack_exists_in_cloudformation(stack_name): 

2696 return True 

2697 print( 

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

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

2700 ) 

2701 return self._cloudformation_delete_stack( 

2702 stack_name, 

2703 expected_stack_id=expected_stack_id, 

2704 authorize_stack=authorize_stack, 

2705 ) 

2706 

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

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

2709 toggle_restored = False 

2710 if ( 

2711 stack_name 

2712 and not all_stacks 

2713 and "analytics" in stack_name 

2714 and self._stack_exists_in_cloudformation(stack_name) 

2715 ): 

2716 toggle_restored = self._ensure_analytics_enabled_for_destroy() 

2717 

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

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

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

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

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

2723 safe_to_destroy = self._remove_api_gateway_analytics_dependency( 

2724 allow_bootstrap=allow_bootstrap, 

2725 bootstrap_stacks=bootstrap_stacks, 

2726 expected_stack_ids=expected_stack_ids, 

2727 prepared_change_sets=prepared_change_sets, 

2728 authorize_stack=authorize_stack, 

2729 strict_deployment_token=strict_deployment_token, 

2730 on_change_set_prepared=on_change_set_prepared, 

2731 on_ecr_repository_created=on_ecr_repository_created, 

2732 ) 

2733 if not safe_to_destroy: 

2734 # Restore analytics toggle before bailing out. 

2735 if toggle_restored: 

2736 self._restore_analytics_disabled() 

2737 project = self.config.project_name 

2738 print( 

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

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

2741 ) 

2742 return False 

2743 

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

2745 # returned above after exact-ARN deletion. 

2746 cmd = ["destroy"] 

2747 

2748 if all_stacks: 

2749 cmd.append("--all") 

2750 elif stack_name: 

2751 cmd.append(stack_name) 

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

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

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

2755 # presigned-URL Lambda ARN). 

2756 cmd.append("--exclusively") 

2757 

2758 if force: 

2759 cmd.append("--force") 

2760 

2761 if output_dir: 

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

2763 

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

2765 # post-delete polling loop hanging after CloudFormation has 

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

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

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

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

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

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

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

2773 # GCO_CDK_DESTROY_TIMEOUT_SECONDS. 

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

2775 

2776 try: 

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

2778 cdk_succeeded = result.returncode == 0 

2779 except subprocess.TimeoutExpired: 

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

2781 # is gone in CloudFormation, the destroy actually succeeded 

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

2783 print( 

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

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

2786 ) 

2787 cdk_succeeded = False 

2788 

2789 if self._cdk_cancel_event.is_set(): 

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

2791 

2792 # Restore the toggle if we changed it 

2793 if toggle_restored: 

2794 self._restore_analytics_disabled() 

2795 

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

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

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

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

2800 if stack_name and not all_stacks: 

2801 still_present = self._stack_exists_in_cloudformation(stack_name) 

2802 if not still_present: 

2803 if not cdk_succeeded: 

2804 print( 

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

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

2807 ) 

2808 return True 

2809 

2810 status = self._get_stack_status(stack_name, expected_stack_id) 

2811 if status == "DELETE_IN_PROGRESS": 

2812 return self._wait_for_stack_delete_convergence( 

2813 stack_name, 

2814 initial_status=status, 

2815 ) 

2816 if status == "DELETE_FAILED": 

2817 self._print_stack_delete_heartbeat( 

2818 stack_name, 

2819 status, 

2820 expected_stack_id, 

2821 ) 

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

2823 return False 

2824 

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

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

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

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

2829 if cdk_succeeded: 

2830 return self._cloudformation_delete_stack(stack_name) 

2831 print( 

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

2833 "no active CloudFormation delete operation was confirmed." 

2834 ) 

2835 return False 

2836 

2837 return cdk_succeeded 

2838 

2839 # ------------------------------------------------------------------ 

2840 # Image registry pre-destroy guards 

2841 # ------------------------------------------------------------------ 

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

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

2844 

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

2846 can reason about the same fields without importing the CDK 

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

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

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

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

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

2852 stack's responsibility. 

2853 """ 

2854 import json 

2855 

2856 cdk_json_path = _find_cdk_json() 

2857 if not cdk_json_path: 

2858 return { 

2859 "removal_policy": "retain", 

2860 "empty_on_delete": False, 

2861 } 

2862 try: 

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

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

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

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

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

2868 

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

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

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

2872 removal_policy = "retain" 

2873 return { 

2874 "removal_policy": removal_policy, 

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

2876 } 

2877 

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

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

2880 

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

2882 effort: a missing ImageManager dependency or an AWS error 

2883 produces a partially-populated dict rather than raising. 

2884 """ 

2885 inventory: dict[str, Any] = { 

2886 "repo_count": 0, 

2887 "tag_count": 0, 

2888 "total_bytes": 0, 

2889 "endpoint_refs": 0, 

2890 "job_refs": 0, 

2891 } 

2892 try: 

2893 from cli.images import ImageManager 

2894 except Exception as exc: 

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

2896 return inventory 

2897 

2898 try: 

2899 manager = ImageManager(config=self.config) 

2900 repos = manager.list_repos() 

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

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

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

2904 for repo in repos: 

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

2906 if not repo_name.startswith(repo_prefix): 

2907 continue 

2908 short = repo_name.removeprefix(repo_prefix) 

2909 try: 

2910 tags = manager.list_tags(short) 

2911 except Exception as exc: 

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

2913 continue 

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

2915 for row in tags: 

2916 size = row.get("size_bytes") 

2917 if isinstance(size, int): 

2918 inventory["total_bytes"] += size 

2919 try: 

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

2921 except Exception as exc: 

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

2923 try: 

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

2925 except Exception as exc: 

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

2927 except Exception as exc: 

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

2929 return inventory 

2930 

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

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

2933 

2934 Two rules: 

2935 

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

2937 → refuse with the literal helpful-error message pointing 

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

2939 ``empty_on_delete: true``. 

2940 

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

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

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

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

2945 

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

2947 been refused or declined. 

2948 """ 

2949 cfg = self._read_images_config() 

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

2951 return True 

2952 

2953 if not cfg["empty_on_delete"]: 

2954 print( 

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

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

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

2958 ) 

2959 return False 

2960 

2961 inventory = self._build_image_registry_inventory() 

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

2963 interactive_echo("Image registry inventory before destroy:") 

2964 interactive_echo(f" repos: {inventory['repo_count']}") 

2965 interactive_echo(f" tags: {inventory['tag_count']}") 

2966 interactive_echo(f" total size: {gib:.2f} GiB") 

2967 interactive_echo(f" referencing endpoints: {inventory['endpoint_refs']}") 

2968 interactive_echo(f" recent job refs: {inventory['job_refs']}") 

2969 

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

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

2972 return True 

2973 

2974 try: 

2975 if confirm( 

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

2977 f"{self.config.project_name}/* repo?", 

2978 default=False, 

2979 ): 

2980 return True 

2981 except Abort: 

2982 interactive_echo("Aborted.") 

2983 return False 

2984 interactive_echo("Aborted.") 

2985 return False 

2986 

2987 @staticmethod 

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

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

2990 return bool( 

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

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

2993 ) 

2994 

2995 @staticmethod 

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

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

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

2999 

3000 def _describe_stack_target( 

3001 self, 

3002 stack_name: str, 

3003 *, 

3004 expected_stack_id: str | None = None, 

3005 require_expected_identity: bool = False, 

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

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

3008 import boto3 

3009 

3010 region = self._get_destroy_region(stack_name) 

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

3012 

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

3014 try: 

3015 response = cfn.describe_stacks(StackName=identifier) 

3016 except ClientError as exc: 

3017 if self._stack_missing(exc): 

3018 return None 

3019 raise 

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

3021 if len(stacks) != 1: 

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

3023 stack = stacks[0] 

3024 if not isinstance(stack, dict): 

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

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

3027 if stack.get("StackName") != stack_name or not stack_id: 

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

3029 return stack 

3030 

3031 exact = describe(expected_stack_id) if expected_stack_id else None 

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

3033 if str(exact.get("StackId") or "") != expected_stack_id: 

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

3035 return region, cfn, exact 

3036 

3037 by_name = describe(stack_name) 

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

3039 return None 

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

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

3042 raise RuntimeError( 

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

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

3045 ) 

3046 if expected_stack_id is None and require_expected_identity: 

3047 raise RuntimeError( 

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

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

3050 ) 

3051 return region, cfn, by_name 

3052 

3053 def _stack_exists_in_cloudformation( 

3054 self, 

3055 stack_name: str, 

3056 expected_stack_id: str | None = None, 

3057 *, 

3058 require_expected_identity: bool = False, 

3059 ) -> bool: 

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

3061 target = self._describe_stack_target( 

3062 stack_name, 

3063 expected_stack_id=expected_stack_id, 

3064 require_expected_identity=require_expected_identity, 

3065 ) 

3066 return target is not None 

3067 

3068 def _get_stack_status( 

3069 self, 

3070 stack_name: str, 

3071 stack_identifier: str | None = None, 

3072 ) -> str | None: 

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

3074 

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

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

3077 actually finished CREATE_COMPLETE or UPDATE_COMPLETE on the AWS 

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

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

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

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

3082 """ 

3083 import boto3 

3084 

3085 try: 

3086 region = self._get_destroy_region(stack_name) 

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

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

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

3090 except Exception: 

3091 return None 

3092 

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

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

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

3096 

3097 Uses ``LastUpdatedTime`` when the stack has been updated at least once, 

3098 falling back to ``CreationTime`` for a stack that has only ever been 

3099 created. ``deploy()`` compares this against the moment the deploy 

3100 attempt started to decide whether a cdk failure/timeout that leaves the 

3101 stack ``*_COMPLETE`` reflects a *fresh* operation (cdk's polling merely 

3102 gave up early — success) or a *stale* one left by a previous deploy 

3103 (cdk failed before touching CloudFormation — a real failure). A None 

3104 return keeps the conservative 'cdk's failure stands' verdict. 

3105 """ 

3106 import boto3 

3107 

3108 try: 

3109 region = self._get_destroy_region(stack_name) 

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

3111 resp = cfn.describe_stacks(StackName=stack_name) 

3112 stack = resp["Stacks"][0] 

3113 last_op = stack.get("LastUpdatedTime") or stack.get("CreationTime") 

3114 return last_op if isinstance(last_op, datetime) else None 

3115 except Exception: 

3116 return None 

3117 

3118 def _wait_for_stack_settle( 

3119 self, 

3120 stack_name: str, 

3121 timeout: float | None = None, 

3122 stack_identifier: str | None = None, 

3123 ) -> str | None: 

3124 """Poll CloudFormation until a stack settles, retrying transient unknown reads.""" 

3125 import time 

3126 

3127 if timeout is None: 

3128 timeout = float(os.environ.get("GCO_CDK_SETTLE_TIMEOUT_SECONDS", "1200")) 

3129 deadline = time.monotonic() + timeout 

3130 unknown_started: float | None = None 

3131 status = self._get_stack_status(stack_name, stack_identifier) 

3132 while True: 

3133 now = time.monotonic() 

3134 if self._cdk_cancel_event.is_set(): 

3135 return status 

3136 if status is None: 

3137 if unknown_started is None: 

3138 unknown_started = now 

3139 unknown_deadline = min( 

3140 deadline, 

3141 unknown_started + _CLOUDFORMATION_SETTLE_UNKNOWN_TIMEOUT_SECONDS, 

3142 ) 

3143 if now >= unknown_deadline: 

3144 return None 

3145 time.sleep( 

3146 min( 

3147 _CLOUDFORMATION_SETTLE_UNKNOWN_POLL_SECONDS, 

3148 unknown_deadline - now, 

3149 ) 

3150 ) 

3151 status = self._get_stack_status(stack_name, stack_identifier) 

3152 continue 

3153 unknown_started = None 

3154 if not status.endswith("_IN_PROGRESS") or now >= deadline: 

3155 return status 

3156 time.sleep(min(15.0, deadline - now)) 

3157 status = self._get_stack_status(stack_name, stack_identifier) 

3158 

3159 def _get_latest_stack_event( 

3160 self, 

3161 stack_name: str, 

3162 stack_identifier: str | None = None, 

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

3164 """Return the newest CloudFormation event for delete heartbeats.""" 

3165 import boto3 

3166 

3167 try: 

3168 region = self._get_destroy_region(stack_name) 

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

3170 events = cfn.describe_stack_events(StackName=stack_identifier or stack_name).get( 

3171 "StackEvents", [] 

3172 ) 

3173 return events[0] if events else None 

3174 except Exception: 

3175 logger.debug("Could not read delete events for %s", stack_name, exc_info=True) 

3176 return None 

3177 

3178 def _print_stack_delete_heartbeat( 

3179 self, 

3180 stack_name: str, 

3181 status: str | None, 

3182 stack_identifier: str | None = None, 

3183 ) -> None: 

3184 """Print the latest AWS-side state while a long delete converges.""" 

3185 event = self._get_latest_stack_event(stack_name, stack_identifier) 

3186 if not event: 

3187 print(f" {stack_name}: CloudFormation status {status or 'unknown'}") 

3188 return 

3189 

3190 timestamp = event.get("Timestamp") 

3191 timestamp_text = ( 

3192 timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp or "") 

3193 ) 

3194 logical_id = str(event.get("LogicalResourceId") or stack_name) 

3195 resource_status = str(event.get("ResourceStatus") or status or "unknown") 

3196 reason = " ".join(str(event.get("ResourceStatusReason") or "").split()) 

3197 if len(reason) > 400: 

3198 reason = reason[:397] + "..." 

3199 suffix = f" — {reason}" if reason else "" 

3200 print( 

3201 f" {stack_name}: {status or 'unknown'}; latest event " 

3202 f"{timestamp_text} {logical_id} {resource_status}{suffix}" 

3203 ) 

3204 

3205 def _wait_for_stack_delete_convergence( 

3206 self, 

3207 stack_name: str, 

3208 *, 

3209 timeout: float | None = None, 

3210 poll_interval: float = _CLOUDFORMATION_DELETE_POLL_SECONDS, 

3211 heartbeat_interval: float = _CLOUDFORMATION_DELETE_HEARTBEAT_SECONDS, 

3212 initial_status: str = "DELETE_IN_PROGRESS", 

3213 expected_stack_id: str | None = None, 

3214 require_expected_identity: bool = False, 

3215 ) -> bool: 

3216 """Wait for an AWS-side stack delete to finish without trusting CDK polling. 

3217 

3218 The caller must already have evidence that a delete operation started. 

3219 Transient status-read failures are tolerated after that proof, but a 

3220 terminal ``DELETE_FAILED`` or the overall deadline fails closed. 

3221 """ 

3222 if timeout is None: 

3223 try: 

3224 timeout = float( 

3225 os.environ.get( 

3226 "GCO_CLOUDFORMATION_DELETE_TIMEOUT_SECONDS", 

3227 str(_CLOUDFORMATION_DELETE_TIMEOUT_SECONDS), 

3228 ) 

3229 ) 

3230 except ValueError: 

3231 timeout = _CLOUDFORMATION_DELETE_TIMEOUT_SECONDS 

3232 if not math.isfinite(timeout) or timeout <= 0: 

3233 raise ValueError("CloudFormation delete timeout must be positive and finite") 

3234 if ( 

3235 not math.isfinite(poll_interval) 

3236 or not math.isfinite(heartbeat_interval) 

3237 or poll_interval <= 0 

3238 or heartbeat_interval <= 0 

3239 ): 

3240 raise ValueError("CloudFormation delete polling intervals must be positive and finite") 

3241 

3242 deadline = time.monotonic() + timeout 

3243 next_heartbeat = time.monotonic() 

3244 status: str | None = initial_status 

3245 last_printed_status: str | None = None 

3246 

3247 while True: 

3248 if self._cdk_cancel_event.is_set(): 

3249 logger.warning("CloudFormation delete wait cancelled for %s", stack_name) 

3250 return False 

3251 try: 

3252 if not self._stack_exists_in_cloudformation( 

3253 stack_name, 

3254 expected_stack_id=expected_stack_id, 

3255 require_expected_identity=require_expected_identity, 

3256 ): 

3257 print(f" {stack_name} is absent from CloudFormation.") 

3258 return True 

3259 except RuntimeError: 

3260 raise 

3261 except Exception: 

3262 logger.debug( 

3263 "CloudFormation presence check failed for %s", 

3264 stack_name, 

3265 exc_info=True, 

3266 ) 

3267 

3268 now = time.monotonic() 

3269 if status == "DELETE_COMPLETE": 

3270 return True 

3271 if status == "DELETE_FAILED": 

3272 self._print_stack_delete_heartbeat( 

3273 stack_name, 

3274 status, 

3275 expected_stack_id, 

3276 ) 

3277 return False 

3278 if status not in (None, "DELETE_IN_PROGRESS"): 

3279 self._print_stack_delete_heartbeat( 

3280 stack_name, 

3281 status, 

3282 expected_stack_id, 

3283 ) 

3284 print( 

3285 f" {stack_name} left DELETE_IN_PROGRESS without being deleted; " 

3286 "refusing to continue teardown." 

3287 ) 

3288 return False 

3289 if now >= deadline: 

3290 self._print_stack_delete_heartbeat( 

3291 stack_name, 

3292 status, 

3293 expected_stack_id, 

3294 ) 

3295 print( 

3296 f" Timed out after {timeout:.0f}s waiting for {stack_name} " 

3297 "to disappear from CloudFormation." 

3298 ) 

3299 return False 

3300 if status != last_printed_status or now >= next_heartbeat: 

3301 self._print_stack_delete_heartbeat( 

3302 stack_name, 

3303 status, 

3304 expected_stack_id, 

3305 ) 

3306 last_printed_status = status 

3307 next_heartbeat = now + heartbeat_interval 

3308 

3309 time.sleep(min(poll_interval, max(0.0, deadline - now))) 

3310 status = self._get_stack_status(stack_name, expected_stack_id) 

3311 

3312 def _cloudformation_delete_stack( 

3313 self, 

3314 stack_name: str, 

3315 *, 

3316 expected_stack_id: str | None = None, 

3317 authorize_stack: StackAuthorizationCallback | None = None, 

3318 require_expected_identity: bool = False, 

3319 ) -> bool: 

3320 """Delete an immediately revalidated stack by immutable ARN.""" 

3321 if self._cdk_cancel_event.is_set(): 

3322 raise RuntimeError(f"CloudFormation deletion cancelled before {stack_name}") 

3323 target = self._describe_stack_target( 

3324 stack_name, 

3325 expected_stack_id=expected_stack_id, 

3326 require_expected_identity=require_expected_identity, 

3327 ) 

3328 if target is None: 

3329 return True 

3330 region, cfn, stack = target 

3331 stack_id = str(stack["StackId"]) 

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

3333 if authorize_stack is not None: 

3334 authorize_stack(stack_name, region, stack_id) 

3335 if status == "DELETE_IN_PROGRESS": 

3336 return self._wait_for_stack_delete_convergence( 

3337 stack_name, 

3338 initial_status=status, 

3339 expected_stack_id=stack_id, 

3340 require_expected_identity=require_expected_identity, 

3341 ) 

3342 try: 

3343 cfn.delete_stack(StackName=stack_id) 

3344 except Exception: 

3345 logger.debug("Direct CloudFormation delete failed for %s", stack_id, exc_info=True) 

3346 return False 

3347 return self._wait_for_stack_delete_convergence( 

3348 stack_name, 

3349 expected_stack_id=stack_id, 

3350 require_expected_identity=require_expected_identity, 

3351 ) 

3352 

3353 def _validated_regional_api_region(self, stack_name: str) -> str | None: 

3354 """Return an exact project bridge's SDK-known CloudFormation Region.""" 

3355 bridge_prefix = f"{self.config.project_name}-regional-api-" 

3356 if not stack_name.startswith(bridge_prefix): 

3357 return None 

3358 

3359 region = stack_name[len(bridge_prefix) :] 

3360 if not region: 

3361 return None 

3362 try: 

3363 return region if region in _known_cloudformation_regions() else None 

3364 except Exception: 

3365 logger.debug( 

3366 "Could not validate regional API bridge Region for %s", 

3367 stack_name, 

3368 exc_info=True, 

3369 ) 

3370 return None 

3371 

3372 def _configured_regional_api_regions( 

3373 self, 

3374 ) -> tuple[frozenset[str], str] | None: 

3375 """Read valid root regions and their partition for orphan deletion. 

3376 

3377 Returning ``None`` means the configuration could not prove anything: 

3378 missing, unreadable, malformed, wrong-project, incomplete, empty, and 

3379 duplicate Region configurations all fail closed under the same contract 

3380 used by :class:`ConfigLoader`. 

3381 """ 

3382 path = self.project_root / "cdk.json" 

3383 try: 

3384 data = json.loads(path.read_text(encoding="utf-8")) 

3385 if not isinstance(data, dict): 

3386 return None 

3387 context = data.get("context") 

3388 if not isinstance(context, dict): 

3389 return None 

3390 configured_project = context.get("project_name") 

3391 if ( 

3392 not isinstance(configured_project, str) 

3393 or not configured_project 

3394 or configured_project != self.config.project_name 

3395 ): 

3396 return None 

3397 deployment_regions = context.get("deployment_regions") 

3398 if not isinstance(deployment_regions, dict): 

3399 return None 

3400 

3401 known_regions = _known_cloudformation_regions() 

3402 for key in ("global", "api_gateway", "monitoring"): 

3403 region = deployment_regions.get(key) 

3404 if not isinstance(region, str) or region not in known_regions: 

3405 return None 

3406 

3407 try: 

3408 regional = validated_regional_deployment_regions( 

3409 deployment_regions.get("regional"), 

3410 known_regions=known_regions, 

3411 ) 

3412 deployment_partition = validated_deployment_partition( 

3413 ( 

3414 deployment_regions["global"], 

3415 deployment_regions["api_gateway"], 

3416 deployment_regions["monitoring"], 

3417 *regional, 

3418 ) 

3419 ) 

3420 except RuntimeError, ValueError: 

3421 return None 

3422 return frozenset(regional), deployment_partition 

3423 except OSError, UnicodeError, json.JSONDecodeError, TypeError: 

3424 logger.debug( 

3425 "Could not read authoritative regional configuration from %s", 

3426 path, 

3427 exc_info=True, 

3428 ) 

3429 return None 

3430 

3431 def _get_orphan_regional_api_region(self, stack_name: str) -> str | None: 

3432 """Return a bridge Region only when valid root config proves it absent. 

3433 

3434 This result authorizes bypassing CDK and deleting a stack directly via 

3435 CloudFormation. Merely failing a normal configuration lookup can never 

3436 be interpreted as proof that the stack is orphaned. 

3437 """ 

3438 region = self._validated_regional_api_region(stack_name) 

3439 if region is None: 

3440 return None 

3441 configured = self._configured_regional_api_regions() 

3442 if configured is None: 

3443 return None 

3444 configured_regions, deployment_partition = configured 

3445 if region in configured_regions: 

3446 return None 

3447 try: 

3448 candidate_partition = validated_deployment_partition((region,)) 

3449 except RuntimeError, ValueError: 

3450 return None 

3451 if candidate_partition != deployment_partition: 

3452 return None 

3453 return region 

3454 

3455 def _get_destroy_region(self, stack_name: str) -> str: 

3456 """Determine a configured or cryptographically bounded destroy Region. 

3457 

3458 Deploy resolution intentionally requires bridge Regions to remain in 

3459 ``cdk.json``. A removed bridge may still resolve through the orphan 

3460 path, but that path validates the exact project-scoped name, SDK-known 

3461 CloudFormation Region, authoritative root configuration, and matching 

3462 AWS partition. Reconciliation must reuse that same proof rather than 

3463 trusting a bridge-shaped suffix independently. 

3464 """ 

3465 try: 

3466 region = self._get_deploy_region(stack_name) 

3467 except Exception: 

3468 logger.debug( 

3469 "Configured deploy Region lookup failed for %s; checking orphan shape", 

3470 stack_name, 

3471 exc_info=True, 

3472 ) 

3473 else: 

3474 if region: 

3475 return region 

3476 

3477 orphan_region = self._get_orphan_regional_api_region(stack_name) 

3478 return orphan_region or self.config.api_gateway_region 

3479 

3480 def _ensure_analytics_enabled_for_destroy(self) -> bool: 

3481 """Temporarily enable analytics so CDK includes the stack for destroy.""" 

3482 try: 

3483 current = get_analytics_config() 

3484 if not current.get("enabled"): 

3485 update_analytics_config({"enabled": True}) 

3486 return True 

3487 except Exception as exc: 

3488 logger.debug( 

3489 "Failed to enable analytics toggle for destroy: %s", 

3490 exc, 

3491 exc_info=True, 

3492 ) 

3493 return False 

3494 

3495 def _restore_analytics_disabled(self) -> None: 

3496 """Restore analytics toggle to disabled after destroy.""" 

3497 try: 

3498 update_analytics_config({"enabled": False}) 

3499 except Exception as exc: 

3500 logger.warning( 

3501 "Failed to restore analytics toggle to disabled after destroy: %s", 

3502 exc, 

3503 exc_info=True, 

3504 ) 

3505 

3506 def _remove_api_gateway_analytics_dependency( 

3507 self, 

3508 *, 

3509 allow_bootstrap: bool = True, 

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

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

3512 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

3513 authorize_stack: StackAuthorizationCallback | None = None, 

3514 strict_deployment_token: str | None = None, 

3515 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

3516 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

3517 ) -> bool: 

3518 """Redeploy gco-api-gateway with analytics disabled to drop cross-stack imports. 

3519 

3520 The analytics stack exports values (Cognito pool ARN, presigned-URL 

3521 Lambda ARN) that gco-api-gateway imports for the /studio/* routes. 

3522 CloudFormation blocks deletion of stacks with consumed exports. By 

3523 disabling analytics and redeploying the API gateway, the /studio/* 

3524 routes are removed and the imports are dropped, unblocking the 

3525 analytics stack deletion. 

3526 

3527 Returns: 

3528 True if the analytics stack is safe to destroy (either because 

3529 no consumer remains or because the redeploy successfully 

3530 dropped the imports). False if a consumer of the analytics 

3531 exports still exists and the analytics destroy will fail. 

3532 """ 

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

3534 analytics_stack = f"{self.config.project_name}-analytics" 

3535 

3536 strict_identity = expected_stack_ids is not None 

3537 if strict_identity: 

3538 assert expected_stack_ids is not None 

3539 if api_gateway_stack not in expected_stack_ids: 

3540 raise RuntimeError( 

3541 f"Strict teardown lacks authoritative target state for {api_gateway_stack}" 

3542 ) 

3543 api_gateway_expected_id = expected_stack_ids[api_gateway_stack] 

3544 else: 

3545 api_gateway_expected_id = None 

3546 

3547 # Fast path: if the api-gateway stack doesn't exist (or has already 

3548 # been deleted/rolled-back into a non-consuming state), there's 

3549 # nothing importing the analytics exports. Skip the redeploy entirely. 

3550 if not self._stack_exists_in_cloudformation( 

3551 api_gateway_stack, 

3552 expected_stack_id=api_gateway_expected_id, 

3553 require_expected_identity=strict_identity, 

3554 ): 

3555 logger.info( 

3556 "%s does not exist in CloudFormation; skipping redeploy before analytics destroy.", 

3557 api_gateway_stack, 

3558 ) 

3559 return True 

3560 

3561 # Second fast path: if the deployed api-gateway isn't actually 

3562 # importing anything from the analytics stack, we don't need to 

3563 # touch it. This happens when analytics was never fully wired up. 

3564 if not self._api_gateway_imports_from_analytics(): 

3565 logger.info( 

3566 "%s does not import any %s exports; skipping redeploy before analytics destroy.", 

3567 api_gateway_stack, 

3568 analytics_stack, 

3569 ) 

3570 return True 

3571 

3572 if strict_identity and ( 

3573 not strict_deployment_token or on_change_set_prepared is None or authorize_stack is None 

3574 ): 

3575 raise RuntimeError( 

3576 "Strict analytics teardown cannot remove API imports without " 

3577 "prepared-change-set authority" 

3578 ) 

3579 

3580 try: 

3581 # Temporarily disable analytics so CDK drops the /studio/* routes. 

3582 current = get_analytics_config() 

3583 was_enabled = current.get("enabled", False) 

3584 if was_enabled: 

3585 update_analytics_config({"enabled": False}) 

3586 

3587 print(f" Updating {api_gateway_stack} to remove analytics routes...") 

3588 import tempfile 

3589 

3590 with tempfile.TemporaryDirectory() as tmp_out: 

3591 success = self.deploy( 

3592 stack_name=api_gateway_stack, 

3593 require_approval=False, 

3594 exclusively=True, 

3595 output_dir=tmp_out, 

3596 allow_bootstrap=allow_bootstrap, 

3597 bootstrap_stacks=bootstrap_stacks, 

3598 expected_stack_ids=expected_stack_ids, 

3599 prepared_change_sets=prepared_change_sets, 

3600 authorize_stack=authorize_stack, 

3601 strict_deployment_token=strict_deployment_token, 

3602 on_change_set_prepared=on_change_set_prepared, 

3603 on_ecr_repository_created=on_ecr_repository_created, 

3604 ) 

3605 

3606 # Re-enable analytics so CDK can synthesize the analytics stack 

3607 # for the destroy operation (custom resources need to fire). 

3608 if was_enabled: 

3609 update_analytics_config({"enabled": True}) 

3610 

3611 if not success: 

3612 # The redeploy failed. That's only a real problem if the 

3613 # api-gateway still imports analytics exports. Recheck: 

3614 # the auto-cleanup of ROLLBACK_COMPLETE stacks may have 

3615 # deleted the consumer entirely, in which case the destroy 

3616 # can still proceed. 

3617 if not self._api_gateway_imports_from_analytics(): 

3618 logger.info( 

3619 "%s redeploy failed, but the stack no longer imports " 

3620 "analytics exports (likely deleted during cleanup). " 

3621 "Analytics destroy can proceed.", 

3622 api_gateway_stack, 

3623 ) 

3624 return True 

3625 logger.error( 

3626 "Failed to redeploy %s to drop analytics imports, and the " 

3627 "stack still consumes analytics exports. Destroying %s will " 

3628 "fail with 'Export ... cannot be deleted as it is in use'. " 

3629 "Fix %s first (see events above) and retry.", 

3630 api_gateway_stack, 

3631 analytics_stack, 

3632 api_gateway_stack, 

3633 ) 

3634 return False 

3635 

3636 return True 

3637 except Exception as exc: 

3638 logger.warning( 

3639 "Failed to remove API gateway analytics dependency: %s", 

3640 exc, 

3641 exc_info=True, 

3642 ) 

3643 # On unexpected exceptions, recheck whether imports remain. 

3644 # Be permissive only if we can confirm the destroy is safe. 

3645 try: 

3646 return not self._api_gateway_imports_from_analytics() 

3647 except Exception: 

3648 return False 

3649 

3650 def _api_gateway_imports_from_analytics(self) -> bool: 

3651 """Return True if gco-api-gateway imports any exports from gco-analytics. 

3652 

3653 Uses CloudFormation's ``list_exports`` + ``list_imports`` to detect 

3654 cross-stack references at runtime. This is more reliable than 

3655 inspecting the CDK app because it reflects what's actually 

3656 deployed. 

3657 """ 

3658 import boto3 

3659 

3660 analytics_stack = f"{self.config.project_name}-analytics" 

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

3662 

3663 region = self._get_deploy_region(analytics_stack) 

3664 if not region: 

3665 return False 

3666 

3667 try: 

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

3669 # Collect every export whose owning stack is the analytics stack. 

3670 analytics_exports: list[str] = [] 

3671 paginator = cfn.get_paginator("list_exports") 

3672 for page in paginator.paginate(): 

3673 for export in page.get("Exports", []): 

3674 owner = export.get("ExportingStackId", "") 

3675 # ExportingStackId is a full ARN; match by stack name. 

3676 if f":stack/{analytics_stack}/" in owner: 

3677 analytics_exports.append(export["Name"]) 

3678 

3679 if not analytics_exports: 

3680 return False 

3681 

3682 # For each export, check whether the api-gateway stack is 

3683 # listed as an importer. ``list_imports`` returns the stack 

3684 # names that currently import the given export. 

3685 import_paginator = cfn.get_paginator("list_imports") 

3686 for export_name in analytics_exports: 

3687 try: 

3688 for page in import_paginator.paginate(ExportName=export_name): 

3689 for importer in page.get("Imports", []): 

3690 if importer == api_gateway_stack: 

3691 return True 

3692 except Exception as exc: 

3693 # ``list_imports`` raises when an export has zero 

3694 # consumers — treat that as "not imported" and move on. 

3695 logger.debug( 

3696 "list_imports(%s) failed (likely no consumers): %s", 

3697 export_name, 

3698 exc, 

3699 ) 

3700 return False 

3701 except Exception as exc: 

3702 logger.debug( 

3703 "Failed to check analytics imports for %s: %s", 

3704 api_gateway_stack, 

3705 exc, 

3706 exc_info=True, 

3707 ) 

3708 # On failure to check, err on the side of attempting the 

3709 # redeploy so we don't skip necessary cleanup. 

3710 return True 

3711 

3712 def bootstrap( 

3713 self, 

3714 account: str | None = None, 

3715 region: str | None = None, 

3716 ) -> bool: 

3717 """Bootstrap CDK in an AWS account/region.""" 

3718 cmd = ["bootstrap"] 

3719 

3720 if account and region: 

3721 cmd.append(f"aws://{account}/{region}") 

3722 elif region: 

3723 cmd.append(f"aws://unknown-account/{region}") 

3724 

3725 result = self._run_cdk(cmd) 

3726 return result.returncode == 0 

3727 

3728 def is_bootstrapped(self, region: str) -> bool: 

3729 """Check if CDK has been bootstrapped in a region. 

3730 

3731 Looks for the CDKToolkit CloudFormation stack which is created 

3732 by ``cdk bootstrap``. Result is cached per region for the lifetime 

3733 of this StackManager instance. 

3734 """ 

3735 if not hasattr(self, "_bootstrap_cache"): 

3736 self._bootstrap_cache: dict[str, bool] = {} 

3737 

3738 if region in self._bootstrap_cache: 

3739 return self._bootstrap_cache[region] 

3740 

3741 import boto3 

3742 

3743 cf = boto3.client("cloudformation", region_name=region) 

3744 try: 

3745 response = cf.describe_stacks(StackName="CDKToolkit") 

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

3747 if stacks: 

3748 status = stacks[0].get("StackStatus", "") 

3749 # Any non-deleted state counts as bootstrapped 

3750 result = "DELETE" not in status 

3751 self._bootstrap_cache[region] = result 

3752 return result 

3753 except ClientError: 

3754 pass # Stack doesn't exist — not bootstrapped 

3755 except Exception as e: 

3756 logger.debug("Failed to check CDK bootstrap in %s: %s", region, e) 

3757 

3758 self._bootstrap_cache[region] = False 

3759 return False 

3760 

3761 def _validate_bootstrap_stack( 

3762 self, 

3763 region: str, 

3764 expected: Mapping[str, str], 

3765 ) -> None: 

3766 """Require the exact preflighted CDKToolkit ARN and healthy status.""" 

3767 import boto3 

3768 

3769 expected_id = str(expected.get("stack_id") or "") 

3770 expected_status = str(expected.get("status") or "") 

3771 if not expected_id or expected_status not in _BOOTSTRAP_HEALTHY_STATUSES: 

3772 raise RuntimeError(f"Invalid checkpointed CDKToolkit identity for {region}") 

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

3774 try: 

3775 response = cfn.describe_stacks(StackName=expected_id) 

3776 except Exception as exc: 

3777 raise RuntimeError( 

3778 f"Could not revalidate checkpointed CDKToolkit {expected_id} in {region}" 

3779 ) from exc 

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

3781 if len(stacks) != 1: 

3782 raise RuntimeError(f"CDKToolkit {expected_id} returned an invalid identity") 

3783 stack = stacks[0] 

3784 actual_id = str(stack.get("StackId") or "") 

3785 actual_status = str(stack.get("StackStatus") or "") 

3786 if stack.get("StackName") != "CDKToolkit" or actual_id != expected_id: 

3787 raise RuntimeError(f"CDKToolkit identity changed in {region}") 

3788 if actual_status != expected_status or actual_status not in _BOOTSTRAP_HEALTHY_STATUSES: 

3789 raise RuntimeError( 

3790 f"CDKToolkit {expected_id} status changed from {expected_status} " 

3791 f"to {actual_status or 'unknown'}" 

3792 ) 

3793 

3794 @staticmethod 

3795 def _strict_change_set_name(stack_name: str, token: str) -> str: 

3796 """Return one deterministic, run-scoped CloudFormation change-set name.""" 

3797 safe_token = "".join( 

3798 character if character.isascii() and character.isalnum() else "-" for character in token 

3799 ) 

3800 safe_token = "-".join(part for part in safe_token.split("-") if part) 

3801 digest = hashlib.sha256(f"{token}:{stack_name}".encode()).hexdigest()[:16] 

3802 namespace = "gco" 

3803 max_token_length = 128 - len(namespace) - len(digest) - 2 

3804 safe_token = (safe_token or "live-validation")[:max_token_length] 

3805 return f"{namespace}-{safe_token}-{digest}" 

3806 

3807 def _preflight_strict_change_set( 

3808 self, 

3809 *, 

3810 stack_name: str, 

3811 change_set_name: str, 

3812 expected_stack_id: str | None, 

3813 prepared_change_sets: Mapping[str, Mapping[str, str]], 

3814 ) -> None: 

3815 """Reject an existing deterministic change set without checkpoint authority.""" 

3816 import boto3 

3817 

3818 region = self._get_deploy_region(stack_name) 

3819 if not region: 

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

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

3822 try: 

3823 change_set = cfn.describe_change_set( 

3824 ChangeSetName=change_set_name, 

3825 StackName=stack_name, 

3826 ) 

3827 except ClientError as exc: 

3828 if self._change_set_missing(exc): 

3829 return 

3830 # DescribeChangeSet reports a stack-style ValidationError when both 

3831 # the deterministic change set and its fresh target stack are absent. 

3832 # The target was authoritatively checked immediately above; only an 

3833 # empty create history can safely interpret this as "not prepared". 

3834 if expected_stack_id is None and not prepared_change_sets and self._stack_missing(exc): 

3835 return 

3836 raise RuntimeError( 

3837 f"Could not preflight strict change set {change_set_name} for {stack_name}" 

3838 ) from exc 

3839 

3840 change_set_id = str(change_set.get("ChangeSetId") or "") 

3841 observed_change_set_name = str(change_set.get("ChangeSetName") or "") 

3842 stack_id = str(change_set.get("StackId") or "") 

3843 if not change_set_id or not stack_id or observed_change_set_name != change_set_name: 

3844 raise RuntimeError( 

3845 f"Existing strict change set {change_set_name} omitted immutable identities" 

3846 ) 

3847 self._validate_strict_change_set_arns( 

3848 stack_name=stack_name, 

3849 change_set_name=change_set_name, 

3850 stack_id=stack_id, 

3851 change_set_id=change_set_id, 

3852 region=region, 

3853 ) 

3854 prepared_record = prepared_change_sets.get(change_set_id) 

3855 if prepared_record is None: 

3856 raise RuntimeError( 

3857 f"Existing strict change set {change_set_id} lacks checkpoint authority" 

3858 ) 

3859 recorded_change_set_id = str(prepared_record.get("change_set_id") or "") 

3860 recorded_stack_id = str(prepared_record.get("stack_id") or "") 

3861 recorded_type = str(prepared_record.get("change_set_type") or "") 

3862 if ( 

3863 expected_stack_id is None 

3864 or stack_id != expected_stack_id 

3865 or recorded_change_set_id != change_set_id 

3866 or recorded_stack_id != stack_id 

3867 or recorded_type not in {"CREATE", "UPDATE"} 

3868 ): 

3869 raise RuntimeError(f"Existing strict change-set authority changed for {stack_name}") 

3870 

3871 @staticmethod 

3872 def _validate_strict_change_set_arns( 

3873 *, 

3874 stack_name: str, 

3875 change_set_name: str, 

3876 stack_id: str, 

3877 change_set_id: str, 

3878 region: str, 

3879 ) -> None: 

3880 """Require both prepared identities to be exact, related CloudFormation ARNs.""" 

3881 

3882 def split_arn(identifier: str, label: str) -> tuple[str, str, str, str]: 

3883 parts = identifier.split(":", 5) 

3884 if ( 

3885 len(parts) != 6 

3886 or parts[0] != "arn" 

3887 or not (parts[1] == "aws" or parts[1].startswith("aws-")) 

3888 or parts[2] != "cloudformation" 

3889 or parts[3] != region 

3890 or not parts[4] 

3891 or not parts[5] 

3892 ): 

3893 raise RuntimeError(f"Strict {label} has an invalid CloudFormation ARN") 

3894 return parts[1], parts[3], parts[4], parts[5] 

3895 

3896 stack_partition, _stack_region, stack_account, stack_resource = split_arn( 

3897 stack_id, 

3898 "stack identity", 

3899 ) 

3900 stack_prefix = f"stack/{stack_name}/" 

3901 if not stack_resource.startswith(stack_prefix) or not stack_resource.removeprefix( 

3902 stack_prefix 

3903 ): 

3904 raise RuntimeError( 

3905 f"Strict stack identity {stack_id} does not name expected stack {stack_name}" 

3906 ) 

3907 

3908 change_partition, _change_region, change_account, change_resource = split_arn( 

3909 change_set_id, 

3910 "change-set identity", 

3911 ) 

3912 change_prefix = f"changeSet/{change_set_name}/" 

3913 if not change_resource.startswith(change_prefix) or not change_resource.removeprefix( 

3914 change_prefix 

3915 ): 

3916 raise RuntimeError( 

3917 f"Strict change-set identity {change_set_id} does not name {change_set_name}" 

3918 ) 

3919 if change_partition != stack_partition or change_account != stack_account: 

3920 raise RuntimeError( 

3921 "Strict stack and change-set identities belong to different AWS authorities" 

3922 ) 

3923 

3924 def _execute_prepared_change_set( 

3925 self, 

3926 *, 

3927 stack_name: str, 

3928 change_set_name: str, 

3929 expected_stack_id: str | None, 

3930 expected_tags: Mapping[str, str] | None, 

3931 prepared_change_sets: Mapping[str, Mapping[str, str]], 

3932 preparation_succeeded: bool, 

3933 authorize_stack: StackAuthorizationCallback | None, 

3934 on_change_set_prepared: ChangeSetPreparedCallback, 

3935 allow_noop: bool, 

3936 timeout: float, 

3937 ) -> bool: 

3938 """Validate, checkpoint, and execute only the deterministic CDK change set.""" 

3939 import boto3 

3940 

3941 region = self._get_deploy_region(stack_name) 

3942 if not region: 

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

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

3945 change_set: dict[str, Any] = {} 

3946 inspection_attempts = ( 

3947 _STRICT_CHANGE_SET_INSPECTION_ATTEMPTS 

3948 if expected_stack_id is None and not prepared_change_sets 

3949 else 1 

3950 ) 

3951 inspection_attempt = 0 

3952 while True: 

3953 try: 

3954 change_set = cfn.describe_change_set( 

3955 ChangeSetName=change_set_name, 

3956 StackName=stack_name, 

3957 ) 

3958 break 

3959 except ClientError as exc: 

3960 fresh_create_not_visible = bool( 

3961 expected_stack_id is None 

3962 and not prepared_change_sets 

3963 and (self._change_set_missing(exc) or self._stack_missing(exc)) 

3964 ) 

3965 if fresh_create_not_visible and inspection_attempt + 1 < inspection_attempts: 

3966 if self._cdk_cancel_event.is_set(): 

3967 raise RuntimeError( 

3968 "Strict change-set inspection cancelled before ownership checkpoint" 

3969 ) from exc 

3970 time.sleep(_STRICT_CHANGE_SET_INSPECTION_RETRY_SECONDS) 

3971 inspection_attempt += 1 

3972 continue 

3973 if not self._change_set_missing(exc) and not fresh_create_not_visible: 

3974 raise RuntimeError( 

3975 f"Could not inspect strict change set {change_set_name} for {stack_name}" 

3976 ) from exc 

3977 if allow_noop and expected_stack_id: 

3978 target = self._describe_stack_target( 

3979 stack_name, 

3980 expected_stack_id=expected_stack_id, 

3981 require_expected_identity=True, 

3982 ) 

3983 if target is not None: 

3984 stack = target[2] 

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

3986 if status in _BOOTSTRAP_HEALTHY_STATUSES: 

3987 if authorize_stack is None: 

3988 raise RuntimeError( 

3989 f"Strict no-op for {stack_name} lacks exact authorization" 

3990 ) from exc 

3991 authorize_stack(stack_name, region, expected_stack_id) 

3992 return True 

3993 raise RuntimeError( 

3994 f"CDK did not create the strict change set {change_set_name} for {stack_name}" 

3995 ) from exc 

3996 

3997 change_set_id = str(change_set.get("ChangeSetId") or "") 

3998 observed_change_set_name = str(change_set.get("ChangeSetName") or "") 

3999 stack_id = str(change_set.get("StackId") or "") 

4000 status = str(change_set.get("Status") or "") 

4001 execution_status = str(change_set.get("ExecutionStatus") or "") 

4002 if not change_set_id or not stack_id: 

4003 raise RuntimeError(f"Strict change set {change_set_name} omitted immutable identities") 

4004 if observed_change_set_name != change_set_name: 

4005 raise RuntimeError( 

4006 f"Strict change set identity changed from {change_set_name} " 

4007 f"to {observed_change_set_name or 'unknown'}" 

4008 ) 

4009 self._validate_strict_change_set_arns( 

4010 stack_name=stack_name, 

4011 change_set_name=change_set_name, 

4012 stack_id=stack_id, 

4013 change_set_id=change_set_id, 

4014 region=region, 

4015 ) 

4016 prepared_record = prepared_change_sets.get(change_set_id) 

4017 if prepared_record is None: 

4018 # DescribeChangeSet does not expose ChangeSetType. For a newly 

4019 # prepared change set, the pre-CDK exact target state is the only 

4020 # authoritative source: absence means CREATE; an exact stack means 

4021 # UPDATE. Resumes use the persisted per-change-set record below. 

4022 change_set_type = "CREATE" if expected_stack_id is None else "UPDATE" 

4023 else: 

4024 recorded_change_set_id = str(prepared_record.get("change_set_id") or "") 

4025 recorded_stack_id = str(prepared_record.get("stack_id") or "") 

4026 change_set_type = str(prepared_record.get("change_set_type") or "") 

4027 if recorded_change_set_id != change_set_id or recorded_stack_id != stack_id: 

4028 raise RuntimeError( 

4029 f"Persisted strict change-set authority changed for {stack_name}" 

4030 ) 

4031 if change_set_type not in {"CREATE", "UPDATE"}: 

4032 raise RuntimeError( 

4033 f"Persisted strict change set for {stack_name} has invalid type " 

4034 f"{change_set_type or 'unknown'}" 

4035 ) 

4036 if change_set_type == "UPDATE" and expected_stack_id is None: 

4037 raise RuntimeError( 

4038 f"Strict change set for absent {stack_name} unexpectedly performs UPDATE" 

4039 ) 

4040 if expected_stack_id is not None and stack_id != expected_stack_id: 

4041 raise RuntimeError( 

4042 f"Strict change set targets replacement {stack_id}; expected {expected_stack_id}" 

4043 ) 

4044 observed_tags = { 

4045 str(tag.get("Key")): str(tag.get("Value")) 

4046 for tag in change_set.get("Tags", []) 

4047 if tag.get("Key") is not None 

4048 } 

4049 for key, value in (expected_tags or {}).items(): 

4050 if observed_tags.get(str(key)) != str(value): 

4051 raise RuntimeError(f"Strict change set {change_set_id} omitted required tag {key}") 

4052 

4053 status_reason = " ".join(str(change_set.get("StatusReason") or "").split()).lower() 

4054 empty_change_set = ( 

4055 "submitted information didn't contain changes" in status_reason 

4056 or "no updates are to be performed" in status_reason 

4057 ) 

4058 if ( 

4059 status == "FAILED" 

4060 and empty_change_set 

4061 and (allow_noop or prepared_record is not None) 

4062 and expected_stack_id 

4063 ): 

4064 # stack_id == expected_stack_id is already guaranteed here: the 

4065 # identity check above raises for any mismatch whenever 

4066 # expected_stack_id is not None, and this branch requires a 

4067 # truthy expected_stack_id. 

4068 target = self._describe_stack_target( 

4069 stack_name, 

4070 expected_stack_id=expected_stack_id, 

4071 require_expected_identity=True, 

4072 ) 

4073 if target is None or str(target[2].get("StackStatus") or "") not in ( 

4074 _BOOTSTRAP_HEALTHY_STATUSES 

4075 ): 

4076 raise RuntimeError( 

4077 f"Empty strict change set {change_set_id} has no healthy exact stack" 

4078 ) 

4079 if authorize_stack is None: 

4080 raise RuntimeError(f"Strict no-op for {stack_name} lacks exact authorization") 

4081 authorize_stack(stack_name, region, expected_stack_id) 

4082 on_change_set_prepared( 

4083 stack_name, 

4084 region, 

4085 stack_id, 

4086 change_set_id, 

4087 change_set_type, 

4088 ) 

4089 return True 

4090 if status != "CREATE_COMPLETE" or execution_status not in { 

4091 "AVAILABLE", 

4092 "EXECUTE_COMPLETE", 

4093 }: 

4094 raise RuntimeError( 

4095 f"Strict change set {change_set_id} is {status}/{execution_status}, not usable" 

4096 ) 

4097 

4098 if execution_status == "AVAILABLE" and ( 

4099 prepared_record is None and not preparation_succeeded 

4100 ): 

4101 raise RuntimeError( 

4102 f"Strict change set {change_set_id} was not produced by this preparation" 

4103 ) 

4104 if execution_status == "EXECUTE_COMPLETE" and ( 

4105 prepared_record is None or expected_stack_id is None 

4106 ): 

4107 raise RuntimeError( 

4108 f"Executed strict change set {change_set_id} lacks prior checkpoint authority" 

4109 ) 

4110 

4111 if expected_stack_id is not None: 

4112 if authorize_stack is None: 

4113 raise RuntimeError(f"Strict change set for {stack_name} lacks exact authorization") 

4114 authorize_stack(stack_name, region, stack_id) 

4115 

4116 if execution_status == "EXECUTE_COMPLETE": 

4117 target = self._describe_stack_target( 

4118 stack_name, 

4119 expected_stack_id=stack_id, 

4120 require_expected_identity=True, 

4121 ) 

4122 if target is None or str(target[2].get("StackStatus") or "") not in ( 

4123 _BOOTSTRAP_HEALTHY_STATUSES 

4124 ): 

4125 raise RuntimeError( 

4126 f"Executed strict change set {change_set_id} has no healthy exact stack" 

4127 ) 

4128 elif change_set_type == "CREATE": 

4129 target = self._describe_stack_target( 

4130 stack_name, 

4131 expected_stack_id=stack_id, 

4132 require_expected_identity=True, 

4133 ) 

4134 if target is None or str(target[2].get("StackStatus") or "") != "REVIEW_IN_PROGRESS": 

4135 raise RuntimeError( 

4136 f"Prepared CREATE change set {change_set_id} has no exact review stack" 

4137 ) 

4138 

4139 on_change_set_prepared( 

4140 stack_name, 

4141 region, 

4142 stack_id, 

4143 change_set_id, 

4144 change_set_type, 

4145 ) 

4146 if execution_status == "EXECUTE_COMPLETE": 

4147 return True 

4148 if self._cdk_cancel_event.is_set(): 

4149 raise RuntimeError( 

4150 f"Strict change set {change_set_id} was checkpointed but execution was cancelled" 

4151 ) 

4152 

4153 cfn.execute_change_set(ChangeSetName=change_set_id) 

4154 settled = self._wait_for_stack_settle( 

4155 stack_name, 

4156 timeout=timeout, 

4157 stack_identifier=stack_id, 

4158 ) 

4159 if settled not in _BOOTSTRAP_HEALTHY_STATUSES: 

4160 logger.error( 

4161 "Strict change set %s for %s settled as %s", 

4162 change_set_id, 

4163 stack_name, 

4164 settled or "unknown", 

4165 ) 

4166 return False 

4167 return True 

4168 

4169 def ensure_bootstrapped(self, region: str) -> bool: 

4170 """Ensure a region is CDK-bootstrapped, auto-bootstrapping if needed. 

4171 

4172 Returns True if the region is (or was successfully) bootstrapped. 

4173 """ 

4174 if self.is_bootstrapped(region): 

4175 return True 

4176 

4177 print(f"ℹ Region {region} is not CDK-bootstrapped. Bootstrapping now...") 

4178 success = self.bootstrap(region=region) 

4179 if success: 

4180 # Update cache so we don't re-check this region 

4181 if not hasattr(self, "_bootstrap_cache"): 

4182 self._bootstrap_cache = {} 

4183 self._bootstrap_cache[region] = True 

4184 print(f"✓ CDK bootstrapped in {region}") 

4185 else: 

4186 print(f"✗ Failed to bootstrap CDK in {region}") 

4187 return success 

4188 

4189 def _get_deploy_region(self, stack_name: str) -> str | None: 

4190 """Determine the target AWS region for a given stack name.""" 

4191 from .config import _load_cdk_json 

4192 

4193 cdk_regions = _load_cdk_json() 

4194 

4195 # Named stacks are classified by suffix and regional stacks by the 

4196 # ``<project>-`` prefix (#139) so a non-``gco`` deployment resolves 

4197 # regions for its own ``<project>-*`` stacks — otherwise the image 

4198 # mirror (which calls this to pick a regional stack's region) would 

4199 # silently no-op. For the default ``gco`` behaviour is unchanged. 

4200 region: str | None 

4201 if stack_name.endswith("-global"): 

4202 region = cdk_regions.get("global") or self.config.global_region 

4203 return region 

4204 if stack_name.endswith("-api-gateway"): 

4205 region = cdk_regions.get("api_gateway") or self.config.api_gateway_region 

4206 return region 

4207 if stack_name.endswith("-monitoring"): 

4208 region = cdk_regions.get("monitoring") or self.config.monitoring_region 

4209 return region 

4210 if stack_name.endswith("-analytics"): 

4211 # The analytics stack shares the API gateway region so the 

4212 # presigned-URL Lambda can hook into the existing /studio/* 

4213 # routes on the same API Gateway. 

4214 region = cdk_regions.get("api_gateway") or self.config.api_gateway_region 

4215 return region 

4216 

4217 # Regional API bridges use ``<project>-regional-api-<region>``. Resolve 

4218 # this exact shape before generic regional stacks; otherwise the generic 

4219 # project-prefix branch returns the malformed ``regional-api-<region>``. 

4220 # Requiring a configured deployment region also prevents bridge-shaped 

4221 # typos from being treated as valid AWS regions. 

4222 bridge_prefix = f"{self.config.project_name}-regional-api-" 

4223 if stack_name.startswith(bridge_prefix): 

4224 region = stack_name[len(bridge_prefix) :] 

4225 configured_regions = {str(item) for item in (cdk_regions.get("regional") or [])} 

4226 return region if region in configured_regions else None 

4227 

4228 # Base regional stacks: {project}-{region}. The region is whatever 

4229 # follows the project prefix (regions contain hyphens, so we strip 

4230 # the known prefix rather than guess a split point). 

4231 prefix = f"{self.config.project_name}-" 

4232 if stack_name.startswith(prefix): 

4233 return stack_name[len(prefix) :] 

4234 

4235 return None 

4236 

4237 def _mirror_target_regions(self, stack_name: str | None, all_stacks: bool) -> list[str]: 

4238 """Regional regions to auto-mirror images for on this deploy. 

4239 

4240 Only regional stacks (``gco-<region>``) run a Helm install that needs the 

4241 mirror; the named global / api-gateway / monitoring / analytics stacks do 

4242 not. For ``--all`` the regional regions come straight from cdk.json 

4243 (``deployment_regions.regional``) so no synth is required. Returns a 

4244 de-duplicated, order-stable list. 

4245 """ 

4246 # Derive the prefix from project_name (#139) so a non-``gco`` 

4247 # deployment's regional stacks (``<project>-<region>``) are still 

4248 # recognised — otherwise the mirror would silently no-op and the 

4249 # regional Volcano Helm install would have no images to pull. 

4250 prefix = f"{self.config.project_name}-" 

4251 named = { 

4252 f"{prefix}global", 

4253 f"{prefix}api-gateway", 

4254 f"{prefix}monitoring", 

4255 f"{prefix}analytics", 

4256 } 

4257 if all_stacks: 

4258 from .config import _load_cdk_json 

4259 

4260 regional = _load_cdk_json().get("regional") or [] 

4261 return list(dict.fromkeys(str(r) for r in regional)) 

4262 

4263 # Bridge stacks contain no regional Helm consumers and must not trigger 

4264 # image mirroring. Match the exact project-scoped prefix so a project 

4265 # name containing ``regional-api`` remains unambiguous. 

4266 bridge_prefix = f"{self.config.project_name}-regional-api-" 

4267 if stack_name and stack_name.startswith(bridge_prefix): 

4268 return [] 

4269 

4270 if stack_name and stack_name.startswith(prefix) and stack_name not in named: 

4271 region = self._get_deploy_region(stack_name) 

4272 return [region] if region else [] 

4273 return [] 

4274 

4275 def _mirror_images_if_enabled( 

4276 self, 

4277 stack_name: str | None, 

4278 all_stacks: bool, 

4279 repository_tags: Mapping[str, str] | None = None, 

4280 on_repository_created: EcrRepositoryCreatedCallback | None = None, 

4281 ) -> None: 

4282 """Mirror third-party images into ECR before a regional stack deploys. 

4283 

4284 No-op unless ``volcano_image_mirror.enabled`` is set in cdk.json. Mirrors 

4285 every relevant regional region (see :meth:`_mirror_target_regions`); the 

4286 copy is idempotent and skips images already present, so a fresh deploy 

4287 seeds the mirror automatically and repeat deploys cost only a few ECR 

4288 describe calls. Raises **before** any CDK call if an enabled mirror fails, 

4289 so a deploy never points a consumer (e.g. Volcano's ``image_registry``) 

4290 at images that aren't in ECR yet. 

4291 """ 

4292 from . import _image_mirror as image_mirror 

4293 

4294 cfg = image_mirror.read_mirror_config() 

4295 if not cfg["enabled"]: 

4296 return 

4297 

4298 regions = self._mirror_target_regions(stack_name, all_stacks) 

4299 for region in regions: 

4300 print(f"Mirroring third-party images into ECR for {region} ...") 

4301 try: 

4302 image_mirror.mirror_images( 

4303 region, 

4304 ecr_namespace=cfg["ecr_namespace"], 

4305 skip_existing=True, 

4306 repository_tags=repository_tags, 

4307 on_repository_created=on_repository_created, 

4308 ) 

4309 except Exception as exc: # surface a clear, actionable failure 

4310 raise RuntimeError( 

4311 f"Image mirror failed for region {region}: {exc}\n" 

4312 "volcano_image_mirror is enabled but the images could not be " 

4313 "mirrored into ECR. Fix the cause (container runtime / network / " 

4314 "credentials) or run " 

4315 f"'gco images mirror --region {region}' manually, " 

4316 "then retry. Aborting before CDK so the deploy never points a " 

4317 "consumer at images that aren't in ECR." 

4318 ) from exc 

4319 

4320 def get_outputs(self, stack_name: str, region: str) -> dict[str, str]: 

4321 """Get stack outputs from CloudFormation.""" 

4322 import boto3 

4323 

4324 cf = boto3.client("cloudformation", region_name=region) 

4325 try: 

4326 response = cf.describe_stacks(StackName=stack_name) 

4327 if response["Stacks"]: 

4328 stack = response["Stacks"][0] 

4329 outputs: dict[str, str] = {} 

4330 for output in stack.get("Outputs", []): 

4331 outputs[str(output["OutputKey"])] = str(output["OutputValue"]) 

4332 return outputs 

4333 except Exception as e: 

4334 logger.debug("Failed to get outputs for %s in %s: %s", stack_name, region, e) 

4335 return {} 

4336 

4337 def get_stack_status(self, stack_name: str, region: str) -> StackInfo | None: 

4338 """Get detailed stack status from CloudFormation.""" 

4339 import boto3 

4340 

4341 cf = boto3.client("cloudformation", region_name=region) 

4342 try: 

4343 response = cf.describe_stacks(StackName=stack_name) 

4344 if response["Stacks"]: 

4345 stack = response["Stacks"][0] 

4346 return StackInfo( 

4347 name=stack["StackName"], 

4348 status=stack["StackStatus"], 

4349 region=region, 

4350 created_time=stack.get("CreationTime"), 

4351 updated_time=stack.get("LastUpdatedTime"), 

4352 outputs={o["OutputKey"]: o["OutputValue"] for o in stack.get("Outputs", [])}, 

4353 tags={t["Key"]: t["Value"] for t in stack.get("Tags", [])}, 

4354 ) 

4355 except Exception as e: 

4356 logger.debug("Failed to get stack status for %s in %s: %s", stack_name, region, e) 

4357 return None 

4358 

4359 def deploy_orchestrated( 

4360 self, 

4361 require_approval: bool = True, 

4362 outputs_file: str | None = None, 

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

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

4365 progress: str = "events", 

4366 on_stack_start: Callable[[str], None] | None = None, 

4367 on_stack_complete: Callable[[str, bool], None] | None = None, 

4368 parallel: bool = False, 

4369 max_workers: int = 4, 

4370 allow_bootstrap: bool = True, 

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

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

4373 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

4374 authorize_stack: StackAuthorizationCallback | None = None, 

4375 strict_deployment_token: str | None = None, 

4376 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

4377 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

4378 ) -> tuple[bool, list[str], list[str]]: 

4379 """ 

4380 Deploy all stacks in the correct order. 

4381 

4382 Deploys global stacks first, then base regional stacks, regional API 

4383 bridges, and finally monitoring. Parallelism never crosses a dependency 

4384 level. 

4385 

4386 Args: 

4387 require_approval: Whether to require approval for changes 

4388 outputs_file: File to write outputs to 

4389 parameters: CDK parameters 

4390 tags: Tags to apply to stacks 

4391 progress: Progress display type 

4392 on_stack_start: Callback(stack_name) called when starting a stack 

4393 on_stack_complete: Callback(stack_name, success) called when stack completes 

4394 parallel: Deploy regional stacks in parallel 

4395 max_workers: Maximum number of parallel deployments (default: 4) 

4396 

4397 Returns: 

4398 Tuple of (overall_success, successful_stacks, failed_stacks) 

4399 

4400 Raises: 

4401 AssetPermissionError: before the stack listing synthesizes 

4402 anything, when a deployable source is owner-only. 

4403 """ 

4404 self.ensure_asset_sources_readable() 

4405 stacks = self.list_stacks() 

4406 stack_names = set(stacks) 

4407 project_name = self.config.project_name 

4408 ordered_stacks = get_stack_deployment_order(stacks, project_name=project_name) 

4409 

4410 strict_deployment = ( 

4411 strict_deployment_token is not None or on_change_set_prepared is not None 

4412 ) 

4413 if strict_deployment: 

4414 if not strict_deployment_token or on_change_set_prepared is None: 

4415 raise RuntimeError( 

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

4417 ) 

4418 if allow_bootstrap: 

4419 raise RuntimeError("Strict orchestrated deployment cannot auto-bootstrap") 

4420 if authorize_stack is None: 

4421 raise RuntimeError("Strict orchestrated deployment requires an exact authorizer") 

4422 if expected_stack_ids is None: 

4423 raise RuntimeError("Strict orchestrated deployment lacks target identities") 

4424 if prepared_change_sets is None: 

4425 raise RuntimeError("Strict orchestrated deployment lacks change-set history") 

4426 missing = sorted(set(stacks) - set(expected_stack_ids)) 

4427 unexpected = sorted(set(expected_stack_ids) - set(stacks)) 

4428 if missing or unexpected: 

4429 raise RuntimeError( 

4430 "Strict deployment target map does not match the CDK graph; " 

4431 f"missing={missing}, unexpected={unexpected}" 

4432 ) 

4433 missing_history = sorted(set(stacks) - set(prepared_change_sets)) 

4434 unexpected_history = sorted(set(prepared_change_sets) - set(stacks)) 

4435 if missing_history or unexpected_history: 

4436 raise RuntimeError( 

4437 "Strict change-set history does not match the CDK graph; " 

4438 f"missing={missing_history}, unexpected={unexpected_history}" 

4439 ) 

4440 

4441 # Validate every toolkit and every expected stack before the first 

4442 # repository copy, stuck-stack recovery, or CloudFormation mutation. 

4443 validated_regions: set[str] = set() 

4444 for target_name in ordered_stacks: 

4445 region = self._get_deploy_region(target_name) 

4446 if not region: 

4447 raise RuntimeError(f"Could not resolve deploy Region for {target_name}") 

4448 if region not in validated_regions: 

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

4450 if expected_bootstrap is None: 

4451 raise RuntimeError( 

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

4453 ) 

4454 self._validate_bootstrap_stack(region, expected_bootstrap) 

4455 validated_regions.add(region) 

4456 

4457 expected_id = expected_stack_ids[target_name] 

4458 target = self._describe_stack_target( 

4459 target_name, 

4460 expected_stack_id=expected_id, 

4461 require_expected_identity=True, 

4462 ) 

4463 if expected_id is not None and target is None: 

4464 raise RuntimeError( 

4465 f"Checkpointed stack {expected_id} is absent; refusing recreation" 

4466 ) 

4467 if target is not None: 

4468 authorize_stack(target_name, region, str(target[2]["StackId"])) 

4469 

4470 # Separate stacks into four dependency levels by suffix/marker so 

4471 # ordering is independent of project_name (#139): 

4472 # 1. Pre-regional global stacks (<project>-global, <project>-api-gateway) 

4473 # 2. Base regional stacks (<project>-<region>, parallel-safe) 

4474 # 3. Regional API bridges (<project>-regional-api-<region>, depend on base) 

4475 # 4. Monitoring (depends on regional stacks) 

4476 pre_regional_stacks = [s for s in ordered_stacks if s.endswith(("-global", "-api-gateway"))] 

4477 regional_api_stacks = [ 

4478 s 

4479 for s in ordered_stacks 

4480 if _is_regional_api_bridge_stack( 

4481 s, 

4482 project_name=project_name, 

4483 stack_names=stack_names, 

4484 ) 

4485 ] 

4486 regional_stacks = [ 

4487 s 

4488 for s in ordered_stacks 

4489 if not s.endswith(("-global", "-api-gateway", "-monitoring")) 

4490 and not _is_regional_api_bridge_stack( 

4491 s, 

4492 project_name=project_name, 

4493 stack_names=stack_names, 

4494 ) 

4495 ] 

4496 post_regional_stacks = [s for s in ordered_stacks if s.endswith("-monitoring")] 

4497 

4498 successful: list[str] = [] 

4499 failed: list[str] = [] 

4500 deployment_safety: _StackOperationSafetyKwargs = { 

4501 "allow_bootstrap": allow_bootstrap, 

4502 "bootstrap_stacks": bootstrap_stacks, 

4503 "expected_stack_ids": expected_stack_ids, 

4504 "prepared_change_sets": prepared_change_sets, 

4505 "authorize_stack": authorize_stack, 

4506 "strict_deployment_token": strict_deployment_token, 

4507 "on_change_set_prepared": on_change_set_prepared, 

4508 "on_ecr_repository_created": on_ecr_repository_created, 

4509 } 

4510 

4511 # Phase 1: Deploy pre-regional global stacks sequentially 

4512 for stack_name in pre_regional_stacks: 

4513 if on_stack_start: 

4514 on_stack_start(stack_name) 

4515 

4516 success = self.deploy( 

4517 stack_name=stack_name, 

4518 require_approval=require_approval, 

4519 outputs_file=outputs_file, 

4520 parameters=parameters, 

4521 tags=tags, 

4522 progress=progress, 

4523 **deployment_safety, 

4524 ) 

4525 

4526 if success: 

4527 successful.append(stack_name) 

4528 else: 

4529 failed.append(stack_name) 

4530 

4531 if on_stack_complete: 

4532 on_stack_complete(stack_name, success) 

4533 

4534 # Stop on failure to prevent cascading issues 

4535 if not success: 

4536 return False, successful, failed 

4537 

4538 # Phase 2: Deploy regional stacks (parallel or sequential) 

4539 # All regional stacks pass --exclusively: globals are already deployed 

4540 # in Phase 1, so CDK doesn't need to re-evaluate them. Skipping that 

4541 # re-evaluation avoids re-running custom resources (notably 

4542 # KubectlApplyManifests) on the global stacks every time a regional 

4543 # stack is deployed — that would otherwise re-apply manifests and 

4544 # rollout-restart controllers for no actual change. 

4545 if regional_stacks: 

4546 if parallel and len(regional_stacks) > 1: 

4547 # Parallel deployment of regional stacks 

4548 successful_regional, failed_regional = self._deploy_stacks_parallel( 

4549 stacks=regional_stacks, 

4550 require_approval=require_approval, 

4551 outputs_file=outputs_file, 

4552 parameters=parameters, 

4553 tags=tags, 

4554 progress=progress, 

4555 on_stack_start=on_stack_start, 

4556 on_stack_complete=on_stack_complete, 

4557 max_workers=max_workers, 

4558 allow_bootstrap=allow_bootstrap, 

4559 bootstrap_stacks=bootstrap_stacks, 

4560 expected_stack_ids=expected_stack_ids, 

4561 prepared_change_sets=prepared_change_sets, 

4562 authorize_stack=authorize_stack, 

4563 strict_deployment_token=strict_deployment_token, 

4564 on_change_set_prepared=on_change_set_prepared, 

4565 on_ecr_repository_created=on_ecr_repository_created, 

4566 ) 

4567 successful.extend(successful_regional) 

4568 failed.extend(failed_regional) 

4569 

4570 # Stop if any regional stack failed 

4571 if failed_regional: 

4572 return False, successful, failed 

4573 else: 

4574 # Sequential deployment 

4575 for stack_name in regional_stacks: 

4576 if on_stack_start: 

4577 on_stack_start(stack_name) 

4578 

4579 success = self.deploy( 

4580 stack_name=stack_name, 

4581 require_approval=require_approval, 

4582 outputs_file=outputs_file, 

4583 parameters=parameters, 

4584 tags=tags, 

4585 progress=progress, 

4586 exclusively=True, 

4587 **deployment_safety, 

4588 ) 

4589 

4590 if success: 

4591 successful.append(stack_name) 

4592 else: 

4593 failed.append(stack_name) 

4594 

4595 if on_stack_complete: 

4596 on_stack_complete(stack_name, success) 

4597 

4598 # Stop on failure 

4599 if not success: 

4600 return False, successful, failed 

4601 

4602 # Phase 3: Deploy regional API bridges only after every base regional 

4603 # stack is complete. Bridges within this level remain parallel-safe. 

4604 if regional_api_stacks: 

4605 if parallel and len(regional_api_stacks) > 1: 

4606 successful_api, failed_api = self._deploy_stacks_parallel( 

4607 stacks=regional_api_stacks, 

4608 require_approval=require_approval, 

4609 outputs_file=outputs_file, 

4610 parameters=parameters, 

4611 tags=tags, 

4612 progress=progress, 

4613 on_stack_start=on_stack_start, 

4614 on_stack_complete=on_stack_complete, 

4615 max_workers=max_workers, 

4616 allow_bootstrap=allow_bootstrap, 

4617 bootstrap_stacks=bootstrap_stacks, 

4618 expected_stack_ids=expected_stack_ids, 

4619 prepared_change_sets=prepared_change_sets, 

4620 authorize_stack=authorize_stack, 

4621 strict_deployment_token=strict_deployment_token, 

4622 on_change_set_prepared=on_change_set_prepared, 

4623 on_ecr_repository_created=on_ecr_repository_created, 

4624 ) 

4625 successful.extend(successful_api) 

4626 failed.extend(failed_api) 

4627 if failed_api: 

4628 return False, successful, failed 

4629 else: 

4630 for stack_name in regional_api_stacks: 

4631 if on_stack_start: 

4632 on_stack_start(stack_name) 

4633 

4634 success = self.deploy( 

4635 stack_name=stack_name, 

4636 require_approval=require_approval, 

4637 outputs_file=outputs_file, 

4638 parameters=parameters, 

4639 tags=tags, 

4640 progress=progress, 

4641 exclusively=True, 

4642 **deployment_safety, 

4643 ) 

4644 if success: 

4645 successful.append(stack_name) 

4646 else: 

4647 failed.append(stack_name) 

4648 if on_stack_complete: 

4649 on_stack_complete(stack_name, success) 

4650 if not success: 

4651 return False, successful, failed 

4652 

4653 # Phase 4: Deploy post-regional stacks (monitoring) sequentially. 

4654 # Same rationale as Phase 2: every upstream stack is already 

4655 # deployed, so --exclusively prevents a redundant pass over 

4656 # global/api-gateway/regional. 

4657 for stack_name in post_regional_stacks: 

4658 if on_stack_start: 

4659 on_stack_start(stack_name) 

4660 

4661 success = self.deploy( 

4662 stack_name=stack_name, 

4663 require_approval=require_approval, 

4664 outputs_file=outputs_file, 

4665 parameters=parameters, 

4666 tags=tags, 

4667 progress=progress, 

4668 exclusively=True, 

4669 **deployment_safety, 

4670 ) 

4671 

4672 if success: 

4673 successful.append(stack_name) 

4674 else: 

4675 failed.append(stack_name) 

4676 

4677 if on_stack_complete: 

4678 on_stack_complete(stack_name, success) 

4679 

4680 if not success: 

4681 return False, successful, failed 

4682 

4683 return len(failed) == 0, successful, failed 

4684 

4685 def _deploy_stacks_parallel( 

4686 self, 

4687 stacks: list[str], 

4688 require_approval: bool, 

4689 outputs_file: str | None, 

4690 parameters: dict[str, str] | None, 

4691 tags: dict[str, str] | None, 

4692 progress: str, 

4693 on_stack_start: Callable[[str], None] | None, 

4694 on_stack_complete: Callable[[str, bool], None] | None, 

4695 max_workers: int, 

4696 allow_bootstrap: bool, 

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

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

4699 prepared_change_sets: PreparedChangeSetAuthority | None, 

4700 authorize_stack: StackAuthorizationCallback | None, 

4701 strict_deployment_token: str | None = None, 

4702 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

4703 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

4704 ) -> tuple[list[str], list[str]]: 

4705 """Deploy multiple stacks in parallel using separate CDK output directories.""" 

4706 import tempfile 

4707 

4708 successful: list[str] = [] 

4709 failed: list[str] = [] 

4710 lock = Lock() 

4711 

4712 def deploy_single(stack_name: str) -> tuple[str, bool]: 

4713 # Use a unique output directory in /tmp for each parallel deployment 

4714 # This avoids CDK copying cdk.out.* directories into assets 

4715 output_dir = tempfile.mkdtemp(prefix=f"cdk-{stack_name}-") 

4716 try: 

4717 if on_stack_start: 

4718 with lock: 

4719 on_stack_start(stack_name) 

4720 

4721 success = self.deploy( 

4722 stack_name=stack_name, 

4723 require_approval=require_approval, 

4724 outputs_file=outputs_file, 

4725 parameters=parameters, 

4726 tags=tags, 

4727 progress=progress, 

4728 output_dir=output_dir, 

4729 exclusively=True, 

4730 allow_bootstrap=allow_bootstrap, 

4731 bootstrap_stacks=bootstrap_stacks, 

4732 expected_stack_ids=expected_stack_ids, 

4733 prepared_change_sets=prepared_change_sets, 

4734 authorize_stack=authorize_stack, 

4735 strict_deployment_token=strict_deployment_token, 

4736 on_change_set_prepared=on_change_set_prepared, 

4737 on_ecr_repository_created=on_ecr_repository_created, 

4738 ) 

4739 return stack_name, success 

4740 finally: 

4741 try: 

4742 import shutil 

4743 

4744 if os.path.exists(output_dir): 

4745 shutil.rmtree(output_dir) 

4746 except Exception as e: 

4747 logger.debug("Cleanup of %s failed: %s", output_dir, e) 

4748 

4749 self._cdk_cancel_event.clear() 

4750 futures: dict[Any, str] = {} 

4751 executor = ThreadPoolExecutor(max_workers=max_workers) 

4752 try: 

4753 futures = {executor.submit(deploy_single, stack): stack for stack in stacks} 

4754 

4755 for future in as_completed(futures): 

4756 stack_name, success = future.result() 

4757 

4758 with lock: 

4759 if success: 

4760 successful.append(stack_name) 

4761 else: 

4762 failed.append(stack_name) 

4763 

4764 if on_stack_complete: 

4765 on_stack_complete(stack_name, success) 

4766 except BaseException: 

4767 # Terminate registered process groups before waiting for executor 

4768 # shutdown; the context-manager form waits first and can deadlock an 

4769 # interrupted orchestration behind a still-running CDK worker. 

4770 self.cancel_active_cdk_processes() 

4771 for future in futures: 

4772 future.cancel() 

4773 executor.shutdown(wait=True, cancel_futures=True) 

4774 raise 

4775 else: 

4776 executor.shutdown(wait=True) 

4777 finally: 

4778 self._cdk_cancel_event.clear() 

4779 

4780 return successful, failed 

4781 

4782 def _resolve_strict_teardown_resources( 

4783 self, 

4784 *, 

4785 stacks: Collection[str], 

4786 regional_stacks: Collection[str], 

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

4788 authorize_stack: StackAuthorizationCallback, 

4789 ) -> dict[str, dict[str, str]]: 

4790 """Authorize every live stack, then resolve helper IDs from exact stack ARNs.""" 

4791 import boto3 

4792 

4793 live_targets: dict[str, tuple[str, Any, dict[str, Any]]] = {} 

4794 for stack_name in stacks: 

4795 expected_stack_id = expected_stack_ids[stack_name] 

4796 target = self._describe_stack_target( 

4797 stack_name, 

4798 expected_stack_id=expected_stack_id, 

4799 require_expected_identity=True, 

4800 ) 

4801 if target is None: 

4802 continue 

4803 region, _cloudformation, stack = target 

4804 stack_id = str(stack["StackId"]) 

4805 authorize_stack(stack_name, region, stack_id) 

4806 live_targets[stack_name] = target 

4807 

4808 project_name = self.config.project_name 

4809 base_regional_stacks: list[str] = [] 

4810 for stack_name in regional_stacks: 

4811 deploy_region = self._get_deploy_region(stack_name) 

4812 if deploy_region and stack_name == f"{project_name}-{deploy_region}": 

4813 base_regional_stacks.append(stack_name) 

4814 

4815 resolved: dict[str, dict[str, str]] = {} 

4816 for stack_name in base_regional_stacks: 

4817 target = live_targets.get(stack_name) 

4818 if target is None: 

4819 continue 

4820 region, cloudformation, stack = target 

4821 stack_id = str(stack["StackId"]) 

4822 summaries: list[dict[str, Any]] = [] 

4823 paginator = cloudformation.get_paginator("list_stack_resources") 

4824 for page in paginator.paginate(StackName=stack_id): 

4825 summaries.extend(page.get("StackResourceSummaries", [])) 

4826 

4827 vpc_ids = { 

4828 str(item["PhysicalResourceId"]) 

4829 for item in summaries 

4830 if item.get("ResourceType") == "AWS::EC2::VPC" and item.get("PhysicalResourceId") 

4831 } 

4832 cluster_names = { 

4833 str(item["PhysicalResourceId"]) 

4834 for item in summaries 

4835 if item.get("ResourceType") == "AWS::EKS::Cluster" 

4836 and item.get("PhysicalResourceId") 

4837 } 

4838 if len(vpc_ids) > 1 or len(cluster_names) > 1: 

4839 raise RuntimeError(f"Exact stack {stack_id} returned ambiguous VPC/EKS resources") 

4840 

4841 details = { 

4842 "stack_name": stack_name, 

4843 "stack_id": stack_id, 

4844 "region": region, 

4845 } 

4846 vpc_id = next(iter(vpc_ids), "") 

4847 cluster_name = next(iter(cluster_names), "") 

4848 if vpc_id: 

4849 details["vpc_id"] = vpc_id 

4850 if cluster_name: 

4851 details["cluster_name"] = cluster_name 

4852 cluster: dict[str, Any] | None 

4853 try: 

4854 cluster = boto3.client("eks", region_name=region).describe_cluster( 

4855 name=cluster_name 

4856 )["cluster"] 

4857 except ClientError as exc: 

4858 if exc.response.get("Error", {}).get("Code") == "ResourceNotFoundException": 

4859 cluster = None 

4860 else: 

4861 raise 

4862 if cluster is not None: 

4863 if str(cluster.get("name") or "") != cluster_name: 

4864 raise RuntimeError( 

4865 f"EKS returned a changed identity for {region}:{cluster_name}" 

4866 ) 

4867 networking = cluster.get("resourcesVpcConfig") or {} 

4868 cluster_vpc_id = str(networking.get("vpcId") or "") 

4869 security_group_id = str(networking.get("clusterSecurityGroupId") or "") 

4870 if vpc_id and cluster_vpc_id != vpc_id: 

4871 raise RuntimeError( 

4872 f"EKS cluster {cluster_name} no longer belongs to exact VPC {vpc_id}" 

4873 ) 

4874 if not security_group_id: 

4875 raise RuntimeError( 

4876 f"EKS cluster {cluster_name} omitted its security-group identity" 

4877 ) 

4878 details["cluster_security_group_id"] = security_group_id 

4879 elif vpc_id: 

4880 # On teardown resume the cluster may already be gone while 

4881 # its managed SG remains. Resolve the SG ID inside the exact 

4882 # stack VPC using the exact cluster physical ID. 

4883 ec2 = boto3.client("ec2", region_name=region) 

4884 groups = ec2.describe_security_groups( 

4885 Filters=[ 

4886 {"Name": "vpc-id", "Values": [vpc_id]}, 

4887 { 

4888 "Name": "tag:aws:eks:cluster-name", 

4889 "Values": [cluster_name], 

4890 }, 

4891 ] 

4892 ).get("SecurityGroups", []) 

4893 group_ids = {str(group["GroupId"]) for group in groups if group.get("GroupId")} 

4894 if len(group_ids) > 1: 

4895 raise RuntimeError( 

4896 f"Exact VPC {vpc_id} has ambiguous EKS security groups for " 

4897 f"{cluster_name}" 

4898 ) 

4899 if group_ids: 

4900 details["cluster_security_group_id"] = next(iter(group_ids)) 

4901 resolved[stack_name] = details 

4902 return resolved 

4903 

4904 def _destroy_phase_remaining_stacks( 

4905 self, 

4906 phase_name: str, 

4907 stacks: Collection[str], 

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

4909 ) -> list[str]: 

4910 """Return stacks still present after a dependency phase. 

4911 

4912 A lookup error is treated as present: advancing when absence cannot be 

4913 proven is less safe than stopping for an operator retry. 

4914 """ 

4915 remaining: list[str] = [] 

4916 for stack_name in stacks: 

4917 try: 

4918 present = self._stack_exists_in_cloudformation( 

4919 stack_name, 

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

4921 require_expected_identity=expected_stack_ids is not None, 

4922 ) 

4923 except Exception: 

4924 logger.exception( 

4925 "Could not verify %s absence after %s", 

4926 stack_name, 

4927 phase_name, 

4928 ) 

4929 present = True 

4930 if present: 

4931 remaining.append(stack_name) 

4932 if remaining: 

4933 print( 

4934 f" {phase_name} barrier blocked: stack absence was not confirmed for " 

4935 + ", ".join(remaining) 

4936 ) 

4937 return remaining 

4938 

4939 def detach_monitoring_from_regions(self) -> bool: 

4940 """Update ``<project>-monitoring`` in place so it references no regional stack. 

4941 

4942 The monitoring stack holds cross-region references into every regional 

4943 stack (FSx file-system IDs, Aurora cluster identifiers, …), and 

4944 CloudFormation refuses to delete a producing stack while a consumer 

4945 still reads its exports. Synthesizing the app with the 

4946 ``gco:control-plane-only`` context — the run-scoped equivalent of an 

4947 empty ``deployment_regions.regional`` — and deploying only the 

4948 monitoring stack drops those references first, which is what lets the 

4949 regional stacks be destroyed while the monitoring stack (and the 

4950 cost-report data it owns) stays. A monitoring stack that is not 

4951 deployed has nothing to detach and is left alone. ``cdk.json`` is never 

4952 edited; the caller's run-scoped context is restored afterwards. 

4953 

4954 Raises: 

4955 AssetPermissionError: when a deployable source is owner-only; the 

4956 detach is a deploy, and it runs before any stack is deleted. 

4957 """ 

4958 monitoring_stack = f"{self.config.project_name}-monitoring" 

4959 if not self._stack_exists_in_cloudformation(monitoring_stack): 

4960 return True 

4961 saved_context = dict(self._extra_cdk_context) 

4962 self.set_extra_cdk_context({**saved_context, CONTROL_PLANE_ONLY_CONTEXT_KEY: "true"}) 

4963 try: 

4964 print( 

4965 f" Updating {monitoring_stack} so it no longer references the regional stacks..." 

4966 ) 

4967 return self.deploy( 

4968 stack_name=monitoring_stack, 

4969 require_approval=False, 

4970 exclusively=True, 

4971 ) 

4972 finally: 

4973 self.set_extra_cdk_context(saved_context) 

4974 

4975 def destroy_orchestrated( 

4976 self, 

4977 force: bool = False, 

4978 on_stack_start: Callable[[str], None] | None = None, 

4979 on_stack_complete: Callable[[str, bool], None] | None = None, 

4980 parallel: bool = False, 

4981 max_workers: int = 4, 

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

4983 prepared_change_sets: PreparedChangeSetAuthority | None = None, 

4984 authorize_stack: StackAuthorizationCallback | None = None, 

4985 allow_bootstrap: bool = True, 

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

4987 on_cleanup_complete: CleanupOutcomeCallback | None = None, 

4988 strict_deployment_token: str | None = None, 

4989 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

4990 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

4991 retain_volumes: bool = False, 

4992 keep_control_plane: bool = False, 

4993 ) -> tuple[bool, list[str], list[str]]: 

4994 """Destroy stacks in dependency order with optional exact-ARN authority. 

4995 

4996 ``retain_volumes`` reports the destroyed clusters' orphaned CSI volumes 

4997 instead of deleting them; see ``_cleanup_cluster_volumes``. 

4998 

4999 ``keep_control_plane`` tears down only the workload tier — the regional 

5000 API bridges and the base regional stacks — and leaves 

5001 ``<project>-global``, ``<project>-api-gateway`` and 

5002 ``<project>-monitoring`` standing. It first updates the monitoring 

5003 stack in place so it no longer references the regional stacks (see 

5004 :meth:`detach_monitoring_from_regions`), then skips every sweep that 

5005 only makes sense when the whole deployment is going away: the 

5006 image-registry preflight, the backup-vault purge, bastion IAM 

5007 retirement, and the traffic-dial parameter purge. This is how a 

5008 deployment is scaled to zero workload Regions and the teardown half 

5009 of ``gco upgrade``. 

5010 """ 

5011 app_stacks = self.list_stacks() 

5012 strict_identity = expected_stack_ids is not None 

5013 if strict_identity: 

5014 assert expected_stack_ids is not None 

5015 missing = sorted(set(app_stacks) - set(expected_stack_ids)) 

5016 if missing: 

5017 raise RuntimeError( 

5018 f"Strict teardown target map is incomplete before cleanup; missing={missing}" 

5019 ) 

5020 if authorize_stack is None: 

5021 raise RuntimeError("Strict teardown requires an exact stack authorizer") 

5022 invalid = sorted( 

5023 name 

5024 for name, stack_id in expected_stack_ids.items() 

5025 if stack_id is not None and not str(stack_id).startswith("arn:") 

5026 ) 

5027 if invalid: 

5028 raise RuntimeError( 

5029 f"Strict teardown has invalid stack identities for: {', '.join(invalid)}" 

5030 ) 

5031 

5032 strict_prepared_deployment = ( 

5033 strict_deployment_token is not None or on_change_set_prepared is not None 

5034 ) 

5035 if strict_prepared_deployment: 

5036 if not strict_deployment_token or on_change_set_prepared is None: 

5037 raise RuntimeError( 

5038 "Strict teardown dependency deployment requires both a run token " 

5039 "and a prepared-change-set callback" 

5040 ) 

5041 if prepared_change_sets is None: 

5042 raise RuntimeError("Strict teardown lacks prepared change-set history") 

5043 expected_history_keys = set(expected_stack_ids or {}) 

5044 if set(prepared_change_sets) != expected_history_keys: 

5045 raise RuntimeError( 

5046 "Strict teardown change-set history does not match target identities" 

5047 ) 

5048 

5049 stacks = list(app_stacks) 

5050 if expected_stack_ids is not None: 

5051 for stack_name in expected_stack_ids: 

5052 if stack_name not in stacks: 

5053 stacks.append(stack_name) 

5054 project_name = self.config.project_name 

5055 ( 

5056 post_regional_stacks, 

5057 regional_api_stacks, 

5058 regional_stacks, 

5059 pre_regional_stacks, 

5060 ) = _get_stack_destroy_phases(stacks, project_name=project_name) 

5061 

5062 if keep_control_plane: 

5063 # The monitoring stack stays, so its cross-region references to the 

5064 # regional stacks must be dropped before those can be deleted. 

5065 if not self.detach_monitoring_from_regions(): 

5066 return False, [], [f"{project_name}-monitoring"] 

5067 workload_tier = set(regional_api_stacks) | set(regional_stacks) 

5068 post_regional_stacks = [] 

5069 pre_regional_stacks = [] 

5070 stacks = [stack for stack in stacks if stack in workload_tier] 

5071 

5072 strict_resources: dict[str, dict[str, str]] = {} 

5073 if strict_identity: 

5074 assert expected_stack_ids is not None 

5075 assert authorize_stack is not None 

5076 strict_resources = self._resolve_strict_teardown_resources( 

5077 stacks=stacks, 

5078 regional_stacks=regional_stacks, 

5079 expected_stack_ids=expected_stack_ids, 

5080 authorize_stack=authorize_stack, 

5081 ) 

5082 

5083 destroy_safety: _StackOperationSafetyKwargs = { 

5084 "expected_stack_ids": expected_stack_ids, 

5085 "prepared_change_sets": prepared_change_sets, 

5086 "authorize_stack": authorize_stack, 

5087 "allow_bootstrap": allow_bootstrap, 

5088 "bootstrap_stacks": bootstrap_stacks, 

5089 "strict_deployment_token": strict_deployment_token, 

5090 "on_change_set_prepared": on_change_set_prepared, 

5091 "on_ecr_repository_created": on_ecr_repository_created, 

5092 } 

5093 

5094 def record_cleanup(name: str, details: dict[str, Any]) -> None: 

5095 if on_cleanup_complete is not None: 

5096 on_cleanup_complete(name, details) 

5097 

5098 if not keep_control_plane and not self._image_registry_destroy_preflight(force=force): 

5099 return False, [], list(stacks) 

5100 

5101 bastion_targets = { 

5102 name: details for name, details in strict_resources.items() if details.get("vpc_id") 

5103 } 

5104 bastions = self.cleanup_orphaned_bastions( 

5105 stacks, 

5106 parallel=parallel, 

5107 resource_targets=bastion_targets if strict_identity else None, 

5108 ) 

5109 record_cleanup("bastions", {"terminated_instances": bastions}) 

5110 

5111 # Non-strict teardowns also retire the bastion's standing IAM 

5112 # role/profile and, below, the implicit log groups CloudFormation 

5113 # never modeled. Strict (live-validation) teardowns skip both: the 

5114 # harness owns fenced log-group deletion and audits IAM itself. 

5115 if not strict_identity and not keep_control_plane: 

5116 record_cleanup("bastion-iam", self._cleanup_bastion_iam()) 

5117 

5118 # A regional stack's role that something outside the stack put into an 

5119 # instance profile makes IAM refuse the role delete, leaving the stack 

5120 # DELETE_FAILED. Detach those memberships first. Runs for upgrades 

5121 # (keep_control_plane) too, since they delete every regional stack. 

5122 if not strict_identity: 

5123 record_cleanup( 

5124 "role-instance-profiles", 

5125 self._detach_stack_roles_from_instance_profiles(list(regional_stacks)), 

5126 ) 

5127 

5128 # The backup vault belongs to the global stack. When that stack stays, 

5129 # so do its recovery points: they are the EFS backups an operator may 

5130 # restore from once the regional stacks are recreated. 

5131 if not keep_control_plane: 

5132 global_stack_name = f"{project_name}-global" 

5133 backup = self._cleanup_backup_vault( 

5134 expected_stack_id=(expected_stack_ids or {}).get(global_stack_name), 

5135 authorize_stack=authorize_stack, 

5136 require_expected_identity=strict_identity, 

5137 ) 

5138 record_cleanup("backup-vault", backup) 

5139 if strict_identity and backup.get("errors"): 

5140 raise RuntimeError( 

5141 "Strict backup-vault cleanup failed before stack deletion: " 

5142 + json.dumps(backup["errors"], sort_keys=True) 

5143 ) 

5144 

5145 successful: list[str] = [] 

5146 failed: list[str] = [] 

5147 

5148 # Capture implicit log-group names while the source stacks still 

5149 # exist; the exact derived names are deleted by ``finish`` below 

5150 # once their stacks are gone. Strict teardowns collect nothing — 

5151 # the live-validation harness owns fenced log-group deletion. 

5152 implicit_log_groups: dict[str, dict[str, Any]] = {} 

5153 if not strict_identity: 

5154 implicit_log_groups = self._collect_implicit_log_groups(stacks) 

5155 

5156 def finish(overall: bool) -> tuple[bool, list[str], list[str]]: 

5157 """Funnel every exit through the teardown sweeps. 

5158 

5159 Called at each return point so a partially failed teardown 

5160 still cleans up the stacks that DID delete (implicit log 

5161 groups), while the success-only sweeps (runtime traffic-dial 

5162 parameters) run exactly when everything is gone. New exit 

5163 paths must return through here as well. 

5164 """ 

5165 if implicit_log_groups: 

5166 record_cleanup( 

5167 "implicit-log-groups", 

5168 self._cleanup_implicit_log_groups(implicit_log_groups, successful), 

5169 ) 

5170 if overall and not keep_control_plane: 

5171 # Only after a complete teardown: while any stack survives, 

5172 # the accelerator may still be live and a manual override on 

5173 # it is standing operator intent the purge must not erase. 

5174 # Strict teardowns run this too — the runtime dial parameters 

5175 # are untagged, so the harness's tagging-index audit cannot 

5176 # see them and no one else owns their removal. 

5177 record_cleanup( 

5178 "traffic-dial-parameters", 

5179 self._cleanup_traffic_dial_parameters(), 

5180 ) 

5181 return overall, successful, failed 

5182 

5183 for stack_name in post_regional_stacks: 

5184 if on_stack_start: 

5185 on_stack_start(stack_name) 

5186 success = self.destroy( 

5187 stack_name=stack_name, 

5188 force=force, 

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

5190 **destroy_safety, 

5191 ) 

5192 (successful if success else failed).append(stack_name) 

5193 if on_stack_complete: 

5194 on_stack_complete(stack_name, success) 

5195 

5196 phase_remaining = self._destroy_phase_remaining_stacks( 

5197 "post-regional", 

5198 post_regional_stacks, 

5199 expected_stack_ids, 

5200 ) 

5201 for stack_name in phase_remaining: 

5202 if stack_name not in failed: 

5203 failed.append(stack_name) 

5204 if any(stack in failed for stack in post_regional_stacks) or phase_remaining: 

5205 return finish(False) 

5206 

5207 if regional_api_stacks: 

5208 if parallel and len(regional_api_stacks) > 1: 

5209 successful_api, failed_api = self._destroy_stacks_parallel( 

5210 stacks=regional_api_stacks, 

5211 force=force, 

5212 on_stack_start=on_stack_start, 

5213 on_stack_complete=on_stack_complete, 

5214 max_workers=max_workers, 

5215 expected_stack_ids=expected_stack_ids, 

5216 authorize_stack=authorize_stack, 

5217 allow_bootstrap=allow_bootstrap, 

5218 bootstrap_stacks=bootstrap_stacks, 

5219 prepared_change_sets=prepared_change_sets, 

5220 strict_deployment_token=strict_deployment_token, 

5221 on_change_set_prepared=on_change_set_prepared, 

5222 on_ecr_repository_created=on_ecr_repository_created, 

5223 ) 

5224 successful.extend(successful_api) 

5225 failed.extend(failed_api) 

5226 else: 

5227 for stack_name in regional_api_stacks: 

5228 if on_stack_start: 

5229 on_stack_start(stack_name) 

5230 success = self.destroy( 

5231 stack_name=stack_name, 

5232 force=force, 

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

5234 **destroy_safety, 

5235 ) 

5236 (successful if success else failed).append(stack_name) 

5237 if on_stack_complete: 

5238 on_stack_complete(stack_name, success) 

5239 phase_remaining = self._destroy_phase_remaining_stacks( 

5240 "regional API bridge", 

5241 regional_api_stacks, 

5242 expected_stack_ids, 

5243 ) 

5244 for stack_name in phase_remaining: 

5245 if stack_name not in failed: 

5246 failed.append(stack_name) 

5247 if any(stack in failed for stack in regional_api_stacks) or phase_remaining: 

5248 return finish(False) 

5249 

5250 watchdog_stops: dict[str, Event] = {} 

5251 watchdog_threads: dict[str, Thread] = {} 

5252 watchdog_targets = ( 

5253 [ 

5254 name 

5255 for name in regional_stacks 

5256 if strict_resources.get(name, {}).get("cluster_security_group_id") 

5257 ] 

5258 if strict_identity 

5259 else list(regional_stacks) 

5260 ) 

5261 try: 

5262 for stack_name in watchdog_targets: 

5263 details = strict_resources.get(stack_name, {}) 

5264 stop_event = Event() 

5265 watchdog_stops[stack_name] = stop_event 

5266 watchdog_threads[stack_name] = self._start_eks_sg_watchdog( 

5267 stack_name, 

5268 stop_event, 

5269 region=details.get("region"), 

5270 security_group_id=details.get("cluster_security_group_id"), 

5271 vpc_id=details.get("vpc_id"), 

5272 ) 

5273 

5274 if regional_stacks: 

5275 if parallel and len(regional_stacks) > 1: 

5276 successful_regional, failed_regional = self._destroy_stacks_parallel( 

5277 stacks=regional_stacks, 

5278 force=force, 

5279 on_stack_start=on_stack_start, 

5280 on_stack_complete=on_stack_complete, 

5281 max_workers=max_workers, 

5282 expected_stack_ids=expected_stack_ids, 

5283 authorize_stack=authorize_stack, 

5284 allow_bootstrap=allow_bootstrap, 

5285 bootstrap_stacks=bootstrap_stacks, 

5286 prepared_change_sets=prepared_change_sets, 

5287 strict_deployment_token=strict_deployment_token, 

5288 on_change_set_prepared=on_change_set_prepared, 

5289 on_ecr_repository_created=on_ecr_repository_created, 

5290 ) 

5291 successful.extend(successful_regional) 

5292 failed.extend(failed_regional) 

5293 else: 

5294 for stack_name in regional_stacks: 

5295 if on_stack_start: 

5296 on_stack_start(stack_name) 

5297 success = self.destroy( 

5298 stack_name=stack_name, 

5299 force=force, 

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

5301 **destroy_safety, 

5302 ) 

5303 (successful if success else failed).append(stack_name) 

5304 if on_stack_complete: 

5305 on_stack_complete(stack_name, success) 

5306 finally: 

5307 for stop_event in watchdog_stops.values(): 

5308 stop_event.set() 

5309 for stack_name, thread in watchdog_threads.items(): 

5310 try: 

5311 thread.join(timeout=5) 

5312 except Exception as exc: 

5313 logger.exception("Could not join teardown watchdog for %s", stack_name) 

5314 record_cleanup( 

5315 "eks-security-group", 

5316 {"stack": stack_name, "errors": [f"{type(exc).__name__}: {exc}"]}, 

5317 ) 

5318 if strict_identity and stack_name not in failed: 

5319 failed.append(stack_name) 

5320 continue 

5321 details = strict_resources.get(stack_name, {}) 

5322 if strict_identity and thread.is_alive(): 

5323 outcome = { 

5324 "stack": stack_name, 

5325 "errors": ["watchdog thread did not stop"], 

5326 } 

5327 else: 

5328 outcome = self._cleanup_eks_security_groups( 

5329 stack_name, 

5330 region=details.get("region"), 

5331 security_group_id=details.get("cluster_security_group_id"), 

5332 vpc_id=details.get("vpc_id"), 

5333 ) 

5334 record_cleanup("eks-security-group", outcome) 

5335 if ( 

5336 strict_identity 

5337 and (outcome.get("errors") or outcome.get("blocked_by_enis")) 

5338 and stack_name not in failed 

5339 ): 

5340 failed.append(stack_name) 

5341 

5342 phase_remaining = self._destroy_phase_remaining_stacks( 

5343 "regional", 

5344 regional_stacks, 

5345 expected_stack_ids, 

5346 ) 

5347 for stack_name in phase_remaining: 

5348 if stack_name not in failed: 

5349 failed.append(stack_name) 

5350 if any(stack in failed for stack in regional_stacks) or phase_remaining: 

5351 return finish(False) 

5352 

5353 # Every regional stack is verifiably absent here, so each EKS cluster and 

5354 # its CSI driver are gone and the volumes it provisioned can never 

5355 # reattach. Running after the barrier (rather than inside the per-stack 

5356 # loop above) means parallel and sequential teardowns publish the same 

5357 # outcomes in the same order, with no concurrent access to the report. 

5358 # Cleanup results deliberately do not feed ``failed``: these stacks are 

5359 # already deleted, and relabeling one as failed would send the CLI's 

5360 # retry loop back to CDK for a stack that no longer exists. 

5361 for stack_name in regional_stacks: 

5362 record_cleanup( 

5363 "dynamic-pvs", 

5364 self._cleanup_cluster_volumes( 

5365 stack_name, 

5366 region=strict_resources.get(stack_name, {}).get("region"), 

5367 retain=retain_volumes, 

5368 ), 

5369 ) 

5370 

5371 for stack_name in pre_regional_stacks: 

5372 if on_stack_start: 

5373 on_stack_start(stack_name) 

5374 success = self.destroy( 

5375 stack_name=stack_name, 

5376 force=force, 

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

5378 **destroy_safety, 

5379 ) 

5380 (successful if success else failed).append(stack_name) 

5381 if on_stack_complete: 

5382 on_stack_complete(stack_name, success) 

5383 

5384 phase_remaining = self._destroy_phase_remaining_stacks( 

5385 "pre-regional global", 

5386 [stack_name], 

5387 expected_stack_ids, 

5388 ) 

5389 for remaining_stack in phase_remaining: 

5390 if remaining_stack not in failed: 

5391 failed.append(remaining_stack) 

5392 if not success or phase_remaining: 

5393 return finish(False) 

5394 

5395 return finish(len(failed) == 0) 

5396 

5397 def _destroy_stacks_parallel( 

5398 self, 

5399 stacks: list[str], 

5400 force: bool, 

5401 on_stack_start: Callable[[str], None] | None, 

5402 on_stack_complete: Callable[[str, bool], None] | None, 

5403 max_workers: int, 

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

5405 authorize_stack: StackAuthorizationCallback | None, 

5406 allow_bootstrap: bool, 

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

5408 prepared_change_sets: PreparedChangeSetAuthority | None, 

5409 strict_deployment_token: str | None = None, 

5410 on_change_set_prepared: ChangeSetPreparedCallback | None = None, 

5411 on_ecr_repository_created: EcrRepositoryCreatedCallback | None = None, 

5412 ) -> tuple[list[str], list[str]]: 

5413 """Destroy multiple stacks in parallel using separate CDK output directories.""" 

5414 import tempfile 

5415 

5416 successful: list[str] = [] 

5417 failed: list[str] = [] 

5418 lock = Lock() 

5419 

5420 def destroy_single(stack_name: str) -> tuple[str, bool]: 

5421 # Use a unique output directory in /tmp for each parallel destruction 

5422 output_dir = tempfile.mkdtemp(prefix=f"cdk-{stack_name}-") 

5423 try: 

5424 if on_stack_start: 

5425 with lock: 

5426 on_stack_start(stack_name) 

5427 

5428 success = self.destroy( 

5429 stack_name=stack_name, 

5430 force=force, 

5431 output_dir=output_dir, 

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

5433 expected_stack_ids=expected_stack_ids, 

5434 authorize_stack=authorize_stack, 

5435 allow_bootstrap=allow_bootstrap, 

5436 bootstrap_stacks=bootstrap_stacks, 

5437 prepared_change_sets=prepared_change_sets, 

5438 strict_deployment_token=strict_deployment_token, 

5439 on_change_set_prepared=on_change_set_prepared, 

5440 on_ecr_repository_created=on_ecr_repository_created, 

5441 ) 

5442 return stack_name, success 

5443 finally: 

5444 try: 

5445 import shutil 

5446 

5447 if os.path.exists(output_dir): 

5448 shutil.rmtree(output_dir) 

5449 except Exception as e: 

5450 logger.debug("Cleanup of %s failed: %s", output_dir, e) 

5451 

5452 self._cdk_cancel_event.clear() 

5453 futures: dict[Any, str] = {} 

5454 executor = ThreadPoolExecutor(max_workers=max_workers) 

5455 try: 

5456 futures = {executor.submit(destroy_single, stack): stack for stack in stacks} 

5457 

5458 for future in as_completed(futures): 

5459 stack_name, success = future.result() 

5460 

5461 with lock: 

5462 if success: 

5463 successful.append(stack_name) 

5464 else: 

5465 failed.append(stack_name) 

5466 

5467 if on_stack_complete: 

5468 on_stack_complete(stack_name, success) 

5469 except BaseException: 

5470 self.cancel_active_cdk_processes() 

5471 for future in futures: 

5472 future.cancel() 

5473 executor.shutdown(wait=True, cancel_futures=True) 

5474 raise 

5475 else: 

5476 executor.shutdown(wait=True) 

5477 finally: 

5478 self._cdk_cancel_event.clear() 

5479 

5480 return successful, failed 

5481 

5482 def _cleanup_backup_vault( 

5483 self, 

5484 *, 

5485 expected_stack_id: str | None = None, 

5486 authorize_stack: StackAuthorizationCallback | None = None, 

5487 require_expected_identity: bool = False, 

5488 ) -> dict[str, Any]: 

5489 """Delete points only from the exact stack resource's physical vault.""" 

5490 import boto3 

5491 

5492 global_region = self.config.global_region 

5493 global_stack_name = f"{self.config.project_name}-global" 

5494 result: dict[str, Any] = { 

5495 "stack_name": global_stack_name, 

5496 "stack_id": expected_stack_id, 

5497 "status": "not-needed", 

5498 "deleted_recovery_points": 0, 

5499 "errors": [], 

5500 } 

5501 

5502 try: 

5503 target = self._describe_stack_target( 

5504 global_stack_name, 

5505 expected_stack_id=expected_stack_id, 

5506 require_expected_identity=require_expected_identity, 

5507 ) 

5508 if target is None: 

5509 result["status"] = "stack-absent" 

5510 return result 

5511 region, cloudformation, stack = target 

5512 stack_id = str(stack["StackId"]) 

5513 result["stack_id"] = stack_id 

5514 if region != global_region: 

5515 raise RuntimeError(f"Global stack resolved to {region}, expected {global_region}") 

5516 if authorize_stack is not None: 

5517 authorize_stack(global_stack_name, region, stack_id) 

5518 

5519 resources: list[dict[str, Any]] = [] 

5520 for page in cloudformation.get_paginator("list_stack_resources").paginate( 

5521 StackName=stack_id 

5522 ): 

5523 resources.extend( 

5524 resource 

5525 for resource in page.get("StackResourceSummaries", []) 

5526 if resource.get("ResourceType") == "AWS::Backup::BackupVault" 

5527 and resource.get("PhysicalResourceId") 

5528 ) 

5529 if not resources: 

5530 result["status"] = "vault-resource-absent" 

5531 return result 

5532 if len(resources) != 1: 

5533 raise RuntimeError( 

5534 f"Expected one AWS::Backup::BackupVault in {stack_id}; found {len(resources)}" 

5535 ) 

5536 

5537 resource = resources[0] 

5538 physical_id = str(resource["PhysicalResourceId"]) 

5539 if physical_id.startswith("arn:"): 

5540 parts = physical_id.split(":", 5) 

5541 if len(parts) != 6 or not parts[5].startswith("backup-vault:"): 

5542 raise RuntimeError(f"Invalid backup vault physical ARN: {physical_id}") 

5543 vault_name = parts[5].removeprefix("backup-vault:") 

5544 else: 

5545 vault_name = physical_id 

5546 if not vault_name: 

5547 raise RuntimeError("CloudFormation returned an empty backup vault physical ID") 

5548 

5549 backup_client = boto3.client("backup", region_name=global_region) 

5550 described_vault = backup_client.describe_backup_vault(BackupVaultName=vault_name) 

5551 vault_arn = str(described_vault.get("BackupVaultArn") or "") 

5552 arn_parts = vault_arn.split(":", 5) 

5553 if ( 

5554 len(arn_parts) != 6 

5555 or arn_parts[2] != "backup" 

5556 or arn_parts[3] != global_region 

5557 or arn_parts[5] != f"backup-vault:{vault_name}" 

5558 ): 

5559 raise RuntimeError( 

5560 "AWS Backup identity does not match the CloudFormation physical resource" 

5561 ) 

5562 if physical_id.startswith("arn:") and physical_id != vault_arn: 

5563 raise RuntimeError("Backup vault ARN changed after CloudFormation resolution") 

5564 

5565 result.update( 

5566 { 

5567 "status": "inspected", 

5568 "logical_id": str(resource.get("LogicalResourceId") or ""), 

5569 "physical_id": physical_id, 

5570 "vault_name": vault_name, 

5571 "vault_arn": vault_arn, 

5572 } 

5573 ) 

5574 paginator = backup_client.get_paginator("list_recovery_points_by_backup_vault") 

5575 for page in paginator.paginate(BackupVaultName=vault_name): 

5576 for recovery_point in page.get("RecoveryPoints", []): 

5577 recovery_point_arn = recovery_point.get("RecoveryPointArn") 

5578 if not recovery_point_arn: 

5579 continue 

5580 try: 

5581 backup_client.delete_recovery_point( 

5582 BackupVaultName=vault_name, 

5583 RecoveryPointArn=recovery_point_arn, 

5584 ) 

5585 result["deleted_recovery_points"] += 1 

5586 except Exception as exc: 

5587 result["errors"].append( 

5588 { 

5589 "recovery_point_arn": str(recovery_point_arn), 

5590 "error": f"{type(exc).__name__}: {exc}", 

5591 } 

5592 ) 

5593 if result["deleted_recovery_points"]: 

5594 print( 

5595 f" Cleaned up {result['deleted_recovery_points']} backup recovery " 

5596 f"points from {vault_name}" 

5597 ) 

5598 result["status"] = "completed" if not result["errors"] else "partial" 

5599 except Exception as exc: 

5600 result["status"] = "failed" 

5601 result["errors"].append({"error": f"{type(exc).__name__}: {exc}"}) 

5602 print(f" Warning: Backup vault cleanup failed (non-fatal): {exc}") 

5603 return result 

5604 

5605 def cleanup_orphaned_bastions( 

5606 self, 

5607 stacks: list[str] | None = None, 

5608 *, 

5609 parallel: bool = True, 

5610 resource_targets: Mapping[str, Mapping[str, str]] | None = None, 

5611 ) -> int: 

5612 """Terminate CLI bastions, using exact stack VPC IDs in strict mode.""" 

5613 if stacks is None: 

5614 stacks = self.list_stacks() 

5615 if resource_targets is not None: 

5616 regional_stacks = [name for name in stacks if name in resource_targets] 

5617 else: 

5618 regional_stacks = [ 

5619 stack 

5620 for stack in stacks 

5621 if not stack.endswith(("-global", "-api-gateway", "-monitoring", "-analytics")) 

5622 ] 

5623 

5624 def cleanup_one(stack_name: str) -> int: 

5625 details = (resource_targets or {}).get(stack_name, {}) 

5626 return self._cleanup_orphaned_bastions( 

5627 stack_name, 

5628 region=details.get("region"), 

5629 vpc_id=details.get("vpc_id"), 

5630 fail_closed=resource_targets is not None, 

5631 ) 

5632 

5633 if not regional_stacks: 

5634 return 0 

5635 if len(regional_stacks) == 1 or not parallel: 

5636 terminated = sum(cleanup_one(stack_name) for stack_name in regional_stacks) 

5637 else: 

5638 # Each region has independent EC2 waiters. Run them concurrently so 

5639 # one slow termination does not add its full timeout to every other 

5640 # region before CloudFormation can start deleting stacks. 

5641 with ThreadPoolExecutor(max_workers=min(4, len(regional_stacks))) as executor: 

5642 terminated = sum(executor.map(cleanup_one, regional_stacks)) 

5643 if terminated: 

5644 print( 

5645 f" Requested termination for {terminated} orphaned ephemeral " 

5646 "SSM bastion(s) before stack deletion." 

5647 ) 

5648 return terminated 

5649 

5650 def _cleanup_orphaned_bastions( 

5651 self, 

5652 stack_name: str, 

5653 *, 

5654 region: str | None = None, 

5655 vpc_id: str | None = None, 

5656 fail_closed: bool = False, 

5657 ) -> int: 

5658 """Terminate tagged bastions only inside a resolved stack VPC.""" 

5659 import boto3 

5660 

5661 from .ephemeral_bastion import ( 

5662 BASTION_PURPOSE, 

5663 TAG_EPHEMERAL_KEY, 

5664 TAG_PROJECT_KEY, 

5665 TAG_PURPOSE_KEY, 

5666 bastion_instance_name, 

5667 ) 

5668 

5669 region = region or self._get_deploy_region(stack_name) 

5670 if not region: 

5671 if fail_closed: 

5672 raise RuntimeError(f"Strict bastion cleanup lacks a Region for {stack_name}") 

5673 return 0 

5674 

5675 project_name = str(self.config.project_name) 

5676 expected_name = bastion_instance_name(project_name) 

5677 try: 

5678 ec2 = boto3.client("ec2", region_name=region) 

5679 if vpc_id: 

5680 vpcs = [{"VpcId": vpc_id}] 

5681 else: 

5682 vpcs = ec2.describe_vpcs( 

5683 Filters=[ 

5684 { 

5685 "Name": "tag:aws:cloudformation:stack-name", 

5686 "Values": [stack_name], 

5687 } 

5688 ] 

5689 ).get("Vpcs", []) 

5690 except Exception as exc: 

5691 if fail_closed: 

5692 raise RuntimeError( 

5693 f"Strict bastion cleanup could not inspect {stack_name}" 

5694 ) from exc 

5695 print(f" Warning: Bastion cleanup could not inspect {stack_name}: {exc}") 

5696 return 0 

5697 

5698 instance_ids: list[str] = [] 

5699 eni_ids: list[str] = [] 

5700 for vpc in vpcs: 

5701 candidate_vpc_id = str(vpc.get("VpcId") or "") 

5702 if not candidate_vpc_id: 

5703 if fail_closed: 

5704 raise RuntimeError(f"Strict bastion cleanup has no VPC ID for {stack_name}") 

5705 continue 

5706 try: 

5707 reservations = ec2.describe_instances( 

5708 Filters=[ 

5709 {"Name": "vpc-id", "Values": [candidate_vpc_id]}, 

5710 {"Name": f"tag:{TAG_EPHEMERAL_KEY}", "Values": ["true"]}, 

5711 {"Name": f"tag:{TAG_PURPOSE_KEY}", "Values": [BASTION_PURPOSE]}, 

5712 { 

5713 "Name": "instance-state-name", 

5714 "Values": [ 

5715 "pending", 

5716 "running", 

5717 "stopping", 

5718 "stopped", 

5719 "shutting-down", 

5720 ], 

5721 }, 

5722 ] 

5723 ).get("Reservations", []) 

5724 except Exception as exc: 

5725 if fail_closed: 

5726 raise RuntimeError( 

5727 f"Strict bastion lookup failed in {stack_name} ({candidate_vpc_id})" 

5728 ) from exc 

5729 logger.warning( 

5730 "Bastion lookup failed in %s (%s): %s", 

5731 stack_name, 

5732 candidate_vpc_id, 

5733 exc, 

5734 ) 

5735 continue 

5736 

5737 for reservation in reservations: 

5738 for instance in reservation.get("Instances", []): 

5739 tags = { 

5740 str(tag.get("Key")): str(tag.get("Value")) 

5741 for tag in instance.get("Tags", []) 

5742 if tag.get("Key") is not None 

5743 } 

5744 tagged_project = tags.get(TAG_PROJECT_KEY) 

5745 if tagged_project != project_name and not ( 

5746 tagged_project is None and tags.get("Name") == expected_name 

5747 ): 

5748 continue 

5749 instance_id = instance.get("InstanceId") 

5750 if instance_id: 

5751 instance_ids.append(str(instance_id)) 

5752 for interface in instance.get("NetworkInterfaces", []): 

5753 attachment = interface.get("Attachment") or {} 

5754 if not ( 

5755 attachment.get("DeviceIndex") == 0 

5756 and attachment.get("DeleteOnTermination") is True 

5757 ): 

5758 continue 

5759 eni_id = interface.get("NetworkInterfaceId") 

5760 if eni_id: 

5761 eni_ids.append(str(eni_id)) 

5762 

5763 instance_ids = list(dict.fromkeys(instance_ids)) 

5764 eni_ids = list(dict.fromkeys(eni_ids)) 

5765 if not instance_ids: 

5766 return 0 

5767 

5768 try: 

5769 ec2.terminate_instances(InstanceIds=instance_ids) 

5770 except Exception as exc: 

5771 if fail_closed: 

5772 raise RuntimeError(f"Strict bastion termination failed in {stack_name}") from exc 

5773 print(f" Warning: Failed to terminate ephemeral bastion(s) in {stack_name}: {exc}") 

5774 return 0 

5775 

5776 print( 

5777 f" Terminating {len(instance_ids)} ephemeral SSM bastion(s) in " 

5778 f"{stack_name}: {', '.join(instance_ids)}" 

5779 ) 

5780 try: 

5781 ec2.get_waiter("instance_terminated").wait( 

5782 InstanceIds=instance_ids, 

5783 WaiterConfig={"Delay": 5, "MaxAttempts": 60}, 

5784 ) 

5785 except Exception as exc: 

5786 if fail_closed: 

5787 raise RuntimeError( 

5788 f"Strict bastion termination did not converge in {stack_name}" 

5789 ) from exc 

5790 logger.warning("Timed out waiting for bastion termination in %s: %s", stack_name, exc) 

5791 

5792 remaining_enis = self._wait_for_bastion_network_interfaces(ec2, eni_ids) 

5793 if remaining_enis: 

5794 message = ( 

5795 f"{len(remaining_enis)} bastion network interface(s) in {stack_name} " 

5796 f"have not released: {', '.join(sorted(remaining_enis))}" 

5797 ) 

5798 if fail_closed: 

5799 raise RuntimeError(message) 

5800 print(f" Warning: {message}. The destroy retry will check again.") 

5801 return len(instance_ids) 

5802 

5803 @staticmethod 

5804 def _wait_for_bastion_network_interfaces( 

5805 ec2: Any, 

5806 eni_ids: list[str], 

5807 *, 

5808 timeout_seconds: float = 120.0, 

5809 poll_interval_seconds: float = 2.0, 

5810 ) -> set[str]: 

5811 """Wait for terminated bastion ENIs, deleting detached leftovers. 

5812 

5813 EC2 normally deletes a primary ENI with its instance. If it becomes 

5814 detached instead, it is safe to delete here because its owning instance 

5815 was selected by the project/VPC bastion filters and termination has 

5816 already been requested. 

5817 """ 

5818 import time as _time 

5819 

5820 remaining = set(eni_ids) 

5821 deadline = _time.monotonic() + timeout_seconds 

5822 while remaining: 

5823 for eni_id in tuple(remaining): 

5824 try: 

5825 response = ec2.describe_network_interfaces(NetworkInterfaceIds=[eni_id]) 

5826 except ClientError as exc: 

5827 code = exc.response.get("Error", {}).get("Code") 

5828 if code == "InvalidNetworkInterfaceID.NotFound": 

5829 remaining.discard(eni_id) 

5830 continue 

5831 logger.warning("Could not inspect bastion ENI %s: %s", eni_id, exc) 

5832 return remaining 

5833 except Exception as exc: # cleanup is best-effort 

5834 logger.warning("Could not inspect bastion ENI %s: %s", eni_id, exc) 

5835 return remaining 

5836 

5837 interfaces = response.get("NetworkInterfaces", []) 

5838 if not interfaces: 

5839 remaining.discard(eni_id) 

5840 continue 

5841 if interfaces[0].get("Status") == "available": 

5842 try: 

5843 ec2.delete_network_interface(NetworkInterfaceId=eni_id) 

5844 remaining.discard(eni_id) 

5845 except ClientError as exc: 

5846 code = exc.response.get("Error", {}).get("Code") 

5847 if code == "InvalidNetworkInterfaceID.NotFound": 

5848 remaining.discard(eni_id) 

5849 else: 

5850 logger.debug("Delete of bastion ENI %s failed: %s", eni_id, exc) 

5851 except Exception as exc: # retry until timeout 

5852 logger.debug("Delete of bastion ENI %s failed: %s", eni_id, exc) 

5853 

5854 if not remaining or _time.monotonic() >= deadline: 

5855 break 

5856 _time.sleep(poll_interval_seconds) 

5857 return remaining 

5858 

5859 # ------------------------------------------------------------------ 

5860 # Implicit log-group + bastion IAM cleanup (non-strict destroy only) 

5861 # ------------------------------------------------------------------ 

5862 # 

5863 # CloudFormation only deletes the log groups it modeled. Lambda default 

5864 # groups (``/aws/lambda/<function>``), the EKS control-plane group 

5865 # (``/aws/eks/<cluster>/cluster``), and the Container Insights groups 

5866 # (``/aws/containerinsights/<cluster>/…``) are created out-of-band by 

5867 # the services themselves, so ``destroy-all`` used to report success 

5868 # while leaving them behind — a real teardown orphaned 22 of them plus 

5869 # the ephemeral-bastion IAM role/profile, which then failed the live 

5870 # release validation's clean-account baseline gate. 

5871 # 

5872 # The cleanup below deletes ONLY exact names derived from the project's 

5873 # own stack resources, captured while the stacks still exist, and only 

5874 # for stacks whose deletion actually succeeded. It never runs in strict 

5875 # (live-validation) teardowns: the harness checkpoints, tags, and 

5876 # fences its own log-group generations and must remain the single 

5877 # owner of that deletion authority. 

5878 

5879 # The service-side patterns implicit log groups follow. An explicit 

5880 # ``AWS::Logs::LogGroup`` resource is deliberately absent here — 

5881 # CloudFormation owns those directly. 

5882 _EKS_CONTAINER_INSIGHTS_SUFFIXES = ("application", "dataplane", "host", "performance") 

5883 

5884 @staticmethod 

5885 def _implicit_log_group_names(resource_type: str, physical_id: str) -> tuple[str, ...]: 

5886 """Exact implicit log-group names a stack resource creates out-of-band.""" 

5887 if resource_type == "AWS::Lambda::Function": 

5888 return (f"/aws/lambda/{physical_id}",) 

5889 if resource_type == "AWS::EKS::Cluster": 

5890 return ( 

5891 f"/aws/eks/{physical_id}/cluster", 

5892 *( 

5893 f"/aws/containerinsights/{physical_id}/{suffix}" 

5894 for suffix in StackManager._EKS_CONTAINER_INSIGHTS_SUFFIXES 

5895 ), 

5896 ) 

5897 return () 

5898 

5899 def _collect_implicit_log_groups(self, stacks: Collection[str]) -> dict[str, dict[str, Any]]: 

5900 """Derive per-stack implicit log-group names while the stacks are live. 

5901 

5902 Best-effort: a stack that cannot be described or listed is skipped 

5903 with a warning — collection must never block the destroy itself. 

5904 """ 

5905 collected: dict[str, dict[str, Any]] = {} 

5906 for stack_name in stacks: 

5907 try: 

5908 target = self._describe_stack_target(stack_name) 

5909 if target is None: 

5910 continue 

5911 region, cloudformation, stack = target 

5912 names: list[str] = [] 

5913 paginator = cloudformation.get_paginator("list_stack_resources") 

5914 for page in paginator.paginate(StackName=str(stack["StackId"])): 

5915 for item in page.get("StackResourceSummaries", []): 

5916 resource_type = str(item.get("ResourceType") or "") 

5917 physical_id = str(item.get("PhysicalResourceId") or "") 

5918 if not physical_id: 

5919 continue 

5920 names.extend(self._implicit_log_group_names(resource_type, physical_id)) 

5921 if names: 

5922 collected[stack_name] = {"region": region, "log_groups": sorted(set(names))} 

5923 except Exception as exc: # best-effort collection 

5924 logger.warning("Could not derive implicit log groups for %s: %s", stack_name, exc) 

5925 return collected 

5926 

5927 def _cleanup_implicit_log_groups( 

5928 self, 

5929 collected: Mapping[str, Mapping[str, Any]], 

5930 successful_stacks: Collection[str], 

5931 ) -> dict[str, Any]: 

5932 """Delete the exact derived log groups of successfully destroyed stacks. 

5933 

5934 A missing group is normal (a Lambda that never logged, or a custom 

5935 ``LoggingConfig`` pointing elsewhere) and is recorded, not retried. 

5936 Every error is recorded and swallowed: cleanup never converts a 

5937 successful destroy into a failure. 

5938 """ 

5939 import boto3 

5940 

5941 outcome: dict[str, Any] = {"deleted": [], "missing": [], "errors": []} 

5942 clients: dict[str, Any] = {} 

5943 for stack_name in sorted(successful_stacks): 

5944 details = collected.get(stack_name) 

5945 if not details: 

5946 continue 

5947 region = str(details.get("region") or "") 

5948 for name in details.get("log_groups", []): 

5949 try: 

5950 client = clients.get(region) 

5951 if client is None: 

5952 client = boto3.client("logs", region_name=region) 

5953 clients[region] = client 

5954 client.delete_log_group(logGroupName=name) 

5955 outcome["deleted"].append(f"{region}:{name}") 

5956 except ClientError as exc: 

5957 code = str(exc.response.get("Error", {}).get("Code") or "") 

5958 if code == "ResourceNotFoundException": 

5959 outcome["missing"].append(f"{region}:{name}") 

5960 else: 

5961 outcome["errors"].append(f"{region}:{name}: {code}") 

5962 except Exception as exc: # best-effort cleanup 

5963 outcome["errors"].append(f"{region}:{name}: {type(exc).__name__}: {exc}") 

5964 if outcome["deleted"]: 

5965 print( 

5966 f" Deleted {len(outcome['deleted'])} implicit CloudWatch log group(s) " 

5967 "left behind by Lambda/EKS/Container Insights." 

5968 ) 

5969 for failure in outcome["errors"]: 

5970 logger.warning("Implicit log-group cleanup failed for %s", failure) 

5971 return outcome 

5972 

5973 def _cleanup_traffic_dial_parameters(self) -> dict[str, Any]: 

5974 """Best-effort purge of the runtime traffic-dial SSM parameter tree. 

5975 

5976 The traffic-dial controller Lambda writes ``/{project}/traffic-dial/ 

5977 state`` and ``gco capacity traffic-dial set`` writes ``override-*`` 

5978 siblings at runtime; CloudFormation never owns them, so stack 

5979 deletion leaves them behind. A surviving override is the real 

5980 hazard: the scheduled controller honors overrides indefinitely, so 

5981 the next deployment in this account would silently pin that region's 

5982 dial until someone noticed. Runs only after a *fully* successful 

5983 teardown — while any stack remains, the accelerator may still be 

5984 live and an override on it is standing operator intent. 

5985 """ 

5986 from .capacity.traffic_dial import TrafficDialManager 

5987 

5988 outcome: dict[str, Any] = {"deleted": [], "errors": []} 

5989 try: 

5990 outcome["deleted"] = TrafficDialManager(self.config).purge_runtime_parameters() 

5991 except Exception as exc: # best-effort cleanup 

5992 outcome["errors"].append(f"{type(exc).__name__}: {exc}") 

5993 if outcome["deleted"]: 

5994 print( 

5995 f" Deleted {len(outcome['deleted'])} runtime traffic-dial SSM " 

5996 "parameter(s) (controller state / manual overrides)." 

5997 ) 

5998 for failure in outcome["errors"]: 

5999 logger.warning("Traffic-dial parameter cleanup failed: %s", failure) 

6000 return outcome 

6001 

6002 def _cleanup_bastion_iam(self) -> dict[str, Any]: 

6003 """Best-effort teardown of the ephemeral-bastion IAM role + profile. 

6004 

6005 ``destroy_ephemeral_bastion`` already attempts this when a tunnel 

6006 closes normally, but a killed process leaves the pair behind (they 

6007 cost nothing, yet fail any clean-account audit). Deletion is by the 

6008 exact project-scoped names from the bastion naming contract; a 

6009 ``NoSuchEntity`` response simply means there was nothing to clean. 

6010 """ 

6011 from .ephemeral_bastion import ( 

6012 _run_aws, 

6013 bastion_profile_name, 

6014 bastion_role_name, 

6015 build_iam_teardown_commands, 

6016 ) 

6017 

6018 outcome: dict[str, Any] = { 

6019 "completed_steps": 0, 

6020 "absent_steps": 0, 

6021 "errors": [], 

6022 } 

6023 try: 

6024 role_name = bastion_role_name(self.config.project_name) 

6025 profile_name = bastion_profile_name(self.config.project_name) 

6026 outcome["role"] = role_name 

6027 outcome["profile"] = profile_name 

6028 steps = build_iam_teardown_commands( 

6029 role_name, 

6030 profile_name, 

6031 self.config.global_region, 

6032 ) 

6033 except Exception as exc: # best-effort cleanup 

6034 outcome["errors"].append(f"{type(exc).__name__}: {exc}") 

6035 return outcome 

6036 for step in steps: 

6037 try: 

6038 _run_aws(step) 

6039 outcome["completed_steps"] += 1 

6040 except RuntimeError as exc: 

6041 if "NoSuchEntity" in str(exc): 

6042 outcome["absent_steps"] += 1 

6043 continue 

6044 outcome["errors"].append(f"{' '.join(step[:3])}: {exc}") 

6045 if outcome["completed_steps"] and not outcome["errors"]: 

6046 print(" Removed the ephemeral-bastion IAM role and instance profile.") 

6047 for failure in outcome["errors"]: 

6048 logger.warning("Bastion IAM teardown step failed: %s", failure) 

6049 return outcome 

6050 

6051 def _detach_stack_roles_from_instance_profiles( 

6052 self, regional_stacks: list[str] 

6053 ) -> dict[str, Any]: 

6054 """Remove each regional stack's own IAM roles from any instance profile. 

6055 

6056 IAM refuses to delete a role that is still in an instance profile 

6057 (``DeleteConflict``: "must remove roles from instance profile first"), 

6058 and CloudFormation then leaves the stack in ``DELETE_FAILED``. Profiles 

6059 are created outside the stack (by hand, by another tool, or by a 

6060 service), so the stack never models them. Only roles the stack itself 

6061 owns (``AWS::IAM::Role`` resources) are touched, and only the role 

6062 membership is removed; the profile is left for whoever created it. 

6063 Best-effort: failures are recorded and the stack delete still runs. 

6064 """ 

6065 import boto3 

6066 

6067 outcome: dict[str, Any] = {"detached": [], "errors": []} 

6068 if not regional_stacks: 

6069 return outcome 

6070 project_name = self.config.project_name 

6071 iam = boto3.client("iam") 

6072 for stack_name in regional_stacks: 

6073 region = stack_name.replace(f"{project_name}-", "", 1) 

6074 try: 

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

6076 role_names: list[str] = [] 

6077 for page in cfn.get_paginator("list_stack_resources").paginate( 

6078 StackName=stack_name 

6079 ): 

6080 for resource in page.get("StackResourceSummaries", []): 

6081 if resource.get("ResourceType") != "AWS::IAM::Role": 

6082 continue 

6083 if resource.get("ResourceStatus") == "DELETE_COMPLETE": 

6084 continue 

6085 physical_id = resource.get("PhysicalResourceId") 

6086 if physical_id: 

6087 role_names.append(str(physical_id)) 

6088 except ClientError as exc: 

6089 if "does not exist" in str(exc): 

6090 continue 

6091 outcome["errors"].append(f"{stack_name}: {exc}") 

6092 continue 

6093 except Exception as exc: # best-effort cleanup 

6094 outcome["errors"].append(f"{stack_name}: {type(exc).__name__}: {exc}") 

6095 continue 

6096 

6097 for role_name in role_names: 

6098 try: 

6099 profiles = [ 

6100 profile["InstanceProfileName"] 

6101 for page in iam.get_paginator("list_instance_profiles_for_role").paginate( 

6102 RoleName=role_name 

6103 ) 

6104 for profile in page.get("InstanceProfiles", []) 

6105 ] 

6106 except ClientError as exc: 

6107 if exc.response.get("Error", {}).get("Code") == "NoSuchEntity": 

6108 continue 

6109 outcome["errors"].append(f"{role_name}: {exc}") 

6110 continue 

6111 for profile_name in profiles: 

6112 try: 

6113 iam.remove_role_from_instance_profile( 

6114 InstanceProfileName=profile_name, RoleName=role_name 

6115 ) 

6116 except ClientError as exc: 

6117 if exc.response.get("Error", {}).get("Code") == "NoSuchEntity": 

6118 continue 

6119 outcome["errors"].append(f"{role_name} in {profile_name}: {exc}") 

6120 continue 

6121 outcome["detached"].append( 

6122 {"stack": stack_name, "role": role_name, "instance_profile": profile_name} 

6123 ) 

6124 print( 

6125 f" Removed {stack_name} role {role_name} from instance profile " 

6126 f"{profile_name} so the stack can delete it (the profile is kept)." 

6127 ) 

6128 for failure in outcome["errors"]: 

6129 logger.warning("Instance-profile detach before stack deletion failed: %s", failure) 

6130 return outcome 

6131 

6132 def cleanup_eks_security_groups(self) -> None: 

6133 """Clean up EKS-managed security groups across all regional stacks. 

6134 

6135 Called between destroy retries to remove orphaned security groups 

6136 that block VPC deletion. 

6137 """ 

6138 stacks = self.list_stacks() 

6139 # Regional stacks are everything that isn't a named global stack; 

6140 # classify by suffix so this works for any project_name (#139). 

6141 regional_stacks = [ 

6142 s for s in stacks if not s.endswith(("-global", "-api-gateway", "-monitoring")) 

6143 ] 

6144 for stack_name in regional_stacks: 

6145 self._cleanup_eks_security_groups(stack_name) 

6146 

6147 def cleanup_orphaned_network_interfaces(self) -> None: 

6148 """Report and clear resources that can block VPC deletion, across all 

6149 regional stacks. Run between destroy retries. 

6150 

6151 Generalizes ``cleanup_eks_security_groups`` (which force-deletes the 

6152 ``eks-cluster-sg-*`` security group + its ENIs that EKS leaves behind) 

6153 with a broader sweep: for each regional stack's VPC it enumerates every 

6154 remaining network interface, categorizes them (Global Accelerator / ELB 

6155 / EKS / other), deletes the ones that are safe to remove (detached and 

6156 not service-managed), and prints a friendly summary of what it found and 

6157 what the next retry is waiting on. Service-managed ENIs (Global 

6158 Accelerator, ELB) are released asynchronously by AWS once the endpoint / 

6159 load balancer is gone, so we report them rather than fight them. 

6160 """ 

6161 stacks = self.list_stacks() 

6162 regional_stacks = [ 

6163 s for s in stacks if not s.endswith(("-global", "-api-gateway", "-monitoring")) 

6164 ] 

6165 for stack_name in regional_stacks: 

6166 # Existing behaviour first: clear the EKS cluster SG + its ENIs. 

6167 self._cleanup_eks_security_groups(stack_name) 

6168 # Then report (and safely clear) anything else lingering in the VPC. 

6169 summary = self._summarize_orphaned_enis(stack_name) 

6170 self._print_orphaned_eni_summary(stack_name, summary) 

6171 

6172 @staticmethod 

6173 def _classify_orphaned_eni(eni: dict[str, Any]) -> str: 

6174 """Bucket a network interface by which AWS service owns it. 

6175 

6176 Uses ``InterfaceType`` first (authoritative for Global Accelerator and 

6177 the load-balancer types) and falls back to the human ``Description`` 

6178 string for the EKS / ELB cases that present as a plain ``interface``. 

6179 Returns one of ``global_accelerator`` / ``elb`` / ``eks`` / ``other``. 

6180 """ 

6181 itype = str(eni.get("InterfaceType") or "").lower() 

6182 desc = str(eni.get("Description") or "").lower() 

6183 if ( 

6184 itype == "global_accelerator_managed" 

6185 or "global_accelerator" in desc 

6186 or "global accelerator" in desc 

6187 ): 

6188 return "global_accelerator" 

6189 if itype in ("load_balancer", "network_load_balancer") or desc.startswith("elb "): 

6190 return "elb" 

6191 if "eks" in desc or "k8s" in desc or "kubernetes" in desc: 

6192 return "eks" 

6193 return "other" 

6194 

6195 def _summarize_orphaned_enis(self, stack_name: str) -> dict[str, int]: 

6196 """Inspect the stack's VPC(s) for lingering ENIs, categorize them, and 

6197 best-effort delete the ones that are safe to remove. 

6198 

6199 "Safe to remove" means ``Status == "available"`` (detached) and not 

6200 ``RequesterManaged`` (i.e. not owned by a service like GA / ELB, which 

6201 rejects manual deletion and releases the ENI on its own schedule). 

6202 

6203 Returns a dict of counts: per-category totals plus ``deleted`` and 

6204 ``vpcs``. Wholly best-effort — any AWS error degrades to the counts 

6205 gathered so far rather than raising into the destroy flow. 

6206 """ 

6207 import boto3 

6208 

6209 region = stack_name.replace(f"{self.config.project_name}-", "", 1) 

6210 summary: dict[str, int] = { 

6211 "global_accelerator": 0, 

6212 "elb": 0, 

6213 "eks": 0, 

6214 "other": 0, 

6215 "deleted": 0, 

6216 "vpcs": 0, 

6217 } 

6218 try: 

6219 ec2 = boto3.client("ec2", region_name=region) 

6220 vpcs = ec2.describe_vpcs( 

6221 Filters=[{"Name": "tag:aws:cloudformation:stack-name", "Values": [stack_name]}] 

6222 ).get("Vpcs", []) 

6223 except Exception as e: 

6224 logger.debug("ENI sweep: VPC lookup failed for %s: %s", stack_name, e) 

6225 return summary 

6226 

6227 for vpc in vpcs: 

6228 summary["vpcs"] += 1 

6229 vpc_id = vpc.get("VpcId") 

6230 try: 

6231 enis = ec2.describe_network_interfaces( 

6232 Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] 

6233 ).get("NetworkInterfaces", []) 

6234 except Exception as e: 

6235 logger.debug("ENI sweep: describe ENIs failed for %s: %s", vpc_id, e) 

6236 continue 

6237 

6238 for eni in enis: 

6239 summary[self._classify_orphaned_eni(eni)] += 1 

6240 detached = eni.get("Status") == "available" 

6241 service_managed = bool(eni.get("RequesterManaged", False)) 

6242 if detached and not service_managed: 

6243 eni_id = eni.get("NetworkInterfaceId") 

6244 try: 

6245 ec2.delete_network_interface(NetworkInterfaceId=eni_id) 

6246 summary["deleted"] += 1 

6247 logger.debug("ENI sweep: deleted detached ENI %s in %s", eni_id, vpc_id) 

6248 except Exception as e: 

6249 logger.debug("ENI sweep: delete of %s failed: %s", eni_id, e) 

6250 return summary 

6251 

6252 @staticmethod 

6253 def _print_orphaned_eni_summary(stack_name: str, summary: dict[str, int]) -> None: 

6254 """Print a friendly summary of what the ENI sweep found and handled.""" 

6255 categories = ( 

6256 ("global_accelerator", "Global Accelerator-managed"), 

6257 ("elb", "ELB-managed"), 

6258 ("eks", "EKS-managed"), 

6259 ("other", "other"), 

6260 ) 

6261 total = sum(summary.get(key, 0) for key, _ in categories) 

6262 if total == 0: 

6263 return 

6264 breakdown = ", ".join( 

6265 f"{summary[key]} {label}" for key, label in categories if summary.get(key) 

6266 ) 

6267 print(f" {stack_name}: {total} network interface(s) still in the VPC ({breakdown}).") 

6268 if summary.get("deleted"): 

6269 print(f" Removed {summary['deleted']} detached interface(s).") 

6270 remaining = total - summary.get("deleted", 0) 

6271 if remaining > 0: 

6272 print( 

6273 f" {remaining} still held by AWS — Global Accelerator / ELB release these " 

6274 "asynchronously once the endpoint and load balancer are gone; the next retry " 

6275 "proceeds once they drain." 

6276 ) 

6277 

6278 def _cleanup_eks_security_groups( 

6279 self, 

6280 stack_name: str, 

6281 *, 

6282 region: str | None = None, 

6283 security_group_id: str | None = None, 

6284 vpc_id: str | None = None, 

6285 ) -> dict[str, Any]: 

6286 """Delete empty EKS SGs, optionally by one exact preauthorized ID.""" 

6287 import boto3 

6288 

6289 project_name = self.config.project_name 

6290 region = region or stack_name.replace(f"{project_name}-", "", 1) 

6291 cluster_name = stack_name 

6292 outcome: dict[str, Any] = { 

6293 "stack": stack_name, 

6294 "region": region, 

6295 "security_group_id": security_group_id, 

6296 "inspected": 0, 

6297 "deleted": [], 

6298 "blocked_by_enis": [], 

6299 "errors": [], 

6300 } 

6301 

6302 try: 

6303 ec2 = boto3.client("ec2", region_name=region) 

6304 try: 

6305 if security_group_id: 

6306 response = ec2.describe_security_groups(GroupIds=[security_group_id]) 

6307 else: 

6308 response = ec2.describe_security_groups( 

6309 Filters=[ 

6310 { 

6311 "Name": "group-name", 

6312 "Values": [f"eks-cluster-sg-{cluster_name}-*"], 

6313 } 

6314 ] 

6315 ) 

6316 except ClientError as exc: 

6317 if ( 

6318 security_group_id 

6319 and exc.response.get("Error", {}).get("Code") == "InvalidGroup.NotFound" 

6320 ): 

6321 outcome["absent"] = True 

6322 return outcome 

6323 raise 

6324 

6325 for security_group in response.get("SecurityGroups", []): 

6326 outcome["inspected"] += 1 

6327 group_id = str(security_group["GroupId"]) 

6328 group_name = str(security_group.get("GroupName", "")) 

6329 if security_group_id and group_id != security_group_id: 

6330 raise RuntimeError( 

6331 f"EC2 returned changed security-group identity for {security_group_id}" 

6332 ) 

6333 if vpc_id and str(security_group.get("VpcId") or "") != vpc_id: 

6334 raise RuntimeError( 

6335 f"Security group {group_id} no longer belongs to exact VPC {vpc_id}" 

6336 ) 

6337 interfaces = ec2.describe_network_interfaces( 

6338 Filters=[{"Name": "group-id", "Values": [group_id]}] 

6339 ).get("NetworkInterfaces", []) 

6340 if interfaces: 

6341 outcome["blocked_by_enis"].append( 

6342 { 

6343 "group_id": group_id, 

6344 "group_name": group_name, 

6345 "network_interface_ids": sorted( 

6346 str(interface.get("NetworkInterfaceId") or "") 

6347 for interface in interfaces 

6348 if interface.get("NetworkInterfaceId") 

6349 ), 

6350 } 

6351 ) 

6352 logger.debug( 

6353 "Waiting for AWS to release %d EKS-managed ENI(s) from %s", 

6354 len(interfaces), 

6355 group_name, 

6356 ) 

6357 continue 

6358 try: 

6359 ec2.delete_security_group(GroupId=group_id) 

6360 outcome["deleted"].append({"group_id": group_id, "group_name": group_name}) 

6361 print(f" Cleaned up empty EKS security group: {group_name} ({group_id})") 

6362 except ClientError as exc: 

6363 if exc.response.get("Error", {}).get("Code") == "InvalidGroup.NotFound": 

6364 outcome["absent"] = True 

6365 continue 

6366 outcome["errors"].append( 

6367 {"group_id": group_id, "error": f"{type(exc).__name__}: {exc}"} 

6368 ) 

6369 except Exception as exc: 

6370 outcome["errors"].append( 

6371 {"group_id": group_id, "error": f"{type(exc).__name__}: {exc}"} 

6372 ) 

6373 except Exception as exc: 

6374 outcome["errors"].append({"error": f"{type(exc).__name__}: {exc}"}) 

6375 logger.debug("EKS security group cleanup for %s failed: %s", stack_name, exc) 

6376 return outcome 

6377 

6378 def cleanup_cluster_volumes( 

6379 self, 

6380 stack_name: str, 

6381 *, 

6382 region: str | None = None, 

6383 retain: bool = False, 

6384 ) -> dict[str, Any]: 

6385 """Sweep one regional stack's orphaned CSI volumes; no-op for global stacks. 

6386 

6387 Entry point for the single-stack ``gco stacks destroy`` path, which has no 

6388 orchestrated cleanup barrier of its own. Global stacks host no cluster, so 

6389 they resolve to no work rather than a derived pseudo-Region. 

6390 """ 

6391 if stack_name.endswith(("-global", "-api-gateway", "-monitoring")): 

6392 return {"stack": stack_name, "skipped": "not-a-regional-stack"} 

6393 return self._cleanup_cluster_volumes(stack_name, region=region, retain=retain) 

6394 

6395 def _cleanup_cluster_volumes( 

6396 self, 

6397 stack_name: str, 

6398 *, 

6399 region: str | None = None, 

6400 retain: bool = False, 

6401 ) -> dict[str, Any]: 

6402 """Delete the EBS volumes a destroyed cluster's CSI driver left behind. 

6403 

6404 Deleting an EKS cluster does not delete the PersistentVolumes its EBS CSI 

6405 driver provisioned, so every deploy/destroy cycle strands ``available`` 

6406 volumes tagged ``kubernetes.io/cluster/<cluster>`` for a cluster that no 

6407 longer exists. Nothing can reattach them and they bill indefinitely (#268). 

6408 They carry the CSI driver's tags rather than the CDK ``Project`` tag, so no 

6409 project-scoped sweep or cost query can see them. 

6410 

6411 Deletion is the default because it honors intent already declared 

6412 elsewhere: the ``gco-observability-gp3`` StorageClass sets 

6413 ``reclaimPolicy: Delete``, and these volumes survive only because the 

6414 cluster is torn down before its PVCs are, so the CSI driver never receives 

6415 the delete event. ``retain=True`` reports them instead, and either way 

6416 every volume is named in the outcome — a silent leak is the actual bug. 

6417 

6418 Fail-closed on ordering: the sweep first proves the cluster is absent, so a 

6419 still-reconciling CSI driver is never raced. Ownership, ``available`` 

6420 state, and zero attachments are then rechecked immediately before each 

6421 delete rather than trusted from the discovery snapshot. One volume's 

6422 failure never stops the others, and nothing raises into the destroy flow. 

6423 """ 

6424 import boto3 

6425 

6426 project_name = self.config.project_name 

6427 region = region or stack_name.replace(f"{project_name}-", "", 1) 

6428 cluster_name = stack_name 

6429 cluster_tag = f"kubernetes.io/cluster/{cluster_name}" 

6430 outcome: dict[str, Any] = { 

6431 "stack": stack_name, 

6432 "region": region, 

6433 "cluster": cluster_name, 

6434 "retained": retain, 

6435 "inspected": 0, 

6436 "deleted": [], 

6437 "surviving": [], 

6438 "errors": [], 

6439 } 

6440 

6441 try: 

6442 # Ordering gate: a live cluster means its CSI driver may still be 

6443 # reconciling, so a detached volume can simply be between pod 

6444 # restarts. Only a proven-absent cluster makes these volumes garbage. 

6445 try: 

6446 boto3.client("eks", region_name=region).describe_cluster(name=cluster_name) 

6447 except ClientError as exc: 

6448 if exc.response.get("Error", {}).get("Code") != "ResourceNotFoundException": 

6449 raise 

6450 else: 

6451 outcome["cluster_present"] = True 

6452 logger.debug( 

6453 "Skipping volume cleanup for %s: cluster is still present", 

6454 cluster_name, 

6455 ) 

6456 return outcome 

6457 

6458 ec2 = boto3.client("ec2", region_name=region) 

6459 volumes: list[dict[str, Any]] = [] 

6460 for page in ec2.get_paginator("describe_volumes").paginate( 

6461 Filters=[ 

6462 {"Name": "tag-key", "Values": [cluster_tag]}, 

6463 {"Name": "status", "Values": ["available"]}, 

6464 ] 

6465 ): 

6466 volumes.extend(page.get("Volumes", [])) 

6467 

6468 for volume in volumes: 

6469 outcome["inspected"] += 1 

6470 volume_id = str(volume["VolumeId"]) 

6471 record = { 

6472 "volume_id": volume_id, 

6473 "size_gib": volume.get("Size"), 

6474 "volume_type": volume.get("VolumeType"), 

6475 "availability_zone": volume.get("AvailabilityZone"), 

6476 "pvc": _volume_pvc_name(volume), 

6477 } 

6478 if retain: 

6479 outcome["surviving"].append({**record, "reason": "retained-by-request"}) 

6480 continue 

6481 blocked = self._volume_delete_blocked(ec2, volume_id, cluster_tag=cluster_tag) 

6482 if blocked is not None: 

6483 outcome["surviving"].append({**record, "reason": blocked}) 

6484 logger.debug("Leaving volume %s in place: %s", volume_id, blocked) 

6485 continue 

6486 try: 

6487 ec2.delete_volume(VolumeId=volume_id) 

6488 outcome["deleted"].append(record) 

6489 except ClientError as exc: 

6490 if exc.response.get("Error", {}).get("Code") == "InvalidVolume.NotFound": 

6491 outcome["absent"] = True 

6492 continue 

6493 outcome["errors"].append( 

6494 {"volume_id": volume_id, "error": f"{type(exc).__name__}: {exc}"} 

6495 ) 

6496 except Exception as exc: 

6497 outcome["errors"].append( 

6498 {"volume_id": volume_id, "error": f"{type(exc).__name__}: {exc}"} 

6499 ) 

6500 except Exception as exc: 

6501 outcome["errors"].append({"error": f"{type(exc).__name__}: {exc}"}) 

6502 logger.debug("Cluster volume cleanup for %s failed: %s", stack_name, exc) 

6503 self._price_surviving_volumes(outcome) 

6504 _print_cluster_volume_outcome(outcome) 

6505 return outcome 

6506 

6507 @staticmethod 

6508 def _volume_storage_price_per_gib_month(region: str, volume_type: str) -> float | None: 

6509 """Return the current on-demand $/GiB-month for a volume type in a Region. 

6510 

6511 Priced at teardown time against the target Region rather than from a 

6512 constant in this file: EBS rates differ per Region and change over time, so 

6513 a checked-in number would quietly drift into misinforming the operator. 

6514 

6515 Returns ``None`` whenever the real rate cannot be established — no 

6516 credentials for ``pricing:GetProducts``, an unroutable endpoint, an 

6517 emulator that does not implement the Price List API, or an unrecognized 

6518 response shape. Callers must say the cost is unknown rather than 

6519 substitute a guess. Timeouts are short and retries few because this is a 

6520 cosmetic annotation on the teardown path and must never hold it up. 

6521 """ 

6522 try: 

6523 import boto3 

6524 from botocore.config import Config 

6525 

6526 # The Price List API is only offered in a few Regions; us-east-1 is 

6527 # the canonical endpoint and is what cli/capacity/checker.py uses. 

6528 # The Region being priced is a filter, not the endpoint. 

6529 pricing = boto3.client( 

6530 "pricing", 

6531 region_name="us-east-1", 

6532 config=Config( 

6533 connect_timeout=3, 

6534 read_timeout=5, 

6535 retries={"max_attempts": 2}, 

6536 ), 

6537 ) 

6538 response = pricing.get_products( 

6539 ServiceCode="AmazonEC2", 

6540 Filters=[ 

6541 {"Type": "TERM_MATCH", "Field": "productFamily", "Value": "Storage"}, 

6542 {"Type": "TERM_MATCH", "Field": "volumeApiName", "Value": volume_type}, 

6543 {"Type": "TERM_MATCH", "Field": "regionCode", "Value": region}, 

6544 ], 

6545 MaxResults=1, 

6546 ) 

6547 for entry in response.get("PriceList") or []: 

6548 product = json.loads(entry) 

6549 for term in (product.get("terms") or {}).get("OnDemand", {}).values(): 

6550 for dimension in (term.get("priceDimensions") or {}).values(): 

6551 usd = (dimension.get("pricePerUnit") or {}).get("USD") 

6552 if usd is not None: 

6553 return float(usd) 

6554 except Exception as exc: 

6555 logger.debug( 

6556 "Could not price %s storage in %s: %s", 

6557 volume_type, 

6558 region, 

6559 exc, 

6560 ) 

6561 return None 

6562 

6563 def _price_surviving_volumes(self, outcome: dict[str, Any]) -> None: 

6564 """Annotate an outcome with the monthly cost of the volumes left behind. 

6565 

6566 Sets ``monthly_cost_usd`` when every surviving volume's type could be 

6567 priced, and ``monthly_cost_unavailable`` with the reason otherwise. Only 

6568 runs when volumes actually survived, so a teardown that cleaned up 

6569 completely makes no pricing call at all. 

6570 """ 

6571 surviving = list(outcome.get("surviving") or []) 

6572 if not surviving: 

6573 return 

6574 region = str(outcome.get("region") or "") 

6575 rates: dict[str, float | None] = {} 

6576 for record in surviving: 

6577 volume_type = str(record.get("volume_type") or "") 

6578 if volume_type and volume_type not in rates: 

6579 rates[volume_type] = self._volume_storage_price_per_gib_month(region, volume_type) 

6580 

6581 unpriced = sorted({name for name, rate in rates.items() if rate is None}) 

6582 if not rates or unpriced: 

6583 outcome["monthly_cost_unavailable"] = ( 

6584 "could not retrieve current EBS pricing for " 

6585 + (", ".join(unpriced) if unpriced else "these volumes") 

6586 + f" in {region}" 

6587 ) 

6588 return 

6589 outcome["monthly_cost_usd"] = round( 

6590 sum( 

6591 int(record.get("size_gib") or 0) 

6592 * (rates.get(str(record.get("volume_type"))) or 0.0) 

6593 for record in surviving 

6594 ), 

6595 2, 

6596 ) 

6597 

6598 @staticmethod 

6599 def _volume_delete_blocked( 

6600 ec2: Any, 

6601 volume_id: str, 

6602 *, 

6603 cluster_tag: str, 

6604 ) -> str | None: 

6605 """Return why ``volume_id`` must not be deleted, or None when it may be. 

6606 

6607 Re-reads the volume immediately before deletion so a volume that was 

6608 reattached, or whose ownership tag changed, since discovery is left alone. 

6609 An unreadable volume is reported as blocked: acting without confirmation 

6610 is worse than leaving one volume behind for the operator to see. 

6611 """ 

6612 try: 

6613 described = ec2.describe_volumes(VolumeIds=[volume_id]).get("Volumes", []) 

6614 except ClientError as exc: 

6615 if exc.response.get("Error", {}).get("Code") == "InvalidVolume.NotFound": 

6616 return "already-absent" 

6617 return f"recheck-failed: {exc.response.get('Error', {}).get('Code') or 'ClientError'}" 

6618 except Exception as exc: 

6619 return f"recheck-failed: {type(exc).__name__}" 

6620 if len(described) != 1: 

6621 return "recheck-returned-ambiguous-identity" 

6622 current = described[0] 

6623 if str(current.get("VolumeId") or "") != volume_id: 

6624 return "recheck-returned-changed-identity" 

6625 if str(current.get("State") or "") != "available": 

6626 return f"state-is-{current.get('State') or 'unknown'}" 

6627 if current.get("Attachments"): 

6628 return "volume-has-attachments" 

6629 if not any(str(tag.get("Key") or "") == cluster_tag for tag in current.get("Tags") or []): 

6630 return "cluster-ownership-tag-absent" 

6631 return None 

6632 

6633 def _start_eks_sg_watchdog( 

6634 self, 

6635 stack_name: str, 

6636 stop_event: Event, 

6637 *, 

6638 region: str | None = None, 

6639 security_group_id: str | None = None, 

6640 vpc_id: str | None = None, 

6641 ) -> Thread: 

6642 """Start a background thread that polls for orphaned EKS security groups. 

6643 

6644 EKS creates an ``eks-cluster-sg-<cluster-name>-*`` security group that 

6645 is owned by the EKS service (not CloudFormation). The watchdog observes 

6646 it throughout regional teardown and removes it only after AWS has 

6647 released every attached ENI. Service-managed interfaces are never 

6648 detached or deleted by the CLI. 

6649 

6650 The thread exits when ``stop_event`` is set by the orchestrator at 

6651 the end of the regional phase. 

6652 """ 

6653 

6654 def _watchdog() -> None: 

6655 while not stop_event.is_set(): 

6656 try: 

6657 self._cleanup_eks_security_groups( 

6658 stack_name, 

6659 region=region, 

6660 security_group_id=security_group_id, 

6661 vpc_id=vpc_id, 

6662 ) 

6663 except Exception as e: 

6664 logger.debug( 

6665 "EKS SG watchdog tick for %s failed (non-fatal): %s", 

6666 stack_name, 

6667 e, 

6668 ) 

6669 # ``wait`` returns immediately when the event is set, so this 

6670 # doubles as the sleep-and-shutdown-check in one call. 

6671 stop_event.wait(timeout=30) 

6672 

6673 thread = Thread( 

6674 target=_watchdog, 

6675 name=f"eks-sg-watchdog-{stack_name}", 

6676 daemon=True, 

6677 ) 

6678 thread.start() 

6679 return thread 

6680 

6681 

6682def get_stack_manager(config: GCOConfig) -> StackManager: 

6683 """Factory function to get a StackManager instance.""" 

6684 return StackManager(config) 

6685 

6686 

6687def _volume_pvc_name(volume: Mapping[str, Any]) -> str | None: 

6688 """Return the PVC name the CSI driver recorded on a volume, when present.""" 

6689 for tag in volume.get("Tags") or []: 

6690 if str(tag.get("Key") or "") == "kubernetes.io/created-for/pvc/name": 

6691 return str(tag.get("Value") or "") or None 

6692 return None 

6693 

6694 

6695def _describe_volume_record(record: Mapping[str, Any]) -> str: 

6696 """Render one volume as ``vol-x (50 GiB gp3, us-west-2a, pvc=prometheus-db)``.""" 

6697 size = f"{record['size_gib']} GiB" if record.get("size_gib") else "unknown size" 

6698 if record.get("volume_type"): 

6699 size = f"{size} {record['volume_type']}" 

6700 parts = [size] 

6701 if record.get("availability_zone"): 

6702 parts.append(str(record["availability_zone"])) 

6703 if record.get("pvc"): 

6704 parts.append(f"pvc={record['pvc']}") 

6705 return f"{record['volume_id']} ({', '.join(parts)})" 

6706 

6707 

6708def _print_cluster_volume_outcome(outcome: Mapping[str, Any]) -> None: 

6709 """Report what a cluster-volume sweep deleted, left behind, or could not do. 

6710 

6711 Silent retention is the defect this feature exists to fix (#268), so every 

6712 surviving volume is named on stdout — not only the deleted ones. 

6713 """ 

6714 deleted = list(outcome.get("deleted") or []) 

6715 surviving = list(outcome.get("surviving") or []) 

6716 errors = list(outcome.get("errors") or []) 

6717 cluster = outcome.get("cluster") 

6718 

6719 if deleted: 

6720 total_gib = sum(int(record.get("size_gib") or 0) for record in deleted) 

6721 print( 

6722 f" Cleaned up {len(deleted)} orphaned EBS volume(s) " 

6723 f"({total_gib} GiB) left by cluster {cluster}:" 

6724 ) 

6725 for record in deleted: 

6726 print(f" - {_describe_volume_record(record)}") 

6727 

6728 if surviving: 

6729 total_gib = sum(int(record.get("size_gib") or 0) for record in surviving) 

6730 cost = outcome.get("monthly_cost_usd") 

6731 if cost is not None: 

6732 billing = f", ${cost:.2f}/month at current {outcome.get('region')} rates" 

6733 else: 

6734 billing = "" 

6735 print( 

6736 f" {len(surviving)} EBS volume(s) ({total_gib} GiB{billing}) from " 

6737 f"cluster {cluster} were left in place:" 

6738 ) 

6739 for record in surviving: 

6740 print(f" - {_describe_volume_record(record)} [{record.get('reason')}]") 

6741 if cost is None: 

6742 print(f" Ongoing cost: {outcome.get('monthly_cost_unavailable')}.") 

6743 print( 

6744 " Nothing can reattach these once the cluster is gone. Delete them " 

6745 "with: aws ec2 delete-volume --region " 

6746 f"{outcome.get('region')} --volume-id <vol-id>" 

6747 ) 

6748 

6749 for failure in errors: 

6750 target = failure.get("volume_id") or cluster 

6751 logger.warning("Could not dispose of EBS volume %s: %s", target, failure.get("error")) 

6752 print(f" Could not dispose of EBS volume {target}: {failure.get('error')}") 

6753 

6754 

6755def _is_regional_api_bridge_stack( 

6756 stack: str, 

6757 *, 

6758 project_name: str, 

6759 stack_names: Collection[str], 

6760) -> bool: 

6761 """Return whether ``stack`` is a configured per-Region API bridge. 

6762 

6763 A bare ``"-regional-api-"`` substring is ambiguous because it is valid 

6764 inside ``project_name``. Match the exact project-scoped bridge prefix and 

6765 require the corresponding configured ``<project>-<region>`` base stack. 

6766 """ 

6767 bridge_prefix = f"{project_name}-regional-api-" 

6768 if not stack.startswith(bridge_prefix): 

6769 return False 

6770 region = stack.removeprefix(bridge_prefix) 

6771 return bool(region) and f"{project_name}-{region}" in stack_names 

6772 

6773 

6774def _get_stack_destroy_phases( 

6775 stacks: list[str], 

6776 *, 

6777 project_name: str, 

6778) -> tuple[list[str], list[str], list[str], list[str]]: 

6779 """Classify and order the exact phases used by orchestrated destroy. 

6780 

6781 Returns monitoring, regional API bridge, base regional, and pre-regional 

6782 global phases. The public preview helper and the execution path both flatten 

6783 this result, so custom project names and bridge dependencies cannot drift. 

6784 """ 

6785 stack_names = set(stacks) 

6786 monitoring_stacks = sorted( 

6787 (stack for stack in stacks if stack.endswith("-monitoring")), 

6788 reverse=True, 

6789 ) 

6790 regional_api_stacks = sorted( 

6791 ( 

6792 stack 

6793 for stack in stacks 

6794 if _is_regional_api_bridge_stack( 

6795 stack, 

6796 project_name=project_name, 

6797 stack_names=stack_names, 

6798 ) 

6799 ), 

6800 reverse=True, 

6801 ) 

6802 regional_stacks = sorted( 

6803 ( 

6804 stack 

6805 for stack in stacks 

6806 if not stack.endswith(("-global", "-api-gateway", "-monitoring")) 

6807 and not _is_regional_api_bridge_stack( 

6808 stack, 

6809 project_name=project_name, 

6810 stack_names=stack_names, 

6811 ) 

6812 ), 

6813 reverse=True, 

6814 ) 

6815 pre_regional_stacks = sorted( 

6816 (stack for stack in stacks if stack.endswith(("-global", "-api-gateway"))), 

6817 key=lambda stack: ( 

6818 1 if stack.endswith("-api-gateway") else (2 if stack.endswith("-global") else 0) 

6819 ), 

6820 ) 

6821 return ( 

6822 monitoring_stacks, 

6823 regional_api_stacks, 

6824 regional_stacks, 

6825 pre_regional_stacks, 

6826 ) 

6827 

6828 

6829def get_stack_deployment_order( 

6830 stacks: list[str], 

6831 *, 

6832 project_name: str = "gco", 

6833) -> list[str]: 

6834 """ 

6835 Get the correct deployment order for stacks. 

6836 

6837 Order: global stacks first, then regional stacks. 

6838 Global stacks: <project>-global, <project>-api-gateway, 

6839 <project>-analytics, <project>-monitoring 

6840 Regional stacks: <project>-{region} (e.g., gco-us-east-1) 

6841 

6842 Named stacks are classified by suffix so ordering is independent of 

6843 ``project_name`` (#139): a non-``gco`` deployment (``acme-global`` …) 

6844 orders identically. Regional stacks are ``<project>-<region>`` and match 

6845 no named suffix, so they fall through to the regional bucket. 

6846 """ 

6847 stack_names = set(stacks) 

6848 global_stacks = [] 

6849 regional_stacks = [] 

6850 regional_api_stacks = [] 

6851 

6852 # Named (non-regional) stack priority by suffix (lower = deploy first). 

6853 suffix_priority = { 

6854 "-global": 1, 

6855 "-api-gateway": 2, 

6856 "-analytics": 2.5, 

6857 "-monitoring": 3, 

6858 } 

6859 

6860 def _named_priority(stack: str) -> float | None: 

6861 for suffix, prio in suffix_priority.items(): 

6862 if stack.endswith(suffix): 

6863 return prio 

6864 return None 

6865 

6866 for stack in stacks: 

6867 priority = _named_priority(stack) 

6868 if priority is not None: 

6869 global_stacks.append((priority, stack)) 

6870 elif _is_regional_api_bridge_stack( 

6871 stack, 

6872 project_name=project_name, 

6873 stack_names=stack_names, 

6874 ): 

6875 regional_api_stacks.append(stack) 

6876 else: 

6877 regional_stacks.append(stack) 

6878 

6879 # Keep bridge dependencies after every base regional stack. The 

6880 # orchestrated lifecycle further separates monitoring into its own phase. 

6881 global_stacks.sort(key=lambda x: x[0]) 

6882 regional_stacks.sort() 

6883 regional_api_stacks.sort() 

6884 

6885 return [s[1] for s in global_stacks] + regional_stacks + regional_api_stacks 

6886 

6887 

6888def get_stack_destroy_order( 

6889 stacks: list[str], 

6890 *, 

6891 project_name: str = "gco", 

6892 keep_control_plane: bool = False, 

6893) -> list[str]: 

6894 """Return the exact project-aware order used by orchestrated destroy. 

6895 

6896 With ``keep_control_plane`` only the workload tier is listed — the regional 

6897 API bridges followed by the base regional stacks — mirroring what 

6898 ``StackManager.destroy_orchestrated(keep_control_plane=True)`` deletes. 

6899 """ 

6900 monitoring, bridges, regional, pre_regional = _get_stack_destroy_phases( 

6901 stacks, project_name=project_name 

6902 ) 

6903 if keep_control_plane: 

6904 return [*bridges, *regional] 

6905 return [*monitoring, *bridges, *regional, *pre_regional] 

6906 

6907 

6908# ============================================================================= 

6909# Feature toggle helpers 

6910# ============================================================================= 

6911 

6912_FSX_DEFAULTS: dict[str, Any] = { 

6913 "enabled": False, 

6914 "storage_capacity_gib": 1200, 

6915 "deployment_type": "SCRATCH_2", 

6916 "per_unit_storage_throughput": 200, 

6917 "data_compression_type": "LZ4", 

6918 "import_path": None, 

6919 "export_path": None, 

6920 "auto_import_policy": "NEW_CHANGED_DELETED", 

6921} 

6922 

6923 

6924def _find_cdk_json() -> Path | None: 

6925 """Find cdk.json in current or parent directories.""" 

6926 current = Path.cwd() 

6927 for parent in [current, *current.parents]: 

6928 cdk_path = parent / "cdk.json" 

6929 if cdk_path.exists(): 

6930 return cdk_path 

6931 return None 

6932 

6933 

6934def get_fsx_config(region: str | None = None) -> dict[str, Any]: 

6935 """Get current FSx for Lustre configuration from cdk.json. 

6936 

6937 Args: 

6938 region: Optional region to get config for. If provided, checks for 

6939 region-specific overrides first. 

6940 

6941 Returns: 

6942 FSx configuration dictionary 

6943 """ 

6944 return _get_feature_config("fsx_lustre", _FSX_DEFAULTS, region) 

6945 

6946 

6947def update_fsx_config(settings: dict[str, Any], region: str | None = None) -> None: 

6948 """Update FSx for Lustre configuration in cdk.json. 

6949 

6950 Args: 

6951 settings: FSx settings to update 

6952 region: Optional region for region-specific config. If None, updates global config. 

6953 """ 

6954 _update_feature_config("fsx_lustre", settings, _FSX_DEFAULTS, region) 

6955 

6956 

6957# ============================================================================= 

6958# EKS cluster access configuration (endpoint mode + CIDR allowlist) 

6959# ============================================================================= 

6960 

6961_EKS_CLUSTER_DEFAULTS: dict[str, Any] = { 

6962 "endpoint_access": "PRIVATE", 

6963 "public_access_cidrs": [], 

6964 "developer_access": [], 

6965} 

6966 

6967 

6968def get_eks_cluster_config() -> dict[str, Any]: 

6969 """Get the current eks_cluster configuration from cdk.json. 

6970 

6971 Synth-time only: the values are read by ``gco/stacks/regional_stack.py`` 

6972 at the next deploy. There is no per-region override — the block applies 

6973 to every regional cluster. 

6974 """ 

6975 return _get_feature_config("eks_cluster", _EKS_CLUSTER_DEFAULTS, None) 

6976 

6977 

6978def update_eks_cluster_config(settings: dict[str, Any]) -> None: 

6979 """Update the eks_cluster configuration in cdk.json (config only).""" 

6980 _update_feature_config("eks_cluster", settings, _EKS_CLUSTER_DEFAULTS, None) 

6981 

6982 

6983# ============================================================================= 

6984# Generic feature toggle helpers (used by FSx, Valkey, Aurora, and future features) 

6985# ============================================================================= 

6986 

6987 

6988def _get_feature_config( 

6989 feature_key: str, 

6990 default_config: dict[str, Any], 

6991 region: str | None = None, 

6992) -> dict[str, Any]: 

6993 """Get configuration for a toggleable feature from cdk.json. 

6994 

6995 Args: 

6996 feature_key: The cdk.json context key (e.g. "valkey", "aurora_pgvector"). 

6997 default_config: Default configuration values when the key is missing. 

6998 region: Optional region for region-specific overrides. 

6999 

7000 Returns: 

7001 Merged configuration dictionary. 

7002 """ 

7003 cdk_json_path = _find_cdk_json() 

7004 if not cdk_json_path: 

7005 raise RuntimeError("cdk.json not found") 

7006 

7007 import json 

7008 

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

7010 cdk_config = json.load(f) 

7011 

7012 global_config = cdk_config.get("context", {}).get(feature_key, default_config) 

7013 

7014 if region: 

7015 region_key = f"{feature_key}_regions" 

7016 region_overrides = cdk_config.get("context", {}).get(region_key, {}) 

7017 if region in region_overrides: 

7018 merged = {**global_config, **region_overrides[region]} 

7019 merged["region"] = region 

7020 merged["is_region_specific"] = True 

7021 return merged 

7022 

7023 result = {**default_config, **global_config} 

7024 result["is_region_specific"] = False 

7025 return result 

7026 

7027 

7028def _update_feature_config( 

7029 feature_key: str, 

7030 settings: dict[str, Any], 

7031 default_config: dict[str, Any], 

7032 region: str | None = None, 

7033) -> None: 

7034 """Update configuration for a toggleable feature in cdk.json. 

7035 

7036 Args: 

7037 feature_key: The cdk.json context key (e.g. "valkey", "aurora_pgvector"). 

7038 settings: Settings to update. 

7039 default_config: Default configuration values when the key is missing. 

7040 region: Optional region for region-specific config. 

7041 """ 

7042 cdk_json_path = _find_cdk_json() 

7043 if not cdk_json_path: 

7044 raise RuntimeError("cdk.json not found") 

7045 

7046 import json 

7047 

7048 with _config_mutation_lock(cdk_json_path): 

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

7050 cdk_config = json.load(f) 

7051 

7052 if "context" not in cdk_config: 

7053 cdk_config["context"] = {} 

7054 

7055 if region: 

7056 region_key = f"{feature_key}_regions" 

7057 if region_key not in cdk_config["context"]: 

7058 cdk_config["context"][region_key] = {} 

7059 if region not in cdk_config["context"][region_key]: 

7060 cdk_config["context"][region_key][region] = {} 

7061 for key, value in settings.items(): 

7062 if value is not None or key == "enabled": 

7063 cdk_config["context"][region_key][region][key] = value 

7064 else: 

7065 if feature_key not in cdk_config["context"]: 

7066 cdk_config["context"][feature_key] = {**default_config} 

7067 for key, value in settings.items(): 

7068 if value is not None or key == "enabled": 

7069 cdk_config["context"][feature_key][key] = value 

7070 

7071 serialized = json.dumps(cdk_config, indent=2).encode("utf-8") 

7072 _atomic_write_bytes( 

7073 cdk_json_path, 

7074 serialized, 

7075 mode=stat.S_IMODE(cdk_json_path.stat().st_mode), 

7076 ) 

7077 

7078 

7079# ============================================================================= 

7080# Valkey configuration 

7081# ============================================================================= 

7082 

7083_VALKEY_DEFAULTS: dict[str, Any] = { 

7084 "enabled": False, 

7085 "max_data_storage_gb": 5, 

7086 "max_ecpu_per_second": 5000, 

7087 "snapshot_retention_limit": 1, 

7088} 

7089 

7090 

7091def get_valkey_config(region: str | None = None) -> dict[str, Any]: 

7092 """Get current Valkey Serverless configuration from cdk.json.""" 

7093 return _get_feature_config("valkey", _VALKEY_DEFAULTS, region) 

7094 

7095 

7096def update_valkey_config(settings: dict[str, Any], region: str | None = None) -> None: 

7097 """Update Valkey Serverless configuration in cdk.json.""" 

7098 _update_feature_config("valkey", settings, _VALKEY_DEFAULTS, region) 

7099 

7100 

7101# ============================================================================= 

7102# Aurora pgvector configuration 

7103# ============================================================================= 

7104 

7105_AURORA_DEFAULTS: dict[str, Any] = { 

7106 "enabled": False, 

7107 "min_acu": 0, 

7108 "max_acu": 16, 

7109 "backup_retention_days": 7, 

7110 "deletion_protection": False, 

7111} 

7112 

7113 

7114def get_aurora_config(region: str | None = None) -> dict[str, Any]: 

7115 """Get current Aurora pgvector configuration from cdk.json.""" 

7116 return _get_feature_config("aurora_pgvector", _AURORA_DEFAULTS, region) 

7117 

7118 

7119def update_aurora_config(settings: dict[str, Any], region: str | None = None) -> None: 

7120 """Update Aurora pgvector configuration in cdk.json.""" 

7121 _update_feature_config("aurora_pgvector", settings, _AURORA_DEFAULTS, region) 

7122 

7123 

7124# ============================================================================= 

7125# Analytics environment configuration 

7126# ============================================================================= 

7127 

7128_ANALYTICS_DEFAULTS: dict[str, Any] = { 

7129 "enabled": False, 

7130 "hyperpod": {"enabled": False}, 

7131 "canvas": {"enabled": False}, 

7132 "cognito": {"domain_prefix": None, "removal_policy": "destroy"}, 

7133 "efs": {"removal_policy": "destroy"}, 

7134 "studio": {"user_profile_name_prefix": None}, 

7135} 

7136 

7137 

7138def get_analytics_config() -> dict[str, Any]: 

7139 """Get the analytics environment configuration from cdk.json. 

7140 

7141 The analytics stack is single-region by construction (lives in the 

7142 api-gateway region), so this helper does not accept a region argument. 

7143 Returned dict is the defaults merged with any operator overrides from 

7144 the ``context.analytics_environment`` block. 

7145 """ 

7146 return _get_feature_config("analytics_environment", _ANALYTICS_DEFAULTS) 

7147 

7148 

7149def update_analytics_config(settings: dict[str, Any]) -> None: 

7150 """Update the analytics environment configuration in cdk.json. 

7151 

7152 Mirrors ``update_valkey_config`` / ``update_aurora_config``. Nested 

7153 keys under ``analytics_environment`` (``hyperpod``, ``canvas``, 

7154 ``cognito``, ``efs``, ``studio``) are merged one level deep rather 

7155 than replaced wholesale — ``enable --hyperpod`` must not clobber 

7156 ``cognito.removal_policy``. 

7157 """ 

7158 _update_feature_config("analytics_environment", settings, _ANALYTICS_DEFAULTS) 

7159 

7160 

7161# ============================================================================= 

7162# Cluster observability configuration 

7163# ============================================================================= 

7164 

7165# Mirrors the on-by-default cdk.json cluster_observability block. Unlike the 

7166# other feature toggles this one defaults to enabled=True: a stock deploy 

7167# installs kube-prometheus-stack on every regional cluster and operators opt 

7168# out. The CDK side reads/validates the same block via 

7169# ConfigLoader.get_cluster_observability_config. 

7170_CLUSTER_OBSERVABILITY_DEFAULTS: dict[str, Any] = { 

7171 "enabled": True, 

7172 "grafana": { 

7173 "persistence_size": "10Gi", 

7174 "admin_user": "admin", 

7175 "admin_password_rotation_schedule": "0 4 1 * *", # nosec B105 # cron schedule, not a password 

7176 }, 

7177 "prometheus": {"persistence_size": "50Gi", "retention": "15d"}, 

7178 "alertmanager": {"enabled": True, "persistence_size": "5Gi"}, 

7179} 

7180 

7181 

7182def get_cluster_observability_config() -> dict[str, Any]: 

7183 """Get the cluster observability configuration from cdk.json. 

7184 

7185 Observability is per-region (installed on every regional cluster) but the 

7186 toggle itself is global, so this takes no region argument. Returns the 

7187 defaults merged with any operator overrides from the 

7188 ``context.cluster_observability`` block. 

7189 """ 

7190 return _get_feature_config("cluster_observability", _CLUSTER_OBSERVABILITY_DEFAULTS) 

7191 

7192 

7193def update_cluster_observability_config(settings: dict[str, Any]) -> None: 

7194 """Update the cluster observability toggle in cdk.json. 

7195 

7196 ``gco monitoring enable`` / ``disable`` pass ``{"enabled": True/False}``; 

7197 the grafana/prometheus/alertmanager sub-blocks are left untouched so an 

7198 operator's sizing/retention/rotation overrides survive a disable/enable 

7199 cycle. 

7200 """ 

7201 _update_feature_config("cluster_observability", settings, _CLUSTER_OBSERVABILITY_DEFAULTS)