Coverage for gco_mcp/tools/stacks.py: 93.03%

182 statements  

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

1"""Infrastructure stack management MCP tools.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from typing import Any 

7 

8import cli_runner 

9from audit import audit_logged 

10from feature_flags import ( 

11 FLAG_INFRASTRUCTURE_DEPLOY, 

12 FLAG_INFRASTRUCTURE_DESTROY, 

13 is_enabled, 

14) 

15from server import mcp 

16 

17from tools._long_task import _run_long_task 

18 

19# FastMCP's Progress / Context dependencies are optional from this 

20# module's perspective — when ``fastmcp[tasks]`` is reachable they 

21# inject real instances per call; otherwise the gated long-running 

22# tools still register but rely on caller-provided fakes (the test path). 

23try: 

24 from fastmcp.server.dependencies import CurrentContext, Progress 

25except ImportError: # pragma: no cover - degraded fastmcp install 

26 CurrentContext = None # type: ignore[assignment] 

27 Progress = None # type: ignore[misc,assignment] 

28 

29# TaskConfig opts the gated stack-lifecycle tools into FastMCP's 

30# task protocol with ``mode="optional"`` — clients that support the task 

31# protocol receive a task ID immediately and poll for progress, while 

32# clients without task-protocol support fall back to inline execution 

33# with progress streamed through FastMCP's Progress dependency. 

34# Required-mode would lock out clients that don't speak the task protocol 

35# (e.g. the GCO MCP orchestrator's ``call_tool`` proxy), and these tools 

36# are useful enough that the inline fallback is worth keeping. 

37# If the import path moves between fastmcp versions, the tools register 

38# without the task config and run synchronously. 

39try: 

40 from fastmcp.server.tasks.config import TaskConfig 

41 

42 _TASK_CONFIG_OPTIONAL: Any = TaskConfig(mode="optional") 

43except ImportError: # pragma: no cover - degraded fastmcp install 

44 _TASK_CONFIG_OPTIONAL = None 

45 

46 

47def _expected_stack_count_for_all() -> int | None: 

48 """Return the number of stacks ``deploy-all`` / ``destroy-all`` will touch. 

49 

50 Reads ``cdk.json``'s ``context.deployment_regions`` and counts the 

51 fixed-position stacks (gco-global, gco-api-gateway, gco-monitoring) 

52 plus one per regional region. Returns ``None`` when the config is 

53 unreadable or empty so the caller falls back to indeterminate 

54 progress instead of an inaccurate total. 

55 

56 The count drives ``progress.set_total(...)`` so MCP clients render 

57 a real percentage during a multi-stack deploy or destroy. 

58 """ 

59 try: 

60 from cli.config import _load_cdk_json 

61 except Exception: # noqa: BLE001 — best-effort 

62 return None 

63 try: 

64 cdk_regions = _load_cdk_json() 

65 except Exception: # noqa: BLE001 — best-effort 

66 return None 

67 if not isinstance(cdk_regions, dict): 

68 return None 

69 if "regional" not in cdk_regions: 

70 return None 

71 regional = cdk_regions["regional"] 

72 if not isinstance(regional, list): 

73 return None 

74 # Three fixed stacks (global / api-gateway / monitoring) plus one 

75 # per regional region. Analytics is opt-in and omitted from the 

76 # baseline count — when enabled it adds one more stack but 

77 # under-reporting is preferable to over-reporting (the progress 

78 # bar rolls over rather than stopping at 95 %). 

79 return 3 + len(regional) 

80 

81 

82@mcp.tool(tags={"safe", "stacks"}) 

83@audit_logged 

84def list_stacks() -> str: 

85 """List all GCO CDK stacks.""" 

86 return cli_runner._run_cli("stacks", "list") 

87 

88 

89@mcp.tool(tags={"safe", "stacks"}) 

90@audit_logged 

91def stack_status(stack_name: str, region: str) -> str: 

92 """Get detailed status of a CloudFormation stack. 

93 

94 Args: 

95 stack_name: Stack name (e.g. gco-us-east-1). 

96 region: AWS region. 

97 """ 

98 return cli_runner._run_cli("stacks", "status", stack_name, "-r", region) 

99 

100 

101@mcp.tool(tags={"low-risk", "stacks"}) 

102@audit_logged 

103def setup_cluster_access(cluster: str | None = None, region: str | None = None) -> str: 

104 """Configure kubectl access to a GCO EKS cluster. 

105 

106 Updates kubeconfig, creates an EKS access entry for your IAM principal, 

107 and associates the cluster admin policy. Handles assumed roles automatically. 

108 

109 Args: 

110 cluster: Cluster name (default: <project_name>-{region}). 

111 region: AWS region (default: first deployment region from cdk.json). 

112 """ 

113 args = ["stacks", "access"] 

114 if cluster: 

115 args.extend(["-c", cluster]) 

116 if region: 

117 args.extend(["-r", region]) 

118 return cli_runner._run_cli(*args) 

119 

120 

121@mcp.tool(tags={"safe", "stacks"}) 

122@audit_logged 

123def fsx_status() -> str: 

124 """Check FSx for Lustre configuration status.""" 

125 return cli_runner._run_cli("stacks", "fsx", "status") 

126 

127 

128# ============================================================================= 

129# Read-only inspection tools (async) 

130# ============================================================================= 

131 

132 

133@mcp.tool(tags={"safe", "stacks"}) 

134@audit_logged 

135async def stack_diff(stack_name: str | None = None) -> str: 

136 """`gco stacks diff` — show CloudFormation diff for a stack. 

137 

138 Args: 

139 stack_name: Stack to diff. If omitted, diffs all stacks. 

140 """ 

141 args = ["stacks", "diff"] 

142 if stack_name: 

143 args.append(stack_name) 

144 return await asyncio.to_thread(cli_runner._run_cli, *args) 

145 

146 

147@mcp.tool(tags={"safe", "stacks"}) 

148@audit_logged 

149async def stack_outputs(stack_name: str, region: str) -> str: 

150 """`gco stacks outputs` — fetch CloudFormation outputs for a stack. 

151 

152 Args: 

153 stack_name: Stack name (e.g. gco-us-east-1). 

154 region: AWS region. 

155 """ 

156 return await asyncio.to_thread( 

157 cli_runner._run_cli, "stacks", "outputs", stack_name, "-r", region 

158 ) 

159 

160 

161@mcp.tool(tags={"safe", "stacks"}) 

162@audit_logged 

163async def stack_synth(stack_name: str | None = None, quiet: bool = True) -> str: 

164 """`gco stacks synth` — synthesize CloudFormation templates from CDK. 

165 

166 Args: 

167 stack_name: Stack to synthesize. If omitted, synthesizes all stacks. 

168 quiet: When True, pass ``--quiet`` to suppress verbose CDK output. 

169 """ 

170 args = ["stacks", "synth"] 

171 if stack_name: 

172 args.append(stack_name) 

173 if quiet: 

174 args.append("--quiet") 

175 return await asyncio.to_thread(cli_runner._run_cli, *args) 

176 

177 

178@mcp.tool(tags={"safe", "stacks"}) 

179@audit_logged 

180async def addons_status(region: str | None = None, all_regions: bool = False) -> str: 

181 """`gco stacks addons status` — show per-chart Helm add-on status from SSM. 

182 

183 Args: 

184 region: Region to inspect. Omit for the first deployment region. 

185 all_regions: Inspect every configured deployment region. 

186 """ 

187 args = ["stacks", "addons", "status"] 

188 if all_regions: 

189 args.append("--all-regions") 

190 elif region: 190 ↛ 192line 190 didn't jump to line 192 because the condition on line 190 was always true

191 args += ["-r", region] 

192 return await asyncio.to_thread(cli_runner._run_cli, *args) 

193 

194 

195@mcp.tool(tags={"safe", "stacks"}) 

196@audit_logged 

197async def valkey_status() -> str: 

198 """`gco stacks valkey status` — show Valkey cache stack status.""" 

199 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "valkey", "status") 

200 

201 

202@mcp.tool(tags={"safe", "stacks"}) 

203@audit_logged 

204async def aurora_status() -> str: 

205 """`gco stacks aurora status` — show Aurora database stack status.""" 

206 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "aurora", "status") 

207 

208 

209# ============================================================================= 

210# Mutating cdk.json toggles (low-risk) 

211# ============================================================================= 

212 

213 

214@mcp.tool(tags={"low-risk", "stacks"}) 

215@audit_logged 

216async def enable_fsx() -> str: 

217 """`gco stacks fsx enable` — flip FSx Lustre on in cdk.json. 

218 

219 Note: this only edits the cdk.json toggle. The change does not take effect 

220 until ``gco stacks deploy-all`` runs to provision the FSx file system. 

221 """ 

222 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "fsx", "enable", "-y") 

223 

224 

225@mcp.tool(tags={"low-risk", "stacks"}) 

226@audit_logged 

227async def disable_fsx() -> str: 

228 """`gco stacks fsx disable` — flip FSx Lustre off in cdk.json. 

229 

230 Note: this only edits the cdk.json toggle. The change does not take effect 

231 until ``gco stacks deploy-all`` runs to remove the FSx file system. 

232 """ 

233 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "fsx", "disable", "-y") 

234 

235 

236@mcp.tool(tags={"low-risk", "stacks"}) 

237@audit_logged 

238async def enable_valkey() -> str: 

239 """`gco stacks valkey enable` — flip Valkey Serverless on in cdk.json. 

240 

241 Note: this only edits the cdk.json toggle. The change does not take effect 

242 until ``gco stacks deploy-all`` runs to provision the Valkey cache. 

243 """ 

244 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "valkey", "enable", "-y") 

245 

246 

247@mcp.tool(tags={"low-risk", "stacks"}) 

248@audit_logged 

249async def disable_valkey() -> str: 

250 """`gco stacks valkey disable` — flip Valkey Serverless off in cdk.json. 

251 

252 Note: this only edits the cdk.json toggle. The change does not take effect 

253 until ``gco stacks deploy-all`` runs to remove the Valkey cache. 

254 """ 

255 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "valkey", "disable", "-y") 

256 

257 

258@mcp.tool(tags={"low-risk", "stacks"}) 

259@audit_logged 

260async def enable_aurora() -> str: 

261 """`gco stacks aurora enable` — flip Aurora pgvector on in cdk.json. 

262 

263 Note: this only edits the cdk.json toggle. The change does not take effect 

264 until ``gco stacks deploy-all`` runs to provision the Aurora cluster. 

265 """ 

266 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "aurora", "enable", "-y") 

267 

268 

269@mcp.tool(tags={"low-risk", "stacks"}) 

270@audit_logged 

271async def disable_aurora() -> str: 

272 """`gco stacks aurora disable` — flip Aurora pgvector off in cdk.json. 

273 

274 Note: this only edits the cdk.json toggle. The change does not take effect 

275 until ``gco stacks deploy-all`` runs to remove the Aurora cluster. 

276 """ 

277 return await asyncio.to_thread(cli_runner._run_cli, "stacks", "aurora", "disable", "-y") 

278 

279 

280# ============================================================================= 

281# Long-running stack lifecycle tools — gated by GCO_ENABLE_INFRASTRUCTURE_DEPLOY 

282# ============================================================================= 

283# 

284# deploy_stack / deploy_all / bootstrap_cdk drive CDK lifecycle operations 

285# that exceed the short-running ``cli_runner._run_cli`` 120-second timeout. 

286# They run via ``_run_long_task`` so progress streams back through the 

287# FastMCP Progress dependency and clients can poll task status through 

288# the standard MCP task protocol. 

289 

290if is_enabled(FLAG_INFRASTRUCTURE_DEPLOY): 

291 # Build the decorator kwargs dict so we only pass ``task=...`` when 

292 # TaskConfig was importable on this fastmcp version. 

293 _deploy_decorator_kwargs: dict[str, Any] = {"tags": {"infrastructure", "stacks"}} 

294 if _TASK_CONFIG_OPTIONAL is not None: 294 ↛ 297line 294 didn't jump to line 297 because the condition on line 294 was always true

295 _deploy_decorator_kwargs["task"] = _TASK_CONFIG_OPTIONAL 

296 

297 @mcp.tool(tags={"infrastructure", "stacks"}) 

298 @audit_logged 

299 async def addons_install(region: str | None = None, all_regions: bool = False) -> str: 

300 """[gated by GCO_ENABLE_INFRASTRUCTURE_DEPLOY] infrastructure mutation. 

301 

302 `gco stacks addons install` — start an idempotent Helm add-on 

303 re-convergence from the deployment input persisted in SSM. The command 

304 starts each region's installer state machine and returns immediately; 

305 inspect progress with ``addons_status``. 

306 

307 Args: 

308 region: Region to re-converge. Omit for the first deployment region. 

309 all_regions: Re-converge every configured deployment region. 

310 """ 

311 args = ["stacks", "addons", "install"] 

312 if all_regions: 

313 args.append("--all-regions") 

314 elif region: 

315 args += ["-r", region] 

316 return await asyncio.to_thread(cli_runner._run_cli, *args) 

317 

318 if Progress is not None and CurrentContext is not None: 318 ↛ 465line 318 didn't jump to line 465 because the condition on line 318 was always true

319 

320 @mcp.tool(**_deploy_decorator_kwargs) # type: ignore[untyped-decorator] 

321 @audit_logged 

322 async def deploy_stack( 

323 stack_name: str, 

324 yes: bool = True, 

325 outputs_file: str | None = None, 

326 tags: list[str] | None = None, 

327 *, 

328 ctx: Any = CurrentContext(), 

329 progress: Any = Progress(), 

330 ) -> str: 

331 """[gated by GCO_ENABLE_INFRASTRUCTURE_DEPLOY] long-running. 

332 

333 `gco stacks deploy` — deploy a single CDK stack to AWS. 

334 

335 Typical wall-clock: 15-30 minutes per regional stack. Clients that 

336 speak FastMCP's task protocol can receive a task ID immediately 

337 and poll `tasks://gco/{task_id}` for progress; clients that don't 

338 run the tool inline with progress streamed through the FastMCP 

339 Progress dependency. Cancellation sends SIGTERM to the running 

340 CDK process and partial CloudFormation state may remain — inspect 

341 via stack_status or the AWS console. 

342 

343 Args: 

344 stack_name: Stack to deploy (e.g. ``gco-us-east-1``). 

345 yes: Skip approval prompts (passes ``-y``). Defaults to True. 

346 outputs_file: Optional path to write stack outputs JSON. 

347 tags: Optional list of ``key=value`` tag strings applied to the stack. 

348 """ 

349 argv = [ 

350 "gco", 

351 "stacks", 

352 "deploy", 

353 stack_name, 

354 ] 

355 if yes: 

356 argv.append("-y") 

357 if outputs_file: 

358 argv += ["--outputs-file", outputs_file] 

359 for tag in tags or []: 

360 argv += ["--tag", tag] 

361 return await _run_long_task( 

362 argv, 

363 ctx=ctx, 

364 progress=progress, 

365 is_stack_op=True, 

366 total_units=1, 

367 ) 

368 

369 @mcp.tool(**_deploy_decorator_kwargs) # type: ignore[untyped-decorator] 

370 @audit_logged 

371 async def deploy_all( 

372 yes: bool = True, 

373 outputs_file: str | None = None, 

374 tags: list[str] | None = None, 

375 parallel: bool = False, 

376 max_workers: int | None = None, 

377 *, 

378 ctx: Any = CurrentContext(), 

379 progress: Any = Progress(), 

380 ) -> str: 

381 """[gated by GCO_ENABLE_INFRASTRUCTURE_DEPLOY] long-running. 

382 

383 `gco stacks deploy-all` — deploy every CDK stack in dependency order. 

384 

385 Typical wall-clock: 30-60 minutes for a fresh multi-region deploy. 

386 Clients that speak FastMCP's task protocol can receive a task ID 

387 immediately and poll `tasks://gco/{task_id}` for progress; clients 

388 that don't run the tool inline with progress streamed through the 

389 FastMCP Progress dependency. Cancellation sends SIGTERM to the 

390 running CDK process and partial CloudFormation state may remain — 

391 inspect via stack_status or the AWS console. 

392 

393 Args: 

394 yes: Skip approval prompts (passes ``-y``). Defaults to True. 

395 outputs_file: Optional path to write stack outputs JSON. 

396 tags: Optional list of ``key=value`` tag strings applied to every stack. 

397 parallel: Deploy regional stacks concurrently when True. 

398 max_workers: Cap on parallel deployments when ``parallel=True``. 

399 """ 

400 argv = [ 

401 "gco", 

402 "stacks", 

403 "deploy-all", 

404 ] 

405 if yes: 405 ↛ 407line 405 didn't jump to line 407 because the condition on line 405 was always true

406 argv.append("-y") 

407 if outputs_file: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 argv += ["--outputs-file", outputs_file] 

409 for tag in tags or []: 409 ↛ 410line 409 didn't jump to line 410 because the loop on line 409 never started

410 argv += ["--tag", tag] 

411 if parallel: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was always true

412 argv.append("--parallel") 

413 if max_workers is not None: 413 ↛ 415line 413 didn't jump to line 415 because the condition on line 413 was always true

414 argv += ["--max-workers", str(max_workers)] 

415 return await _run_long_task( 

416 argv, 

417 ctx=ctx, 

418 progress=progress, 

419 is_stack_op=True, 

420 total_units=_expected_stack_count_for_all(), 

421 ) 

422 

423 @mcp.tool(**_deploy_decorator_kwargs) # type: ignore[untyped-decorator] 

424 @audit_logged 

425 async def bootstrap_cdk( 

426 region: str, 

427 account: str | None = None, 

428 *, 

429 ctx: Any = CurrentContext(), 

430 progress: Any = Progress(), 

431 ) -> str: 

432 """[gated by GCO_ENABLE_INFRASTRUCTURE_DEPLOY] long-running. 

433 

434 `gco stacks bootstrap` — bootstrap CDK in an AWS account/region. 

435 

436 Typical wall-clock: 2-5 minutes. Required before any stack can be 

437 deployed to a new account/region. Clients that speak FastMCP's 

438 task protocol can receive a task ID immediately and poll 

439 `tasks://gco/{task_id}` for progress; clients that don't run the 

440 tool inline with progress streamed through the FastMCP Progress 

441 dependency. Cancellation sends SIGTERM to the running CDK process 

442 and partial CloudFormation state may remain — inspect via 

443 stack_status or the AWS console. 

444 

445 Args: 

446 region: Target AWS region. 

447 account: Optional AWS account ID. Defaults to the caller's account. 

448 """ 

449 argv = ["gco", "stacks", "bootstrap", "--region", region] 

450 if account: 

451 argv += ["--account", account] 

452 return await _run_long_task( 

453 argv, 

454 ctx=ctx, 

455 progress=progress, 

456 is_stack_op=True, 

457 total_units=1, 

458 ) 

459 

460 

461# ============================================================================= 

462# Long-running stack lifecycle tools — gated by GCO_ENABLE_INFRASTRUCTURE_DESTROY 

463# ============================================================================= 

464 

465if is_enabled(FLAG_INFRASTRUCTURE_DESTROY): 

466 _destroy_decorator_kwargs: dict[str, Any] = {"tags": {"infrastructure", "stacks"}} 

467 if _TASK_CONFIG_OPTIONAL is not None: 467 ↛ 470line 467 didn't jump to line 470 because the condition on line 467 was always true

468 _destroy_decorator_kwargs["task"] = _TASK_CONFIG_OPTIONAL 

469 

470 if Progress is not None and CurrentContext is not None: 470 ↛ exitline 470 didn't exit the module because the condition on line 470 was always true

471 

472 @mcp.tool(**_destroy_decorator_kwargs) # type: ignore[untyped-decorator] 

473 @audit_logged 

474 async def destroy_stack( 

475 stack_name: str, 

476 yes: bool = True, 

477 *, 

478 ctx: Any = CurrentContext(), 

479 progress: Any = Progress(), 

480 ) -> str: 

481 """[gated by GCO_ENABLE_INFRASTRUCTURE_DESTROY] long-running. 

482 

483 `gco stacks destroy` — destroy a single CDK stack. 

484 

485 Typical wall-clock: 5-20 minutes per stack. Clients that speak 

486 FastMCP's task protocol can receive a task ID immediately and 

487 poll `tasks://gco/{task_id}` for progress; clients that don't 

488 run the tool inline with progress streamed through the FastMCP 

489 Progress dependency. Cancellation sends SIGTERM to the running 

490 CDK process and partial CloudFormation state may remain — 

491 inspect via stack_status or the AWS console before retrying. 

492 

493 Args: 

494 stack_name: Stack to destroy (e.g. ``gco-us-east-1``). 

495 yes: Skip the confirmation prompt (passes ``-y``). Defaults to True. 

496 """ 

497 argv = ["gco", "stacks", "destroy", stack_name] 

498 if yes: 

499 argv.append("-y") 

500 return await _run_long_task( 

501 argv, 

502 ctx=ctx, 

503 progress=progress, 

504 is_stack_op=True, 

505 total_units=1, 

506 ) 

507 

508 @mcp.tool(**_destroy_decorator_kwargs) # type: ignore[untyped-decorator] 

509 @audit_logged 

510 async def destroy_all( 

511 yes: bool = True, 

512 parallel: bool = False, 

513 max_workers: int | None = None, 

514 *, 

515 ctx: Any = CurrentContext(), 

516 progress: Any = Progress(), 

517 ) -> str: 

518 """[gated by GCO_ENABLE_INFRASTRUCTURE_DESTROY] long-running. 

519 

520 `gco stacks destroy-all` — destroy every CDK stack in reverse dependency order. 

521 

522 Typical wall-clock: 20-40 minutes for a multi-region teardown. 

523 Clients that speak FastMCP's task protocol can receive a task 

524 ID immediately and poll `tasks://gco/{task_id}` for progress; 

525 clients that don't run the tool inline with progress streamed 

526 through the FastMCP Progress dependency. Cancellation sends 

527 SIGTERM to the running CDK process and partial CloudFormation 

528 state may remain — inspect via stack_status or the AWS console 

529 before retrying. 

530 

531 Args: 

532 yes: Skip the confirmation prompt (passes ``-y``). Defaults to True. 

533 parallel: Destroy regional stacks concurrently when True. 

534 max_workers: Cap on parallel destructions when ``parallel=True``. 

535 """ 

536 argv = ["gco", "stacks", "destroy-all"] 

537 if yes: 537 ↛ 539line 537 didn't jump to line 539 because the condition on line 537 was always true

538 argv.append("-y") 

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

540 argv.append("--parallel") 

541 if max_workers is not None: 541 ↛ 543line 541 didn't jump to line 543 because the condition on line 541 was always true

542 argv += ["--max-workers", str(max_workers)] 

543 return await _run_long_task( 

544 argv, 

545 ctx=ctx, 

546 progress=progress, 

547 is_stack_op=True, 

548 total_units=_expected_stack_count_for_all(), 

549 )