Coverage for cli / commands / analytics_cmd.py: 100.00%

332 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""GCO analytics environment command group. 

2 

3Provides the ``gco analytics`` sub-commands: 

4 

5* ``enable`` / ``disable`` / ``status`` — flip the 

6 ``analytics_environment.enabled`` toggle in ``cdk.json``. 

7* ``users add`` / ``users list`` / ``users remove`` — manage Cognito 

8 users against the auto-discovered pool id from ``gco-analytics``. 

9* ``studio login`` — SRP-authenticate against Cognito and fetch a 

10 SageMaker Studio presigned URL from ``/studio/login`` on the 

11 existing ``gco-api-gateway``. 

12* ``doctor`` — pre-flight checks before ``gco stacks deploy 

13 gco-analytics``. 

14 

15The Click wiring mirrors ``stacks_cmd.py::fsx_cmd`` exactly. Every 

16command delegates to helpers in :mod:`cli.analytics_user_mgmt` so the 

17command layer stays thin and testable via ``click.testing.CliRunner``. 

18""" 

19 

20from __future__ import annotations 

21 

22import json 

23import os 

24import sys 

25import urllib.error 

26from typing import Any 

27 

28import click 

29 

30from ..config import GCOConfig 

31from ..output import confirm, emit_structured_document, get_output_formatter, prompt 

32 

33pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

34 

35 

36def _stack_missing_message(project_name: str) -> str: 

37 """Error text when the analytics stack isn't deployed (#139 project-scoped).""" 

38 stack = f"{project_name}-analytics" 

39 return ( 

40 f"{stack} stack not deployed — run `gco analytics enable` then `gco stacks deploy {stack}`" 

41 ) 

42 

43 

44@click.group() 

45@pass_config 

46def analytics(config: Any) -> None: 

47 """Manage the GCO analytics (SageMaker Studio + EMR) environment.""" 

48 

49 

50# --------------------------------------------------------------------------- 

51# Toggle commands — enable / disable / status 

52# --------------------------------------------------------------------------- 

53 

54 

55@analytics.command("status") 

56@pass_config 

57def analytics_status(config: Any) -> None: 

58 """Show the current analytics environment toggle state from cdk.json.""" 

59 from ..stacks import get_analytics_config 

60 

61 formatter = get_output_formatter(config) 

62 try: 

63 current = get_analytics_config() 

64 formatter.print_info("Analytics environment config:") 

65 formatter.print(current) 

66 except Exception as exc: # noqa: BLE001 — surface every loader error 

67 formatter.print_error(f"Failed to read analytics config: {exc}") 

68 sys.exit(1) 

69 

70 

71@analytics.command("enable") 

72@click.option("--hyperpod", is_flag=True, help="Also enable SageMaker HyperPod job submission.") 

73@click.option( 

74 "--canvas", 

75 is_flag=True, 

76 help="Also enable the SageMaker Canvas no-code ML app.", 

77) 

78@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.") 

79@pass_config 

80def analytics_enable(config: Any, hyperpod: bool, canvas: bool, yes: bool) -> None: 

81 """Enable the analytics environment in cdk.json. 

82 

83 Flips ``analytics_environment.enabled`` to ``true``; ``--hyperpod`` 

84 additionally flips ``analytics_environment.hyperpod.enabled``, and 

85 ``--canvas`` flips ``analytics_environment.canvas.enabled`` (which 

86 attaches ``AmazonSageMakerCanvasFullAccess`` to the SageMaker 

87 execution role). Prints the follow-up ``gco stacks deploy 

88 gco-analytics`` command — does not deploy automatically. 

89 """ 

90 from ..stacks import get_analytics_config, update_analytics_config 

91 

92 formatter = get_output_formatter(config) 

93 

94 if not yes: 

95 formatter.print_info("Analytics environment will be enabled in cdk.json.") 

96 if hyperpod: 

97 formatter.print_info(" Hyperpod sub-toggle will also be enabled.") 

98 if canvas: 

99 formatter.print_info(" Canvas sub-toggle will also be enabled.") 

100 confirm("\nEnable the analytics environment?", abort=True) 

101 

102 try: 

103 current = get_analytics_config() 

104 # Preserve everything the operator has set under ``hyperpod`` / 

105 # ``canvas`` — the underlying helper replaces nested blocks 

106 # wholesale, so we rebuild each sub-dict with only the field we own. 

107 hyperpod_block = dict(current.get("hyperpod") or {}) 

108 if hyperpod: 

109 hyperpod_block["enabled"] = True 

110 hyperpod_block.setdefault("enabled", False) 

111 

112 canvas_block = dict(current.get("canvas") or {}) 

113 if canvas: 

114 canvas_block["enabled"] = True 

115 canvas_block.setdefault("enabled", False) 

116 

117 update_analytics_config( 

118 { 

119 "enabled": True, 

120 "hyperpod": hyperpod_block, 

121 "canvas": canvas_block, 

122 } 

123 ) 

124 formatter.print_success("Analytics environment enabled in cdk.json") 

125 formatter.print_info( 

126 f"Run `gco stacks deploy {config.project_name}-analytics` to apply changes" 

127 ) 

128 except Exception as exc: # noqa: BLE001 — user-facing error from file I/O 

129 formatter.print_error(f"Failed to enable analytics environment: {exc}") 

130 sys.exit(1) 

131 

132 

133@analytics.command("disable") 

134@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.") 

135@pass_config 

136def analytics_disable(config: Any, yes: bool) -> None: 

137 """Disable the analytics environment in cdk.json. 

138 

139 Only flips ``analytics_environment.enabled`` to ``false``; the 

140 ``hyperpod`` / ``canvas`` / ``cognito`` / ``efs`` sub-blocks are 

141 left untouched so the operator's existing preferences survive a 

142 disable/enable cycle. 

143 """ 

144 from ..stacks import update_analytics_config 

145 

146 formatter = get_output_formatter(config) 

147 

148 if not yes: 

149 formatter.print_warning("This will disable the analytics environment.") 

150 formatter.print_warning( 

151 "Existing SageMaker Studio / Cognito / EMR resources will be destroyed on next deploy." 

152 ) 

153 confirm("Are you sure?", abort=True) 

154 

155 try: 

156 update_analytics_config({"enabled": False}) 

157 formatter.print_success("Analytics environment disabled in cdk.json") 

158 formatter.print_info( 

159 f"Run `gco stacks destroy {config.project_name}-analytics` to tear down resources" 

160 ) 

161 except Exception as exc: # noqa: BLE001 — user-facing error from file I/O 

162 formatter.print_error(f"Failed to disable analytics environment: {exc}") 

163 sys.exit(1) 

164 

165 

166# --------------------------------------------------------------------------- 

167# Users subgroup 

168# --------------------------------------------------------------------------- 

169 

170 

171@analytics.group("users") 

172@pass_config 

173def users_cmd(config: Any) -> None: 

174 """Manage Cognito users who can sign in to SageMaker Studio.""" 

175 

176 

177def _require_cognito_pool_id(config: Any) -> tuple[str, str]: 

178 """Return ``(pool_id, region)`` or exit with the documented error message.""" 

179 from ..analytics_user_mgmt import discover_cognito_pool_id 

180 

181 formatter = get_output_formatter(config) 

182 region = config.api_gateway_region 

183 pool_id = discover_cognito_pool_id(region, config.project_name) 

184 if not pool_id: 

185 formatter.print_error(_stack_missing_message(config.project_name)) 

186 sys.exit(1) 

187 return pool_id, region 

188 

189 

190@users_cmd.command("add") 

191@click.option("--username", required=True, help="Cognito username to create.") 

192@click.option("--email", help="Email address for the new user (optional).") 

193@click.option( 

194 "--no-email", 

195 is_flag=True, 

196 help="Suppress the Cognito welcome email (MessageAction=SUPPRESS).", 

197) 

198@click.option( 

199 "--password", 

200 envvar="GCO_STUDIO_PASSWORD", 

201 help=( 

202 "Set a permanent password via admin_set_user_password (also read " 

203 "from $GCO_STUDIO_PASSWORD). Mutually exclusive with --generate-password." 

204 ), 

205) 

206@click.option( 

207 "--generate-password", 

208 is_flag=True, 

209 help=( 

210 "Generate a strong random password, set it as permanent via " 

211 "admin_set_user_password, and print it once. Mutually exclusive " 

212 "with --password." 

213 ), 

214) 

215@pass_config 

216def users_add( 

217 config: Any, 

218 username: str, 

219 email: str | None, 

220 no_email: bool, 

221 password: str | None, 

222 generate_password: bool, 

223) -> None: 

224 """Create a Cognito user and print the temporary password exactly once. 

225 

226 When ``--password`` or ``--generate-password`` is passed, the user is 

227 created and then has a permanent password set via 

228 ``admin_set_user_password`` — this skips the ``NEW_PASSWORD_REQUIRED`` 

229 challenge on first login, so the resulting credentials work directly 

230 with ``gco analytics studio login``. 

231 """ 

232 from botocore.exceptions import ClientError 

233 

234 from ..analytics_user_mgmt import ( 

235 admin_create_user, 

236 admin_set_user_password, 

237 generate_strong_password, 

238 ) 

239 

240 formatter = get_output_formatter(config) 

241 

242 if password and generate_password: 

243 formatter.print_error("--password and --generate-password are mutually exclusive") 

244 sys.exit(1) 

245 

246 pool_id, region = _require_cognito_pool_id(config) 

247 

248 try: 

249 _, temporary_password = admin_create_user( 

250 pool_id=pool_id, 

251 region=region, 

252 username=username, 

253 email=email, 

254 suppress_email=no_email, 

255 ) 

256 except ClientError as exc: 

257 error_code = exc.response.get("Error", {}).get("Code", "Unknown") 

258 formatter.print_error(f"Failed to create user {username}: {error_code}") 

259 sys.exit(1) 

260 

261 if config.output_format == "table": 

262 formatter.print_success(f"Created Cognito user: {username}") 

263 

264 # Password path — explicit or generated — takes precedence over the 

265 # temporary-password path so the resulting credentials don't get 

266 # blocked by NEW_PASSWORD_REQUIRED on first sign-in. 

267 if password or generate_password: 

268 final_password = password or generate_strong_password() 

269 try: 

270 admin_set_user_password( 

271 pool_id=pool_id, 

272 region=region, 

273 username=username, 

274 password=final_password, 

275 permanent=True, 

276 ) 

277 except ClientError as exc: 

278 error_code = exc.response.get("Error", {}).get("Code", "Unknown") 

279 formatter.print_error( 

280 f"User {username} created, but setting the password " 

281 f"failed: {error_code}. Retry with " 

282 "`aws cognito-idp admin-set-user-password --permanent`." 

283 ) 

284 sys.exit(1) 

285 

286 if config.output_format == "table": 

287 if generate_password: 

288 formatter.print_info(f"Generated password (printed exactly once): {final_password}") 

289 else: 

290 formatter.print_info(f"Password set (permanent) for {username}") 

291 else: 

292 result: dict[str, Any] = { 

293 "created": True, 

294 "username": username, 

295 "email": email, 

296 "user_pool_id": pool_id, 

297 "region": region, 

298 "password_state": "permanent", 

299 "password_generated": generate_password, 

300 "password_source": "generated" if generate_password else "provided", 

301 "password_permanent": True, 

302 } 

303 if generate_password: 

304 result["password"] = final_password 

305 formatter.print(result) 

306 return 

307 

308 if config.output_format == "table": 

309 if temporary_password: 

310 formatter.print_info(f"Temporary password (printed exactly once): {temporary_password}") 

311 else: 

312 formatter.print_info( 

313 "Cognito did not return a temporary password. " 

314 "If --no-email was passed, set one via " 

315 "`aws cognito-idp admin-set-user-password` " 

316 "or re-run `gco analytics users add` with --password or --generate-password." 

317 ) 

318 else: 

319 result = { 

320 "created": True, 

321 "username": username, 

322 "email": email, 

323 "user_pool_id": pool_id, 

324 "region": region, 

325 "password_state": "temporary" if temporary_password else "not_returned", 

326 "password_generated": False, 

327 "password_source": "cognito" if temporary_password else "unavailable", 

328 "password_permanent": False if temporary_password else None, 

329 } 

330 if temporary_password: 

331 result["password"] = temporary_password 

332 formatter.print(result) 

333 

334 

335@users_cmd.command("list") 

336@click.option("--as-json", "as_json", is_flag=True, help="Emit JSON instead of a table.") 

337@pass_config 

338def users_list(config: Any, as_json: bool) -> None: 

339 """List Cognito users in the analytics user pool.""" 

340 from botocore.exceptions import ClientError 

341 

342 from ..analytics_user_mgmt import list_users as _list_users 

343 

344 formatter = get_output_formatter(config) 

345 pool_id, region = _require_cognito_pool_id(config) 

346 

347 try: 

348 users = _list_users(pool_id, region) 

349 except ClientError as exc: 

350 error_code = exc.response.get("Error", {}).get("Code", "Unknown") 

351 formatter.print_error(f"Failed to list users: {error_code}") 

352 sys.exit(1) 

353 

354 if as_json: 

355 emit_structured_document( 

356 users, 

357 output_format="json", 

358 rendered=json.dumps(users, indent=2), 

359 ) 

360 return 

361 formatter.print(users) 

362 

363 

364@users_cmd.command("remove") 

365@click.option("--username", required=True, help="Cognito username to remove.") 

366@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.") 

367@pass_config 

368def users_remove(config: Any, username: str, yes: bool) -> None: 

369 """Delete a Cognito user from the analytics user pool.""" 

370 from botocore.exceptions import ClientError 

371 

372 from ..analytics_user_mgmt import admin_delete_user 

373 

374 formatter = get_output_formatter(config) 

375 pool_id, region = _require_cognito_pool_id(config) 

376 

377 if not yes: 

378 confirm(f"Delete Cognito user '{username}'?", abort=True) 

379 

380 try: 

381 admin_delete_user(pool_id, region, username) 

382 except ClientError as exc: 

383 error_code = exc.response.get("Error", {}).get("Code", "Unknown") 

384 formatter.print_error(f"Failed to delete user {username}: {error_code}") 

385 sys.exit(1) 

386 

387 formatter.print_success(f"Deleted Cognito user: {username}") 

388 

389 

390@users_cmd.command("set-password") 

391@click.option("--username", required=True, help="Cognito username whose password to change.") 

392@click.option( 

393 "--password", 

394 envvar="GCO_STUDIO_PASSWORD", 

395 help=( 

396 "New password (also read from $GCO_STUDIO_PASSWORD; prompted " 

397 "otherwise). Mutually exclusive with --generate-password." 

398 ), 

399) 

400@click.option( 

401 "--generate-password", 

402 is_flag=True, 

403 help=( 

404 "Generate a strong random password, set it, and print it once. " 

405 "Mutually exclusive with --password." 

406 ), 

407) 

408@click.option( 

409 "--temporary", 

410 is_flag=True, 

411 help=( 

412 "Set the password as temporary so the user is forced to change " 

413 "it on first login (Permanent=false). Default is permanent." 

414 ), 

415) 

416@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.") 

417@pass_config 

418def users_set_password( 

419 config: Any, 

420 username: str, 

421 password: str | None, 

422 generate_password: bool, 

423 temporary: bool, 

424 yes: bool, 

425) -> None: 

426 """Change a Cognito user's password via AdminSetUserPassword. 

427 

428 By default the new password is marked ``Permanent=true`` so the 

429 user can sign in directly with ``gco analytics studio login`` 

430 without the ``NEW_PASSWORD_REQUIRED`` challenge. Pass 

431 ``--temporary`` to require the user to choose their own password 

432 on first sign-in. 

433 """ 

434 from botocore.exceptions import ClientError 

435 

436 from ..analytics_user_mgmt import admin_set_user_password, generate_strong_password 

437 

438 formatter = get_output_formatter(config) 

439 

440 if password and generate_password: 

441 formatter.print_error("--password and --generate-password are mutually exclusive") 

442 sys.exit(1) 

443 

444 pool_id, region = _require_cognito_pool_id(config) 

445 

446 if generate_password: 

447 new_password = generate_strong_password() 

448 elif password is not None: 

449 new_password = password 

450 else: 

451 prompt_kwargs: dict[str, Any] = { 

452 "hide_input": True, 

453 "confirmation_prompt": True, 

454 } 

455 if config.output_format != "table": 

456 prompt_kwargs["err"] = True 

457 new_password = prompt("New password", **prompt_kwargs) 

458 

459 if not yes: 

460 qualifier = "temporary" if temporary else "permanent" 

461 confirm_kwargs: dict[str, Any] = {"abort": True} 

462 if config.output_format != "table": 

463 confirm_kwargs["err"] = True 

464 confirm( 

465 f"Set a new {qualifier} password for Cognito user '{username}'?", 

466 **confirm_kwargs, 

467 ) 

468 

469 try: 

470 admin_set_user_password( 

471 pool_id=pool_id, 

472 region=region, 

473 username=username, 

474 password=new_password, 

475 permanent=not temporary, 

476 ) 

477 except ClientError as exc: 

478 error_code = exc.response.get("Error", {}).get("Code", "Unknown") 

479 formatter.print_error(f"Failed to set password for {username}: {error_code}") 

480 sys.exit(1) 

481 

482 qualifier = "temporary" if temporary else "permanent" 

483 if config.output_format == "table": 

484 formatter.print_success(f"Password set ({qualifier}) for {username}") 

485 if generate_password: 

486 formatter.print_info(f"Generated password (printed exactly once): {new_password}") 

487 else: 

488 result: dict[str, Any] = { 

489 "password_set": True, 

490 "username": username, 

491 "user_pool_id": pool_id, 

492 "region": region, 

493 "password_state": qualifier, 

494 "password_generated": generate_password, 

495 "password_source": "generated" if generate_password else "provided", 

496 "password_permanent": not temporary, 

497 } 

498 if generate_password: 

499 result["password"] = new_password 

500 formatter.print(result) 

501 

502 

503# --------------------------------------------------------------------------- 

504# Studio login subgroup 

505# --------------------------------------------------------------------------- 

506 

507 

508@analytics.group("studio") 

509@pass_config 

510def studio_cmd(config: Any) -> None: 

511 """SageMaker Studio helpers (login, etc.).""" 

512 

513 

514@studio_cmd.command("login") 

515@click.option("--username", required=True, help="Cognito username to sign in with.") 

516@click.option( 

517 "--password", 

518 envvar="GCO_STUDIO_PASSWORD", 

519 help="Password (also read from $GCO_STUDIO_PASSWORD; prompted otherwise).", 

520) 

521@click.option("--api-url", help="Override the API Gateway base URL (otherwise auto-discovered).") 

522@click.option("--open", "open_browser", is_flag=True, help="Open the URL in the default browser.") 

523@pass_config 

524def studio_login( 

525 config: Any, 

526 username: str, 

527 password: str | None, 

528 api_url: str | None, 

529 open_browser: bool, 

530) -> None: 

531 """Sign in to SageMaker Studio via Cognito SRP and print the presigned URL.""" 

532 from botocore.exceptions import ClientError 

533 

534 from ..analytics_user_mgmt import ( 

535 discover_api_endpoint, 

536 discover_cognito_client_id, 

537 discover_cognito_pool_id, 

538 fetch_studio_url, 

539 srp_authenticate, 

540 ) 

541 

542 formatter = get_output_formatter(config) 

543 region = config.api_gateway_region 

544 project_name = config.project_name 

545 

546 pool_id = discover_cognito_pool_id(region, project_name) 

547 client_id = discover_cognito_client_id(region, project_name) 

548 if not pool_id or not client_id: 

549 formatter.print_error(_stack_missing_message(config.project_name)) 

550 sys.exit(1) 

551 

552 api_base = ( 

553 api_url 

554 or discover_api_endpoint(region, project_name) 

555 or os.environ.get("GCO_API_GATEWAY_URL") 

556 ) 

557 if not api_base: 

558 formatter.print_error( 

559 "Could not resolve API Gateway endpoint — pass --api-url or deploy gco-api-gateway." 

560 ) 

561 sys.exit(1) 

562 

563 if password is None: 

564 password = prompt("Password", hide_input=True) 

565 

566 try: 

567 tokens = srp_authenticate( 

568 pool_id=pool_id, 

569 client_id=client_id, 

570 username=username, 

571 password=password, 

572 region=region, 

573 ) 

574 except ClientError as exc: 

575 error_code = exc.response.get("Error", {}).get("Code", "Unknown") 

576 formatter.print_error(f"Cognito authentication failed: {error_code}") 

577 sys.exit(1) 

578 

579 id_token = tokens.get("IdToken") 

580 if not id_token: 

581 formatter.print_error("Cognito authentication failed: no IdToken returned") 

582 sys.exit(1) 

583 

584 try: 

585 # Poll until the Lambda returns HTTP 200 with the presigned URL. 

586 # First-time logins trigger user-profile provisioning (30-60s); 

587 # the Lambda returns HTTP 202 while the profile is pending. 

588 import time as _time 

589 

590 max_wait = 120 # seconds 

591 poll_interval = 5 # seconds 

592 elapsed = 0 

593 url = "" 

594 expires_in = 0 

595 

596 while elapsed < max_wait: 

597 url, expires_in, _ = fetch_studio_url(api_base, id_token) 

598 if url: 

599 break 

600 # 202 -- profile still provisioning. 

601 if elapsed == 0: 

602 click.echo(" Waiting for user profile to provision...", nl=False) 

603 click.echo(".", nl=False) 

604 _time.sleep(poll_interval) 

605 elapsed += poll_interval 

606 

607 if elapsed > 0 and url: 

608 click.echo(" ready") 

609 elif not url: 

610 click.echo("") 

611 formatter.print_error( 

612 f"User profile did not become ready within {max_wait}s. Try again in a minute." 

613 ) 

614 sys.exit(2) 

615 except urllib.error.HTTPError as exc: 

616 correlation_id = exc.headers.get("x-amzn-RequestId") if exc.headers else "N/A" 

617 formatter.print_error( 

618 f"login failed: HTTP {exc.code}, correlation_id={correlation_id or 'N/A'}" 

619 ) 

620 sys.exit(2) 

621 except urllib.error.URLError as exc: 

622 formatter.print_error(f"login failed: network error: {exc.reason!r}") 

623 sys.exit(2) 

624 except ValueError as exc: 

625 formatter.print_error(f"login failed: {exc}") 

626 sys.exit(2) 

627 

628 # Print the URL on its own line for pipe-friendliness. 

629 click.echo(url) 

630 if open_browser: 

631 click.launch(url) 

632 

633 

634# --------------------------------------------------------------------------- 

635# Doctor subcommand 

636# --------------------------------------------------------------------------- 

637 

638 

639@analytics.command("doctor") 

640@pass_config 

641def analytics_doctor(config: Any) -> None: 

642 """Run pre-flight checks before `gco stacks deploy gco-analytics`. 

643 

644 Exits non-zero on any failing check. Each check prints ``✓``/``✗`` 

645 plus a short remediation line so the operator knows exactly what 

646 to fix. 

647 """ 

648 from ..analytics_user_mgmt import ( 

649 check_ssm_parameter, 

650 check_stack_complete, 

651 scan_orphan_analytics_resources, 

652 ) 

653 from ..config import _load_cdk_json 

654 from ..stacks import _find_cdk_json 

655 

656 formatter = get_output_formatter(config) 

657 any_failed = False 

658 

659 def _emit(name: str, ok: bool, remediation: str) -> None: 

660 nonlocal any_failed 

661 if ok: 

662 click.echo(f"{name}") 

663 else: 

664 any_failed = True 

665 click.echo(f"{name}") 

666 if remediation: 

667 click.echo(f"{remediation}") 

668 

669 # 1. cdk.json parses 

670 cdk_json_path = _find_cdk_json() 

671 if cdk_json_path is None: 

672 _emit( 

673 "cdk.json present", 

674 False, 

675 "run `gco analytics doctor` from the project root (cdk.json not found).", 

676 ) 

677 else: 

678 try: 

679 with open(cdk_json_path, encoding="utf-8") as fh: 

680 json.load(fh) 

681 _emit("cdk.json parses as JSON", True, "") 

682 except json.JSONDecodeError as exc: 

683 _emit( 

684 "cdk.json parses as JSON", 

685 False, 

686 f"fix malformed JSON at {cdk_json_path}: {exc.msg} (line {exc.lineno})", 

687 ) 

688 

689 # 2. Prerequisite stacks healthy 

690 for region, stack_name in ( 

691 (config.global_region, f"{config.project_name}-global"), 

692 (config.api_gateway_region, f"{config.project_name}-api-gateway"), 

693 ): 

694 ok, remediation = check_stack_complete(region, stack_name) 

695 _emit( 

696 f"{stack_name} is CREATE_COMPLETE", 

697 ok, 

698 remediation or f"deploy with `gco stacks deploy {stack_name}`", 

699 ) 

700 

701 cdk_regions = _load_cdk_json() 

702 regional_regions = cdk_regions.get("regional", []) if isinstance(cdk_regions, dict) else [] 

703 for region in regional_regions: 

704 stack_name = f"{config.project_name}-{region}" 

705 ok, remediation = check_stack_complete(region, stack_name) 

706 _emit( 

707 f"{stack_name} is CREATE_COMPLETE", 

708 ok, 

709 remediation or f"deploy with `gco stacks deploy {stack_name}`", 

710 ) 

711 

712 # 3. SSM cluster-shared-bucket parameters exist 

713 from gco.stacks.constants import cluster_shared_ssm_parameter_prefix 

714 

715 ssm_prefix = cluster_shared_ssm_parameter_prefix(config.project_name) 

716 for suffix in ("name", "arn", "region"): 

717 param = f"{ssm_prefix}/{suffix}" 

718 ok, remediation = check_ssm_parameter(config.global_region, param) 

719 _emit( 

720 f"SSM parameter {param} exists", 

721 ok, 

722 remediation and f"deploy {config.project_name}-global first ({remediation})", 

723 ) 

724 

725 # 4. No orphaned retained analytics resources 

726 orphan_cmds = scan_orphan_analytics_resources(config.api_gateway_region) 

727 _emit( 

728 "no orphaned retained analytics resources", 

729 not orphan_cmds, 

730 "; ".join(orphan_cmds) if orphan_cmds else "", 

731 ) 

732 

733 if any_failed: 

734 formatter.print_error("Doctor checks failed — see remediation lines above.") 

735 sys.exit(1) 

736 formatter.print_success("All pre-flight checks passed.")