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

310 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +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 get_output_formatter 

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: 96 ↛ 98line 96 didn't jump to line 98 because the condition on line 96 was always true

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

98 if canvas: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

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

100 click.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 click.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 formatter.print_success(f"Created Cognito user: {username}") 

262 

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

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

265 # blocked by NEW_PASSWORD_REQUIRED on first sign-in. 

266 if password or generate_password: 

267 final_password = password or generate_strong_password() 

268 try: 

269 admin_set_user_password( 

270 pool_id=pool_id, 

271 region=region, 

272 username=username, 

273 password=final_password, 

274 permanent=True, 

275 ) 

276 except ClientError as exc: 

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

278 formatter.print_error( 

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

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

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

282 ) 

283 sys.exit(1) 

284 

285 if generate_password: 

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

287 else: 

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

289 return 

290 

291 if temporary_password: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true

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

293 else: 

294 formatter.print_info( 

295 "Cognito did not return a temporary password. " 

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

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

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

299 ) 

300 

301 

302@users_cmd.command("list") 

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

304@pass_config 

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

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

307 from botocore.exceptions import ClientError 

308 

309 from ..analytics_user_mgmt import list_users as _list_users 

310 

311 formatter = get_output_formatter(config) 

312 pool_id, region = _require_cognito_pool_id(config) 

313 

314 try: 

315 users = _list_users(pool_id, region) 

316 except ClientError as exc: 

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

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

319 sys.exit(1) 

320 

321 if as_json: 

322 print(json.dumps(users, indent=2)) 

323 return 

324 formatter.print(users) 

325 

326 

327@users_cmd.command("remove") 

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

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

330@pass_config 

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

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

333 from botocore.exceptions import ClientError 

334 

335 from ..analytics_user_mgmt import admin_delete_user 

336 

337 formatter = get_output_formatter(config) 

338 pool_id, region = _require_cognito_pool_id(config) 

339 

340 if not yes: 

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

342 

343 try: 

344 admin_delete_user(pool_id, region, username) 

345 except ClientError as exc: 

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

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

348 sys.exit(1) 

349 

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

351 

352 

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

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

355@click.option( 

356 "--password", 

357 envvar="GCO_STUDIO_PASSWORD", 

358 help=( 

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

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

361 ), 

362) 

363@click.option( 

364 "--generate-password", 

365 is_flag=True, 

366 help=( 

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

368 "Mutually exclusive with --password." 

369 ), 

370) 

371@click.option( 

372 "--temporary", 

373 is_flag=True, 

374 help=( 

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

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

377 ), 

378) 

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

380@pass_config 

381def users_set_password( 

382 config: Any, 

383 username: str, 

384 password: str | None, 

385 generate_password: bool, 

386 temporary: bool, 

387 yes: bool, 

388) -> None: 

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

390 

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

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

393 without the ``NEW_PASSWORD_REQUIRED`` challenge. Pass 

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

395 on first sign-in. 

396 """ 

397 from botocore.exceptions import ClientError 

398 

399 from ..analytics_user_mgmt import admin_set_user_password, generate_strong_password 

400 

401 formatter = get_output_formatter(config) 

402 

403 if password and generate_password: 

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

405 sys.exit(1) 

406 

407 pool_id, region = _require_cognito_pool_id(config) 

408 

409 if generate_password: 

410 new_password = generate_strong_password() 

411 elif password is not None: 411 ↛ 414line 411 didn't jump to line 414 because the condition on line 411 was always true

412 new_password = password 

413 else: 

414 new_password = click.prompt( 

415 "New password", 

416 hide_input=True, 

417 confirmation_prompt=True, 

418 ) 

419 

420 if not yes: 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true

421 qualifier = "temporary" if temporary else "permanent" 

422 click.confirm( 

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

424 abort=True, 

425 ) 

426 

427 try: 

428 admin_set_user_password( 

429 pool_id=pool_id, 

430 region=region, 

431 username=username, 

432 password=new_password, 

433 permanent=not temporary, 

434 ) 

435 except ClientError as exc: 

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

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

438 sys.exit(1) 

439 

440 qualifier = "temporary" if temporary else "permanent" 

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

442 if generate_password: 

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

444 

445 

446# --------------------------------------------------------------------------- 

447# Studio login subgroup 

448# --------------------------------------------------------------------------- 

449 

450 

451@analytics.group("studio") 

452@pass_config 

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

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

455 

456 

457@studio_cmd.command("login") 

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

459@click.option( 

460 "--password", 

461 envvar="GCO_STUDIO_PASSWORD", 

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

463) 

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

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

466@pass_config 

467def studio_login( 

468 config: Any, 

469 username: str, 

470 password: str | None, 

471 api_url: str | None, 

472 open_browser: bool, 

473) -> None: 

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

475 from botocore.exceptions import ClientError 

476 

477 from ..analytics_user_mgmt import ( 

478 discover_api_endpoint, 

479 discover_cognito_client_id, 

480 discover_cognito_pool_id, 

481 fetch_studio_url, 

482 srp_authenticate, 

483 ) 

484 

485 formatter = get_output_formatter(config) 

486 region = config.api_gateway_region 

487 project_name = config.project_name 

488 

489 pool_id = discover_cognito_pool_id(region, project_name) 

490 client_id = discover_cognito_client_id(region, project_name) 

491 if not pool_id or not client_id: 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

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

493 sys.exit(1) 

494 

495 api_base = ( 

496 api_url 

497 or discover_api_endpoint(region, project_name) 

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

499 ) 

500 if not api_base: 

501 formatter.print_error( 

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

503 ) 

504 sys.exit(1) 

505 

506 if password is None: 

507 password = click.prompt("Password", hide_input=True) 

508 

509 try: 

510 tokens = srp_authenticate( 

511 pool_id=pool_id, 

512 client_id=client_id, 

513 username=username, 

514 password=password, 

515 region=region, 

516 ) 

517 except ClientError as exc: 

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

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

520 sys.exit(1) 

521 

522 id_token = tokens.get("IdToken") 

523 if not id_token: 

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

525 sys.exit(1) 

526 

527 try: 

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

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

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

531 import time as _time 

532 

533 max_wait = 120 # seconds 

534 poll_interval = 5 # seconds 

535 elapsed = 0 

536 url = "" 

537 expires_in = 0 

538 

539 while elapsed < max_wait: 539 ↛ 550line 539 didn't jump to line 550 because the condition on line 539 was always true

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

541 if url: 541 ↛ 544line 541 didn't jump to line 544 because the condition on line 541 was always true

542 break 

543 # 202 -- profile still provisioning. 

544 if elapsed == 0: 

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

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

547 _time.sleep(poll_interval) 

548 elapsed += poll_interval 

549 

550 if elapsed > 0 and url: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true

551 click.echo(" ready") 

552 elif not url: 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true

553 click.echo("") 

554 formatter.print_error( 

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

556 ) 

557 sys.exit(2) 

558 except urllib.error.HTTPError as exc: 

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

560 formatter.print_error( 

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

562 ) 

563 sys.exit(2) 

564 except urllib.error.URLError as exc: 

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

566 sys.exit(2) 

567 except ValueError as exc: 

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

569 sys.exit(2) 

570 

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

572 click.echo(url) 

573 if open_browser: 573 ↛ 574line 573 didn't jump to line 574 because the condition on line 573 was never true

574 click.launch(url) 

575 

576 

577# --------------------------------------------------------------------------- 

578# Doctor subcommand 

579# --------------------------------------------------------------------------- 

580 

581 

582@analytics.command("doctor") 

583@pass_config 

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

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

586 

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

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

589 to fix. 

590 """ 

591 from ..analytics_user_mgmt import ( 

592 check_ssm_parameter, 

593 check_stack_complete, 

594 scan_orphan_analytics_resources, 

595 ) 

596 from ..config import _load_cdk_json 

597 from ..stacks import _find_cdk_json 

598 

599 formatter = get_output_formatter(config) 

600 any_failed = False 

601 

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

603 nonlocal any_failed 

604 if ok: 

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

606 else: 

607 any_failed = True 

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

609 if remediation: 609 ↛ exitline 609 didn't return from function '_emit' because the condition on line 609 was always true

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

611 

612 # 1. cdk.json parses 

613 cdk_json_path = _find_cdk_json() 

614 if cdk_json_path is None: 

615 _emit( 

616 "cdk.json present", 

617 False, 

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

619 ) 

620 else: 

621 try: 

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

623 json.load(fh) 

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

625 except json.JSONDecodeError as exc: 

626 _emit( 

627 "cdk.json parses as JSON", 

628 False, 

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

630 ) 

631 

632 # 2. Prerequisite stacks healthy 

633 for region, stack_name in ( 

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

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

636 ): 

637 ok, remediation = check_stack_complete(region, stack_name) 

638 _emit( 

639 f"{stack_name} is CREATE_COMPLETE", 

640 ok, 

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

642 ) 

643 

644 cdk_regions = _load_cdk_json() 

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

646 for region in regional_regions: 

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

648 ok, remediation = check_stack_complete(region, stack_name) 

649 _emit( 

650 f"{stack_name} is CREATE_COMPLETE", 

651 ok, 

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

653 ) 

654 

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

656 from gco.stacks.constants import cluster_shared_ssm_parameter_prefix 

657 

658 ssm_prefix = cluster_shared_ssm_parameter_prefix(config.project_name) 

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

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

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

662 _emit( 

663 f"SSM parameter {param} exists", 

664 ok, 

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

666 ) 

667 

668 # 4. No orphaned retained analytics resources 

669 orphan_cmds = scan_orphan_analytics_resources(config.api_gateway_region) 

670 _emit( 

671 "no orphaned retained analytics resources", 

672 not orphan_cmds, 

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

674 ) 

675 

676 if any_failed: 

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

678 sys.exit(1) 

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