diff --git a/cforge/commands/deploy/builder/__init__.py b/cforge/commands/deploy/builder/__init__.py new file mode 100644 index 0000000..f12eda5 --- /dev/null +++ b/cforge/commands/deploy/builder/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +"""Location: ./cforge/commands/deploy/builder/__init__.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Builder Package. +""" diff --git a/cforge/commands/deploy/builder/common.py b/cforge/commands/deploy/builder/common.py new file mode 100644 index 0000000..43a66e7 --- /dev/null +++ b/cforge/commands/deploy/builder/common.py @@ -0,0 +1,1266 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/tools/builder/common.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Common utilities shared between Dagger and plain Python implementations. + +This module contains shared functionality to avoid code duplication between +the Dagger-based (dagger_module.py) and plain Python (plain_deploy.py) +implementations of the MCP Stack deployment system. + +Shared functions: +- load_config: Load and parse YAML configuration file +- generate_plugin_config: Generate plugins-config.yaml for gateway from mcp-stack.yaml +- generate_kubernetes_manifests: Generate Kubernetes deployment manifests +- generate_compose_manifests: Generate Docker Compose manifest +- copy_env_template: Copy .env.template from plugin repo to env.d/ directory +- handle_registry_operations: Tag and push images to container registry +- get_docker_compose_command: Detect available docker compose command +- run_compose: Run docker compose with error handling +- deploy_compose: Deploy using docker compose up -d +- verify_compose: Verify deployment with docker compose ps +- destroy_compose: Destroy deployment with docker compose down -v +- deploy_kubernetes: Deploy to Kubernetes using kubectl +- verify_kubernetes: Verify Kubernetes deployment health +- destroy_kubernetes: Destroy Kubernetes deployment with kubectl delete +""" + +# Standard +import base64 +import os +from pathlib import Path +import shutil +import subprocess # nosec B404 +from typing import List + +# Third-Party +from jinja2 import Environment, FileSystemLoader +import yaml + +# First-Party +from cforge.commands.deploy.builder.schema import MCPStackConfig +from cforge.common import get_console + + +def get_deploy_dir() -> Path: + """Get deployment directory from environment variable or default. + + Checks MCP_DEPLOY_DIR environment variable, defaults to './deploy'. + + Returns: + Path to deployment directory + + Examples: + >>> # Test with default value (when MCP_DEPLOY_DIR is not set) + >>> import os + >>> old_value = os.environ.pop("MCP_DEPLOY_DIR", None) + >>> result = get_deploy_dir() + >>> isinstance(result, Path) + True + >>> str(result) + 'deploy' + + >>> # Test with custom environment variable + >>> os.environ["MCP_DEPLOY_DIR"] = "/custom/deploy" + >>> result = get_deploy_dir() + >>> str(result) + '/custom/deploy' + + >>> # Cleanup: restore original value + >>> if old_value is not None: + ... os.environ["MCP_DEPLOY_DIR"] = old_value + ... else: + ... _ = os.environ.pop("MCP_DEPLOY_DIR", None) + """ + deploy_dir = os.environ.get("MCP_DEPLOY_DIR", "./deploy") + return Path(deploy_dir) + + +def load_config(config_file: str) -> MCPStackConfig: + """Load and parse YAML configuration file into validated Pydantic model. + + Args: + config_file: Path to mcp-stack.yaml configuration file + + Returns: + Validated MCPStackConfig Pydantic model + + Raises: + FileNotFoundError: If configuration file doesn't exist + ValidationError: If configuration validation fails + + Examples: + >>> # Test with non-existent file + >>> try: + ... load_config("/nonexistent/path/config.yaml") + ... except FileNotFoundError as e: + ... "Configuration file not found" in str(e) + True + + >>> # Test that function returns MCPStackConfig type + >>> from cforge.commands.deploy.builder.schema import MCPStackConfig + >>> # Actual file loading would require a real file: + >>> # config = load_config("mcp-stack.yaml") + >>> # assert isinstance(config, MCPStackConfig) + """ + config_path = Path(config_file) + if not config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {config_file}") + + with open(config_path, encoding="utf-8") as f: + config_dict = yaml.safe_load(f) + + # Validate and return Pydantic model + return MCPStackConfig.model_validate(config_dict) + + +def generate_plugin_config(config: MCPStackConfig, output_dir: Path, verbose: bool = False) -> Path: + """Generate plugin config.yaml for gateway from mcp-stack.yaml. + + This function is shared between Dagger and plain Python implementations + to avoid code duplication. + + Args: + config: Validated MCPStackConfig Pydantic model + output_dir: Output directory for generated config + verbose: Print verbose output + + Returns: + Path to generated plugins-config.yaml file + + Raises: + FileNotFoundError: If template directory not found + + Examples: + >>> from pathlib import Path + >>> from cforge.commands.deploy.builder.schema import MCPStackConfig, DeploymentConfig, GatewayConfig + >>> import tempfile + >>> # Test with minimal config + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... output = Path(tmpdir) + ... config = MCPStackConfig( + ... deployment=DeploymentConfig(type="compose"), + ... gateway=GatewayConfig(image="test:latest"), + ... plugins=[] + ... ) + ... result = generate_plugin_config(config, output, verbose=False) + ... result.name + 'plugins-config.yaml' + + >>> # Test return type + >>> # result_path = generate_plugin_config(config, output_dir) + >>> # isinstance(result_path, Path) + >>> # True + """ + + deployment_type = config.deployment.type + plugins = config.plugins + + # Load template + template_dir = Path(__file__).parent / "templates" + if not template_dir.exists(): + raise FileNotFoundError(f"Template directory not found: {template_dir}") + + # YAML files should not use HTML autoescape + env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=False) # nosec B701 + template = env.get_template("plugins-config.yaml.j2") + + # Prepare plugin data with computed URLs + plugin_data = [] + for plugin in plugins: + plugin_name = plugin.name + port = plugin.port or 8000 + + # Determine URL based on deployment type + if deployment_type == "compose": + # Use container hostname (lowercase) + hostname = plugin_name.lower() + # Use HTTPS if mTLS is enabled + protocol = "https" if plugin.mtls_enabled else "http" + url = f"{protocol}://{hostname}:{port}/mcp" + else: # kubernetes + # Use Kubernetes service DNS + namespace = config.deployment.namespace or "mcp-gateway" + service_name = f"mcp-plugin-{plugin_name.lower()}" + protocol = "https" if plugin.mtls_enabled else "http" + url = f"{protocol}://{service_name}.{namespace}.svc:{port}/mcp" + + # Build plugin entry with computed URL + plugin_entry = { + "name": plugin_name, + "port": port, + "url": url, + } + + # Merge plugin_overrides (client-side config only, excludes 'config') + # Allowed client-side fields that plugin manager uses + if plugin.plugin_overrides: + overrides = plugin.plugin_overrides + allowed_fields = ["priority", "mode", "description", "version", "author", "hooks", "tags", "conditions"] + for field in allowed_fields: + if field in overrides: + plugin_entry[field] = overrides[field] + + plugin_data.append(plugin_entry) + + # Render template + rendered = template.render(plugins=plugin_data) + + # Write config file + config_path = output_dir / "plugins-config.yaml" + config_path.write_text(rendered) + + if verbose: + print(f"✓ Plugin config generated: {config_path}") + + return config_path + + +def generate_kubernetes_manifests(config: MCPStackConfig, output_dir: Path, verbose: bool = False) -> None: + """Generate Kubernetes manifests from configuration. + + Args: + config: Validated MCPStackConfig Pydantic model + output_dir: Output directory for manifests + verbose: Print verbose output + + Raises: + FileNotFoundError: If template directory not found + + Examples: + >>> from pathlib import Path + >>> import inspect + >>> # Test function signature + >>> sig = inspect.signature(generate_kubernetes_manifests) + >>> list(sig.parameters.keys()) + ['config', 'output_dir', 'verbose'] + + >>> # Test that verbose parameter has default + >>> sig.parameters['verbose'].default + False + + >>> # Actual usage requires valid config and templates: + >>> # from cforge.commands.deploy.builder.schema import MCPStackConfig + >>> # generate_kubernetes_manifests(config, Path("./output")) + """ + + # Load templates + template_dir = Path(__file__).parent / "templates" / "kubernetes" + if not template_dir.exists(): + raise FileNotFoundError(f"Template directory not found: {template_dir}") + + # Auto-detect and assign env files if not specified + _auto_detect_env_files(config, output_dir, verbose=verbose) + + env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True) # nosec B701 + + # Generate namespace + namespace = config.deployment.namespace or "mcp-gateway" + + # Generate mTLS certificate resources if enabled + gateway_mtls = config.gateway.mtls_enabled if config.gateway.mtls_enabled is not None else True + cert_config = config.certificates + use_cert_manager = cert_config.use_cert_manager if cert_config else False + + if gateway_mtls: + if use_cert_manager: + # Generate cert-manager Certificate CRDs + cert_manager_template = env.get_template("cert-manager-certificates.yaml.j2") + + # Calculate duration and renewBefore in hours + validity_days = cert_config.validity_days or 825 + duration_hours = validity_days * 24 + # Renew at 2/3 of lifetime (cert-manager default) + renew_before_hours = int(duration_hours * 2 / 3) + + # Prepare certificate data + cert_data = { + "namespace": namespace, + "gateway_name": "mcpgateway", + "issuer_name": cert_config.cert_manager_issuer or "mcp-ca-issuer", + "issuer_kind": cert_config.cert_manager_kind or "Issuer", + "duration": duration_hours, + "renew_before": renew_before_hours, + "plugins": [], + } + + # Add plugins with mTLS enabled + for plugin in config.plugins: + if plugin.mtls_enabled if plugin.mtls_enabled is not None else True: + cert_data["plugins"].append({"name": f"mcp-plugin-{plugin.name.lower()}"}) + + # Generate cert-manager certificates manifest + cert_manager_manifest = cert_manager_template.render(**cert_data) + (output_dir / "cert-manager-certificates.yaml").write_text(cert_manager_manifest) + if verbose: + print(" ✓ cert-manager Certificate CRDs manifest generated") + + else: + # Generate traditional certificate secrets (backward compatibility) + cert_secrets_template = env.get_template("cert-secrets.yaml.j2") + + # Prepare certificate data + cert_data = {"namespace": namespace, "gateway_name": "mcpgateway", "plugins": []} + + # Read and encode CA certificate + ca_cert_path = Path("certs/mcp/ca/ca.crt") + if ca_cert_path.exists(): + cert_data["ca_cert_b64"] = base64.b64encode(ca_cert_path.read_bytes()).decode("utf-8") + else: + if verbose: + print(f"[yellow]Warning: CA certificate not found at {ca_cert_path}[/yellow]") + + # Read and encode gateway certificates + gateway_cert_path = Path("certs/mcp/gateway/client.crt") + gateway_key_path = Path("certs/mcp/gateway/client.key") + if gateway_cert_path.exists() and gateway_key_path.exists(): + cert_data["gateway_cert_b64"] = base64.b64encode(gateway_cert_path.read_bytes()).decode("utf-8") + cert_data["gateway_key_b64"] = base64.b64encode(gateway_key_path.read_bytes()).decode("utf-8") + else: + if verbose: + print("[yellow]Warning: Gateway certificates not found[/yellow]") + + # Read and encode plugin certificates + for plugin in config.plugins: + if plugin.mtls_enabled if plugin.mtls_enabled is not None else True: + plugin_name = plugin.name + plugin_cert_path = Path(f"certs/mcp/plugins/{plugin_name}/server.crt") + plugin_key_path = Path(f"certs/mcp/plugins/{plugin_name}/server.key") + + if plugin_cert_path.exists() and plugin_key_path.exists(): + cert_data["plugins"].append( + { + "name": f"mcp-plugin-{plugin_name.lower()}", + "cert_b64": base64.b64encode(plugin_cert_path.read_bytes()).decode("utf-8"), + "key_b64": base64.b64encode(plugin_key_path.read_bytes()).decode("utf-8"), + } + ) + else: + if verbose: + print(f"[yellow]Warning: Plugin {plugin_name} certificates not found[/yellow]") + + # Generate certificate secrets manifest + if "ca_cert_b64" in cert_data: + cert_secrets_manifest = cert_secrets_template.render(**cert_data) + (output_dir / "cert-secrets.yaml").write_text(cert_secrets_manifest) + if verbose: + print(" ✓ mTLS certificate secrets manifest generated") + + # Generate infrastructure manifests (postgres, redis) if enabled + infrastructure = config.infrastructure + + # PostgreSQL + if infrastructure and infrastructure.postgres and infrastructure.postgres.enabled: + postgres_config = infrastructure.postgres + postgres_template = env.get_template("postgres.yaml.j2") + postgres_manifest = postgres_template.render( + namespace=namespace, + image=postgres_config.image or "quay.io/sclorg/postgresql-15-c9s:latest", + database=postgres_config.database or "mcp", + user=postgres_config.user or "postgres", + password=postgres_config.password or "mysecretpassword", + storage_size=postgres_config.storage_size or "10Gi", + storage_class=postgres_config.storage_class, + ) + (output_dir / "postgres-deployment.yaml").write_text(postgres_manifest) + if verbose: + print(" ✓ PostgreSQL deployment manifest generated") + + # Redis + if infrastructure and infrastructure.redis and infrastructure.redis.enabled: + redis_config = infrastructure.redis + redis_template = env.get_template("redis.yaml.j2") + redis_manifest = redis_template.render(namespace=namespace, image=redis_config.image or "redis:latest") + (output_dir / "redis-deployment.yaml").write_text(redis_manifest) + if verbose: + print(" ✓ Redis deployment manifest generated") + + # Generate plugins ConfigMap if plugins are configured + if config.plugins and len(config.plugins) > 0: + configmap_template = env.get_template("plugins-configmap.yaml.j2") + # Read the generated plugins-config.yaml file + plugins_config_path = output_dir / "plugins-config.yaml" + if plugins_config_path.exists(): + plugins_config_content = plugins_config_path.read_text() + configmap_manifest = configmap_template.render(namespace=namespace, plugins_config=plugins_config_content) + (output_dir / "plugins-configmap.yaml").write_text(configmap_manifest) + if verbose: + print(" ✓ Plugins ConfigMap manifest generated") + + # Generate gateway deployment + gateway_template = env.get_template("deployment.yaml.j2") + # Convert Pydantic model to dict for template rendering + gateway_dict = config.gateway.model_dump(exclude_none=True) + gateway_dict["name"] = "mcpgateway" + gateway_dict["namespace"] = namespace + gateway_dict["has_plugins"] = config.plugins and len(config.plugins) > 0 + + # Update image to use full registry path if registry is enabled + if config.gateway.registry and config.gateway.registry.enabled: + base_image_name = config.gateway.image.split(":")[0].split("/")[-1] + image_version = config.gateway.image.split(":")[-1] if ":" in config.gateway.image else "latest" + gateway_dict["image"] = f"{config.gateway.registry.url}/{config.gateway.registry.namespace}/{base_image_name}:{image_version}" + # Set imagePullPolicy from registry config + if config.gateway.registry.image_pull_policy: + gateway_dict["image_pull_policy"] = config.gateway.registry.image_pull_policy + + # Add DATABASE_URL and REDIS_URL to gateway environment if infrastructure is enabled + if "env_vars" not in gateway_dict: + gateway_dict["env_vars"] = {} + + # Enable plugins if any are configured + if config.plugins and len(config.plugins) > 0: + gateway_dict["env_vars"]["PLUGINS_ENABLED"] = "true" + gateway_dict["env_vars"]["PLUGIN_CONFIG_FILE"] = "/app/config/plugins.yaml" + + # Add init containers to wait for infrastructure services + init_containers = [] + + if infrastructure and infrastructure.postgres and infrastructure.postgres.enabled: + postgres = infrastructure.postgres + db_user = postgres.user or "postgres" + db_password = postgres.password or "mysecretpassword" + db_name = postgres.database or "mcp" + gateway_dict["env_vars"]["DATABASE_URL"] = f"postgresql://{db_user}:{db_password}@postgres:5432/{db_name}" + + # Add init container to wait for PostgreSQL + init_containers.append({"name": "wait-for-postgres", "image": "busybox:1.36", "command": ["sh", "-c", "until nc -z postgres 5432; do echo waiting for postgres; sleep 2; done"]}) + + if infrastructure and infrastructure.redis and infrastructure.redis.enabled: + gateway_dict["env_vars"]["REDIS_URL"] = "redis://redis:6379/0" + + # Add init container to wait for Redis + init_containers.append({"name": "wait-for-redis", "image": "busybox:1.36", "command": ["sh", "-c", "until nc -z redis 6379; do echo waiting for redis; sleep 2; done"]}) + + # Add init containers to wait for plugins to be ready + if config.plugins and len(config.plugins) > 0: + for plugin in config.plugins: + plugin_service_name = f"mcp-plugin-{plugin.name.lower()}" + plugin_port = plugin.port or 8000 + # Wait for plugin service to be available + init_containers.append( + { + "name": f"wait-for-{plugin.name.lower()}", + "image": "busybox:1.36", + "command": ["sh", "-c", f"until nc -z {plugin_service_name} {plugin_port}; do echo waiting for {plugin_service_name}; sleep 2; done"], + } + ) + + if init_containers: + gateway_dict["init_containers"] = init_containers + + gateway_manifest = gateway_template.render(**gateway_dict) + (output_dir / "gateway-deployment.yaml").write_text(gateway_manifest) + + # Generate OpenShift Route if configured + if config.deployment.openshift and config.deployment.openshift.create_routes: + route_template = env.get_template("route.yaml.j2") + openshift_config = config.deployment.openshift + + # Auto-detect OpenShift apps domain if not specified + openshift_domain = openshift_config.domain + if not openshift_domain: + try: + # Try to get domain from OpenShift cluster info + result = subprocess.run( + ["kubectl", "get", "ingresses.config.openshift.io", "cluster", "-o", "jsonpath={.spec.domain}"], capture_output=True, text=True, check=False + ) # nosec B603, B607 + if result.returncode == 0 and result.stdout.strip(): + openshift_domain = result.stdout.strip() + if verbose: + get_console().print(f"[dim]Auto-detected OpenShift domain: {openshift_domain}[/dim]") + else: + # Fallback to common OpenShift Local domain + openshift_domain = "apps-crc.testing" + if verbose: + get_console().print(f"[yellow]Could not auto-detect OpenShift domain, using default: {openshift_domain}[/yellow]") + except Exception: + # Fallback to common OpenShift Local domain + openshift_domain = "apps-crc.testing" + if verbose: + get_console().print(f"[yellow]Could not auto-detect OpenShift domain, using default: {openshift_domain}[/yellow]") + + route_manifest = route_template.render(namespace=namespace, openshift_domain=openshift_domain, tls_termination=openshift_config.tls_termination) + (output_dir / "gateway-route.yaml").write_text(route_manifest) + if verbose: + print(" ✓ OpenShift Route manifest generated") + + # Generate plugin deployments + for plugin in config.plugins: + # Convert Pydantic model to dict for template rendering + plugin_dict = plugin.model_dump(exclude_none=True) + plugin_dict["name"] = f"mcp-plugin-{plugin.name.lower()}" + plugin_dict["namespace"] = namespace + + # Update image to use full registry path if registry is enabled + if plugin.registry and plugin.registry.enabled: + base_image_name = plugin.image.split(":")[0].split("/")[-1] + image_version = plugin.image.split(":")[-1] if ":" in plugin.image else "latest" + plugin_dict["image"] = f"{plugin.registry.url}/{plugin.registry.namespace}/{base_image_name}:{image_version}" + # Set imagePullPolicy from registry config + if plugin.registry.image_pull_policy: + plugin_dict["image_pull_policy"] = plugin.registry.image_pull_policy + + plugin_manifest = gateway_template.render(**plugin_dict) + (output_dir / f"plugin-{plugin.name.lower()}-deployment.yaml").write_text(plugin_manifest) + + if verbose: + print(f"✓ Kubernetes manifests generated in {output_dir}") + + +def generate_compose_manifests(config: MCPStackConfig, output_dir: Path, verbose: bool = False) -> None: + """Generate Docker Compose manifest from configuration. + + Args: + config: Validated MCPStackConfig Pydantic model + output_dir: Output directory for manifests + verbose: Print verbose output + + Raises: + FileNotFoundError: If template directory not found + + Examples: + >>> from pathlib import Path + >>> import inspect + >>> # Test function signature + >>> sig = inspect.signature(generate_compose_manifests) + >>> list(sig.parameters.keys()) + ['config', 'output_dir', 'verbose'] + + >>> # Test default parameters + >>> sig.parameters['verbose'].default + False + + >>> # Actual execution requires templates and config: + >>> # from cforge.commands.deploy.builder.schema import MCPStackConfig + >>> # generate_compose_manifests(config, Path("./output")) + """ + + # Load templates + template_dir = Path(__file__).parent / "templates" / "compose" + if not template_dir.exists(): + raise FileNotFoundError(f"Template directory not found: {template_dir}") + + # Auto-detect and assign env files if not specified + _auto_detect_env_files(config, output_dir, verbose=verbose) + + # Auto-assign host_ports if expose_port is true but host_port not specified + next_host_port = 8000 + for plugin in config.plugins: + # Port defaults are handled by Pydantic defaults in schema + + # Auto-assign host_port if expose_port is true + if plugin.expose_port and not plugin.host_port: + plugin.host_port = next_host_port # type: ignore + next_host_port += 1 + + # Compute relative certificate paths (from output_dir to project root certs/) + # Certificates are at: ./certs/mcp/... + # Output dir is at: ./deploy/manifests/ + # So relative path is: ../../certs/mcp/... + certs_base = Path.cwd() / "certs" + certs_rel_base = os.path.relpath(certs_base, output_dir) + + # Add computed cert paths to context for template + cert_paths = { + "certs_base": certs_rel_base, + "gateway_cert_dir": os.path.join(certs_rel_base, "mcp/gateway"), + "ca_cert_file": os.path.join(certs_rel_base, "mcp/ca/ca.crt"), + "plugins_cert_base": os.path.join(certs_rel_base, "mcp/plugins"), + } + + env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True) # nosec B701 + + # Generate compose file + compose_template = env.get_template("docker-compose.yaml.j2") + # Convert Pydantic model to dict for template rendering + config_dict = config.model_dump(exclude_none=True) + compose_manifest = compose_template.render(**config_dict, cert_paths=cert_paths) + (output_dir / "docker-compose.yaml").write_text(compose_manifest) + + if verbose: + print(f"✓ Compose manifest generated in {output_dir}") + + +def _auto_detect_env_files(config: MCPStackConfig, output_dir: Path, verbose: bool = False) -> None: + """Auto-detect and assign env files if not explicitly specified. + + If env_file is not specified in the config, check if {deploy_dir}/env/.env.{name} + exists and use it. Warn the user when auto-detection is used. + + Args: + config: MCPStackConfig Pydantic model (modified in-place via attribute assignment) + output_dir: Output directory where manifests will be generated (for relative paths) + verbose: Print verbose output + + Examples: + >>> from pathlib import Path + >>> from cforge.commands.deploy.builder.schema import MCPStackConfig, DeploymentConfig, GatewayConfig + >>> import tempfile + >>> # Test function modifies config in place + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... output = Path(tmpdir) + ... config = MCPStackConfig( + ... deployment=DeploymentConfig(type="compose"), + ... gateway=GatewayConfig(image="test:latest"), + ... plugins=[] + ... ) + ... # Function modifies config if env files exist + ... _auto_detect_env_files(config, output, verbose=False) + ... # Config object is modified in place + ... isinstance(config, MCPStackConfig) + True + + >>> # Test function signature + >>> import inspect + >>> sig = inspect.signature(_auto_detect_env_files) + >>> 'verbose' in sig.parameters + True + """ + deploy_dir = get_deploy_dir() + env_dir = deploy_dir / "env" + + # Check gateway - since we need to modify the model, we access env_file directly + # Note: Pydantic models allow attribute assignment after creation + if not hasattr(config.gateway, "env_file") or not config.gateway.env_file: + gateway_env = env_dir / ".env.gateway" + if gateway_env.exists(): + # Make path relative to output_dir (where docker-compose.yaml will be) + relative_path = os.path.relpath(gateway_env, output_dir) + config.gateway.env_file = relative_path # type: ignore + print(f"⚠ Auto-detected env file: {gateway_env}") + if verbose: + print(" (Gateway env_file not specified in config)") + + # Check plugins + for plugin in config.plugins: + plugin_name = plugin.name + if not hasattr(plugin, "env_file") or not plugin.env_file: + plugin_env = env_dir / f".env.{plugin_name}" + if plugin_env.exists(): + # Make path relative to output_dir (where docker-compose.yaml will be) + relative_path = os.path.relpath(plugin_env, output_dir) + plugin.env_file = relative_path # type: ignore + print(f"⚠ Auto-detected env file: {plugin_env}") + if verbose: + print(f" (Plugin {plugin_name} env_file not specified in config)") + + +def copy_env_template(plugin_name: str, plugin_build_dir: Path, verbose: bool = False) -> None: + """Copy .env.template from plugin repo to {deploy_dir}/env/ directory. + + Uses MCP_DEPLOY_DIR environment variable if set, defaults to './deploy'. + This function is shared between Dagger and plain Python implementations. + + Args: + plugin_name: Name of the plugin + plugin_build_dir: Path to plugin build directory (contains .env.template) + verbose: Print verbose output + + Examples: + >>> from pathlib import Path + >>> import tempfile + >>> import os + >>> # Test with non-existent template (should return early) + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... build_dir = Path(tmpdir) + ... # No .env.template exists, function returns early + ... copy_env_template("test-plugin", build_dir, verbose=False) + + >>> # Test directory creation + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... os.environ["MCP_DEPLOY_DIR"] = tmpdir + ... build_dir = Path(tmpdir) / "build" + ... build_dir.mkdir() + ... template = build_dir / ".env.template" + ... _ = template.write_text("TEST=value") + ... copy_env_template("test", build_dir, verbose=False) + ... env_file = Path(tmpdir) / "env" / ".env.test" + ... env_file.exists() + True + + >>> # Cleanup + >>> _ = os.environ.pop("MCP_DEPLOY_DIR", None) + """ + # Create {deploy_dir}/env directory if it doesn't exist + deploy_dir = get_deploy_dir() + env_dir = deploy_dir / "env" + env_dir.mkdir(parents=True, exist_ok=True) + + # Look for .env.template in plugin build directory + template_file = plugin_build_dir / ".env.template" + if not template_file.exists(): + if verbose: + print(f"No .env.template found in {plugin_name}") + return + + # Target file path + target_file = env_dir / f".env.{plugin_name}" + + # Only copy if target doesn't exist (don't overwrite user edits) + if target_file.exists(): + if verbose: + print(f"⚠ {target_file} already exists, skipping") + return + + # Copy template + shutil.copy2(template_file, target_file) + if verbose: + print(f"✓ Copied .env.template -> {target_file}") + + +def handle_registry_operations(component, component_name: str, image_tag: str, container_runtime: str, verbose: bool = False) -> str: + """Handle registry tagging and pushing for a built component. + + This function is shared between Dagger and plain Python implementations. + It tags the locally built image with the registry path and optionally pushes it. + + Args: + component: BuildableConfig component (GatewayConfig or PluginConfig) + component_name: Name of the component (gateway or plugin name) + image_tag: Current local image tag + container_runtime: Container runtime to use ("docker" or "podman") + verbose: Print verbose output + + Returns: + Final image tag (registry path if registry enabled, otherwise original tag) + + Raises: + TypeError: If component is not a BuildableConfig instance + ValueError: If registry enabled but missing required configuration + subprocess.CalledProcessError: If tag or push command fails + + Examples: + >>> from cforge.commands.deploy.builder.schema import GatewayConfig, RegistryConfig + >>> # Test with registry disabled (returns original tag) + >>> gateway = GatewayConfig(image="test:latest") + >>> result = handle_registry_operations(gateway, "gateway", "test:latest", "docker") + >>> result + 'test:latest' + + >>> # Test type checking - wrong type raises TypeError + >>> try: + ... handle_registry_operations("not a config", "test", "tag:latest", "docker") + ... except TypeError as e: + ... "BuildableConfig" in str(e) + True + + >>> # Test validation error - registry enabled but missing config + >>> from cforge.commands.deploy.builder.schema import GatewayConfig, RegistryConfig + >>> gateway_bad = GatewayConfig( + ... image="test:latest", + ... registry=RegistryConfig(enabled=True, url="docker.io") # missing namespace + ... ) + >>> try: + ... handle_registry_operations(gateway_bad, "gateway", "test:latest", "docker") + ... except ValueError as e: + ... "missing" in str(e) and "namespace" in str(e) + True + + >>> # Test validation error - missing URL + >>> gateway_bad2 = GatewayConfig( + ... image="test:latest", + ... registry=RegistryConfig(enabled=True, namespace="myns") # missing url + ... ) + >>> try: + ... handle_registry_operations(gateway_bad2, "gateway", "test:latest", "docker") + ... except ValueError as e: + ... "missing" in str(e) and "url" in str(e) + True + + >>> # Test function signature + >>> import inspect + >>> sig = inspect.signature(handle_registry_operations) + >>> list(sig.parameters.keys()) + ['component', 'component_name', 'image_tag', 'container_runtime', 'verbose'] + + >>> # Test return type + >>> sig.return_annotation + + """ + # First-Party + from cforge.commands.deploy.builder.schema import BuildableConfig + + # Type check for better error messages + if not isinstance(component, BuildableConfig): + raise TypeError(f"Component must be a BuildableConfig instance, got {type(component)}") + + # Check if registry is enabled + if not component.registry or not component.registry.enabled: + return image_tag + + registry_config = component.registry + + # Validate registry configuration + if not registry_config.url or not registry_config.namespace: + raise ValueError(f"Registry enabled for {component_name} but missing 'url' or 'namespace' configuration") + + # Construct registry image path + # Format: {registry_url}/{namespace}/{image_name}:{tag} + base_image_name = image_tag.split(":")[0].split("/")[-1] # Extract base name (e.g., "mcpgateway-gateway") + image_version = image_tag.split(":")[-1] if ":" in image_tag else "latest" # Extract tag + registry_image = f"{registry_config.url}/{registry_config.namespace}/{base_image_name}:{image_version}" + + # Tag image for registry + if verbose: + get_console().print(f"[dim]Tagging {image_tag} as {registry_image}[/dim]") + tag_cmd = [container_runtime, "tag", image_tag, registry_image] + result = subprocess.run(tag_cmd, capture_output=True, text=True, check=True) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + + # Push to registry if enabled + if registry_config.push: + if verbose: + get_console().print(f"[blue]Pushing {registry_image} to registry...[/blue]") + + # Build push command with TLS options + push_cmd = [container_runtime, "push"] + + # For podman, add --tls-verify=false for registries with self-signed certs + # This is common for OpenShift internal registries and local development + if container_runtime == "podman": + push_cmd.append("--tls-verify=false") + + push_cmd.append(registry_image) + + try: + result = subprocess.run(push_cmd, capture_output=True, text=True, check=True) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + get_console().print(f"[green]✓ Pushed to registry: {registry_image}[/green]") + except subprocess.CalledProcessError as e: + get_console().print(f"[red]✗ Failed to push to registry: {e}[/red]") + if e.stderr: + get_console().print(f"[red]Error output: {e.stderr}[/red]") + get_console().print("[yellow]Tip: Authenticate to the registry first:[/yellow]") + get_console().print(f" {container_runtime} login {registry_config.url}") + raise + + # Update component image reference to use registry path for manifests + component.image = registry_image + + return registry_image + + +# Docker Compose Utilities + + +def get_docker_compose_command() -> List[str]: + """Detect and return available docker compose command. + + Tries to detect docker compose plugin first, then falls back to + standalone docker-compose command. + + Returns: + Command to use: ["docker", "compose"] or ["docker-compose"] + + Raises: + RuntimeError: If neither command is available + + Examples: + >>> # Test that function returns a list + >>> try: + ... cmd = get_docker_compose_command() + ... isinstance(cmd, list) + ... except RuntimeError: + ... # Docker compose not installed in test environment + ... True + True + + >>> # Test that it returns valid command formats + >>> try: + ... cmd = get_docker_compose_command() + ... # Should be either ["docker", "compose"] or ["docker-compose"] + ... cmd in [["docker", "compose"], ["docker-compose"]] + ... except RuntimeError: + ... # Docker compose not installed + ... True + True + + >>> # Test error case (requires mocking, shown for documentation) + >>> # from unittest.mock import patch + >>> # with patch('shutil.which', return_value=None): + >>> # try: + >>> # get_docker_compose_command() + >>> # except RuntimeError as e: + >>> # "Docker Compose not found" in str(e) + >>> # True + """ + # Try docker compose (new plugin) first + if shutil.which("docker"): + try: + subprocess.run(["docker", "compose", "version"], capture_output=True, check=True) # nosec B603, B607 + return ["docker", "compose"] + except (subprocess.CalledProcessError, FileNotFoundError): + pass + + # Fall back to standalone docker-compose + if shutil.which("docker-compose"): + return ["docker-compose"] + + raise RuntimeError("Docker Compose not found. Install docker compose plugin or docker-compose.") + + +def run_compose(compose_file: Path, args: List[str], verbose: bool = False, check: bool = True) -> subprocess.CompletedProcess: + """Run docker compose command with given arguments. + + Args: + compose_file: Path to docker-compose.yaml + args: Arguments to pass to compose (e.g., ["up", "-d"]) + verbose: Print verbose output + check: Raise exception on non-zero exit code + + Returns: + CompletedProcess instance + + Raises: + FileNotFoundError: If compose_file doesn't exist + RuntimeError: If docker compose command fails (when check=True) + + Examples: + >>> from pathlib import Path + >>> import tempfile + >>> # Test with non-existent file + >>> try: + ... run_compose(Path("/nonexistent/docker-compose.yaml"), ["ps"]) + ... except FileNotFoundError as e: + ... "Compose file not found" in str(e) + True + + >>> # Test that args are properly formatted + >>> args = ["up", "-d"] + >>> isinstance(args, list) + True + >>> all(isinstance(arg, str) for arg in args) + True + + >>> # Real execution would require docker compose installed: + >>> # with tempfile.NamedTemporaryFile(suffix=".yaml") as f: + >>> # result = run_compose(Path(f.name), ["--version"], check=False) + >>> # isinstance(result, subprocess.CompletedProcess) + """ + if not compose_file.exists(): + raise FileNotFoundError(f"Compose file not found: {compose_file}") + + compose_cmd = get_docker_compose_command() + full_cmd = compose_cmd + ["-f", str(compose_file)] + args + + if verbose: + get_console().print(f"[dim]Running: {' '.join(full_cmd)}[/dim]") + + try: + result = subprocess.run(full_cmd, capture_output=True, text=True, check=check) # nosec B603, B607 + return result + except subprocess.CalledProcessError as e: + get_console().print("\n[red bold]Docker Compose command failed:[/red bold]") + if e.stdout: + get_console().print(f"[yellow]Output:[/yellow]\n{e.stdout}") + if e.stderr: + get_console().print(f"[red]Error:[/red]\n{e.stderr}") + raise RuntimeError(f"Docker Compose failed with exit code {e.returncode}") from e + + +def deploy_compose(compose_file: Path, verbose: bool = False) -> None: + """Deploy using docker compose up -d. + + Args: + compose_file: Path to docker-compose.yaml + verbose: Print verbose output + + Raises: + RuntimeError: If deployment fails + + Examples: + >>> from pathlib import Path + >>> # Test that function signature is correct + >>> import inspect + >>> sig = inspect.signature(deploy_compose) + >>> 'compose_file' in sig.parameters + True + >>> 'verbose' in sig.parameters + True + + >>> # Test with non-existent file (would fail at run_compose) + >>> # deploy_compose(Path("/nonexistent.yaml")) # Raises FileNotFoundError + """ + result = run_compose(compose_file, ["up", "-d"], verbose=verbose) + if result.stdout and verbose: + get_console().print(result.stdout) + get_console().print("[green]✓ Deployed with Docker Compose[/green]") + + +def verify_compose(compose_file: Path, verbose: bool = False) -> str: + """Verify Docker Compose deployment with ps command. + + Args: + compose_file: Path to docker-compose.yaml + verbose: Print verbose output + + Returns: + Output from docker compose ps command + + Examples: + >>> from pathlib import Path + >>> # Test return type + >>> import inspect + >>> sig = inspect.signature(verify_compose) + >>> sig.return_annotation + + + >>> # Test parameters + >>> list(sig.parameters.keys()) + ['compose_file', 'verbose'] + + >>> # Actual execution requires docker compose: + >>> # output = verify_compose(Path("docker-compose.yaml")) + >>> # isinstance(output, str) + """ + result = run_compose(compose_file, ["ps"], verbose=verbose, check=False) + return result.stdout + + +def destroy_compose(compose_file: Path, verbose: bool = False) -> None: + """Destroy Docker Compose deployment with down -v. + + Args: + compose_file: Path to docker-compose.yaml + verbose: Print verbose output + + Raises: + RuntimeError: If destruction fails + + Examples: + >>> from pathlib import Path + >>> # Test with non-existent file (graceful handling) + >>> destroy_compose(Path("/nonexistent/docker-compose.yaml"), verbose=False) + Compose file not found: /nonexistent/docker-compose.yaml + Nothing to destroy + + >>> # Test function signature + >>> import inspect + >>> sig = inspect.signature(destroy_compose) + >>> 'verbose' in sig.parameters + True + """ + if not compose_file.exists(): + get_console().print(f"[yellow]Compose file not found: {compose_file}[/yellow]") + get_console().print("[yellow]Nothing to destroy[/yellow]") + return + + result = run_compose(compose_file, ["down", "-v"], verbose=verbose) + if result.stdout and verbose: + get_console().print(result.stdout) + get_console().print("[green]✓ Destroyed Docker Compose deployment[/green]") + + +# Kubernetes kubectl utilities + + +def deploy_kubernetes(manifests_dir: Path, verbose: bool = False) -> None: + """Deploy to Kubernetes using kubectl. + + Applies manifests in correct order: + 1. Deployments (creates namespaces) + 2. Certificate resources (secrets or cert-manager CRDs) + 3. ConfigMaps (plugins configuration) + 4. Infrastructure (PostgreSQL, Redis) + 5. OpenShift Routes (if configured) + + Excludes plugins-config.yaml (not a Kubernetes resource). + + Args: + manifests_dir: Path to directory containing Kubernetes manifests + verbose: Print verbose output + + Raises: + RuntimeError: If kubectl not found or deployment fails + + Examples: + >>> from pathlib import Path + >>> import shutil + >>> # Test that function checks for kubectl + >>> if not shutil.which("kubectl"): + ... # Would raise RuntimeError + ... print("kubectl not found") + ... else: + ... print("kubectl available") + kubectl... + + >>> # Test function signature + >>> import inspect + >>> sig = inspect.signature(deploy_kubernetes) + >>> list(sig.parameters.keys()) + ['manifests_dir', 'verbose'] + """ + if not shutil.which("kubectl"): + raise RuntimeError("kubectl not found. Cannot deploy to Kubernetes.") + + # Get all manifest files, excluding plugins-config.yaml (not a Kubernetes resource) + all_manifests = sorted(manifests_dir.glob("*.yaml")) + all_manifests = [m for m in all_manifests if m.name != "plugins-config.yaml"] + + # Identify different types of manifests + cert_secrets = manifests_dir / "cert-secrets.yaml" + cert_manager_certs = manifests_dir / "cert-manager-certificates.yaml" + postgres_deploy = manifests_dir / "postgres-deployment.yaml" + redis_deploy = manifests_dir / "redis-deployment.yaml" + plugins_configmap = manifests_dir / "plugins-configmap.yaml" + + # 1. Apply all deployments first (creates namespaces) + deployment_files = [m for m in all_manifests if m.name.endswith("-deployment.yaml") and m not in [cert_secrets, postgres_deploy, redis_deploy]] + + # Apply deployment files (this creates the namespace) + for manifest in deployment_files: + result = subprocess.run(["kubectl", "apply", "-f", str(manifest)], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + raise RuntimeError(f"kubectl apply failed: {result.stderr}") + + # 2. Apply certificate resources (now namespace exists) + # Check for both cert-secrets.yaml (local mode) and cert-manager-certificates.yaml (cert-manager mode) + if cert_manager_certs.exists(): + result = subprocess.run(["kubectl", "apply", "-f", str(cert_manager_certs)], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + raise RuntimeError(f"kubectl apply failed: {result.stderr}") + elif cert_secrets.exists(): + result = subprocess.run(["kubectl", "apply", "-f", str(cert_secrets)], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + raise RuntimeError(f"kubectl apply failed: {result.stderr}") + + # 3. Apply ConfigMaps (needed by deployments) + if plugins_configmap.exists(): + result = subprocess.run(["kubectl", "apply", "-f", str(plugins_configmap)], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + raise RuntimeError(f"kubectl apply failed: {result.stderr}") + + # 4. Apply infrastructure + for infra_file in [postgres_deploy, redis_deploy]: + if infra_file.exists(): + result = subprocess.run(["kubectl", "apply", "-f", str(infra_file)], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + raise RuntimeError(f"kubectl apply failed: {result.stderr}") + + # 5. Apply OpenShift Routes (if configured) + gateway_route = manifests_dir / "gateway-route.yaml" + if gateway_route.exists(): + result = subprocess.run(["kubectl", "apply", "-f", str(gateway_route)], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + # Don't fail on Route errors (may not be on OpenShift) + if verbose: + get_console().print(f"[yellow]Warning: Could not apply Route (may not be on OpenShift): {result.stderr}[/yellow]") + + get_console().print("[green]✓ Deployed to Kubernetes[/green]") + + +def verify_kubernetes(namespace: str, wait: bool = False, timeout: int = 300, verbose: bool = False) -> str: + """Verify Kubernetes deployment health. + + Args: + namespace: Kubernetes namespace to check + wait: Wait for pods to be ready + timeout: Wait timeout in seconds + verbose: Print verbose output + + Returns: + String output from kubectl get pods + + Raises: + RuntimeError: If kubectl not found or verification fails + + Examples: + >>> # Test function signature and return type + >>> import inspect + >>> sig = inspect.signature(verify_kubernetes) + >>> sig.return_annotation + + + >>> # Test parameters + >>> params = list(sig.parameters.keys()) + >>> 'namespace' in params and 'wait' in params and 'timeout' in params + True + + >>> # Test default timeout value + >>> sig.parameters['timeout'].default + 300 + """ + if not shutil.which("kubectl"): + raise RuntimeError("kubectl not found. Cannot verify Kubernetes deployment.") + + # Get pod status + result = subprocess.run(["kubectl", "get", "pods", "-n", namespace], capture_output=True, text=True, check=False) # nosec B603, B607 + output = result.stdout if result.stdout else "" + if result.returncode != 0: + raise RuntimeError(f"kubectl get pods failed: {result.stderr}") + + # Wait for pods if requested + if wait: + result = subprocess.run(["kubectl", "wait", "--for=condition=Ready", "pod", "--all", "-n", namespace, f"--timeout={timeout}s"], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0: + raise RuntimeError(f"kubectl wait failed: {result.stderr}") + + return output + + +def destroy_kubernetes(manifests_dir: Path, verbose: bool = False) -> None: + """Destroy Kubernetes deployment. + + Args: + manifests_dir: Path to directory containing Kubernetes manifests + verbose: Print verbose output + + Raises: + RuntimeError: If kubectl not found or destruction fails + + Examples: + >>> from pathlib import Path + >>> # Test with non-existent directory (graceful handling) + >>> import shutil + >>> if shutil.which("kubectl"): + ... destroy_kubernetes(Path("/nonexistent/manifests"), verbose=False) + ... else: + ... print("kubectl not available") + Manifests directory not found: /nonexistent/manifests + Nothing to destroy + + >>> # Test function signature + >>> import inspect + >>> sig = inspect.signature(destroy_kubernetes) + >>> list(sig.parameters.keys()) + ['manifests_dir', 'verbose'] + """ + if not shutil.which("kubectl"): + raise RuntimeError("kubectl not found. Cannot destroy Kubernetes deployment.") + + if not manifests_dir.exists(): + get_console().print(f"[yellow]Manifests directory not found: {manifests_dir}[/yellow]") + get_console().print("[yellow]Nothing to destroy[/yellow]") + return + + # Delete all manifests except plugins-config.yaml + all_manifests = sorted(manifests_dir.glob("*.yaml")) + all_manifests = [m for m in all_manifests if m.name != "plugins-config.yaml"] + + for manifest in all_manifests: + result = subprocess.run(["kubectl", "delete", "-f", str(manifest), "--ignore-not-found=true"], capture_output=True, text=True, check=False) # nosec B603, B607 + if result.stdout and verbose: + get_console().print(result.stdout) + if result.returncode != 0 and "NotFound" not in result.stderr: + get_console().print(f"[yellow]Warning: {result.stderr}[/yellow]") + + get_console().print("[green]✓ Destroyed Kubernetes deployment[/green]") diff --git a/cforge/commands/deploy/builder/dagger_deploy.py b/cforge/commands/deploy/builder/dagger_deploy.py new file mode 100644 index 0000000..70bf416 --- /dev/null +++ b/cforge/commands/deploy/builder/dagger_deploy.py @@ -0,0 +1,554 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/tools/builder/dagger_deploy.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Dagger-based MCP Stack Deployment Module + +This module provides optimized build and deployment using Dagger. + +Features: +- Automatic caching and parallelization +- Content-addressable storage +- Efficient multi-stage builds +- Built-in layer caching +""" + +# Standard +from pathlib import Path +from typing import List, Optional + +try: + # Third-Party + import dagger + from dagger import dag + + DAGGER_AVAILABLE = True +except ImportError: + DAGGER_AVAILABLE = False + dagger = None # type: ignore + dag = None # type: ignore + +# Third-Party +from rich.progress import Progress, SpinnerColumn, TextColumn + +# First-Party +from cforge.commands.deploy.builder.common import ( + deploy_compose, + deploy_kubernetes, + destroy_compose, + destroy_kubernetes, + generate_compose_manifests, + generate_kubernetes_manifests, + generate_plugin_config, + get_deploy_dir, + handle_registry_operations, + load_config, + verify_compose, + verify_kubernetes, +) +from cforge.commands.deploy.builder.common import copy_env_template as copy_template +from cforge.commands.deploy.builder.pipeline import CICDModule +from cforge.commands.deploy.builder.schema import BuildableConfig, MCPStackConfig + + +class MCPStackDagger(CICDModule): + """Dagger-based implementation of MCP Stack deployment.""" + + def __init__(self, verbose: bool = False): + """Initialize MCPStackDagger instance. + + Args: + verbose: Enable verbose output + + Raises: + ImportError: If dagger is not installed + """ + if not DAGGER_AVAILABLE: + raise ImportError("Dagger is not installed. Install with: pip install dagger-io\n" "Alternatively, use the plain Python deployer with --deployer=python") + super().__init__(verbose) + + async def build(self, config_file: str, plugins_only: bool = False, specific_plugins: Optional[List[str]] = None, no_cache: bool = False, copy_env_templates: bool = False) -> None: + """Build gateway and plugin containers using Dagger. + + Args: + config_file: Path to mcp-stack.yaml + plugins_only: Only build plugins, skip gateway + specific_plugins: List of specific plugin names to build + no_cache: Disable Dagger cache + copy_env_templates: Copy .env.template files from cloned repos + + Raises: + Exception: If build fails for any component + """ + config = load_config(config_file) + + async with dagger.connection(dagger.Config(workdir=str(Path.cwd()))): + # Build gateway (unless plugins_only=True) + if not plugins_only: + gateway = config.gateway + if gateway.repo: + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=self.console) as progress: + task = progress.add_task("Building gateway...", total=None) + try: + await self._build_component_with_dagger(gateway, "gateway", no_cache=no_cache) + progress.update(task, completed=1, description="[green]✓ Built gateway[/green]") + except Exception as e: + progress.update(task, completed=1, description="[red]✗ Failed gateway[/red]") + # Print full error after progress bar closes + self.get_console().print("\n[red bold]Gateway build failed:[/red bold]") + self.get_console().print(f"[red]{type(e).__name__}: {str(e)}[/red]") + if self.verbose: + # Standard + import traceback + + self.get_console().print(f"[dim]{traceback.format_exc()}[/dim]") + raise + elif self.verbose: + self.get_console().print("[dim]Skipping gateway build (using pre-built image)[/dim]") + + # Build plugins + plugins = config.plugins + + if specific_plugins: + plugins = [p for p in plugins if p.name in specific_plugins] + + if not plugins: + self.get_console().print("[yellow]No plugins to build[/yellow]") + return + + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=self.console) as progress: + + for plugin in plugins: + plugin_name = plugin.name + + # Skip if pre-built image specified + if plugin.image and not plugin.repo: + task = progress.add_task(f"Skipping {plugin_name} (using pre-built image)", total=1) + progress.update(task, completed=1) + continue + + task = progress.add_task(f"Building {plugin_name}...", total=None) + + try: + await self._build_component_with_dagger(plugin, plugin_name, no_cache=no_cache, copy_env_templates=copy_env_templates) + progress.update(task, completed=1, description=f"[green]✓ Built {plugin_name}[/green]") + except Exception as e: + progress.update(task, completed=1, description=f"[red]✗ Failed {plugin_name}[/red]") + # Print full error after progress bar closes + self.get_console().print(f"\n[red bold]Plugin '{plugin_name}' build failed:[/red bold]") + self.get_console().print(f"[red]{type(e).__name__}: {str(e)}[/red]") + if self.verbose: + # Standard + import traceback + + self.get_console().print(f"[dim]{traceback.format_exc()}[/dim]") + raise + + async def generate_certificates(self, config_file: str) -> None: + """Generate mTLS certificates for plugins. + + Supports two modes: + 1. Local generation (use_cert_manager=false): Uses Dagger to generate certificates locally + 2. cert-manager (use_cert_manager=true): Skips local generation, cert-manager will create certificates + + Args: + config_file: Path to mcp-stack.yaml + + Raises: + dagger.ExecError: If certificate generation command fails (when using local generation) + dagger.QueryError: If Dagger query fails (when using local generation) + """ + config = load_config(config_file) + + # Check if using cert-manager + cert_config = config.certificates + use_cert_manager = cert_config.use_cert_manager if cert_config else False + validity_days = cert_config.validity_days if cert_config else 825 + + if use_cert_manager: + # Skip local generation - cert-manager will handle certificate creation + if self.verbose: + self.get_console().print("[blue]Using cert-manager for certificate management[/blue]") + self.get_console().print("[dim]Skipping local certificate generation (cert-manager will create certificates)[/dim]") + return + + # Local certificate generation (backward compatibility) + if self.verbose: + self.get_console().print("[blue]Generating mTLS certificates locally...[/blue]") + + # Use Dagger container to run certificate generation + async with dagger.connection(dagger.Config(workdir=str(Path.cwd()))): + # Mount current directory + source = dag.host().directory(".") + try: + # Use Alpine with openssl + container = ( + dag.container() + .from_("alpine:latest") + .with_exec(["apk", "add", "--no-cache", "openssl", "python3", "py3-pip", "make", "bash"]) + .with_mounted_directory("/workspace", source) + .with_workdir("/workspace") + # .with_exec(["python3", "-m", "venv", ".venv"]) + # .with_exec(["sh", "-c", "source .venv/bin/activate && pip install pyyaml"]) + # .with_exec(["pip", "install", "pyyaml"]) + ) + + # Generate CA + container = container.with_exec(["sh", "-c", f"make certs-mcp-ca MCP_CERT_DAYS={validity_days}"]) + + # Generate gateway cert + container = container.with_exec(["sh", "-c", f"make certs-mcp-gateway MCP_CERT_DAYS={validity_days}"]) + + # Generate plugin certificates + plugins = config.plugins + for plugin in plugins: + plugin_name = plugin.name + container = container.with_exec(["sh", "-c", f"make certs-mcp-plugin PLUGIN_NAME={plugin_name} MCP_CERT_DAYS={validity_days}"]) + + # Export certificates back to host + output = container.directory("/workspace/certs") + await output.export("./certs") + except dagger.ExecError as e: + self.get_console().print(f"Dagger Exec Error: {e.message}") + self.get_console().print(f"Exit Code: {e.exit_code}") + self.get_console().print(f"Stderr: {e.stderr}") + raise + except dagger.QueryError as e: + self.get_console().print(f"Dagger Query Error: {e.errors}") + self.get_console().print(f"Debug Query: {e.debug_query()}") + raise + except Exception as e: + self.get_console().print(f"An unexpected error occurred: {e}") + raise + + if self.verbose: + self.get_console().print("[green]✓ Certificates generated locally[/green]") + + async def deploy(self, config_file: str, dry_run: bool = False, skip_build: bool = False, skip_certs: bool = False, output_dir: Optional[str] = None) -> None: + """Deploy MCP stack. + + Args: + config_file: Path to mcp-stack.yaml + dry_run: Generate manifests without deploying + skip_build: Skip building containers + skip_certs: Skip certificate generation + output_dir: Output directory for manifests (default: ./deploy) + + Raises: + ValueError: If unsupported deployment type specified + dagger.ExecError: If deployment command fails + dagger.QueryError: If Dagger query fails + """ + config = load_config(config_file) + + # Build containers + if not skip_build: + await self.build(config_file) + + # Generate certificates (only if mTLS is enabled) + gateway_mtls = config.gateway.mtls_enabled if config.gateway.mtls_enabled is not None else True + plugin_mtls = any((p.mtls_enabled if p.mtls_enabled is not None else True) for p in config.plugins) + mtls_needed = gateway_mtls or plugin_mtls + + if not skip_certs and mtls_needed: + await self.generate_certificates(config_file) + elif not skip_certs and not mtls_needed: + if self.verbose: + self.get_console().print("[dim]Skipping certificate generation (mTLS disabled)[/dim]") + + # Generate manifests + manifests_dir = self.generate_manifests(config_file, output_dir=output_dir) + + if dry_run: + self.get_console().print(f"[yellow]Dry-run: Manifests generated in {manifests_dir}[/yellow]") + return + + # Apply deployment + deployment_type = config.deployment.type + + async with dagger.connection(dagger.Config(workdir=str(Path.cwd()))): + try: + if deployment_type == "kubernetes": + await self._deploy_kubernetes(manifests_dir) + elif deployment_type == "compose": + await self._deploy_compose(manifests_dir) + else: + raise ValueError(f"Unsupported deployment type: {deployment_type}") + except dagger.ExecError as e: + self.get_console().print(f"Dagger Exec Error: {e.message}") + self.get_console().print(f"Exit Code: {e.exit_code}") + self.get_console().print(f"Stderr: {e.stderr}") + raise + except dagger.QueryError as e: + self.get_console().print(f"Dagger Query Error: {e.errors}") + self.get_console().print(f"Debug Query: {e.debug_query()}") + raise + except Exception as e: + # Extract detailed error from Dagger exception + error_msg = str(e) + self.get_console().print("\n[red bold]Deployment failed:[/red bold]") + self.get_console().print(f"[red]{error_msg}[/red]") + + # Check if it's a compose-specific error and try to provide more context + if "compose" in error_msg.lower() and self.verbose: + self.get_console().print("\n[yellow]Hint:[/yellow] Check the generated docker-compose.yaml:") + self.get_console().print(f"[dim] {manifests_dir}/docker-compose.yaml[/dim]") + self.get_console().print("[yellow]Try running manually:[/yellow]") + self.get_console().print(f"[dim] cd {manifests_dir} && docker compose up[/dim]") + + raise + + async def verify(self, config_file: str, wait: bool = False, timeout: int = 300) -> None: + """Verify deployment health. + + Args: + config_file: Path to mcp-stack.yaml + wait: Wait for deployment to be ready + timeout: Wait timeout in seconds + """ + config = load_config(config_file) + deployment_type = config.deployment.type + + if self.verbose: + self.get_console().print("[blue]Verifying deployment...[/blue]") + + async with dagger.connection(dagger.Config(workdir=str(Path.cwd()))): + if deployment_type == "kubernetes": + await self._verify_kubernetes(config, wait=wait, timeout=timeout) + elif deployment_type == "compose": + await self._verify_compose(config, wait=wait, timeout=timeout) + + async def destroy(self, config_file: str) -> None: + """Destroy deployed MCP stack. + + Args: + config_file: Path to mcp-stack.yaml + """ + config = load_config(config_file) + deployment_type = config.deployment.type + + if self.verbose: + self.get_console().print("[blue]Destroying deployment...[/blue]") + + async with dagger.connection(dagger.Config(workdir=str(Path.cwd()))): + if deployment_type == "kubernetes": + await self._destroy_kubernetes(config) + elif deployment_type == "compose": + await self._destroy_compose(config) + + def generate_manifests(self, config_file: str, output_dir: Optional[str] = None) -> Path: + """Generate deployment manifests. + + Args: + config_file: Path to mcp-stack.yaml + output_dir: Output directory for manifests + + Returns: + Path to generated manifests directory + + Raises: + ValueError: If unsupported deployment type specified + """ + config = load_config(config_file) + deployment_type = config.deployment.type + + if output_dir is None: + deploy_dir = get_deploy_dir() + # Separate subdirectories for kubernetes and compose + manifests_path = deploy_dir / "manifests" / deployment_type + else: + manifests_path = Path(output_dir) + + manifests_path.mkdir(parents=True, exist_ok=True) + + # Store output dir for later use + self._last_output_dir = manifests_path + + # Generate plugin config.yaml for gateway (shared function) + generate_plugin_config(config, manifests_path, verbose=self.verbose) + + if deployment_type == "kubernetes": + generate_kubernetes_manifests(config, manifests_path, verbose=self.verbose) + elif deployment_type == "compose": + generate_compose_manifests(config, manifests_path, verbose=self.verbose) + else: + raise ValueError(f"Unsupported deployment type: {deployment_type}") + + return manifests_path + + # Private helper methods + + async def _build_component_with_dagger(self, component: BuildableConfig, component_name: str, no_cache: bool = False, copy_env_templates: bool = False) -> None: + """Build a component (gateway or plugin) container using Dagger. + + Args: + component: Component configuration (GatewayConfig or PluginConfig) + component_name: Name of the component (gateway or plugin name) + no_cache: Disable cache + copy_env_templates: Copy .env.template from repo if it exists + + Raises: + ValueError: If component has no repo field + Exception: If build or export fails + """ + repo = component.repo + + if not repo: + raise ValueError(f"Component '{component_name}' has no 'repo' field") + + # Clone repository to local directory for env template access + git_ref = component.ref or "main" + clone_dir = Path(f"./build/{component_name}") + + # For Dagger, we still need local clone if copying env templates + if copy_env_templates: + # Standard + import subprocess # nosec B404 + + clone_dir.mkdir(parents=True, exist_ok=True) + + if (clone_dir / ".git").exists(): + subprocess.run(["git", "fetch", "origin", git_ref], cwd=clone_dir, check=True, capture_output=True) # nosec B603, B607 + # Checkout what we just fetched (FETCH_HEAD) + subprocess.run(["git", "checkout", "FETCH_HEAD"], cwd=clone_dir, check=True, capture_output=True) # nosec B603, B607 + else: + subprocess.run(["git", "clone", "--branch", git_ref, "--depth", "1", repo, str(clone_dir)], check=True, capture_output=True) # nosec B603, B607 + + # Determine build context + build_context = component.context or "." + build_dir = clone_dir / build_context + + # Copy env template using shared function + copy_template(component_name, build_dir, verbose=self.verbose) + + # Use Dagger for the actual build + source = dag.git(repo).branch(git_ref).tree() + + # If component has context subdirectory, navigate to it + build_context = component.context or "." + if build_context != ".": + source = source.directory(build_context) + + # Detect Containerfile/Dockerfile + containerfile = component.containerfile or "Containerfile" + + # Build container - determine image tag + if component.image: + # Use explicitly specified image name + image_tag = component.image + else: + # Generate default image name based on component type + image_tag = f"mcpgateway-{component_name.lower()}:latest" + + # Build with optional target stage for multi-stage builds + build_kwargs = {"dockerfile": containerfile} + if component.target: + build_kwargs["target"] = component.target + + # Use docker_build on the directory + container = source.docker_build(**build_kwargs) + + # Export image to Docker daemon (always export, Dagger handles caching) + # Workaround for dagger-io 0.19.0 bug: export_image returns None instead of Void + # The export actually works, but beartype complains about the return type + try: + await container.export_image(image_tag) + except Exception as e: + # Ignore beartype validation error - the export actually succeeds + if "BeartypeCallHintReturnViolation" not in str(type(e)): + raise + + # Handle registry operations (tag and push if enabled) + # Note: Dagger exports to local docker/podman, so we need to detect which runtime to use + # Standard + import shutil + + container_runtime = "docker" if shutil.which("docker") else "podman" + image_tag = handle_registry_operations(component, component_name, image_tag, container_runtime, verbose=self.verbose) + + if self.verbose: + self.get_console().print(f"[green]✓ Built {component_name} -> {image_tag}[/green]") + + async def _deploy_kubernetes(self, manifests_dir: Path) -> None: + """Deploy to Kubernetes using kubectl. + + Uses shared deploy_kubernetes() from common.py to avoid code duplication. + + Args: + manifests_dir: Path to directory containing Kubernetes manifests + """ + deploy_kubernetes(manifests_dir, verbose=self.verbose) + + async def _deploy_compose(self, manifests_dir: Path) -> None: + """Deploy using Docker Compose. + + Uses shared deploy_compose() from common.py to avoid code duplication. + + Args: + manifests_dir: Path to directory containing compose manifest + """ + compose_file = manifests_dir / "docker-compose.yaml" + deploy_compose(compose_file, verbose=self.verbose) + + async def _verify_kubernetes(self, config: MCPStackConfig, wait: bool = False, timeout: int = 300) -> None: + """Verify Kubernetes deployment health. + + Uses shared verify_kubernetes() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + wait: Wait for pods to be ready + timeout: Wait timeout in seconds + """ + namespace = config.deployment.namespace or "mcp-gateway" + output = verify_kubernetes(namespace, wait=wait, timeout=timeout, verbose=self.verbose) + self.get_console().print(output) + + async def _verify_compose(self, config: MCPStackConfig, wait: bool = False, timeout: int = 300) -> None: + """Verify Docker Compose deployment health. + + Uses shared verify_compose() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + wait: Wait for containers to be ready + timeout: Wait timeout in seconds + """ + _ = config, wait, timeout # Reserved for future use + # Use the same manifests directory as generate_manifests + deploy_dir = get_deploy_dir() + output_dir = getattr(self, "_last_output_dir", deploy_dir / "manifests" / "compose") + compose_file = output_dir / "docker-compose.yaml" + output = verify_compose(compose_file, verbose=self.verbose) + self.get_console().print(output) + + async def _destroy_kubernetes(self, config: MCPStackConfig) -> None: + """Destroy Kubernetes deployment. + + Uses shared destroy_kubernetes() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + """ + _ = config # Reserved for future use (namespace, labels, etc.) + # Use the same manifests directory as generate_manifests + deploy_dir = get_deploy_dir() + manifests_dir = getattr(self, "_last_output_dir", deploy_dir / "manifests" / "kubernetes") + destroy_kubernetes(manifests_dir, verbose=self.verbose) + + async def _destroy_compose(self, config: MCPStackConfig) -> None: + """Destroy Docker Compose deployment. + + Uses shared destroy_compose() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + """ + _ = config # Reserved for future use (project name, networks, etc.) + # Use the same manifests directory as generate_manifests + deploy_dir = get_deploy_dir() + output_dir = getattr(self, "_last_output_dir", deploy_dir / "manifests" / "compose") + compose_file = output_dir / "docker-compose.yaml" + destroy_compose(compose_file, verbose=self.verbose) diff --git a/cforge/commands/deploy/builder/factory.py b/cforge/commands/deploy/builder/factory.py new file mode 100644 index 0000000..34b8014 --- /dev/null +++ b/cforge/commands/deploy/builder/factory.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/tools/builder/factory.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Factory for creating MCP Stack deployment implementations. + +This module provides a factory pattern for creating the appropriate deployment +implementation (Dagger or Plain Python) based on availability and user preference. + +The factory handles graceful fallback from Dagger to Python if dependencies are +unavailable, ensuring the deployment system works in various environments. + +Example: + >>> deployer, mode = DeployFactory.create_deployer("dagger", verbose=False) + ⚠ Dagger not installed. Using plain python. + >>> # Validate configuration (output varies by config) + >>> # deployer.validate("mcp-stack.yaml") +""" + +# Standard +from enum import Enum + +# First-Party +from cforge.commands.deploy.builder.pipeline import CICDModule +from cforge.common import get_console + + +class CICDTypes(str, Enum): + """Deployment implementation types. + + Attributes: + DAGGER: Dagger-based implementation (optimal performance) + PYTHON: Plain Python implementation (fallback, no dependencies) + + Examples: + >>> # Test enum values + >>> CICDTypes.DAGGER.value + 'dagger' + >>> CICDTypes.PYTHON.value + 'python' + + >>> # Test enum comparison + >>> CICDTypes.DAGGER == "dagger" + True + >>> CICDTypes.PYTHON == "python" + True + + >>> # Test enum membership + >>> "dagger" in [t.value for t in CICDTypes] + True + >>> "python" in [t.value for t in CICDTypes] + True + + >>> # Test enum iteration + >>> types = list(CICDTypes) + >>> len(types) + 2 + >>> CICDTypes.DAGGER in types + True + """ + + DAGGER = "dagger" + PYTHON = "python" + + +class DeployFactory: + """Factory for creating MCP Stack deployment implementations. + + This factory implements the Strategy pattern, allowing dynamic selection + between Dagger and Python implementations based on availability. + """ + + @staticmethod + def create_deployer(deployer: str, verbose: bool = False) -> tuple[CICDModule, CICDTypes]: + """Create a deployment implementation instance. + + Attempts to load the requested deployer type with automatic fallback + to Python implementation if dependencies are missing. + + Args: + deployer: Deployment type to create ("dagger" or "python") + verbose: Enable verbose logging during creation + + Returns: + tuple: (deployer_instance, actual_type) + - deployer_instance: Instance of MCPStackDagger or MCPStackPython + - actual_type: CICDTypes enum indicating which implementation was loaded + + Raises: + RuntimeError: If no implementation can be loaded (critical failure) + + Example: + >>> # Try to load Dagger, fall back to Python if unavailable + >>> deployer, mode = DeployFactory.create_deployer("dagger", verbose=False) + ⚠ Dagger not installed. Using plain python. + >>> if mode == CICDTypes.DAGGER: + ... print("Using optimized Dagger implementation") + ... else: + ... print("Using fallback Python implementation") + Using fallback Python implementation + """ + # Attempt to load Dagger implementation first if requested + if deployer == "dagger": + try: + # First-Party + from cforge.commands.deploy.builder.dagger_deploy import DAGGER_AVAILABLE, MCPStackDagger + + # Check if dagger is actually available (not just the module) + if not DAGGER_AVAILABLE: + raise ImportError("Dagger SDK not installed") + + if verbose: + get_console().print("[green]✓ Dagger module loaded[/green]") + + return (MCPStackDagger(verbose), CICDTypes.DAGGER) + + except ImportError: + # Dagger dependencies not available, fall back to Python + get_console().print("[yellow]⚠ Dagger not installed. Using plain python.[/yellow]") + + # Load plain Python implementation (fallback or explicitly requested) + try: + # First-Party + from cforge.commands.deploy.builder.python_deploy import MCPStackPython + + if verbose and deployer != "dagger": + get_console().print("[blue]Using plain Python implementation[/blue]") + + return (MCPStackPython(verbose), CICDTypes.PYTHON) + + except ImportError as e: + # Critical failure - neither implementation can be loaded + get_console().print("[red]✗ ERROR: Cannot import deployment modules[/red]") + get_console().print(f"[red] Details: {e}[/red]") + get_console().print("[yellow] Make sure you're running from the project root[/yellow]") + get_console().print("[yellow] and PYTHONPATH is set correctly[/yellow]") + + # This should never be reached if PYTHONPATH is set correctly + raise RuntimeError(f"Unable to load deployer of type '{deployer}'. ") diff --git a/cforge/commands/deploy/builder/pipeline.py b/cforge/commands/deploy/builder/pipeline.py new file mode 100644 index 0000000..12c4dc5 --- /dev/null +++ b/cforge/commands/deploy/builder/pipeline.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/tools/builder/pipeline.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Abstract base class for MCP Stack deployment implementations. + +This module defines the CICDModule interface that all deployment implementations +must implement. It provides a common API for building, deploying, and managing +MCP Gateway stacks with external plugin servers. + +The base class implements shared functionality (validation) while requiring +subclasses to implement deployment-specific logic (build, deploy, etc.). + +Design Pattern: + Strategy Pattern - Different implementations (Dagger vs Python) can be + swapped transparently via the DeployFactory. + +Example: + >>> from cforge.commands.deploy.builder.factory import DeployFactory + >>> deployer, mode = DeployFactory.create_deployer("dagger", verbose=False) + ⚠ Dagger not installed. Using plain python. + >>> # Validate configuration (output varies by config) + >>> # deployer.validate("mcp-stack.yaml") + >>> # Async methods must be called with await (see method examples below) +""" + +# Standard +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + +# Third-Party +from pydantic import ValidationError +import yaml + +# First-Party +from cforge.commands.deploy.builder.schema import MCPStackConfig +from cforge.common import get_console + + +class CICDModule(ABC): + """Abstract base class for MCP Stack deployment implementations. + + This class defines the interface that all deployment implementations must + implement. It provides common initialization and validation logic while + deferring implementation-specific details to subclasses. + + Attributes: + verbose (bool): Enable verbose output during operations + console (Console): Rich console for formatted output + + Implementations: + - MCPStackDagger: High-performance implementation using Dagger SDK + - MCPStackPython: Fallback implementation using plain Python + Docker/Podman + + Examples: + >>> # Test that CICDModule is abstract + >>> from abc import ABC + >>> issubclass(CICDModule, ABC) + True + + >>> # Test initialization with defaults + >>> class TestDeployer(CICDModule): + ... async def build(self, config_file: str, **kwargs) -> None: + ... pass + ... async def generate_certificates(self, config_file: str) -> None: + ... pass + ... async def deploy(self, config_file: str, **kwargs) -> None: + ... pass + ... async def verify(self, config_file: str, **kwargs) -> None: + ... pass + ... async def destroy(self, config_file: str) -> None: + ... pass + ... def generate_manifests(self, config_file: str, **kwargs) -> Path: + ... return Path(".") + >>> deployer = TestDeployer() + >>> deployer.verbose + False + + >>> # Test initialization with verbose=True + >>> verbose_deployer = TestDeployer(verbose=True) + >>> verbose_deployer.verbose + True + + >>> # Test that console is available + >>> hasattr(deployer, 'console') + True + """ + + def __init__(self, verbose: bool = False): + """Initialize the deployment module. + + Args: + verbose: Enable verbose output during all operations + + Examples: + >>> # Cannot instantiate abstract class directly + >>> try: + ... CICDModule() + ... except TypeError as e: + ... "abstract" in str(e).lower() + True + """ + self.verbose = verbose + + def validate(self, config_file: str) -> None: + """Validate mcp-stack.yaml configuration using Pydantic schemas. + + This method provides comprehensive validation of the MCP stack configuration + using Pydantic models defined in schema.py. It validates: + - Required sections (deployment, gateway, plugins) + - Deployment type (kubernetes or compose) + - Gateway image specification + - Plugin configurations (name, repo/image, etc.) + - Custom business rules (unique names, valid combinations) + + Args: + config_file: Path to mcp-stack.yaml configuration file + + Raises: + ValueError: If configuration is invalid, with formatted error details + ValidationError: If Pydantic schema validation fails + FileNotFoundError: If config_file does not exist + + Examples: + >>> import tempfile + >>> import yaml + >>> from pathlib import Path + >>> # Create a test deployer + >>> class TestDeployer(CICDModule): + ... async def build(self, config_file: str, **kwargs) -> None: + ... pass + ... async def generate_certificates(self, config_file: str) -> None: + ... pass + ... async def deploy(self, config_file: str, **kwargs) -> None: + ... pass + ... async def verify(self, config_file: str, **kwargs) -> None: + ... pass + ... async def destroy(self, config_file: str) -> None: + ... pass + ... def generate_manifests(self, config_file: str, **kwargs) -> Path: + ... return Path(".") + >>> deployer = TestDeployer(verbose=False) + + >>> # Test with valid minimal config + >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + ... config = { + ... 'deployment': {'type': 'compose'}, + ... 'gateway': {'image': 'test:latest'}, + ... 'plugins': [] + ... } + ... yaml.dump(config, f) + ... config_path = f.name + >>> deployer.validate(config_path) + >>> import os + >>> os.unlink(config_path) + + >>> # Test with missing file + >>> try: + ... deployer.validate("/nonexistent/config.yaml") + ... except FileNotFoundError as e: + ... "config.yaml" in str(e) + True + + >>> # Test with invalid config (missing required fields) + >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + ... bad_config = {'deployment': {'type': 'compose'}} + ... yaml.dump(bad_config, f) + ... bad_path = f.name + >>> try: + ... deployer.validate(bad_path) + ... except ValueError as e: + ... "validation failed" in str(e).lower() + True + >>> os.unlink(bad_path) + """ + if self.verbose: + get_console().print(f"[blue]Validating {config_file}...[/blue]") + + # Load YAML configuration + with open(config_file, "r") as f: + config_dict = yaml.safe_load(f) + + # Validate using Pydantic schema + try: + # Local + + MCPStackConfig(**config_dict) + except ValidationError as e: + # Format validation errors for better readability + error_msg = "Configuration validation failed:\n" + for error in e.errors(): + # Join the error location path (e.g., plugins -> 0 -> name) + loc = " -> ".join(str(x) for x in error["loc"]) + error_msg += f" • {loc}: {error['msg']}\n" + raise ValueError(error_msg) from e + + if self.verbose: + get_console().print("[green]✓ Configuration valid[/green]") + + @abstractmethod + async def build(self, config_file: str, plugins_only: bool = False, specific_plugins: Optional[list[str]] = None, no_cache: bool = False, copy_env_templates: bool = False) -> None: + """Build container images for plugins and/or gateway. + + Subclasses must implement this to build Docker/Podman images from + Git repositories or use pre-built images. + + Args: + config_file: Path to mcp-stack.yaml + plugins_only: Only build plugins, skip gateway + specific_plugins: List of specific plugin names to build (optional) + no_cache: Disable build cache for fresh builds + copy_env_templates: Copy .env.template files from cloned repos + + Raises: + RuntimeError: If build fails + ValueError: If plugin configuration is invalid + + Example: + # await deployer.build("mcp-stack.yaml", plugins_only=True) + # ✓ Built OPAPluginFilter + # ✓ Built LLMGuardPlugin + """ + + @abstractmethod + async def generate_certificates(self, config_file: str) -> None: + """Generate mTLS certificates for gateway and plugins. + + Creates a certificate authority (CA) and issues certificates for: + - Gateway (client certificates for connecting to plugins) + - Each plugin (server certificates for accepting connections) + + Certificates are stored in the paths defined in the config's + certificates section (default: ./certs/mcp/). + + Args: + config_file: Path to mcp-stack.yaml + + Raises: + RuntimeError: If certificate generation fails + FileNotFoundError: If required tools (openssl) are not available + + Example: + # await deployer.generate_certificates("mcp-stack.yaml") + # ✓ Certificates generated + """ + + @abstractmethod + async def deploy(self, config_file: str, dry_run: bool = False, skip_build: bool = False, skip_certs: bool = False) -> None: + """Deploy the MCP stack to Kubernetes or Docker Compose. + + This is the main deployment method that orchestrates: + 1. Building containers (unless skip_build=True) + 2. Generating mTLS certificates (unless skip_certs=True or mTLS disabled) + 3. Generating manifests (Kubernetes YAML or docker-compose.yaml) + 4. Applying the deployment (unless dry_run=True) + + Args: + config_file: Path to mcp-stack.yaml + dry_run: Generate manifests without actually deploying + skip_build: Skip building containers (use existing images) + skip_certs: Skip certificate generation (use existing certs) + + Raises: + RuntimeError: If deployment fails at any stage + ValueError: If configuration is invalid + + Example: + # Full deployment + # await deployer.deploy("mcp-stack.yaml") + # ✓ Build complete + # ✓ Certificates generated + # ✓ Deployment complete + + # Dry run (generate manifests only) + # await deployer.deploy("mcp-stack.yaml", dry_run=True) + # ✓ Dry-run complete (no changes made) + """ + + @abstractmethod + async def verify(self, config_file: str, wait: bool = False, timeout: int = 300) -> None: + """Verify deployment health and readiness. + + Checks that all deployed services are healthy and ready: + - Kubernetes: Checks pod status, optionally waits for Ready + - Docker Compose: Checks container status + + Args: + config_file: Path to mcp-stack.yaml + wait: Wait for deployment to become ready + timeout: Maximum time to wait in seconds (default: 300) + + Raises: + RuntimeError: If verification fails or timeout is reached + TimeoutError: If wait=True and deployment doesn't become ready + + Example: + # Quick health check + # await deployer.verify("mcp-stack.yaml") + # NAME READY STATUS RESTARTS AGE + # mcpgateway-xxx 1/1 Running 0 2m + # mcp-plugin-opa-xxx 1/1 Running 0 2m + + # Wait for ready state + # await deployer.verify("mcp-stack.yaml", wait=True, timeout=600) + # ✓ Deployment healthy + """ + + @abstractmethod + async def destroy(self, config_file: str) -> None: + """Destroy the deployed MCP stack. + + Removes all deployed resources: + - Kubernetes: Deletes all resources in the namespace + - Docker Compose: Stops and removes containers, networks, volumes + + WARNING: This is destructive and cannot be undone! + + Args: + config_file: Path to mcp-stack.yaml + + Raises: + RuntimeError: If destruction fails + + Example: + # await deployer.destroy("mcp-stack.yaml") + # ✓ Deployment destroyed + """ + + @abstractmethod + def generate_manifests(self, config_file: str, output_dir: Optional[str] = None) -> Path: + """Generate deployment manifests (Kubernetes YAML or docker-compose.yaml). + + Creates deployment manifests based on configuration: + - Kubernetes: Generates Deployment, Service, ConfigMap, Secret YAML files + - Docker Compose: Generates docker-compose.yaml with all services + + Also generates: + - plugins-config.yaml: Plugin manager configuration for gateway + - Environment files: .env files for each service + + Args: + config_file: Path to mcp-stack.yaml + output_dir: Output directory for manifests (default: ./deploy/manifests) + + Returns: + Path: Directory containing generated manifests + + Raises: + ValueError: If configuration is invalid + OSError: If output directory cannot be created + + Example: + # manifests_path = deployer.generate_manifests("mcp-stack.yaml") + # print(f"Manifests generated in: {manifests_path}") + # Manifests generated in: /path/to/deploy/manifests + + # Custom output directory + # deployer.generate_manifests("mcp-stack.yaml", output_dir="./my-manifests") + # ✓ Manifests generated: ./my-manifests + """ diff --git a/cforge/commands/deploy/builder/python_deploy.py b/cforge/commands/deploy/builder/python_deploy.py new file mode 100644 index 0000000..f575467 --- /dev/null +++ b/cforge/commands/deploy/builder/python_deploy.py @@ -0,0 +1,601 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/tools/builder/python_deploy.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Plain Python MCP Stack Deployment Module + +This module provides deployment functionality using only standard Python +and system commands (docker/podman, kubectl, docker-compose). + +This is the fallback implementation when Dagger is not available. +""" + +# Standard +from pathlib import Path +import shutil +import subprocess # nosec B404 +from typing import List, Optional + +# Third-Party +from rich.progress import Progress, SpinnerColumn, TextColumn + +# First-Party +from cforge.commands.deploy.builder.common import ( + deploy_compose, + deploy_kubernetes, + destroy_compose, + destroy_kubernetes, + generate_compose_manifests, + generate_kubernetes_manifests, + generate_plugin_config, + get_deploy_dir, + handle_registry_operations, + load_config, + verify_compose, + verify_kubernetes, +) +from cforge.commands.deploy.builder.common import copy_env_template as copy_template +from cforge.commands.deploy.builder.pipeline import CICDModule +from cforge.commands.deploy.builder.schema import BuildableConfig, MCPStackConfig +from cforge.common import get_console + + +class MCPStackPython(CICDModule): + """Plain Python implementation of MCP Stack deployment. + + This implementation uses standard Python and system commands (docker/podman, + kubectl, docker-compose) without requiring additional dependencies like Dagger. + + Examples: + >>> # Test class instantiation + >>> deployer = MCPStackPython(verbose=False) + >>> deployer.verbose + False + + >>> # Test with verbose mode + >>> deployer_verbose = MCPStackPython(verbose=True) + >>> deployer_verbose.verbose + True + + >>> # Test that console is available + >>> hasattr(deployer, 'console') + True + + >>> # Test that it's a CICDModule subclass + >>> from cforge.commands.deploy.builder.pipeline import CICDModule + >>> isinstance(deployer, CICDModule) + True + """ + + async def build(self, config_file: str, plugins_only: bool = False, specific_plugins: Optional[List[str]] = None, no_cache: bool = False, copy_env_templates: bool = False) -> None: + """Build gateway and plugin containers using docker/podman. + + Args: + config_file: Path to mcp-stack.yaml + plugins_only: Only build plugins, skip gateway + specific_plugins: List of specific plugin names to build + no_cache: Disable build cache + copy_env_templates: Copy .env.template files from cloned repos + + Raises: + Exception: If build fails for any component + """ + config = load_config(config_file) + + # Build gateway (unless plugins_only=True) + if not plugins_only: + gateway = config.gateway + if gateway.repo: + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=get_console()) as progress: + task = progress.add_task("Building gateway...", total=None) + try: + self._build_component(gateway, config, "gateway", no_cache=no_cache) + progress.update(task, completed=1, description="[green]✓ Built gateway[/green]") + except Exception as e: + progress.update(task, completed=1, description="[red]✗ Failed gateway[/red]") + # Print full error after progress bar closes + get_console().print("\n[red bold]Gateway build failed:[/red bold]") + get_console().print(f"[red]{type(e).__name__}: {str(e)}[/red]") + if self.verbose: + # Standard + import traceback + + get_console().print(f"[dim]{traceback.format_exc()}[/dim]") + raise + elif self.verbose: + get_console().print("[dim]Skipping gateway build (using pre-built image)[/dim]") + + # Build plugins + plugins = config.plugins + + if specific_plugins: + plugins = [p for p in plugins if p.name in specific_plugins] + + if not plugins: + get_console().print("[yellow]No plugins to build[/yellow]") + return + + with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=get_console()) as progress: + + for plugin in plugins: + plugin_name = plugin.name + + # Skip if pre-built image specified + if plugin.image and not plugin.repo: + task = progress.add_task(f"Skipping {plugin_name} (using pre-built image)", total=1) + progress.update(task, completed=1) + continue + + task = progress.add_task(f"Building {plugin_name}...", total=None) + + try: + self._build_component(plugin, config, plugin_name, no_cache=no_cache, copy_env_templates=copy_env_templates) + progress.update(task, completed=1, description=f"[green]✓ Built {plugin_name}[/green]") + except Exception as e: + progress.update(task, completed=1, description=f"[red]✗ Failed {plugin_name}[/red]") + # Print full error after progress bar closes + get_console().print(f"\n[red bold]Plugin '{plugin_name}' build failed:[/red bold]") + get_console().print(f"[red]{type(e).__name__}: {str(e)}[/red]") + if self.verbose: + # Standard + import traceback + + get_console().print(f"[dim]{traceback.format_exc()}[/dim]") + raise + + async def generate_certificates(self, config_file: str) -> None: + """Generate mTLS certificates for plugins. + + Supports two modes: + 1. Local generation (use_cert_manager=false): Uses Makefile to generate certificates locally + 2. cert-manager (use_cert_manager=true): Skips local generation, cert-manager will create certificates + + Args: + config_file: Path to mcp-stack.yaml + + Raises: + RuntimeError: If make command not found (when using local generation) + """ + config = load_config(config_file) + + # Check if using cert-manager + cert_config = config.certificates + use_cert_manager = cert_config.use_cert_manager if cert_config else False + validity_days = cert_config.validity_days if cert_config else 825 + + if use_cert_manager: + # Skip local generation - cert-manager will handle certificate creation + if self.verbose: + get_console().print("[blue]Using cert-manager for certificate management[/blue]") + get_console().print("[dim]Skipping local certificate generation (cert-manager will create certificates)[/dim]") + return + + # Local certificate generation (backward compatibility) + if self.verbose: + get_console().print("[blue]Generating mTLS certificates locally...[/blue]") + + # Check if make is available + if not shutil.which("make"): + raise RuntimeError("'make' command not found. Cannot generate certificates.") + + # Generate CA + self._run_command(["make", "certs-mcp-ca", f"MCP_CERT_DAYS={validity_days}"]) + + # Generate gateway cert + self._run_command(["make", "certs-mcp-gateway", f"MCP_CERT_DAYS={validity_days}"]) + + # Generate plugin certificates + plugins = config.plugins + for plugin in plugins: + plugin_name = plugin.name + self._run_command(["make", "certs-mcp-plugin", f"PLUGIN_NAME={plugin_name}", f"MCP_CERT_DAYS={validity_days}"]) + + if self.verbose: + get_console().print("[green]✓ Certificates generated locally[/green]") + + async def deploy(self, config_file: str, dry_run: bool = False, skip_build: bool = False, skip_certs: bool = False, output_dir: Optional[str] = None) -> None: + """Deploy MCP stack. + + Args: + config_file: Path to mcp-stack.yaml + dry_run: Generate manifests without deploying + skip_build: Skip building containers + skip_certs: Skip certificate generation + output_dir: Output directory for manifests (default: ./deploy) + + Raises: + ValueError: If unsupported deployment type specified + """ + config = load_config(config_file) + + # Build containers + if not skip_build: + await self.build(config_file) + + # Generate certificates (only if mTLS is enabled) + gateway_mtls = config.gateway.mtls_enabled if config.gateway.mtls_enabled is not None else True + plugin_mtls = any((p.mtls_enabled if p.mtls_enabled is not None else True) for p in config.plugins) + mtls_needed = gateway_mtls or plugin_mtls + + if not skip_certs and mtls_needed: + await self.generate_certificates(config_file) + elif not skip_certs and not mtls_needed: + if self.verbose: + get_console().print("[dim]Skipping certificate generation (mTLS disabled)[/dim]") + + # Generate manifests + manifests_dir = self.generate_manifests(config_file, output_dir=output_dir) + + if dry_run: + get_console().print(f"[yellow]Dry-run: Manifests generated in {manifests_dir}[/yellow]") + return + + # Apply deployment + deployment_type = config.deployment.type + + if deployment_type == "kubernetes": + self._deploy_kubernetes(manifests_dir) + elif deployment_type == "compose": + self._deploy_compose(manifests_dir) + else: + raise ValueError(f"Unsupported deployment type: {deployment_type}") + + async def verify(self, config_file: str, wait: bool = False, timeout: int = 300) -> None: + """Verify deployment health. + + Args: + config_file: Path to mcp-stack.yaml + wait: Wait for deployment to be ready + timeout: Wait timeout in seconds + """ + config = load_config(config_file) + deployment_type = config.deployment.type + + if self.verbose: + get_console().print("[blue]Verifying deployment...[/blue]") + + if deployment_type == "kubernetes": + self._verify_kubernetes(config, wait=wait, timeout=timeout) + elif deployment_type == "compose": + self._verify_compose(config, wait=wait, timeout=timeout) + + async def destroy(self, config_file: str) -> None: + """Destroy deployed MCP stack. + + Args: + config_file: Path to mcp-stack.yaml + """ + config = load_config(config_file) + deployment_type = config.deployment.type + + if self.verbose: + get_console().print("[blue]Destroying deployment...[/blue]") + + if deployment_type == "kubernetes": + self._destroy_kubernetes(config) + elif deployment_type == "compose": + self._destroy_compose(config) + + def generate_manifests(self, config_file: str, output_dir: Optional[str] = None) -> Path: + """Generate deployment manifests. + + Args: + config_file: Path to mcp-stack.yaml + output_dir: Output directory for manifests + + Returns: + Path to generated manifests directory + + Raises: + ValueError: If unsupported deployment type specified + + Examples: + >>> import tempfile + >>> import yaml + >>> from pathlib import Path + >>> deployer = MCPStackPython(verbose=False) + + >>> # Test method signature and return type + >>> import inspect + >>> sig = inspect.signature(deployer.generate_manifests) + >>> 'config_file' in sig.parameters + True + >>> 'output_dir' in sig.parameters + True + >>> sig.return_annotation + + + >>> # Test that method exists and is callable + >>> callable(deployer.generate_manifests) + True + """ + config = load_config(config_file) + deployment_type = config.deployment.type + + if output_dir is None: + deploy_dir = get_deploy_dir() + # Separate subdirectories for kubernetes and compose + output_dir = deploy_dir / "manifests" / deployment_type + else: + output_dir = Path(output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + + # Store output dir for later use + self._last_output_dir = output_dir + + # Generate plugin config.yaml for gateway (shared function) + generate_plugin_config(config, output_dir, verbose=self.verbose) + + if deployment_type == "kubernetes": + generate_kubernetes_manifests(config, output_dir, verbose=self.verbose) + elif deployment_type == "compose": + generate_compose_manifests(config, output_dir, verbose=self.verbose) + else: + raise ValueError(f"Unsupported deployment type: {deployment_type}") + + return output_dir + + # Private helper methods + + def _detect_container_engine(self, config: MCPStackConfig) -> str: + """Detect available container engine (docker or podman). + + Supports both engine names ("docker", "podman") and full paths ("/opt/podman/bin/podman"). + + Args: + config: MCP Stack configuration containing deployment settings + + Returns: + Name or full path to available engine + + Raises: + RuntimeError: If no container engine found + + Examples: + >>> from cforge.commands.deploy.builder.schema import MCPStackConfig, DeploymentConfig, GatewayConfig + >>> deployer = MCPStackPython(verbose=False) + + >>> # Test with docker specified + >>> config = MCPStackConfig( + ... deployment=DeploymentConfig(type="compose", container_engine="docker"), + ... gateway=GatewayConfig(image="test:latest"), + ... plugins=[] + ... ) + >>> result = deployer._detect_container_engine(config) + >>> result in ["docker", "podman"] # Returns available engine + True + + >>> # Test that method returns a string + >>> import shutil + >>> if shutil.which("docker") or shutil.which("podman"): + ... config = MCPStackConfig( + ... deployment=DeploymentConfig(type="compose"), + ... gateway=GatewayConfig(image="test:latest"), + ... plugins=[] + ... ) + ... engine = deployer._detect_container_engine(config) + ... isinstance(engine, str) + ... else: + ... True # Skip test if no container engine available + True + """ + if config.deployment.container_engine: + engine = config.deployment.container_engine + + # Check if it's a full path + if "/" in engine: + if Path(engine).exists() and Path(engine).is_file(): + return engine + else: + raise RuntimeError(f"Specified container engine path does not exist: {engine}") + + # Otherwise treat as command name and check PATH + if shutil.which(engine): + return engine + else: + raise RuntimeError(f"Unable to find specified container engine: {engine}") + + # Auto-detect + if shutil.which("docker"): + return "docker" + elif shutil.which("podman"): + return "podman" + else: + raise RuntimeError("No container engine found. Install docker or podman.") + + def _run_command(self, cmd: List[str], cwd: Optional[Path] = None, capture_output: bool = False) -> subprocess.CompletedProcess: + """Run a shell command. + + Args: + cmd: Command and arguments + cwd: Working directory + capture_output: Capture stdout/stderr + + Returns: + CompletedProcess instance + + Raises: + subprocess.CalledProcessError: If command fails + """ + if self.verbose: + get_console().print(f"[dim]Running: {' '.join(cmd)}[/dim]") + + result = subprocess.run(cmd, cwd=cwd, capture_output=capture_output, text=True, check=True) # nosec B603, B607 + + return result + + def _build_component(self, component: BuildableConfig, config: MCPStackConfig, component_name: str, no_cache: bool = False, copy_env_templates: bool = False) -> None: + """Build a component (gateway or plugin) container using docker/podman. + + Args: + component: Component configuration (GatewayConfig or PluginConfig) + config: Overall stack configuration + component_name: Name of the component (gateway or plugin name) + no_cache: Disable cache + copy_env_templates: Copy .env.template from repo if it exists + + Raises: + ValueError: If component has no repo field + FileNotFoundError: If build context or containerfile not found + """ + repo = component.repo + + container_engine = self._detect_container_engine(config) + + if not repo: + raise ValueError(f"Component '{component_name}' has no 'repo' field") + + # Clone repository + git_ref = component.ref or "main" + clone_dir = Path(f"./build/{component_name}") + clone_dir.mkdir(parents=True, exist_ok=True) + + # Clone or update repo + if (clone_dir / ".git").exists(): + if self.verbose: + get_console().print(f"[dim]Updating {component_name} repository...[/dim]") + self._run_command(["git", "fetch", "origin", git_ref], cwd=clone_dir) + # Checkout what we just fetched (FETCH_HEAD) + self._run_command(["git", "checkout", "FETCH_HEAD"], cwd=clone_dir) + else: + if self.verbose: + get_console().print(f"[dim]Cloning {component_name} repository...[/dim]") + self._run_command(["git", "clone", "--branch", git_ref, "--depth", "1", repo, str(clone_dir)]) + + # Determine build context (subdirectory within repo) + build_context = component.context or "." + build_dir = clone_dir / build_context + + if not build_dir.exists(): + raise FileNotFoundError(f"Build context not found: {build_dir}") + + # Detect Containerfile/Dockerfile + containerfile = component.containerfile or "Containerfile" + containerfile_path = build_dir / containerfile + + if not containerfile_path.exists(): + containerfile = "Dockerfile" + containerfile_path = build_dir / containerfile + if not containerfile_path.exists(): + raise FileNotFoundError(f"No Containerfile or Dockerfile found in {build_dir}") + + # Build container - determine image tag + if component.image: + # Use explicitly specified image name + image_tag = component.image + else: + # Generate default image name based on component type + image_tag = f"mcpgateway-{component_name.lower()}:latest" + + build_cmd = [container_engine, "build", "-f", containerfile, "-t", image_tag] + + if no_cache: + build_cmd.append("--no-cache") + + # Add target stage if specified (for multi-stage builds) + if component.target: + build_cmd.extend(["--target", component.target]) + + # For Docker, add --load to ensure image is loaded into daemon + # (needed for buildx/docker-container driver) + if container_engine == "docker": + build_cmd.append("--load") + + build_cmd.append(".") + + self._run_command(build_cmd, cwd=build_dir) + + # Handle registry operations (tag and push if enabled) + image_tag = handle_registry_operations(component, component_name, image_tag, container_engine, verbose=self.verbose) + + # Copy .env.template if requested and exists + if copy_env_templates: + copy_template(component_name, build_dir, verbose=self.verbose) + + if self.verbose: + get_console().print(f"[green]✓ Built {component_name} -> {image_tag}[/green]") + + def _deploy_kubernetes(self, manifests_dir: Path) -> None: + """Deploy to Kubernetes using kubectl. + + Uses shared deploy_kubernetes() from common.py to avoid code duplication. + + Args: + manifests_dir: Path to directory containing Kubernetes manifests + """ + deploy_kubernetes(manifests_dir, verbose=self.verbose) + + def _deploy_compose(self, manifests_dir: Path) -> None: + """Deploy using Docker Compose. + + Uses shared deploy_compose() from common.py to avoid code duplication. + + Args: + manifests_dir: Path to directory containing compose manifest + """ + compose_file = manifests_dir / "docker-compose.yaml" + deploy_compose(compose_file, verbose=self.verbose) + + def _verify_kubernetes(self, config: MCPStackConfig, wait: bool = False, timeout: int = 300) -> None: + """Verify Kubernetes deployment health. + + Uses shared verify_kubernetes() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + wait: Wait for pods to be ready + timeout: Wait timeout in seconds + """ + namespace = config.deployment.namespace or "mcp-gateway" + output = verify_kubernetes(namespace, wait=wait, timeout=timeout, verbose=self.verbose) + get_console().print(output) + + def _verify_compose(self, config: MCPStackConfig, wait: bool = False, timeout: int = 300) -> None: + """Verify Docker Compose deployment health. + + Uses shared verify_compose() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + wait: Wait for containers to be ready + timeout: Wait timeout in seconds + """ + _ = config, wait, timeout # Reserved for future use + # Use the same manifests directory as generate_manifests + deploy_dir = get_deploy_dir() + output_dir = getattr(self, "_last_output_dir", deploy_dir / "manifests" / "compose") + compose_file = output_dir / "docker-compose.yaml" + output = verify_compose(compose_file, verbose=self.verbose) + get_console().print(output) + + def _destroy_kubernetes(self, config: MCPStackConfig) -> None: + """Destroy Kubernetes deployment. + + Uses shared destroy_kubernetes() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + """ + _ = config # Reserved for future use (namespace, labels, etc.) + # Use the same manifests directory as generate_manifests + deploy_dir = get_deploy_dir() + manifests_dir = getattr(self, "_last_output_dir", deploy_dir / "manifests" / "kubernetes") + destroy_kubernetes(manifests_dir, verbose=self.verbose) + + def _destroy_compose(self, config: MCPStackConfig) -> None: + """Destroy Docker Compose deployment. + + Uses shared destroy_compose() from common.py to avoid code duplication. + + Args: + config: Parsed configuration Pydantic model + """ + _ = config # Reserved for future use (project name, networks, etc.) + # Use the same manifests directory as generate_manifests + deploy_dir = get_deploy_dir() + output_dir = getattr(self, "_last_output_dir", deploy_dir / "manifests" / "compose") + compose_file = output_dir / "docker-compose.yaml" + destroy_compose(compose_file, verbose=self.verbose) diff --git a/cforge/commands/deploy/builder/schema.py b/cforge/commands/deploy/builder/schema.py new file mode 100644 index 0000000..81161ea --- /dev/null +++ b/cforge/commands/deploy/builder/schema.py @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- +"""Location: ./mcpgateway/tools/builder/schema.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Pydantic schemas for MCP Stack configuration validation""" + +# Standard +from typing import Any, Dict, List, Literal, Optional + +# Third-Party +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class OpenShiftConfig(BaseModel): + """OpenShift-specific configuration. + + Routes are OpenShift's native way of exposing services externally (predates Kubernetes Ingress). + They provide built-in TLS termination and are integrated with OpenShift's router/HAProxy infrastructure. + + Attributes: + create_routes: Create OpenShift Route resources for external access (default: False) + domain: OpenShift apps domain for route hostnames (default: auto-detected from cluster) + tls_termination: TLS termination mode - edge, passthrough, or reencrypt (default: edge) + + Examples: + >>> # Test with default values + >>> config = OpenShiftConfig() + >>> config.create_routes + False + >>> config.tls_termination + 'edge' + + >>> # Test with custom values + >>> config = OpenShiftConfig( + ... create_routes=True, + ... domain="apps.example.com", + ... tls_termination="passthrough" + ... ) + >>> config.create_routes + True + >>> config.domain + 'apps.example.com' + >>> config.tls_termination + 'passthrough' + + >>> # Test valid TLS termination modes + >>> for mode in ["edge", "passthrough", "reencrypt"]: + ... cfg = OpenShiftConfig(tls_termination=mode) + ... cfg.tls_termination == mode + True + True + True + """ + + create_routes: bool = Field(False, description="Create OpenShift Route resources") + domain: Optional[str] = Field(None, description="OpenShift apps domain (e.g., apps-crc.testing)") + tls_termination: Literal["edge", "passthrough", "reencrypt"] = Field("edge", description="TLS termination mode") + + +class DeploymentConfig(BaseModel): + """Deployment configuration + + Examples: + >>> # Test compose deployment + >>> config = DeploymentConfig(type="compose", project_name="test-project") + >>> config.type + 'compose' + >>> config.project_name + 'test-project' + + >>> # Test kubernetes deployment + >>> config = DeploymentConfig(type="kubernetes", namespace="mcp-test") + >>> config.type + 'kubernetes' + >>> config.namespace + 'mcp-test' + + >>> # Test container engine options + >>> config = DeploymentConfig(type="compose", container_engine="podman") + >>> config.container_engine + 'podman' + + >>> # Test with OpenShift config + >>> config = DeploymentConfig( + ... type="kubernetes", + ... namespace="test", + ... openshift=OpenShiftConfig(create_routes=True) + ... ) + >>> config.openshift.create_routes + True + """ + + type: Literal["kubernetes", "compose"] = Field(..., description="Deployment type") + container_engine: Optional[str] = Field(default=None, description="Container engine: 'podman', 'docker', or full path (e.g., '/opt/podman/bin/podman')") + project_name: Optional[str] = Field(None, description="Project name for compose") + namespace: Optional[str] = Field(None, description="Namespace for Kubernetes") + openshift: Optional[OpenShiftConfig] = Field(None, description="OpenShift-specific configuration") + + +class RegistryConfig(BaseModel): + """Container registry configuration. + + Optional configuration for pushing built images to a container registry. + When enabled, images will be tagged with the full registry path and optionally pushed. + + Authentication: + Users must authenticate to the registry before running the build: + - Docker Hub: `docker login` + - Quay.io: `podman login quay.io` + - OpenShift internal: `podman login $(oc registry info) -u $(oc whoami) -p $(oc whoami -t)` + - Private registry: `podman login your-registry.com -u username` + + Attributes: + enabled: Enable registry integration (default: False) + url: Registry URL (e.g., "docker.io", "quay.io", "default-route-openshift-image-registry.apps-crc.testing") + namespace: Registry namespace/organization/project (e.g., "myorg", "mcp-gateway-test") + push: Push image after build (default: True) + image_pull_policy: Kubernetes imagePullPolicy (default: "IfNotPresent") + + Examples: + >>> # Test with defaults (registry disabled) + >>> config = RegistryConfig() + >>> config.enabled + False + >>> config.push + True + >>> config.image_pull_policy + 'IfNotPresent' + + >>> # Test Docker Hub configuration + >>> config = RegistryConfig( + ... enabled=True, + ... url="docker.io", + ... namespace="myusername" + ... ) + >>> config.enabled + True + >>> config.url + 'docker.io' + >>> config.namespace + 'myusername' + + >>> # Test with custom pull policy + >>> config = RegistryConfig( + ... enabled=True, + ... url="quay.io", + ... namespace="myorg", + ... image_pull_policy="Always" + ... ) + >>> config.image_pull_policy + 'Always' + + >>> # Test tag-only mode (no push) + >>> config = RegistryConfig( + ... enabled=True, + ... url="registry.local", + ... namespace="test", + ... push=False + ... ) + >>> config.push + False + """ + + enabled: bool = Field(False, description="Enable registry push") + url: Optional[str] = Field(None, description="Registry URL (e.g., docker.io, quay.io, or internal registry)") + namespace: Optional[str] = Field(None, description="Registry namespace/organization/project") + push: bool = Field(True, description="Push image after build") + image_pull_policy: Optional[str] = Field("IfNotPresent", description="Kubernetes imagePullPolicy (IfNotPresent, Always, Never)") + + +class BuildableConfig(BaseModel): + """Base class for components that can be built from source or use pre-built images. + + This base class provides common configuration for both gateway and plugins, + supporting two build modes: + 1. Pre-built image: Specify only 'image' field + 2. Build from source: Specify 'repo' and optionally 'ref', 'context', 'containerfile', 'target' + + Attributes: + image: Pre-built Docker image name (e.g., "mcpgateway/mcpgateway:latest") + repo: Git repository URL to build from + ref: Git branch/tag/commit to checkout (default: "main") + context: Build context subdirectory within repo (default: ".") + containerfile: Path to Containerfile/Dockerfile (default: "Containerfile") + target: Target stage for multi-stage builds (optional) + host_port: Host port mapping for direct access (optional) + env_vars: Environment variables for container + env_file: Path to environment file (.env) + mtls_enabled: Enable mutual TLS authentication (default: True) + """ + + # Allow attribute assignment after model creation (needed for auto-detection of env_file) + model_config = ConfigDict(validate_assignment=True) + + # Build configuration + image: Optional[str] = Field(None, description="Pre-built Docker image") + repo: Optional[str] = Field(None, description="Git repository URL") + ref: Optional[str] = Field("main", description="Git branch/tag/commit") + context: Optional[str] = Field(".", description="Build context subdirectory") + containerfile: Optional[str] = Field("Containerfile", description="Containerfile path") + target: Optional[str] = Field(None, description="Multi-stage build target") + + # Runtime configuration + host_port: Optional[int] = Field(None, description="Host port mapping") + env_vars: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Environment variables") + env_file: Optional[str] = Field(None, description="Path to environment file (.env)") + mtls_enabled: Optional[bool] = Field(True, description="Enable mTLS") + + # Registry configuration + registry: Optional[RegistryConfig] = Field(None, description="Container registry configuration") + + def model_post_init(self, _: Any) -> None: + """Validate that either image or repo is specified + + Raises: + ValueError: If neither image nor repo is specified + + Examples: + >>> # Test that error is raised when neither image nor repo specified + >>> try: + ... # BuildableConfig can't be instantiated directly, use GatewayConfig + ... from cforge.commands.deploy.builder.schema import GatewayConfig + ... GatewayConfig() + ... except ValueError as e: + ... "must specify either 'image' or 'repo'" in str(e) + True + + >>> # Test valid config with image + >>> from cforge.commands.deploy.builder.schema import GatewayConfig + >>> config = GatewayConfig(image="mcpgateway:latest") + >>> config.image + 'mcpgateway:latest' + + >>> # Test valid config with repo + >>> from cforge.commands.deploy.builder.schema import GatewayConfig + >>> config = GatewayConfig(repo="https://github.com/example/repo") + >>> config.repo + 'https://github.com/example/repo' + """ + if not self.image and not self.repo: + component_type = self.__class__.__name__.replace("Config", "") + raise ValueError(f"{component_type} must specify either 'image' or 'repo'") + + +class GatewayConfig(BuildableConfig): + """Gateway configuration. + + Extends BuildableConfig to support either pre-built gateway images or + building the gateway from source repository. + + Attributes: + port: Gateway internal port (default: 4444) + + Examples: + >>> # Test with pre-built image + >>> config = GatewayConfig(image="mcpgateway:latest") + >>> config.image + 'mcpgateway:latest' + >>> config.port + 4444 + + >>> # Test with custom port + >>> config = GatewayConfig(image="mcpgateway:latest", port=8080) + >>> config.port + 8080 + + >>> # Test with source repository + >>> config = GatewayConfig( + ... repo="https://github.com/example/gateway", + ... ref="v1.0.0" + ... ) + >>> config.repo + 'https://github.com/example/gateway' + >>> config.ref + 'v1.0.0' + + >>> # Test with environment variables + >>> config = GatewayConfig( + ... image="mcpgateway:latest", + ... env_vars={"LOG_LEVEL": "DEBUG", "PORT": "4444"} + ... ) + >>> config.env_vars['LOG_LEVEL'] + 'DEBUG' + + >>> # Test with mTLS enabled + >>> config = GatewayConfig(image="mcpgateway:latest", mtls_enabled=True) + >>> config.mtls_enabled + True + """ + + port: Optional[int] = Field(4444, description="Gateway port") + + +class PluginConfig(BuildableConfig): + """Plugin configuration. + + Extends BuildableConfig to support plugin-specific configuration while + inheriting common build and runtime capabilities. + + Attributes: + name: Unique plugin identifier + port: Plugin internal port (default: 8000) + expose_port: Whether to expose plugin port on host (default: False) + plugin_overrides: Plugin-specific override configuration + """ + + name: str = Field(..., description="Plugin name") + port: Optional[int] = Field(8000, description="Plugin port") + expose_port: Optional[bool] = Field(False, description="Expose port on host") + plugin_overrides: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Plugin overrides") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + """Validate plugin name is non-empty + + Args: + v: Plugin name value to validate + + Returns: + Validated plugin name + + Raises: + ValueError: If plugin name is empty or whitespace only + + Examples: + >>> # Test valid plugin names + >>> PluginConfig.validate_name("my-plugin") + 'my-plugin' + >>> PluginConfig.validate_name("plugin_123") + 'plugin_123' + >>> PluginConfig.validate_name("TestPlugin") + 'TestPlugin' + + >>> # Test empty name raises error + >>> try: + ... PluginConfig.validate_name("") + ... except ValueError as e: + ... "cannot be empty" in str(e) + True + + >>> # Test whitespace-only name raises error + >>> try: + ... PluginConfig.validate_name(" ") + ... except ValueError as e: + ... "cannot be empty" in str(e) + True + """ + if not v or not v.strip(): + raise ValueError("Plugin name cannot be empty") + return v + + +class CertificatesConfig(BaseModel): + """Certificate configuration. + + Supports two modes: + 1. Local certificate generation (use_cert_manager=false, default): + - Certificates generated locally using OpenSSL (via Makefile) + - Deployed to Kubernetes as secrets via kubectl + - Manual rotation required before expiry + + 2. cert-manager integration (use_cert_manager=true, Kubernetes only): + - Certificates managed by cert-manager controller + - Automatic renewal before expiry (default: at 2/3 of lifetime) + - Native Kubernetes Certificate resources + - Requires cert-manager to be installed in cluster + + Attributes: + validity_days: Certificate validity period in days (default: 825 ≈ 2.25 years) + auto_generate: Auto-generate certificates locally (default: True) + use_cert_manager: Use cert-manager for certificate management (default: False, Kubernetes only) + cert_manager_issuer: Name of cert-manager Issuer/ClusterIssuer (default: "mcp-ca-issuer") + cert_manager_kind: Type of issuer - Issuer or ClusterIssuer (default: "Issuer") + ca_path: Path to CA certificates for local generation (default: "./certs/mcp/ca") + gateway_path: Path to gateway certificates for local generation (default: "./certs/mcp/gateway") + plugins_path: Path to plugin certificates for local generation (default: "./certs/mcp/plugins") + """ + + validity_days: Optional[int] = Field(825, description="Certificate validity in days") + auto_generate: Optional[bool] = Field(True, description="Auto-generate certificates locally") + + # cert-manager integration (Kubernetes only) + use_cert_manager: Optional[bool] = Field(False, description="Use cert-manager for certificate management (Kubernetes only)") + cert_manager_issuer: Optional[str] = Field("mcp-ca-issuer", description="cert-manager Issuer/ClusterIssuer name") + cert_manager_kind: Optional[Literal["Issuer", "ClusterIssuer"]] = Field("Issuer", description="cert-manager issuer kind") + + ca_path: Optional[str] = Field("./certs/mcp/ca", description="CA certificate path") + gateway_path: Optional[str] = Field("./certs/mcp/gateway", description="Gateway cert path") + plugins_path: Optional[str] = Field("./certs/mcp/plugins", description="Plugins cert path") + + +class PostgresConfig(BaseModel): + """PostgreSQL database configuration""" + + enabled: Optional[bool] = Field(True, description="Enable PostgreSQL deployment") + image: Optional[str] = Field("quay.io/sclorg/postgresql-15-c9s:latest", description="PostgreSQL image (default is OpenShift-compatible)") + database: Optional[str] = Field("mcp", description="Database name") + user: Optional[str] = Field("postgres", description="Database user") + password: Optional[str] = Field("mysecretpassword", description="Database password") + storage_size: Optional[str] = Field("10Gi", description="Persistent volume size (Kubernetes only)") + storage_class: Optional[str] = Field(None, description="Storage class name (Kubernetes only)") + + +class RedisConfig(BaseModel): + """Redis cache configuration""" + + enabled: Optional[bool] = Field(True, description="Enable Redis deployment") + image: Optional[str] = Field("redis:latest", description="Redis image") + + +class InfrastructureConfig(BaseModel): + """Infrastructure services configuration""" + + postgres: Optional[PostgresConfig] = Field(default_factory=PostgresConfig) + redis: Optional[RedisConfig] = Field(default_factory=RedisConfig) + + +class MCPStackConfig(BaseModel): + """Complete MCP Stack configuration""" + + deployment: DeploymentConfig + gateway: GatewayConfig + plugins: List[PluginConfig] = Field(default_factory=list) + certificates: Optional[CertificatesConfig] = Field(default_factory=CertificatesConfig) + infrastructure: Optional[InfrastructureConfig] = Field(default_factory=InfrastructureConfig) + + @field_validator("plugins") + @classmethod + def validate_plugin_names_unique(cls, v: List[PluginConfig]) -> List[PluginConfig]: + """Ensure plugin names are unique + + Args: + v: List of plugin configurations to validate + + Returns: + Validated list of plugin configurations + + Raises: + ValueError: If duplicate plugin names are found + + Examples: + >>> from cforge.commands.deploy.builder.schema import PluginConfig + >>> # Test with unique names (valid) + >>> plugins = [ + ... PluginConfig(name="plugin1", image="img1:latest"), + ... PluginConfig(name="plugin2", image="img2:latest") + ... ] + >>> result = MCPStackConfig.validate_plugin_names_unique(plugins) + >>> len(result) == 2 + True + + >>> # Test with duplicate names (invalid) + >>> try: + ... duplicates = [ + ... PluginConfig(name="duplicate", image="img1:latest"), + ... PluginConfig(name="duplicate", image="img2:latest") + ... ] + ... MCPStackConfig.validate_plugin_names_unique(duplicates) + ... except ValueError as e: + ... "Duplicate plugin names found" in str(e) + True + + >>> # Test with empty list (valid) + >>> empty = MCPStackConfig.validate_plugin_names_unique([]) + >>> len(empty) == 0 + True + """ + names = [p.name for p in v] + if len(names) != len(set(names)): + duplicates = [name for name in names if names.count(name) > 1] + raise ValueError(f"Duplicate plugin names found: {duplicates}") + return v diff --git a/cforge/commands/deploy/builder/templates/compose/docker-compose.yaml.j2 b/cforge/commands/deploy/builder/templates/compose/docker-compose.yaml.j2 new file mode 100644 index 0000000..aaf2fc0 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/compose/docker-compose.yaml.j2 @@ -0,0 +1,198 @@ +# Location: ./mcpgateway/tools/builder/templates/compose/docker-compose.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# Docker Compose manifest for MCP Stack +# Generated from mcp-stack.yaml + +version: '3.8' + +networks: + mcp-network: + driver: bridge + +volumes: + gateway-data: + driver: local + pgdata: + driver: local +{% for plugin in plugins %} + {{ plugin.name | lower }}-data: + driver: local +{% endfor %} + +services: + # MCP Gateway + mcpgateway: + image: {{ gateway.image }} + container_name: mcpgateway + hostname: mcpgateway + + {% if gateway.env_file is defined %} + env_file: + - {{ gateway.env_file }} + {% endif %} + + environment: + {% if gateway.env_vars is defined and gateway.env_vars %} + # User-defined environment variables + {% for key, value in gateway.env_vars.items() %} + - {{ key }}={{ value }} + {% endfor %} + {% endif %} + # Database configuration + - DATABASE_URL=postgresql://postgres:$${POSTGRES_PASSWORD:-mysecretpassword}@postgres:5432/mcp + - REDIS_URL=redis://redis:6379/0 + {% if gateway.mtls_enabled | default(true) %} + # mTLS client configuration (gateway connects to external plugins) + - PLUGINS_CLIENT_MTLS_CA_BUNDLE=/app/certs/mcp/ca/ca.crt + - PLUGINS_CLIENT_MTLS_CERTFILE=/app/certs/mcp/gateway/client.crt + - PLUGINS_CLIENT_MTLS_KEYFILE=/app/certs/mcp/gateway/client.key + - PLUGINS_CLIENT_MTLS_VERIFY={{ gateway.mtls_verify | default('true') }} + - PLUGINS_CLIENT_MTLS_CHECK_HOSTNAME={{ gateway.mtls_check_hostname | default('false') }} + {% endif %} + + ports: + - "{{ gateway.host_port | default(4444) }}:{{ gateway.port | default(4444) }}" + + volumes: + - gateway-data:/app/data + {% if gateway.mtls_enabled | default(true) %} + - {{ cert_paths.gateway_cert_dir }}:/app/certs/mcp/gateway:ro + - {{ cert_paths.ca_cert_file }}:/app/certs/mcp/ca/ca.crt:ro + {% endif %} + # Auto-generated plugin configuration + - ./plugins-config.yaml:/app/config/plugins.yaml:ro + + networks: + - mcp-network + + restart: unless-stopped + + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{{ gateway.port | default(4444) }}/health').read()"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started +{% for plugin in plugins %} {{ plugin.name | lower }}: + condition: service_started +{% endfor %} + +{% for plugin in plugins %} + # Plugin: {{ plugin.name }} + {{ plugin.name | lower }}: + image: {{ plugin.image | default('mcpgateway-' + plugin.name | lower + ':latest') }} + container_name: mcp-plugin-{{ plugin.name | lower }} + hostname: {{ plugin.name | lower }} + + {% if plugin.env_file is defined %} + env_file: + - {{ plugin.env_file }} + {% endif %} + + environment: + {% if plugin.env_vars is defined and plugin.env_vars %} + # User-defined environment variables + {% for key, value in plugin.env_vars.items() %} + - {{ key }}={{ value }} + {% endfor %} + {% endif %} + {% if plugin.mtls_enabled | default(true) %} + # mTLS server configuration (plugin accepts gateway connections) + - PLUGINS_TRANSPORT=http + - PLUGINS_SERVER_HOST=0.0.0.0 + - PLUGINS_SERVER_PORT={{ plugin.port | default(8000) }} + - PLUGINS_SERVER_SSL_ENABLED=true + - PLUGINS_SERVER_SSL_KEYFILE=/app/certs/mcp/server.key + - PLUGINS_SERVER_SSL_CERTFILE=/app/certs/mcp/server.crt + - PLUGINS_SERVER_SSL_CA_CERTS=/app/certs/mcp/ca.crt + - PLUGINS_SERVER_SSL_CERT_REQS=2 # CERT_REQUIRED - enforce client certificates + {% endif %} + + {% if plugin.expose_port | default(false) %} + ports: + - "{{ plugin.host_port }}:{{ plugin.port | default(8000) }}" + {% endif %} + + volumes: + - {{ plugin.name | lower }}-data:/app/data + {% if plugin.mtls_enabled | default(true) %} + - {{ cert_paths.plugins_cert_base }}/{{ plugin.name }}:/app/certs/mcp:ro + {% endif %} + + networks: + - mcp-network + + restart: unless-stopped + + healthcheck: + {% if plugin.mtls_enabled | default(true) %} + # When mTLS is enabled, health check uses separate HTTP server on port+1000 + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{{ (plugin.port | default(8000)) + 1000 }}/health').read()"] + {% else %} + # When mTLS is disabled, health check uses main server + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{{ plugin.port | default(8000) }}/health').read()"] + {% endif %} + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + {% if plugin.depends_on is defined %} + depends_on: + {% for dep in plugin.depends_on %} + - {{ dep }} + {% endfor %} + {% endif %} + +{% endfor %} + # PostgreSQL Database + postgres: + image: postgres:17 + container_name: mcp-postgres + hostname: postgres + + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=$${POSTGRES_PASSWORD:-mysecretpassword} + - POSTGRES_DB=mcp + + ports: + - "5432:5432" + + volumes: + - pgdata:/var/lib/postgresql/data + + networks: + - mcp-network + + restart: unless-stopped + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + # Redis Cache + redis: + image: redis:latest + container_name: mcp-redis + hostname: redis + + ports: + - "6379:6379" + + networks: + - mcp-network + + restart: unless-stopped + diff --git a/cforge/commands/deploy/builder/templates/kubernetes/cert-manager-certificates.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/cert-manager-certificates.yaml.j2 new file mode 100644 index 0000000..e119635 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/cert-manager-certificates.yaml.j2 @@ -0,0 +1,62 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/cert-manager-certificates.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# cert-manager Certificate Resources +# Gateway Certificate +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: mcp-{{ gateway_name }}-cert + namespace: {{ namespace }} +spec: + secretName: mcp-{{ gateway_name }}-server-cert + duration: {{ duration }}h + renewBefore: {{ renew_before }}h + isCA: false + privateKey: + algorithm: RSA + size: 2048 + usages: + - digital signature + - key encipherment + - server auth + - client auth + dnsNames: + - {{ gateway_name }} + - {{ gateway_name }}.{{ namespace }} + - {{ gateway_name }}.{{ namespace }}.svc + - {{ gateway_name }}.{{ namespace }}.svc.cluster.local + issuerRef: + name: {{ issuer_name }} + kind: {{ issuer_kind }} +{% for plugin in plugins %} +--- +# Plugin {{ plugin.name }} Certificate +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: mcp-{{ plugin.name }}-cert + namespace: {{ namespace }} +spec: + secretName: mcp-{{ plugin.name }}-server-cert + duration: {{ duration }}h + renewBefore: {{ renew_before }}h + isCA: false + privateKey: + algorithm: RSA + size: 2048 + usages: + - digital signature + - key encipherment + - server auth + - client auth + dnsNames: + - {{ plugin.name }} + - {{ plugin.name }}.{{ namespace }} + - {{ plugin.name }}.{{ namespace }}.svc + - {{ plugin.name }}.{{ namespace }}.svc.cluster.local + issuerRef: + name: {{ issuer_name }} + kind: {{ issuer_kind }} +{% endfor %} diff --git a/cforge/commands/deploy/builder/templates/kubernetes/cert-secrets.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/cert-secrets.yaml.j2 new file mode 100644 index 0000000..67e5a1e --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/cert-secrets.yaml.j2 @@ -0,0 +1,38 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/cert-secrets.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# mTLS Certificate Secrets +# CA Certificate (shared by all components) +apiVersion: v1 +kind: Secret +metadata: + name: mcp-ca-secret + namespace: {{ namespace }} +type: Opaque +data: + ca.crt: {{ ca_cert_b64 }} +--- +# Gateway Client Certificate +apiVersion: v1 +kind: Secret +metadata: + name: mcp-{{ gateway_name }}-server-cert + namespace: {{ namespace }} +type: kubernetes.io/tls +data: + tls.crt: {{ gateway_cert_b64 }} + tls.key: {{ gateway_key_b64 }} +{% for plugin in plugins %} +--- +# Plugin {{ plugin.name }} Server Certificate +apiVersion: v1 +kind: Secret +metadata: + name: mcp-{{ plugin.name }}-server-cert + namespace: {{ namespace }} +type: kubernetes.io/tls +data: + tls.crt: {{ plugin.cert_b64 }} + tls.key: {{ plugin.key_b64 }} +{% endfor %} diff --git a/cforge/commands/deploy/builder/templates/kubernetes/deployment.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/deployment.yaml.j2 new file mode 100644 index 0000000..843bb5f --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/deployment.yaml.j2 @@ -0,0 +1,248 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/deployment.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# Kubernetes Deployment for {{ name }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ namespace }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ name }}-env + namespace: {{ namespace }} +type: Opaque +stringData: +{% if env_vars is defined and env_vars %} + # Environment variables + # NOTE: In production, these should come from CI/CD vault secrets +{% for key, value in env_vars.items() %} + {{ key }}: "{{ value }}" +{% endfor %} +{% endif %} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ name }} + namespace: {{ namespace }} + labels: + app: {{ name }} + component: {% if name == 'mcpgateway' %}gateway{% else %}plugin{% endif %} +spec: + replicas: {{ replicas | default(1) }} + selector: + matchLabels: + app: {{ name }} + template: + metadata: + labels: + app: {{ name }} + component: {% if name == 'mcpgateway' %}gateway{% else %}plugin{% endif %} + spec: + {% if image_pull_secret is defined %} + imagePullSecrets: + - name: {{ image_pull_secret }} + {% endif %} + + {% if init_containers is defined %} + initContainers: + {% for init_container in init_containers %} + - name: {{ init_container.name }} + image: {{ init_container.image }} + command: {{ init_container.command | tojson }} + {% endfor %} + {% endif %} + + containers: + - name: {{ name }} + image: {{ image }} + imagePullPolicy: {{ image_pull_policy | default('IfNotPresent') }} + + ports: + - name: http + containerPort: {{ port | default(8000) }} + protocol: TCP + {% if mtls_enabled | default(true) and name != 'mcpgateway' %} + - name: health + containerPort: 9000 + protocol: TCP + {% endif %} + + env: + {% if mtls_enabled | default(true) %} + {% if name == 'mcpgateway' %} + # mTLS client configuration (gateway connects to plugins) + - name: PLUGINS_CLIENT_MTLS_CA_BUNDLE + value: "/app/certs/ca/ca.crt" + - name: PLUGINS_CLIENT_MTLS_CERTFILE + value: "/app/certs/mcp/tls.crt" + - name: PLUGINS_CLIENT_MTLS_KEYFILE + value: "/app/certs/mcp/tls.key" + - name: PLUGINS_CLIENT_MTLS_VERIFY + value: "{{ mtls_verify | default('true') }}" + - name: PLUGINS_CLIENT_MTLS_CHECK_HOSTNAME + value: "{{ mtls_check_hostname | default('false') }}" + {% else %} + # mTLS server configuration (plugin accepts gateway connections) + - name: PLUGINS_TRANSPORT + value: "http" + - name: PLUGINS_SERVER_HOST + value: "0.0.0.0" + - name: PLUGINS_SERVER_PORT + value: "{{ port | default(8000) }}" + - name: PLUGINS_SERVER_SSL_ENABLED + value: "true" + - name: PLUGINS_SERVER_SSL_KEYFILE + value: "/app/certs/mcp/tls.key" + - name: PLUGINS_SERVER_SSL_CERTFILE + value: "/app/certs/mcp/tls.crt" + - name: PLUGINS_SERVER_SSL_CA_CERTS + value: "/app/certs/ca/ca.crt" + - name: PLUGINS_SERVER_SSL_CERT_REQS + value: "2" # CERT_REQUIRED + {% endif %} + {% endif %} + + envFrom: + - secretRef: + name: {{ name }}-env + + {% if health_check | default(true) %} + livenessProbe: + httpGet: + path: /health + {% if mtls_enabled | default(true) and name != 'mcpgateway' %} + # Plugin with mTLS: use separate health check server on port 9000 + port: health + scheme: HTTP + {% else %} + # Gateway or non-mTLS: health check on main HTTP port + port: http + scheme: HTTP + {% endif %} + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /health + {% if mtls_enabled | default(true) and name != 'mcpgateway' %} + # Plugin with mTLS: use separate health check server on port 9000 + port: health + scheme: HTTP + {% else %} + # Gateway or non-mTLS: health check on main HTTP port + port: http + scheme: HTTP + {% endif %} + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + {% endif %} + + resources: + requests: + memory: "{{ memory_request | default('256Mi') }}" + cpu: "{{ cpu_request | default('100m') }}" + limits: + memory: "{{ memory_limit | default('512Mi') }}" + cpu: "{{ cpu_limit | default('500m') }}" + + volumeMounts: + {% if mtls_enabled | default(true) %} + - name: server-cert + mountPath: /app/certs/mcp + readOnly: true + - name: ca-cert + mountPath: /app/certs/ca + readOnly: true + {% endif %} + {% if name == 'mcpgateway' and has_plugins | default(false) %} + - name: plugins-config + mountPath: /app/config + readOnly: true + {% endif %} + + {% if volume_mounts is defined %} + {% for mount in volume_mounts %} + - name: {{ mount.name }} + mountPath: {{ mount.path }} + {% if mount.readonly | default(false) %} + readOnly: true + {% endif %} + {% endfor %} + {% endif %} + + securityContext: + runAsNonRoot: true + {% if run_as_user is defined %} + runAsUser: {{ run_as_user }} + {% endif %} + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + + volumes: + {% if mtls_enabled | default(true) %} + - name: server-cert + secret: + secretName: mcp-{{ name }}-server-cert + defaultMode: 0444 + - name: ca-cert + secret: + secretName: mcp-ca-secret + defaultMode: 0444 + {% endif %} + {% if name == 'mcpgateway' and has_plugins | default(false) %} + - name: plugins-config + configMap: + name: plugins-config + defaultMode: 0444 + {% endif %} + + {% if volumes is defined %} + {% for volume in volumes %} + - name: {{ volume.name }} + {% if volume.type == 'secret' %} + secret: + secretName: {{ volume.secret_name }} + {% if volume.default_mode is defined %} + defaultMode: {{ volume.default_mode }} + {% endif %} + {% elif volume.type == 'configmap' %} + configMap: + name: {{ volume.configmap_name }} + {% elif volume.type == 'persistentVolumeClaim' %} + persistentVolumeClaim: + claimName: {{ volume.claim_name }} + {% endif %} + {% endfor %} + {% endif %} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ name }} + namespace: {{ namespace }} + labels: + app: {{ name }} +spec: + type: {{ service_type | default('ClusterIP') }} + ports: + - name: http + port: {{ port | default(8000) }} + targetPort: http + protocol: TCP + {% if service_type == 'NodePort' and node_port is defined %} + nodePort: {{ node_port }} + {% endif %} + selector: + app: {{ name }} diff --git a/cforge/commands/deploy/builder/templates/kubernetes/plugins-configmap.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/plugins-configmap.yaml.j2 new file mode 100644 index 0000000..d517d84 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/plugins-configmap.yaml.j2 @@ -0,0 +1,13 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/plugins-configmap.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# ConfigMap for plugins configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: plugins-config + namespace: {{ namespace }} +data: + plugins.yaml: | +{{ plugins_config | safe | indent(4, first=True) }} diff --git a/cforge/commands/deploy/builder/templates/kubernetes/postgres.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/postgres.yaml.j2 new file mode 100644 index 0000000..de58a28 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/postgres.yaml.j2 @@ -0,0 +1,125 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/postgres.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# PostgreSQL Database for MCP Gateway +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: {{ namespace }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ storage_size }} + {% if storage_class %} + storageClassName: {{ storage_class }} + {% endif %} +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + namespace: {{ namespace }} +type: Opaque +stringData: + # Official PostgreSQL image variables + POSTGRES_USER: {{ user }} + POSTGRES_PASSWORD: {{ password }} + POSTGRES_DB: {{ database }} + # Red Hat/SCL PostgreSQL image variables (OpenShift-compatible) + POSTGRESQL_USER: {{ user }} + POSTGRESQL_PASSWORD: {{ password }} + POSTGRESQL_DATABASE: {{ database }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + namespace: {{ namespace }} + labels: + app: postgres + component: database +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + component: database + spec: + containers: + - name: postgres + image: {{ image }} + imagePullPolicy: IfNotPresent + + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + + envFrom: + - secretRef: + name: postgres-secret + + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + subPath: postgres + + livenessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U {{ user }} + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U {{ user }} + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + + volumes: + - name: postgres-data + persistentVolumeClaim: + claimName: postgres-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: {{ namespace }} + labels: + app: postgres +spec: + type: ClusterIP + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP + selector: + app: postgres diff --git a/cforge/commands/deploy/builder/templates/kubernetes/redis.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/redis.yaml.j2 new file mode 100644 index 0000000..340e2c7 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/redis.yaml.j2 @@ -0,0 +1,76 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/redis.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# Redis Cache for MCP Gateway +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: {{ namespace }} + labels: + app: redis + component: cache +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + component: cache + spec: + containers: + - name: redis + image: {{ image }} + imagePullPolicy: IfNotPresent + + ports: + - name: redis + containerPort: 6379 + protocol: TCP + + livenessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: {{ namespace }} + labels: + app: redis +spec: + type: ClusterIP + ports: + - name: redis + port: 6379 + targetPort: redis + protocol: TCP + selector: + app: redis diff --git a/cforge/commands/deploy/builder/templates/kubernetes/route.yaml.j2 b/cforge/commands/deploy/builder/templates/kubernetes/route.yaml.j2 new file mode 100644 index 0000000..815ace2 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/kubernetes/route.yaml.j2 @@ -0,0 +1,25 @@ +# Location: ./mcpgateway/tools/builder/templates/kubernetes/route.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# OpenShift Route for external access to MCP Gateway +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: mcpgateway-admin + namespace: {{ namespace }} + labels: + app: mcpgateway + component: gateway +spec: + host: mcpgateway-admin-{{ namespace }}.{{ openshift_domain }} + path: / + to: + kind: Service + name: mcpgateway + weight: 100 + port: + targetPort: http + tls: + termination: {{ tls_termination }} + wildcardPolicy: None diff --git a/cforge/commands/deploy/builder/templates/plugins-config.yaml.j2 b/cforge/commands/deploy/builder/templates/plugins-config.yaml.j2 new file mode 100644 index 0000000..a822187 --- /dev/null +++ b/cforge/commands/deploy/builder/templates/plugins-config.yaml.j2 @@ -0,0 +1,49 @@ +# Location: ./mcpgateway/tools/builder/templates/compose/plugins-config.yaml.j2 +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# Plugin configuration for MCP Gateway +# Auto-generated from mcp-stack.yaml + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 120 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 + +# External plugin connections +plugins: +{% for plugin in plugins -%} +- name: {{ plugin.name }} + kind: external +{%- if plugin.description %} + description: "{{ plugin.description }}" +{%- endif %} +{%- if plugin.version %} + version: "{{ plugin.version }}" +{%- endif %} +{%- if plugin.author %} + author: "{{ plugin.author }}" +{%- endif %} +{%- if plugin.hooks %} + hooks: {{ plugin.hooks }} +{%- endif %} +{%- if plugin.tags %} + tags: {{ plugin.tags }} +{%- endif %} +{%- if plugin.mode %} + mode: "{{ plugin.mode }}" +{%- endif %} +{%- if plugin.priority %} + priority: {{ plugin.priority }} +{%- endif %} +{%- if plugin.conditions %} + conditions: {{ plugin.conditions }} +{%- endif %} + mcp: + proto: STREAMABLEHTTP + url: {{ plugin.url }} + +{% endfor %} diff --git a/cforge/commands/deploy/deploy.py b/cforge/commands/deploy/deploy.py index 7fc5483..db94f19 100644 --- a/cforge/commands/deploy/deploy.py +++ b/cforge/commands/deploy/deploy.py @@ -1,22 +1,235 @@ # -*- coding: utf-8 -*- -"""Location: ./cforge/commands/deploy/whoami.py +""" +Location: ./cforge/commands/deploy/deploy.py Copyright 2025 SPDX-License-Identifier: Apache-2.0 -Authors: Gabe Goodhart +Authors: Teryl Taylor + +MCP Stack Deployment Tool - Hybrid Dagger/Python Implementation + +This script can run in two modes: +1. Plain Python mode (default) - No external dependencies +2. Dagger mode (opt-in) - Requires dagger-io package, auto-downloads CLI -CLI command: whoami +Usage: + # Local execution (plain Python mode) + cforge deploy run deploy.yaml + + # Use Dagger mode for optimization (requires dagger-io, auto-downloads CLI) + cforge --dagger deploy run deploy.yaml + +Features: + - Validates deploy.yaml configuration + - Builds plugin containers from git repos + - Generates mTLS certificates + - Deploys to Kubernetes or Docker Compose + - Integrates with CI/CD vault secrets """ +# Standard +import asyncio +from pathlib import Path +from typing import Optional + # Third-Party +from rich.panel import Panel import typer +from typing_extensions import Annotated # First-Party -from cforge.common import get_console +from cforge.commands.deploy.builder.factory import DeployFactory +from cforge.common import get_console, handle_exception +from cforge.config import get_settings + + +def shared_callback( + ctx: typer.Context, + dagger: Annotated[bool, typer.Option("--dagger", help="Use Dagger mode (requires dagger-io package)")] = False, + verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose output")] = False, +): + """MCP Stack deployment tool + + Deploys MCP Gateway + external plugins from a single YAML configuration. + + By default, uses plain Python mode. Use --dagger to enable Dagger optimization. + + Args: + ctx: Typer context object + dagger: Enable Dagger mode (requires dagger-io package and auto-downloads CLI) + verbose: Enable verbose output + """ + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + ctx.obj["dagger"] = dagger + + # Show execution mode - default to Python, opt-in to Dagger + mode = "dagger" if dagger else "python" + ctx.obj["deployer"], ctx.obj["mode"] = DeployFactory.create_deployer(mode, verbose) + mode_color = "green" if ctx.obj["mode"] == "dagger" else "yellow" + env_text = "container" if get_settings().in_container else "local" + + if verbose: + get_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)) + + +def validate(ctx: typer.Context, config_file: Annotated[Path, typer.Argument(help="The deployment configuration file.")]): + """Validate mcp-stack.yaml configuration + + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + """ + impl = ctx.obj["deployer"] + + try: + impl.validate(config_file) + get_console().print("[green]✓ Configuration valid[/green]") + except Exception as e: + handle_exception(e) + + +def build( + ctx: typer.Context, + config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")], + plugins_only: Annotated[bool, typer.Option("--plugins-only", help="Only build plugin containers")] = False, + plugin: Annotated[Optional[list[str]], typer.Option("--plugin", "-p", help="Build specific plugin(s)")] = None, + no_cache: Annotated[bool, typer.Option("--no-cache", help="Disable build cache")] = False, + copy_env_templates: Annotated[bool, typer.Option("--copy-env-templates", help="Copy .env.template files from plugin repos")] = True, +): + """Build containers + + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + plugins_only: Only build plugin containers, skip gateway + plugin: List of specific plugin names to build + no_cache: Disable build cache + copy_env_templates: Copy .env.template files from plugin repos + """ + impl = ctx.obj["deployer"] + + try: + 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)) + get_console().print("[green]✓ Build complete[/green]") + + if copy_env_templates: + get_console().print("[yellow]⚠ IMPORTANT: Review .env files in deploy/env/ before deploying![/yellow]") + get_console().print("[yellow] Update any required configuration values.[/yellow]") + except Exception as e: + handle_exception(e) + + +def certs(ctx: typer.Context, config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")]): + """Generate mTLS certificates + + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + """ + impl = ctx.obj["deployer"] + + try: + asyncio.run(impl.generate_certificates(config_file)) + get_console().print("[green]✓ Certificates generated[/green]") + except Exception as e: + handle_exception(e) + + +def deploy( + ctx: typer.Context, + config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")], + output_dir: Annotated[Optional[Path], typer.Option("--output-dir", "-o", help="The deployment configuration file")] = None, + dry_run: Annotated[bool, typer.Option("--dry-run", help="Generate manifests without deploying")] = False, + skip_build: Annotated[bool, typer.Option("--skip-build", help="Skip building containers")] = False, + skip_certs: Annotated[bool, typer.Option("--skip-certs", help="Skip certificate generation")] = False, +): + """Deploy MCP stack + + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + output_dir: Custom output directory for manifests + dry_run: Generate manifests without deploying + skip_build: Skip building containers + skip_certs: Skip certificate generation + """ + impl = ctx.obj["deployer"] + + try: + asyncio.run(impl.deploy(config_file, dry_run=dry_run, skip_build=skip_build, skip_certs=skip_certs, output_dir=output_dir)) + if dry_run: + get_console().print("[yellow]✓ Dry-run complete (no changes made)[/yellow]") + else: + get_console().print("[green]✓ Deployment complete[/green]") + except Exception as e: + handle_exception(e) + + +def verify( + ctx: typer.Context, + config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")], + wait: Annotated[bool, typer.Option("--wait", help="Wait for deployment to be ready")] = True, + timeout: Annotated[int, typer.Option("--timeout", help="Wait timeout in seconds")] = 300, +): + """Verify deployment health + + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + wait: Wait for deployment to be ready + timeout: Wait timeout in seconds + """ + impl = ctx.obj["deployer"] + + try: + asyncio.run(impl.verify(config_file, wait=wait, timeout=timeout)) + get_console().print("[green]✓ Deployment healthy[/green]") + except Exception as e: + handle_exception(e) + + +def destroy( + ctx: typer.Context, + config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")], + force: Annotated[bool, typer.Option("--force", help="Force destruction without confirmation")] = False, +): + """Destroy deployed MCP stack + + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + force: Force destruction without confirmation + """ + impl = ctx.obj["deployer"] + + if not force: + if not typer.confirm("Are you sure you want to destroy the deployment?"): + get_console().print("[yellow]Aborted[/yellow]") + return + + try: + asyncio.run(impl.destroy(config_file)) + get_console().print("[green]✓ Deployment destroyed[/green]") + except Exception as e: + handle_exception(e) + + +def generate( + ctx: typer.Context, + config_file: Annotated[Path, typer.Argument(help="The deployment configuration file")], + output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Output directory for manifests")] = None, +): + """Generate deployment manifests (k8s or compose) + Args: + ctx: Typer context object + config_file: Path to the deployment configuration file + output: Output directory for manifests + """ + impl = ctx.obj["deployer"] -def deploy() -> None: - """Deploy MCP Gateway (placeholder for future deployment features).""" - console = get_console() - console.print("[yellow]Deploy command is not yet implemented.[/yellow]") - console.print("This is a placeholder for future deployment automation features.") - raise typer.Exit(0) + try: + manifests_dir = impl.generate_manifests(config_file, output_dir=output) + get_console().print(f"[green]✓ Manifests generated: {manifests_dir}[/green]") + except Exception as e: + handle_exception(e) diff --git a/cforge/config.py b/cforge/config.py index 884c865..922e7d6 100644 --- a/cforge/config.py +++ b/cforge/config.py @@ -15,7 +15,7 @@ import os # Third-Party -from pydantic import model_validator +from pydantic import Field, model_validator # First-Party from mcpgateway.config import Settings @@ -43,6 +43,7 @@ class CLISettings(Settings): """CLI-specific superset of core settings.""" contextforge_home: Path = DEFAULT_HOME + in_container: bool = Field(default_factory=lambda: os.path.exists("/.dockerenv")) @model_validator(mode="after") def _set_database_url_default(self) -> Self: diff --git a/cforge/main.py b/cforge/main.py index 794fbd1..a3b1b2d 100644 --- a/cforge/main.py +++ b/cforge/main.py @@ -30,7 +30,16 @@ # First-Party from cforge.common import get_app -from cforge.commands.deploy.deploy import deploy +from cforge.commands.deploy.deploy import ( + build, + certs, + deploy, + destroy, + generate, + shared_callback, + validate, + verify, +) from cforge.commands.server.serve import serve from cforge.commands.settings.login import login from cforge.commands.settings.logout import logout @@ -122,7 +131,19 @@ # Deploy command (hidden stub for future use) # --------------------------------------------------------------------------- -app.command(hidden=True, rich_help_panel="Deployment")(deploy) +deploy_app = typer.Typer(help="Manage contextforge and MCP server deployments") +app.add_typer(deploy_app, name="deploy", rich_help_panel="Deployment") + +# All sub-commands share a callback +deploy_app.callback(invoke_without_command=True)(shared_callback) + +deploy_app.command("run")(deploy) +deploy_app.command("build")(build) +deploy_app.command("certs")(certs) +deploy_app.command("destroy")(destroy) +deploy_app.command("generate")(generate) +deploy_app.command("validate")(validate) +deploy_app.command("verify")(verify) # --------------------------------------------------------------------------- # Tools command group