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
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Infrastructure stack management MCP tools."""
3from __future__ import annotations
5import asyncio
6from typing import Any
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
17from tools._long_task import _run_long_task
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]
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
42 _TASK_CONFIG_OPTIONAL: Any = TaskConfig(mode="optional")
43except ImportError: # pragma: no cover - degraded fastmcp install
44 _TASK_CONFIG_OPTIONAL = None
47def _expected_stack_count_for_all() -> int | None:
48 """Return the number of stacks ``deploy-all`` / ``destroy-all`` will touch.
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.
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)
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")
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.
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)
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.
106 Updates kubeconfig, creates an EKS access entry for your IAM principal,
107 and associates the cluster admin policy. Handles assumed roles automatically.
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)
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")
128# =============================================================================
129# Read-only inspection tools (async)
130# =============================================================================
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.
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)
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.
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 )
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.
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)
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.
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)
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")
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")
209# =============================================================================
210# Mutating cdk.json toggles (low-risk)
211# =============================================================================
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.
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")
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.
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")
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.
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")
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.
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")
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.
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")
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.
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")
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.
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
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.
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``.
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)
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
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.
333 `gco stacks deploy` — deploy a single CDK stack to AWS.
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.
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 )
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.
383 `gco stacks deploy-all` — deploy every CDK stack in dependency order.
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.
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 )
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.
434 `gco stacks bootstrap` — bootstrap CDK in an AWS account/region.
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.
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 )
461# =============================================================================
462# Long-running stack lifecycle tools — gated by GCO_ENABLE_INFRASTRUCTURE_DESTROY
463# =============================================================================
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
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
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.
483 `gco stacks destroy` — destroy a single CDK stack.
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.
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 )
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.
520 `gco stacks destroy-all` — destroy every CDK stack in reverse dependency order.
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.
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 )