Coverage for mcpgateway / tools / builder / cli.py: 100%
114 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
1# -*- coding: utf-8 -*-
2"""
3Location: ./mcpgateway/tools/builder/cli.py
4Copyright 2025
5SPDX-License-Identifier: Apache-2.0
6Authors: Teryl Taylor
8MCP Stack Deployment Tool - Hybrid Dagger/Python Implementation
10This script can run in two modes:
111. Plain Python mode (default) - No external dependencies
122. Dagger mode (opt-in) - Requires dagger-io package, auto-downloads CLI
14Usage:
15 # Local execution (plain Python mode)
16 cforge deploy deploy.yaml
18 # Use Dagger mode for optimization (requires dagger-io, auto-downloads CLI)
19 cforge --dagger deploy deploy.yaml
21 # Inside container
22 docker run -v $PWD:/workspace mcpgateway/mcp-builder:latest deploy deploy.yaml
24Features:
25 - Validates deploy.yaml configuration
26 - Builds plugin containers from git repos
27 - Generates mTLS certificates
28 - Deploys to Kubernetes or Docker Compose
29 - Integrates with CI/CD vault secrets
31Examples:
32 >>> # Test that IN_CONTAINER detection works
33 >>> import os
34 >>> isinstance(IN_CONTAINER, bool)
35 True
37 >>> # Test that BUILDER_DIR is a Path
38 >>> from pathlib import Path
39 >>> isinstance(BUILDER_DIR, Path)
40 True
42 >>> # Test IMPL_MODE is set
43 >>> isinstance(IMPL_MODE, str)
44 True
45"""
47# Standard
48import asyncio
49import os
50from pathlib import Path
51import sys
52from typing import Optional
54# Third-Party
55from rich.console import Console
56from rich.panel import Panel
57import typer
58from typing_extensions import Annotated
60# First-Party
61from mcpgateway.tools.builder.factory import DeployFactory
63app = typer.Typer(
64 help="Command line tools for deploying the gateway and plugins via a config file.",
65)
67console = Console()
69deployer = None
71IN_CONTAINER = os.path.exists("/.dockerenv") or os.environ.get("CONTAINER") == "true"
72BUILDER_DIR = Path(__file__).parent / "builder"
73IMPL_MODE = "plain"
76@app.callback()
77def cli(
78 ctx: typer.Context,
79 dagger: Annotated[bool, typer.Option("--dagger", help="Use Dagger mode (requires dagger-io package)")] = False,
80 verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output")] = False,
81):
82 """MCP Stack deployment tool
84 Deploys MCP Gateway + external plugins from a single YAML configuration.
86 By default, uses plain Python mode. Use --dagger to enable Dagger optimization.
88 Args:
89 ctx: Typer context object
90 dagger: Enable Dagger mode (requires dagger-io package and auto-downloads CLI)
91 verbose: Enable verbose output
92 """
93 ctx.ensure_object(dict)
94 ctx.obj["verbose"] = verbose
95 ctx.obj["dagger"] = dagger
97 if ctx.invoked_subcommand != "version":
98 # Show execution mode - default to Python, opt-in to Dagger
99 mode = "dagger" if dagger else "python"
100 ctx.obj["deployer"], ctx.obj["mode"] = DeployFactory.create_deployer(mode, verbose)
101 mode_color = "green" if ctx.obj["mode"] == "dagger" else "yellow"
102 env_text = "container" if IN_CONTAINER else "local"
104 if verbose:
105 console.print(Panel(f"[bold]Mode:[/bold] [{mode_color}]{ctx.obj['mode']}[/{mode_color}]\n" f"[bold]Environment:[/bold] {env_text}\n", title="MCP Deploy", border_style=mode_color))
108@app.command()
109def validate(ctx: typer.Context, config_file: Annotated[Path, typer.Argument(help="The deployment configuration file.")]):
110 """Validate mcp-stack.yaml configuration
112 Args:
113 ctx: Typer context object
114 config_file: Path to the deployment configuration file
115 """
116 impl = ctx.obj["deployer"]
118 try:
119 impl.validate(config_file)
120 console.print("[green]✓ Configuration valid[/green]")
121 except Exception as e:
122 console.print(f"[red]✗ Validation failed: {e}[/red]")
123 sys.exit(1)
126@app.command()
127def build(
128 ctx: typer.Context,
129 config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")],
130 plugins_only: Annotated[bool, typer.Option("--plugins-only", help="Only build plugin containers")] = False,
131 plugin: Annotated[Optional[list[str]], typer.Option("--plugin", "-p", help="Build specific plugin(s)")] = None,
132 no_cache: Annotated[bool, typer.Option("--no-cache", help="Disable build cache")] = False,
133 copy_env_templates: Annotated[bool, typer.Option("--copy-env-templates", help="Copy .env.template files from plugin repos")] = True,
134):
135 """Build containers
137 Args:
138 ctx: Typer context object
139 config_file: Path to the deployment configuration file
140 plugins_only: Only build plugin containers, skip gateway
141 plugin: List of specific plugin names to build
142 no_cache: Disable build cache
143 copy_env_templates: Copy .env.template files from plugin repos
144 """
145 impl = ctx.obj["deployer"]
147 try:
148 asyncio.run(impl.build(config_file, plugins_only=plugins_only, specific_plugins=list(plugin) if plugin else None, no_cache=no_cache, copy_env_templates=copy_env_templates))
149 console.print("[green]✓ Build complete[/green]")
151 if copy_env_templates:
152 console.print("[yellow]⚠ IMPORTANT: Review .env files in deploy/env/ before deploying![/yellow]")
153 console.print("[yellow] Update any required configuration values.[/yellow]")
154 except Exception as e:
155 console.print(f"[red]✗ Build failed: {e}[/red]")
156 sys.exit(1)
159@app.command()
160def certs(ctx: typer.Context, config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")]):
161 """Generate mTLS certificates
163 Args:
164 ctx: Typer context object
165 config_file: Path to the deployment configuration file
166 """
167 impl = ctx.obj["deployer"]
169 try:
170 asyncio.run(impl.generate_certificates(config_file))
171 console.print("[green]✓ Certificates generated[/green]")
172 except Exception as e:
173 console.print(f"[red]✗ Certificate generation failed: {e}[/red]")
174 sys.exit(1)
177@app.command()
178def deploy(
179 ctx: typer.Context,
180 config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")],
181 output_dir: Annotated[Optional[Path], typer.Option("--output-dir", "-o", help="The deployment configuration file")] = None,
182 dry_run: Annotated[bool, typer.Option("--dry-run", help="Generate manifests without deploying")] = False,
183 skip_build: Annotated[bool, typer.Option("--skip-build", help="Skip building containers")] = False,
184 skip_certs: Annotated[bool, typer.Option("--skip-certs", help="Skip certificate generation")] = False,
185):
186 """Deploy MCP stack
188 Args:
189 ctx: Typer context object
190 config_file: Path to the deployment configuration file
191 output_dir: Custom output directory for manifests
192 dry_run: Generate manifests without deploying
193 skip_build: Skip building containers
194 skip_certs: Skip certificate generation
195 """
196 impl = ctx.obj["deployer"]
198 try:
199 asyncio.run(impl.deploy(config_file, dry_run=dry_run, skip_build=skip_build, skip_certs=skip_certs, output_dir=output_dir))
200 if dry_run:
201 console.print("[yellow]✓ Dry-run complete (no changes made)[/yellow]")
202 else:
203 console.print("[green]✓ Deployment complete[/green]")
204 except Exception as e:
205 console.print(f"[red]✗ Deployment failed: {e}[/red]")
206 sys.exit(1)
209@app.command()
210def verify(
211 ctx: typer.Context,
212 config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")],
213 wait: Annotated[bool, typer.Option("--wait", help="Wait for deployment to be ready")] = True,
214 timeout: Annotated[int, typer.Option("--timeout", help="Wait timeout in seconds")] = 300,
215):
216 """Verify deployment health
218 Args:
219 ctx: Typer context object
220 config_file: Path to the deployment configuration file
221 wait: Wait for deployment to be ready
222 timeout: Wait timeout in seconds
223 """
224 impl = ctx.obj["deployer"]
226 try:
227 asyncio.run(impl.verify(config_file, wait=wait, timeout=timeout))
228 console.print("[green]✓ Deployment healthy[/green]")
229 except Exception as e:
230 console.print(f"[red]✗ Verification failed: {e}[/red]")
231 sys.exit(1)
234@app.command()
235def destroy(
236 ctx: typer.Context,
237 config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")],
238 force: Annotated[bool, typer.Option("--force", help="Force destruction without confirmation")] = False,
239):
240 """Destroy deployed MCP stack
242 Args:
243 ctx: Typer context object
244 config_file: Path to the deployment configuration file
245 force: Force destruction without confirmation
246 """
247 impl = ctx.obj["deployer"]
249 if not force:
250 if not typer.confirm("Are you sure you want to destroy the deployment?"):
251 console.print("[yellow]Aborted[/yellow]")
252 return
254 try:
255 asyncio.run(impl.destroy(config_file))
256 console.print("[green]✓ Deployment destroyed[/green]")
257 except Exception as e:
258 console.print(f"[red]✗ Destruction failed: {e}[/red]")
259 sys.exit(1)
262@app.command()
263def version():
264 """Show version information
266 Examples:
267 >>> # Test that version function exists
268 >>> callable(version)
269 True
271 >>> # Test that it accesses module constants
272 >>> IMPL_MODE in ['plain', 'dagger']
273 True
274 """
275 console.print(
276 Panel(f"[bold]MCP Deploy[/bold]\n" f"Version: 1.0.0\n" f"Mode: {IMPL_MODE}\n" f"Environment: {'container' if IN_CONTAINER else 'local'}\n", title="Version Info", border_style="blue")
277 )
280@app.command()
281def generate(
282 ctx: typer.Context,
283 config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")],
284 output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Output directory for manifests")] = None,
285):
286 """Generate deployment manifests (k8s or compose)
288 Args:
289 ctx: Typer context object
290 config_file: Path to the deployment configuration file
291 output: Output directory for manifests
292 """
293 impl = ctx.obj["deployer"]
295 try:
296 manifests_dir = impl.generate_manifests(config_file, output_dir=output)
297 console.print(f"[green]✓ Manifests generated: {manifests_dir}[/green]")
298 except Exception as e:
299 console.print(f"[red]✗ Manifest generation failed: {e}[/red]")
300 sys.exit(1)
303def main():
304 """Main entry point
306 Raises:
307 Exception: Any unhandled exception from subcommands (re-raised in debug mode)
309 Examples:
310 >>> # Test that main function exists and is callable
311 >>> callable(main)
312 True
314 >>> # Test that app is a Typer instance
315 >>> import typer
316 >>> isinstance(app, typer.Typer)
317 True
319 >>> # Test that console is available
320 >>> from rich.console import Console
321 >>> isinstance(console, Console)
322 True
323 """
324 try:
325 app(obj={})
326 except KeyboardInterrupt:
327 console.print("\n[yellow]Interrupted by user[/yellow]")
328 sys.exit(130)
329 except Exception as e:
330 console.print(f"[red]Fatal error: {e}[/red]")
331 if os.environ.get("MCP_DEBUG"):
332 raise
333 sys.exit(1)
336if __name__ == "__main__":
337 main()