From b1a75058b082af3ed7a140f5478e78a077c72b8a Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Mon, 6 Oct 2025 12:18:00 +0200 Subject: [PATCH 1/4] refactoring module structure --- Dockerfile | 1 + assisted_service_mcp/__init__.py | 7 + assisted_service_mcp/src/__init__.py | 2 + assisted_service_mcp/src/api.py | 24 + assisted_service_mcp/src/main.py | 40 + assisted_service_mcp/src/mcp.py | 160 ++++ assisted_service_mcp/src/tools/__init__.py | 2 + .../src/tools/cluster_tools.py | 459 ++++++++++ .../src/tools/download_tools.py | 144 +++ assisted_service_mcp/src/tools/event_tools.py | 96 ++ assisted_service_mcp/src/tools/host_tools.py | 70 ++ .../src/tools/network_tools.py | 186 ++++ .../src/tools/shared_helpers.py | 42 + .../src/tools/version_tools.py | 121 +++ assisted_service_mcp/utils/__init__.py | 2 + assisted_service_mcp/utils/auth.py | 96 ++ assisted_service_mcp/utils/client_factory.py | 35 + assisted_service_mcp/utils/helpers.py | 35 + server.py | 860 +++--------------- 19 files changed, 1666 insertions(+), 716 deletions(-) create mode 100644 assisted_service_mcp/__init__.py create mode 100644 assisted_service_mcp/src/__init__.py create mode 100644 assisted_service_mcp/src/api.py create mode 100644 assisted_service_mcp/src/main.py create mode 100644 assisted_service_mcp/src/mcp.py create mode 100644 assisted_service_mcp/src/tools/__init__.py create mode 100644 assisted_service_mcp/src/tools/cluster_tools.py create mode 100644 assisted_service_mcp/src/tools/download_tools.py create mode 100644 assisted_service_mcp/src/tools/event_tools.py create mode 100644 assisted_service_mcp/src/tools/host_tools.py create mode 100644 assisted_service_mcp/src/tools/network_tools.py create mode 100644 assisted_service_mcp/src/tools/shared_helpers.py create mode 100644 assisted_service_mcp/src/tools/version_tools.py create mode 100644 assisted_service_mcp/utils/__init__.py create mode 100644 assisted_service_mcp/utils/auth.py create mode 100644 assisted_service_mcp/utils/client_factory.py create mode 100644 assisted_service_mcp/utils/helpers.py diff --git a/Dockerfile b/Dockerfile index 5d436cd..77764f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY service_client ./service_client/ COPY static_net ./static_net/ COPY log_analyzer ./log_analyzer/ COPY metrics ./metrics/ +COPY assisted_service_mcp ./assisted_service_mcp/ RUN chown -R 1001:0 ${APP_HOME} diff --git a/assisted_service_mcp/__init__.py b/assisted_service_mcp/__init__.py new file mode 100644 index 0000000..b66ebc3 --- /dev/null +++ b/assisted_service_mcp/__init__.py @@ -0,0 +1,7 @@ +"""Assisted Service MCP Server. + +MCP server for interacting with the OpenShift assisted installer API. +""" + +__version__ = "0.1.0" + diff --git a/assisted_service_mcp/src/__init__.py b/assisted_service_mcp/src/__init__.py new file mode 100644 index 0000000..2d205c4 --- /dev/null +++ b/assisted_service_mcp/src/__init__.py @@ -0,0 +1,2 @@ +"""Source code for Assisted Service MCP Server.""" + diff --git a/assisted_service_mcp/src/api.py b/assisted_service_mcp/src/api.py new file mode 100644 index 0000000..ca77539 --- /dev/null +++ b/assisted_service_mcp/src/api.py @@ -0,0 +1,24 @@ +"""FastAPI application setup for the Assisted Service MCP server. + +This module initializes the FastAPI app and sets up the MCP server +with appropriate transport protocols. +""" + +import os +from assisted_service_mcp.src.mcp import AssistedServiceMCPServer +from service_client.logger import log + +# Initialize the MCP server +server = AssistedServiceMCPServer() + +# Get transport configuration +transport_type = os.environ.get("TRANSPORT", "sse").lower() + +# Choose the appropriate transport protocol +if transport_type == "streamable-http": + app = server.mcp.streamable_http_app() + log.info("Using StreamableHTTP transport (stateless)") +else: + app = server.mcp.sse_app() + log.info("Using SSE transport (stateful)") + diff --git a/assisted_service_mcp/src/main.py b/assisted_service_mcp/src/main.py new file mode 100644 index 0000000..9c97180 --- /dev/null +++ b/assisted_service_mcp/src/main.py @@ -0,0 +1,40 @@ +"""Main entry point for the Assisted Service MCP Server.""" + +import uvicorn +from assisted_service_mcp.src.api import app, server +from metrics import metrics, initiate_metrics +from service_client.logger import log + + +def main() -> None: + """Main entry point for the MCP server. + + Initializes the server, sets up metrics, and starts the uvicorn server. + """ + try: + log.info("Starting Assisted Service MCP Server") + + # Initialize metrics with list of all tools + tool_names = server.list_tools() + initiate_metrics(tool_names) + log.info(f"Initialized metrics for {len(tool_names)} tools") + + # Add metrics endpoint + app.add_route("/metrics", metrics) + log.info("Metrics endpoint available at /metrics") + + # Start the server + uvicorn.run(app, host="0.0.0.0") + + except KeyboardInterrupt: + log.info("Received keyboard interrupt, shutting down") + except Exception as e: + log.error(f"Server failed to start: {e}", exc_info=True) + raise + finally: + log.info("Assisted Service MCP server shutting down") + + +if __name__ == "__main__": + main() + diff --git a/assisted_service_mcp/src/mcp.py b/assisted_service_mcp/src/mcp.py new file mode 100644 index 0000000..d9eadfe --- /dev/null +++ b/assisted_service_mcp/src/mcp.py @@ -0,0 +1,160 @@ +"""Assisted Service MCP Server implementation. + +This module contains the main Assisted Service MCP Server class that provides +tools for MCP clients. It uses FastMCP to register and manage MCP capabilities. +""" + +import os +import asyncio +import inspect +from functools import wraps +from mcp.server.fastmcp import FastMCP +from service_client.logger import log + +# Import auth utilities +from assisted_service_mcp.utils.auth import get_offline_token, get_access_token + +# Import all tool modules +from assisted_service_mcp.src.tools import ( + cluster_tools, + event_tools, + download_tools, + version_tools, + host_tools, + network_tools, +) + + +class AssistedServiceMCPServer: + """Main Assisted Service MCP Server implementation. + + This server provides tools for managing OpenShift clusters through the + Red Hat Assisted Installer API. + """ + + def __init__(self): + """Initialize the MCP server with assisted service tools.""" + try: + # Get transport configuration + transport_type = os.environ.get("TRANSPORT", "sse").lower() + use_stateless_http = transport_type == "streamable-http" + + # Initialize FastMCP server + self.mcp = FastMCP( + "AssistedService", host="0.0.0.0", stateless_http=use_stateless_http + ) + + # Create closures for auth functions that capture self.mcp + self._get_offline_token = lambda: get_offline_token(self.mcp) + self._get_access_token = lambda: get_access_token(self.mcp) + + self._register_mcp_tools() + + log.info("Assisted Service MCP Server initialized successfully") + + except Exception as e: + log.error(f"Failed to initialize Assisted Service MCP Server: {e}") + raise + + def _register_mcp_tools(self) -> None: + """Register MCP tools for assisted service operations. + + Registers all available tools with the FastMCP server instance. + Tools are organized by functional area: + - Cluster management tools + - Event monitoring tools + - Download/URL tools + - Version and operator tools + - Host management tools + - Network configuration tools + """ + # Register cluster management tools + self.mcp.tool()(self._wrap_tool(cluster_tools.cluster_info)) + self.mcp.tool()(self._wrap_tool(cluster_tools.list_clusters)) + self.mcp.tool()(self._wrap_tool(cluster_tools.create_cluster)) + self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_vips)) + self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_platform)) + self.mcp.tool()(self._wrap_tool(cluster_tools.install_cluster)) + self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_ssh_key)) + + # Register event monitoring tools + self.mcp.tool()(self._wrap_tool(event_tools.cluster_events)) + self.mcp.tool()(self._wrap_tool(event_tools.host_events)) + + # Register download/URL tools + self.mcp.tool()(self._wrap_tool(download_tools.cluster_iso_download_url)) + self.mcp.tool()( + self._wrap_tool(download_tools.cluster_credentials_download_url) + ) + + # Register version and operator tools + self.mcp.tool()(self._wrap_tool(version_tools.list_versions)) + self.mcp.tool()(self._wrap_tool(version_tools.list_operator_bundles)) + self.mcp.tool()(self._wrap_tool(version_tools.add_operator_bundle_to_cluster)) + + # Register host management tools + self.mcp.tool()(self._wrap_tool(host_tools.set_host_role)) + + # Register network configuration tools + self.mcp.tool()(self._wrap_tool(network_tools.validate_nmstate_yaml)) + self.mcp.tool( + description=f""" + Generate an initial nmstate yaml. + + You should call this after gathering information from the user to generate the initial nmstate + yaml. Then you can tweak it as needed. Do not generate nmstate yaml from scratch without calling + this tool. + + Returns: the generated nmstate yaml + + Input param schema: + {network_tools.NMStateTemplateParams.model_json_schema()} + """ + )(self._wrap_tool(network_tools.generate_nmstate_yaml)) + self.mcp.tool()( + self._wrap_tool(network_tools.alter_static_network_config_nmstate_for_host) + ) + self.mcp.tool()(self._wrap_tool(network_tools.list_static_network_config)) + + def _wrap_tool(self, tool_func): + """Wrap a tool function to inject mcp and auth dependencies. + + Args: + tool_func: The tool function to wrap. + + Returns: + A wrapped async function that injects mcp and get_access_token. + """ + + @wraps(tool_func) + async def wrapped(*args, **kwargs): + # Inject mcp instance and auth function as first two parameters + return await tool_func(self.mcp, self._get_access_token, *args, **kwargs) + + # Get the original function signature + sig = inspect.signature(tool_func) + params = list(sig.parameters.values()) + + # Remove the first two parameters (mcp and get_access_token_func) + # since they're injected by the wrapper + if len(params) >= 2 and params[0].name == "mcp" and params[1].name == "get_access_token_func": + params = params[2:] + + # Create new signature with remaining parameters + new_sig = sig.replace(parameters=params) + wrapped.__signature__ = new_sig + + return wrapped + + def list_tools(self) -> list[str]: + """List all registered MCP tools. + + Returns: + list[str]: List of tool names. + """ + + async def mcp_list_tools() -> list[str]: + return [t.name for t in await self.mcp.list_tools()] + + return asyncio.run(mcp_list_tools()) + diff --git a/assisted_service_mcp/src/tools/__init__.py b/assisted_service_mcp/src/tools/__init__.py new file mode 100644 index 0000000..0182e4b --- /dev/null +++ b/assisted_service_mcp/src/tools/__init__.py @@ -0,0 +1,2 @@ +"""MCP tools for Assisted Service operations.""" + diff --git a/assisted_service_mcp/src/tools/cluster_tools.py b/assisted_service_mcp/src/tools/cluster_tools.py new file mode 100644 index 0000000..a4db746 --- /dev/null +++ b/assisted_service_mcp/src/tools/cluster_tools.py @@ -0,0 +1,459 @@ +"""Cluster management tools for Assisted Service MCP Server.""" + +import json +from typing import Annotated +from pydantic import Field + +from metrics import track_tool_usage +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.helpers import Helpers +from service_client.logger import log + + +@track_tool_usage() +async def cluster_info( + mcp, # FastMCP instance passed from mcp.py + get_access_token_func, # Auth function passed from mcp.py + cluster_id: Annotated[ + str, + Field( + description="The unique identifier of the cluster to retrieve information for. This is typically a UUID string." + ), + ], +) -> str: + """Get comprehensive information about a specific assisted installer cluster with comprehensive metadata. + + TOOL_NAME=cluster_info + DISPLAY_NAME=Cluster Information + USECASE=Retrieve detailed configuration, status, network settings, and installation progress for a specific cluster + INSTRUCTIONS=1. Obtain cluster_id from list_clusters or previous cluster operations, 2. Call function with cluster_id, 3. Receive detailed cluster information + INPUT_DESCRIPTION=cluster_id (string): cluster UUID obtained from list_clusters or cluster creation + OUTPUT_DESCRIPTION=Formatted string with cluster name, ID, OpenShift version, installation status/progress, network configuration (VIPs, subnets), and host information/roles + EXAMPLES=cluster_info("550e8400-e29b-41d4-a716-446655440000") + PREREQUISITES=Valid cluster_id, OCM offline token for authentication + RELATED_TOOLS=list_clusters (get cluster IDs), cluster_events (view cluster history), install_cluster, set_cluster_vips + + I/O-bound operation - uses async def for external API calls. + + Retrieves detailed cluster information including configuration, status, hosts, + network settings, and installation progress for the specified cluster ID. + + Args: + cluster_id (str): The unique identifier of the cluster to retrieve information for. + This is typically a UUID string. + + Returns: + str: A formatted string containing detailed cluster information including: + - Cluster name, ID, and OpenShift version + - Installation status and progress + - Network configuration (VIPs, subnets) + - Host information and roles + """ + log.info("Retrieving cluster information for cluster_id: %s", cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.get_cluster(cluster_id=cluster_id) + log.info("Successfully retrieved cluster information for %s", cluster_id) + return result.to_str() + + +@track_tool_usage() +async def list_clusters( + mcp, get_access_token_func # Positional args for consistency +) -> str: + """List all assisted installer clusters for the current user with comprehensive metadata. + + TOOL_NAME=list_clusters + DISPLAY_NAME=List Clusters + USECASE=Retrieve summary of all OpenShift clusters associated with the current user's account + INSTRUCTIONS=1. Call function without parameters, 2. Receive list of cluster summaries + INPUT_DESCRIPTION=No parameters required + OUTPUT_DESCRIPTION=JSON array with cluster objects containing name, id, openshift_version, and status (e.g., 'ready', 'installing', 'error') + EXAMPLES=list_clusters() + PREREQUISITES=Valid OCM offline token for authentication + RELATED_TOOLS=cluster_info (get detailed cluster information), create_cluster (create new cluster), cluster_events (view cluster history) + + I/O-bound operation - uses async def for external API calls. + + Retrieves a summary of all clusters associated with the current user's account. + This provides basic information about each cluster without detailed configuration. + Use cluster_info() to get comprehensive details about a specific cluster. + + Returns: + str: A JSON-formatted string containing an array of cluster objects. + Each cluster object includes: + - name (str): The cluster name + - id (str): The unique cluster identifier + - openshift_version (str): The OpenShift version being installed + - status (str): Current cluster status (e.g., 'ready', 'installing', 'error') + """ + log.info("Retrieving list of all clusters") + client = InventoryClient(get_access_token_func()) + clusters = await client.list_clusters() + resp = [ + { + "name": cluster["name"], + "id": cluster["id"], + "openshift_version": cluster.get("openshift_version", "Unknown"), + "status": cluster["status"], + } + for cluster in clusters + ] + log.info("Successfully retrieved %s clusters", len(resp)) + return json.dumps(resp) + + +@track_tool_usage() +async def create_cluster( # pylint: disable=too-many-arguments,too-many-positional-arguments + mcp, + get_access_token_func, + name: Annotated[str, Field(description="The name of the new cluster.")], + version: Annotated[ + str, + Field( + description="The OpenShift version to install (e.g., '4.18.2', '4.17.1')." + ), + ], + base_domain: Annotated[ + str, + Field( + description="The base DNS domain for the cluster (e.g., 'example.com'). The cluster will be accessible at api..." + ), + ], + single_node: Annotated[ + bool, + Field( + description="Whether to create a single-node cluster.Set to True for edge deployments or resource-constrained environments. Set to False for production high-availability clusters with multiple control plane nodes." + ), + ], + ssh_public_key: Annotated[ + str | None, + Field(default=None, description="SSH public key for accessing cluster nodes."), + ] = None, + cpu_architecture: Annotated[ + str, + Field( + default="x86_64", + description="The CPU architecture for the cluster. Defaults to 'x86_64' if not specified. Valid options are: x86_64, aarch64, arm64, ppc64le, s390x.", + ), + ] = "x86_64", + platform: Annotated[ + Helpers.VALID_PLATFORMS | None, + Field( + default=None, + description="The platform of the cluster. Defaults to 'baremetal' if not specified and single_node is false, or 'none' if not specified and single_node is true. Valid options: baremetal, vsphere, oci, nutanix, none.", + ), + ] = None, +) -> str: + """Create a new OpenShift cluster with comprehensive configuration options. + + TOOL_NAME=create_cluster + DISPLAY_NAME=Create OpenShift Cluster + USECASE=Create new OpenShift cluster for production HA or single-node edge deployments + INSTRUCTIONS=1. Get version from list_versions, 2. Choose single_node (True/False) and platform, 3. Provide name/domain/architecture, 4. Optionally add SSH key, 5. Receive cluster ID + INPUT_DESCRIPTION=name (string): cluster name, version (string): OpenShift version from list_versions, base_domain (string): DNS domain (e.g. 'example.com'), single_node (boolean): True for SNO/False for HA, ssh_public_key (string, optional): SSH public key, cpu_architecture (string, optional): x86_64/aarch64/arm64/ppc64le/s390x (default: x86_64), platform (string, optional): baremetal/vsphere/oci/nutanix/none (auto-selected based on single_node if not specified) + OUTPUT_DESCRIPTION=String containing the created cluster's UUID for use in subsequent operations + EXAMPLES=create_cluster("my-cluster", "4.18.2", "example.com", False, ssh_public_key="ssh-rsa AAAA...", platform="baremetal"), create_cluster("edge-cluster", "4.17.1", "edge.example.com", True) + PREREQUISITES=Valid OCM offline token, OpenShift version from list_versions, DNS domain configured + RELATED_TOOLS=list_versions (get available versions), cluster_info (view created cluster), set_cluster_vips (configure VIPs for HA clusters), install_cluster (start installation) + + I/O-bound operation - uses async def for external API calls. + + Creates a cluster definition and associated infrastructure environment. The cluster can be configured + for high availability (multi-node) or single-node deployment (SNO). For single-node clusters, platform + must be 'none'. For multi-node clusters, platform defaults to 'baremetal' but can be set to vsphere, + oci, or nutanix. + + Args: + name (str): The name for the new cluster. + version (str): The OpenShift version to install (e.g., "4.18.2", "4.17.1"). + Use list_versions() to see available versions. + base_domain (str): The base DNS domain for the cluster (e.g., "example.com"). + The cluster will be accessible at api.{name}.{base_domain}. + single_node (bool): Whether to create a single-node cluster. Set to True for + edge deployments or resource-constrained environments. Set to False for + production high-availability clusters with multiple control plane nodes. + ssh_public_key (str, optional): SSH public key for accessing cluster nodes. + Providing this key will allow SSH access to the nodes during and after + cluster installation. + cpu_architecture (str, optional): The CPU architecture for the cluster. + Valid options: x86_64 (default), aarch64, arm64, ppc64le, s390x. + platform (str, optional): The platform of the cluster. + For multi-node: baremetal (default), vsphere, oci, nutanix, none. + For single-node: must be 'none'. + Auto-selected if not specified. + + Returns: + str: The created cluster's UUID. + """ + log.info( + "Creating cluster: name=%s, version=%s, base_domain=%s, single_node=%s, cpu_architecture=%s, ssh_key_provided=%s, platform=%s", + name, + version, + base_domain, + single_node, + cpu_architecture, + ssh_public_key is not None, + platform, + ) + + if platform: + # Check for invalid combination: single_node = true and platform is specified and not "none" + if single_node is True and platform != "none": + return "Platform must be set to 'none' for single-node clusters" + else: + platform = "baremetal" + if single_node is True: + platform = "none" + + client = InventoryClient(get_access_token_func()) + + # Prepare cluster parameters + cluster_params = { + "base_dns_domain": base_domain, + "tags": "chatbot", + "cpu_architecture": cpu_architecture, + "platform": platform, + } + if ssh_public_key: + cluster_params["ssh_public_key"] = ssh_public_key + + cluster = await client.create_cluster(name, version, single_node, **cluster_params) + if cluster.id is None: + log.error("Failed to create cluster %s: cluster ID is unset", name) + return f"Failed to create cluster {name}: cluster ID is unset" + + log.info("Successfully created cluster %s with ID: %s", name, cluster.id) + + # Prepare infra env parameters + infraenv_params = { + "cluster_id": cluster.id, + "openshift_version": cluster.openshift_version, + "cpu_architecture": cpu_architecture, + } + if ssh_public_key: + infraenv_params["ssh_authorized_key"] = ssh_public_key + + infraenv = await client.create_infra_env(name, **infraenv_params) + log.info( + "Successfully created InfraEnv for cluster %s with ID: %s", + cluster.id, + infraenv.id, + ) + return cluster.id + + +@track_tool_usage() +async def set_cluster_vips( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, Field(description="The unique identifier of the cluster to configure.") + ], + api_vip: Annotated[ + str, + Field( + description="The IP address for the cluster API endpoint. This is where kubectl and other management tools will connect." + ), + ], + ingress_vip: Annotated[ + str, + Field( + description="The IP address for ingress traffic to applications running in the cluster." + ), + ], +) -> str: + """Configure virtual IP addresses (VIPs) for cluster API and ingress traffic. + + TOOL_NAME=set_cluster_vips + DISPLAY_NAME=Configure Cluster VIPs + USECASE=Configure virtual IPs for high-availability cluster API and ingress endpoints + INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Ensure platform is baremetal/vsphere/nutanix, 3. Provide two unused IPs from cluster subnet, 4. Receive updated cluster config + INPUT_DESCRIPTION=cluster_id (string): cluster UUID, api_vip (string): IP for API endpoint (kubectl/management tools), ingress_vip (string): IP for application ingress traffic + OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration showing configured VIP addresses + EXAMPLES=set_cluster_vips("cluster-uuid", "192.168.1.100", "192.168.1.101") + PREREQUISITES=Multi-node cluster on baremetal/vsphere/nutanix platform, IPs within cluster subnet and not assigned to any host, reachable from all cluster nodes + RELATED_TOOLS=create_cluster (create cluster first), cluster_info (verify VIP configuration), install_cluster + + I/O-bound operation - uses async def for external API calls. + + VIPs are only required for clusters on baremetal, vsphere, and nutanix platforms. + Do NOT set VIPs for clusters on 'none' or 'oci' platforms. + + The IP addresses must be within the cluster's network subnet, not assigned to any physical host, + and reachable from all cluster nodes. + + Args: + cluster_id (str): The unique identifier of the cluster to configure. + api_vip (str): The IP address for the cluster API endpoint where kubectl connects. + ingress_vip (str): The IP address for ingress traffic to applications. + + Returns: + str: Formatted string with updated cluster configuration including VIP addresses. + """ + log.info( + "Setting VIPs for cluster %s: API VIP=%s, Ingress VIP=%s", + cluster_id, + api_vip, + ingress_vip, + ) + client = InventoryClient(get_access_token_func()) + result = await client.update_cluster( + cluster_id, api_vip=api_vip, ingress_vip=ingress_vip + ) + log.info("Successfully set VIPs for cluster %s", cluster_id) + return result.to_str() + + +@track_tool_usage() +async def set_cluster_platform( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, Field(description="The unique identifier of the cluster to configure.") + ], + platform: Annotated[ + Helpers.VALID_PLATFORMS, + Field( + description="The platform to set for the cluster. Valid options: baremetal, vsphere, oci, nutanix, none." + ), + ], +) -> str: + """Set or update the platform type for a cluster. + + TOOL_NAME=set_cluster_platform + DISPLAY_NAME=Set Cluster Platform + USECASE=Configure or change the infrastructure platform type for cluster deployment + INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Choose platform type based on infrastructure, 3. Receive updated cluster config, 4. May need to reconfigure network settings + INPUT_DESCRIPTION=cluster_id (string): cluster UUID, platform (string): baremetal/vsphere/oci/nutanix/none + OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration showing new platform setting + EXAMPLES=set_cluster_platform("cluster-uuid", "vsphere"), set_cluster_platform("cluster-uuid", "none") + PREREQUISITES=Existing cluster, compatible platform choice for cluster type (single-node clusters require 'none') + RELATED_TOOLS=create_cluster (creates with default platform), set_cluster_vips (VIP configuration depends on platform), cluster_info + + I/O-bound operation - uses async def for external API calls. + + The platform type determines how the cluster will be deployed and what infrastructure-specific + features are available. Changing the platform may require reconfiguration of network settings + and other platform-specific parameters. + + Args: + cluster_id (str): The unique identifier of the cluster to configure. + platform (str): baremetal, vsphere, oci, nutanix, or none. + + Returns: + str: Formatted string with updated cluster configuration and new platform setting. + """ + log.info("Setting platform '%s' for cluster %s", platform, cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.update_cluster(cluster_id, platform=platform) + log.info("Successfully set platform for cluster %s", cluster_id) + return result.to_str() + + +@track_tool_usage() +async def install_cluster( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, Field(description="The unique identifier of the cluster to install.") + ], +) -> str: + """Trigger the installation process for a prepared cluster. + + TOOL_NAME=install_cluster + DISPLAY_NAME=Install Cluster + USECASE=Start OpenShift installation on validated and prepared cluster + INSTRUCTIONS=1. Ensure all hosts discovered and validated, 2. Verify network config complete (VIPs if needed), 3. Check validations pass, 4. Call with cluster_id, 5. Monitor via cluster_info/cluster_events + INPUT_DESCRIPTION=cluster_id (string): cluster UUID ready for installation + OUTPUT_DESCRIPTION=Formatted string with cluster status after installation triggered, includes progress information + EXAMPLES=install_cluster("cluster-uuid") + PREREQUISITES=All required hosts discovered and ready, network configuration complete (VIPs set if required), all cluster validations passing + RELATED_TOOLS=create_cluster (create first), cluster_info (check readiness and monitor progress), cluster_events (monitor installation), set_cluster_vips (configure network) + + I/O-bound operation - uses async def for external API calls. + + Initiates the OpenShift installation on all discovered and validated hosts. The cluster must + have all prerequisites met before installation can begin. Returns immediately - use cluster_info + and cluster_events to monitor progress. + + Args: + cluster_id (str): The unique identifier of the cluster to install. + + Returns: + str: Formatted string with cluster status and installation progress information. + """ + log.info("Initiating installation for cluster_id: %s", cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.install_cluster(cluster_id) + log.info("Successfully triggered installation for cluster %s", cluster_id) + return result.to_str() + + +@track_tool_usage() +async def set_cluster_ssh_key( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, Field(description="The unique identifier of the cluster to update.") + ], + ssh_public_key: Annotated[ + str, + Field( + description="The SSH public key to set for the cluster. This should be a valid SSH public key in OpenSSH format." + ), + ], +) -> str: + """Set or update the SSH public key for a cluster and its boot images. + + TOOL_NAME=set_cluster_ssh_key + DISPLAY_NAME=Set Cluster SSH Key + USECASE=Configure SSH access to cluster nodes during and after installation + INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Provide SSH public key in OpenSSH format, 3. Download new ISO after update, 4. Boot/reboot hosts with new ISO to apply key + INPUT_DESCRIPTION=cluster_id (string): cluster UUID, ssh_public_key (string): SSH public key in OpenSSH format (e.g., 'ssh-rsa AAAAB3...') + OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration, or partial success message if boot image update fails + EXAMPLES=set_cluster_ssh_key("cluster-uuid", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host") + PREREQUISITES=Existing cluster, valid SSH public key in OpenSSH format + RELATED_TOOLS=create_cluster (can set SSH key at creation), cluster_iso_download_url (get new ISO with updated key), cluster_info + + I/O-bound operation - uses async def for external API calls. + + Updates both the cluster configuration and associated infrastructure environment boot images + with the SSH public key. Only ISO images downloaded after this update will include the new key. + Discovered hosts must be booted with a new ISO to get the updated key. + + Args: + cluster_id (str): The unique identifier of the cluster to update. + ssh_public_key (str): SSH public key in OpenSSH format (e.g., 'ssh-rsa AAAAB3...'). + + Returns: + str: Formatted string with updated cluster configuration, or error message if boot image update fails. + """ + log.info("Setting SSH public key for cluster %s", cluster_id) + client = InventoryClient(get_access_token_func()) + + # Import helper function here to avoid circular imports + from assisted_service_mcp.src.tools.shared_helpers import _get_cluster_infra_env_id + + # Update the cluster with the new SSH public key + result = await client.update_cluster(cluster_id, ssh_public_key=ssh_public_key) + log.info("Successfully updated cluster %s with new SSH key", cluster_id) + + # Get the InfraEnv ID and update it + try: + infra_env_id = await _get_cluster_infra_env_id(client, cluster_id) + except ValueError as e: + log.error("Failed to get InfraEnv ID: %s", str(e)) + return f"Cluster key updated, but failed to get InfraEnv ID: {str(e)}. New cluster: {result.to_str()}" + + try: + await client.update_infra_env(infra_env_id, ssh_authorized_key=ssh_public_key) + log.info("Successfully updated InfraEnv %s with new SSH key", infra_env_id) + except Exception as e: + log.error("Failed to update InfraEnv %s: %s", infra_env_id, str(e)) + return f"Cluster key updated, but boot image key update failed. New cluster: {result.to_str()}" + + log.info( + "Successfully updated SSH key for cluster %s and its InfraEnvs", cluster_id + ) + return result.to_str() + diff --git a/assisted_service_mcp/src/tools/download_tools.py b/assisted_service_mcp/src/tools/download_tools.py new file mode 100644 index 0000000..6ce5640 --- /dev/null +++ b/assisted_service_mcp/src/tools/download_tools.py @@ -0,0 +1,144 @@ +"""Download URL tools for Assisted Service MCP Server.""" + +import json +from typing import Annotated +from pydantic import Field + +from metrics import track_tool_usage +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.logger import log +from assisted_service_mcp.utils.helpers import format_presigned_url + + +@track_tool_usage() +async def cluster_iso_download_url( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, + Field( + description="The unique identifier of the cluster, whose ISO image URL has to be retrieved." + ), + ], +) -> str: + """Get ISO download URL(s) for cluster boot images. + + TOOL_NAME=cluster_iso_download_url + DISPLAY_NAME=Cluster ISO Download URL + USECASE=Get presigned URLs to download bootable ISO images for cluster host discovery and installation + INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Call function to get ISO URLs, 3. Download ISO from returned URL(s), 4. Boot hosts from ISO for discovery + INPUT_DESCRIPTION=cluster_id (string): cluster UUID + OUTPUT_DESCRIPTION=JSON array with ISO download information including presigned URLs and optional expiration timestamps for each infrastructure environment + EXAMPLES=cluster_iso_download_url("cluster-uuid") + PREREQUISITES=Cluster with created infrastructure environments + RELATED_TOOLS=create_cluster (creates cluster and infra env), set_cluster_ssh_key (update SSH key, requires new ISO download), cluster_info + + I/O-bound operation - uses async def for external API calls. + + Retrieves presigned download URLs for all infrastructure environment ISOs associated with the cluster. + These ISOs are used to boot hosts for discovery and installation. URLs are time-limited for security. + + Args: + cluster_id (str): The unique identifier of the cluster. + + Returns: + str: JSON array with ISO URLs and optional expiration times, or message if no ISOs found. + """ + log.info("Retrieving InfraEnv ISO URLs for cluster_id: %s", cluster_id) + client = InventoryClient(get_access_token_func()) + infra_envs = await client.list_infra_envs(cluster_id) + + if not infra_envs: + log.info("No infrastructure environments found for cluster %s", cluster_id) + return "No ISO download URLs found for this cluster." + + log.info( + "Found %d infrastructure environments for cluster %s", + len(infra_envs), + cluster_id, + ) + + # Get presigned URLs for each infra env + iso_info = [] + for infra_env in infra_envs: + infra_env_id = infra_env.get("id", "unknown") + + # Use the new get_infra_env_download_url method + presigned_url = await client.get_infra_env_download_url(infra_env_id) + + if presigned_url.url: + iso_info.append(format_presigned_url(presigned_url)) + else: + log.warning( + "No ISO download URL found for infra env %s", + infra_env_id, + ) + + if not iso_info: + log.info( + "No ISO download URLs found in infrastructure environments for cluster %s", + cluster_id, + ) + return "No ISO download URLs found for this cluster." + + log.info("Returning %d ISO URLs for cluster %s", len(iso_info), cluster_id) + return json.dumps(iso_info) + + +@track_tool_usage() +async def cluster_credentials_download_url( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, + Field( + description="The unique identifier of the cluster to get credentials for." + ), + ], + file_name: Annotated[ + str, + Field( + description="The type of credential file to download. Valid options are: kubeconfig (Standard kubeconfig file for cluster access), kubeconfig-noingress (Kubeconfig without ingress configuration), kubeadmin-password (The kubeadmin user password file)." + ), + ], +) -> str: + """Get presigned download URL for cluster credential files after successful installation. + + TOOL_NAME=cluster_credentials_download_url + DISPLAY_NAME=Cluster Credentials Download URL + USECASE=Get secure presigned URLs to download kubeconfig and kubeadmin password after cluster installation completes + INSTRUCTIONS=1. Ensure cluster installation completed successfully, 2. Get cluster_id, 3. Choose file_name (kubeconfig recommended), 4. Download credentials from returned URL before expiration + INPUT_DESCRIPTION=cluster_id (string): cluster UUID, file_name (string): kubeconfig (standard, use this)/kubeconfig-noingress (without ingress)/kubeadmin-password (admin password) + OUTPUT_DESCRIPTION=JSON object with presigned download URL and optional expiration timestamp for secure credential file access + EXAMPLES=cluster_credentials_download_url("cluster-uuid", "kubeconfig"), cluster_credentials_download_url("cluster-uuid", "kubeadmin-password") + PREREQUISITES=Successfully installed cluster (check with cluster_info) + RELATED_TOOLS=cluster_info (verify installation complete), install_cluster (start installation), cluster_events (monitor installation progress) + + I/O-bound operation - uses async def for external API calls. + + Retrieves a time-limited presigned URL for downloading cluster credential files. For successfully + installed clusters, always use "kubeconfig" over "kubeconfig-noingress". URLs expire for security. + + Args: + cluster_id (str): The unique identifier of the cluster to get credentials for. + file_name (str): kubeconfig, kubeconfig-noingress, or kubeadmin-password. + + Returns: + str: JSON with presigned URL and optional expiration timestamp. + """ + log.info( + "Getting presigned URL for cluster %s credentials file %s", + cluster_id, + file_name, + ) + client = InventoryClient(get_access_token_func()) + result = await client.get_presigned_for_cluster_credentials(cluster_id, file_name) + log.info( + "Successfully retrieved presigned URL for cluster %s credentials file %s - %s", + cluster_id, + file_name, + result, + ) + + return json.dumps(format_presigned_url(result)) + diff --git a/assisted_service_mcp/src/tools/event_tools.py b/assisted_service_mcp/src/tools/event_tools.py new file mode 100644 index 0000000..1e3f405 --- /dev/null +++ b/assisted_service_mcp/src/tools/event_tools.py @@ -0,0 +1,96 @@ +"""Event management tools for Assisted Service MCP Server.""" + +from typing import Annotated +from pydantic import Field + +from metrics import track_tool_usage +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.logger import log + + +@track_tool_usage() +async def cluster_events( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, + Field(description="The unique identifier of the cluster to get events for."), + ], +) -> str: + """Get chronological events for cluster installation progress and diagnostics. + + TOOL_NAME=cluster_events + DISPLAY_NAME=Cluster Events + USECASE=Track cluster installation progress, configuration changes, and diagnose issues through event history + INSTRUCTIONS=1. Get cluster_id from create_cluster or list_clusters, 2. Call function to retrieve events, 3. Review chronological event log for progress and issues + INPUT_DESCRIPTION=cluster_id (string): cluster UUID + OUTPUT_DESCRIPTION=JSON string with timestamped events including event types, severity levels, and descriptive messages about cluster activities + EXAMPLES=cluster_events("cluster-uuid") + PREREQUISITES=Existing cluster with UUID + RELATED_TOOLS=cluster_info (current cluster state), host_events (host-specific events), install_cluster (triggers installation events), list_clusters + + I/O-bound operation - uses async def for external API calls. + + Retrieves chronological events related to cluster installation, configuration changes, and status updates. + Events help track installation progress and diagnose issues. + + Args: + cluster_id (str): The unique identifier of the cluster to get events for. + + Returns: + str: JSON string with timestamped cluster events and descriptive messages. + """ + log.info("Retrieving events for cluster_id: %s", cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.get_events(cluster_id=cluster_id) + log.info("Successfully retrieved events for cluster %s", cluster_id) + return result + + +@track_tool_usage() +async def host_events( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, + Field(description="The unique identifier of the cluster containing the host."), + ], + host_id: Annotated[ + str, + Field( + description="The unique identifier of the specific host to get events for." + ), + ], +) -> str: + """Get events specific to a particular host for installation tracking and diagnostics. + + TOOL_NAME=host_events + DISPLAY_NAME=Host Events + USECASE=Track host-specific installation progress, hardware validation, and diagnose host issues + INSTRUCTIONS=1. Get host_id from cluster_info host list, 2. Get cluster_id from create_cluster or list_clusters, 3. Call function to retrieve host events, 4. Review for validation results and issues + INPUT_DESCRIPTION=cluster_id (string): cluster UUID containing the host, host_id (string): host UUID + OUTPUT_DESCRIPTION=JSON string with host-specific events including hardware validation results, installation steps, role assignment, and error messages + EXAMPLES=host_events("cluster-uuid", "host-uuid") + PREREQUISITES=Existing cluster with discovered hosts + RELATED_TOOLS=cluster_events (cluster-wide events), cluster_info (get host list), set_host_role (configure host role) + + I/O-bound operation - uses async def for external API calls. + + Retrieves events related to a specific host's installation progress, hardware validation, + role assignment, and any host-specific issues or status changes. + + Args: + cluster_id (str): The unique identifier of the cluster containing the host. + host_id (str): The unique identifier of the specific host to get events for. + + Returns: + str: JSON string with host-specific events including validation results and installation steps. + """ + log.info("Retrieving events for host %s in cluster %s", host_id, cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.get_events(cluster_id=cluster_id, host_id=host_id) + log.info( + "Successfully retrieved events for host %s in cluster %s", host_id, cluster_id + ) + return result + diff --git a/assisted_service_mcp/src/tools/host_tools.py b/assisted_service_mcp/src/tools/host_tools.py new file mode 100644 index 0000000..3148323 --- /dev/null +++ b/assisted_service_mcp/src/tools/host_tools.py @@ -0,0 +1,70 @@ +"""Host management tools for Assisted Service MCP Server.""" + +from typing import Annotated +from pydantic import Field + +from metrics import track_tool_usage +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.logger import log +from assisted_service_mcp.src.tools.shared_helpers import _get_cluster_infra_env_id + + +@track_tool_usage() +async def set_host_role( + mcp, + get_access_token_func, + host_id: Annotated[ + str, Field(description="The unique identifier of the host to configure.") + ], + cluster_id: Annotated[ + str, + Field(description="The unique identifier of the cluster containing the host."), + ], + role: Annotated[ + str, + Field( + description="The role to assign to the host. Valid options are: auto-assign (Let the installer automatically determine the role), master (Control plane node - API server, etcd, scheduler), worker (Compute node for running application workloads)." + ), + ], +) -> str: + """Assign a specific role to a discovered host in the cluster. + + TOOL_NAME=set_host_role + DISPLAY_NAME=Set Host Role + USECASE=Configure whether discovered host will be control plane (master) or compute (worker) node + INSTRUCTIONS=1. Boot hosts with cluster ISO, 2. Get host_id from cluster_info, 3. Get cluster_id, 4. Choose role (auto-assign/master/worker), 5. Receive updated host config + INPUT_DESCRIPTION=host_id (string): host UUID from discovered hosts, cluster_id (string): cluster UUID, role (string): auto-assign (automatic)/master (control plane)/worker (compute node) + OUTPUT_DESCRIPTION=Formatted string with updated host configuration showing newly assigned role + EXAMPLES=set_host_role("host-uuid", "cluster-uuid", "master"), set_host_role("host-uuid", "cluster-uuid", "worker") + PREREQUISITES=Host discovered after booting from cluster ISO (visible in cluster_info) + RELATED_TOOLS=cluster_info (get host list and IDs), cluster_iso_download_url (get ISO to boot hosts), host_events (view host-specific events) + + I/O-bound operation - uses async def for external API calls. + + Sets the role for a host that has been discovered through booting from the cluster ISO. + The role determines the host's function in the OpenShift cluster. + + Args: + host_id (str): The unique identifier of the host to configure. + cluster_id (str): The unique identifier of the cluster containing the host. + role (str): auto-assign, master (control plane), or worker (compute). + + Returns: + str: Formatted string with updated host configuration showing assigned role. + """ + log.info("Setting role '%s' for host %s in cluster %s", role, host_id, cluster_id) + client = InventoryClient(get_access_token_func()) + + # Get the InfraEnv ID for the cluster + infra_env_id = await _get_cluster_infra_env_id(client, cluster_id) + + # Update the host with the specified role + result = await client.update_host(host_id, infra_env_id, host_role=role) + log.info( + "Successfully set role '%s' for host %s in cluster %s", + role, + host_id, + cluster_id, + ) + return result.to_str() + diff --git a/assisted_service_mcp/src/tools/network_tools.py b/assisted_service_mcp/src/tools/network_tools.py new file mode 100644 index 0000000..4a7353a --- /dev/null +++ b/assisted_service_mcp/src/tools/network_tools.py @@ -0,0 +1,186 @@ +"""Network configuration tools for Assisted Service MCP Server.""" + +import json +from jinja2 import TemplateError + +from metrics import track_tool_usage +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.logger import log +from static_net import ( + NMStateTemplateParams, + add_or_replace_static_host_config_yaml, + generate_nmstate_from_template, + remove_static_host_config_by_index, + validate_and_parse_nmstate, +) +from assisted_service_mcp.src.tools.shared_helpers import _get_cluster_infra_env_id + + +@track_tool_usage() +async def validate_nmstate_yaml(mcp, get_access_token_func, nmstate_yaml: str) -> str: + """Validate an nmstate YAML document before submission. + + TOOL_NAME=validate_nmstate_yaml + DISPLAY_NAME=Validate NMState YAML + USECASE=Validate static network configuration YAML before applying to hosts + INSTRUCTIONS=1. Generate or obtain nmstate YAML, 2. Call function to validate, 3. Fix errors if validation fails, 4. Apply to hosts after validation succeeds + INPUT_DESCRIPTION=nmstate_yaml (string): NMState YAML document for static network configuration + OUTPUT_DESCRIPTION=String "YAML is valid" on success, or error message with validation failure details + EXAMPLES=validate_nmstate_yaml("interfaces:\\n- name: eth0\\n type: ethernet\\n state: up") + PREREQUISITES=NMState YAML document (from generate_nmstate_yaml or manually created) + RELATED_TOOLS=generate_nmstate_yaml (generate initial YAML), alter_static_network_config_nmstate_for_host (apply validated YAML) + + CPU-bound operation - uses def for validation logic. + + The YAML must be validated before being submitted to the cluster to ensure correct network configuration. + + Args: + nmstate_yaml (str): The nmstate YAML to validate. + + Returns: + str: "YAML is valid" if successful, otherwise error message. + """ + validate_and_parse_nmstate(nmstate_yaml) + return "YAML is valid" + + +@track_tool_usage() +async def generate_nmstate_yaml( + mcp, get_access_token_func, params: NMStateTemplateParams +) -> str: + """Generate initial nmstate YAML from network configuration parameters. + + TOOL_NAME=generate_nmstate_yaml + DISPLAY_NAME=Generate NMState YAML + USECASE=Generate initial static network configuration YAML from structured parameters + INSTRUCTIONS=1. Gather network info from user (interface, IPs, DNS, gateway), 2. Call with NMStateTemplateParams, 3. Receive generated YAML, 4. Validate with validate_nmstate_yaml, 5. Apply with alter_static_network_config_nmstate_for_host + INPUT_DESCRIPTION=params (NMStateTemplateParams): structured network configuration including interface name, IP addresses, DNS servers, gateway, routes + OUTPUT_DESCRIPTION=Generated nmstate YAML string, or error message if generation fails + EXAMPLES=generate_nmstate_yaml(NMStateTemplateParams(interface_name="eth0", ipv4_address="192.168.1.10/24", ipv4_gateway="192.168.1.1")) + PREREQUISITES=Network configuration information from user + RELATED_TOOLS=validate_nmstate_yaml (validate generated YAML), alter_static_network_config_nmstate_for_host (apply to host) + + I/O-bound operation - uses async def for potential future API calls. + + Always use this tool to generate initial YAML from user input rather than creating YAML from scratch. + The generated YAML can be tweaked as needed before validation and application. + + Args: + params: NMStateTemplateParams object containing network configuration. + + Returns: + str: Generated nmstate YAML or error message. + """ + log.info("Generate nmstate yaml with params: %s", params.model_dump_json(indent=2)) + try: + generated = generate_nmstate_from_template(params) + log.debug("Generated yaml: %s", generated) + return generated + except TemplateError as e: + log.error("Failed to render nmstate template", exc_info=e) + return "ERROR: Failed to generate nmstate yaml" + except Exception as e: + log.error("Exception generating nmstate yaml", exc_info=e) + return "ERROR: Unknown error" + + +@track_tool_usage() +async def alter_static_network_config_nmstate_for_host( + mcp, + get_access_token_func, + cluster_id: str, + index: int | None, + new_nmstate_yaml: str | None, +) -> str: + """Add, replace, or delete nmstate YAML configuration for a specific host. + + TOOL_NAME=alter_static_network_config_nmstate_for_host + DISPLAY_NAME=Alter Host Static Network Config + USECASE=Apply, update, or remove static network configuration for individual cluster hosts + INSTRUCTIONS=1. Generate/validate YAML, 2. Get cluster_id, 3. To add: set index=None, provide YAML, 4. To update: set index to host position, provide new YAML, 5. To remove: set index to host position, set YAML=None + INPUT_DESCRIPTION=cluster_id (string): cluster UUID, index (int or null): host position in config list (null to append new), new_nmstate_yaml (string or null): validated nmstate YAML (null to delete config at index) + OUTPUT_DESCRIPTION=Formatted string with updated infrastructure environment showing new static network configuration + EXAMPLES=alter_static_network_config_nmstate_for_host("cluster-uuid", None, "interfaces:\\n- name: eth0..."), alter_static_network_config_nmstate_for_host("cluster-uuid", 0, None) + PREREQUISITES=Validated nmstate YAML (from validate_nmstate_yaml), cluster with infrastructure environment + RELATED_TOOLS=generate_nmstate_yaml (generate YAML), validate_nmstate_yaml (validate before applying), list_static_network_config (view current configs) + + I/O-bound operation - uses async def for external API calls. + + Add new host: index=None, provide YAML (appends to end). + Replace host config: provide index and new YAML. + Delete host config: provide index, set YAML=None. + + Args: + cluster_id (str): The unique identifier of the cluster. + index (int | None): Host position in config list, or None to append. + new_nmstate_yaml (str | None): New nmstate YAML, or None to delete. + + Returns: + str: Updated infrastructure environment with new static network config. + """ + client = InventoryClient(get_access_token_func()) + infra_env_id = await _get_cluster_infra_env_id(client, cluster_id) + infra_env = await client.get_infra_env(infra_env_id) + + if new_nmstate_yaml is None: + if index is None: + raise ValueError("index cannot be null when removing a host yaml") + if not infra_env.static_network_config: + raise ValueError( + "cannot remove host yaml with empty existing static network config" + ) + static_network_config = remove_static_host_config_by_index( + existing_static_network_config=infra_env.static_network_config, index=index + ) + else: + static_network_config = add_or_replace_static_host_config_yaml( + existing_static_network_config=infra_env.static_network_config, + index=index, + new_nmstate_yaml=new_nmstate_yaml, + ) + + result = await client.update_infra_env( + infra_env_id, static_network_config=static_network_config + ) + return result.to_str() + + +@track_tool_usage() +async def list_static_network_config( + mcp, get_access_token_func, cluster_id: str +) -> str: + """List all host static network configurations for a cluster. + + TOOL_NAME=list_static_network_config + DISPLAY_NAME=List Static Network Configs + USECASE=View all static network configurations applied to cluster hosts + INSTRUCTIONS=1. Get cluster_id, 2. Call function, 3. Receive JSON array of host configs with indices + INPUT_DESCRIPTION=cluster_id (string): cluster UUID + OUTPUT_DESCRIPTION=JSON array of static network configurations (one per host), or error if cluster doesn't have exactly one infrastructure environment + EXAMPLES=list_static_network_config("cluster-uuid") + PREREQUISITES=Cluster with infrastructure environment + RELATED_TOOLS=alter_static_network_config_nmstate_for_host (modify configs), generate_nmstate_yaml (create new configs), cluster_info + + I/O-bound operation - uses async def for external API calls. + + Returns all host static network configurations for the cluster's infrastructure environment. + Each configuration corresponds to one host, indexed by position in the array. + + Args: + cluster_id (str): The unique identifier of the cluster. + + Returns: + str: JSON array of static network configs, or error message. + """ + client = InventoryClient(get_access_token_func()) + infra_envs = await client.list_infra_envs(cluster_id) + log.info("Found %d InfraEnvs for cluster %s", len(infra_envs), cluster_id) + + if len(infra_envs) != 1: + log.warning( + "cluster %s has %d infra_envs, expected 1", cluster_id, len(infra_envs) + ) + return "ERROR: this cluster doesn't have exactly 1 infra env, cannot manage static network config" + + return json.dumps(infra_envs[0].get("static_network_config", [])) + diff --git a/assisted_service_mcp/src/tools/shared_helpers.py b/assisted_service_mcp/src/tools/shared_helpers.py new file mode 100644 index 0000000..1285a33 --- /dev/null +++ b/assisted_service_mcp/src/tools/shared_helpers.py @@ -0,0 +1,42 @@ +"""Shared helper functions used across multiple tool modules.""" + +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.logger import log + + +async def _get_cluster_infra_env_id(client: InventoryClient, cluster_id: str) -> str: + """ + Get the InfraEnv ID for a cluster (expecting a single InfraEnv). + + This is shared code used by both set_host_role and set_cluster_ssh_key. + + Args: + client: The InventoryClient instance. + cluster_id: The cluster ID to get InfraEnv ID for. + + Returns: + str: The InfraEnv ID (first valid one if multiple exist). + + Raises: + ValueError: If no InfraEnv is found or InfraEnv doesn't have a valid ID. + """ + log.info("Getting InfraEnv for cluster %s", cluster_id) + infra_envs = await client.list_infra_envs(cluster_id) + + if not infra_envs: + raise ValueError(f"No InfraEnv found for cluster {cluster_id}") + + if len(infra_envs) > 1: + log.warning( + "Found %d InfraEnvs for cluster %s, using the first valid one", + len(infra_envs), + cluster_id, + ) + + infra_env_id = infra_envs[0].get("id") + if not infra_env_id: + raise ValueError(f"No InfraEnv with valid ID found for cluster {cluster_id}") + + log.info("Using InfraEnv %s for cluster %s", infra_env_id, cluster_id) + return infra_env_id + diff --git a/assisted_service_mcp/src/tools/version_tools.py b/assisted_service_mcp/src/tools/version_tools.py new file mode 100644 index 0000000..26d1186 --- /dev/null +++ b/assisted_service_mcp/src/tools/version_tools.py @@ -0,0 +1,121 @@ +"""Version and operator management tools for Assisted Service MCP Server.""" + +import json +from typing import Annotated +from pydantic import Field + +from metrics import track_tool_usage +from assisted_service_mcp.utils.client_factory import InventoryClient +from service_client.logger import log + + +@track_tool_usage() +async def list_versions(mcp, get_access_token_func) -> str: + """List all available OpenShift versions for installation with comprehensive metadata. + + TOOL_NAME=list_versions + DISPLAY_NAME=OpenShift Version List + USECASE=Retrieve available OpenShift versions that can be installed using the assisted installer + INSTRUCTIONS=1. Call function without parameters, 2. Receive list of available versions + INPUT_DESCRIPTION=No parameters required + OUTPUT_DESCRIPTION=JSON string with available OpenShift versions including version numbers, release dates, and support status + EXAMPLES=list_versions() + PREREQUISITES=Valid OCM offline token for authentication + RELATED_TOOLS=create_cluster (uses version from this list), list_operator_bundles + + I/O-bound operation - uses async def for external API calls. + + Retrieves the complete list of OpenShift versions that can be installed + using the assisted installer service, including release versions and + pre-release candidates. + + Returns: + str: A JSON string containing available OpenShift versions with metadata + including version numbers, release dates, and support status. + """ + log.info("Retrieving available OpenShift versions") + client = InventoryClient(get_access_token_func()) + result = await client.get_openshift_versions(True) + log.info("Successfully retrieved OpenShift versions") + return json.dumps(result) + + +@track_tool_usage() +async def list_operator_bundles(mcp, get_access_token_func) -> str: + """List available operator bundles for cluster installation with comprehensive metadata. + + TOOL_NAME=list_operator_bundles + DISPLAY_NAME=Operator Bundle List + USECASE=Retrieve available operator bundles that extend OpenShift cluster functionality + INSTRUCTIONS=1. Call function without parameters, 2. Receive list of available operator bundles + INPUT_DESCRIPTION=No parameters required + OUTPUT_DESCRIPTION=JSON string with available operator bundles including bundle names, descriptions, and operator details + EXAMPLES=list_operator_bundles() + PREREQUISITES=Valid OCM offline token for authentication + RELATED_TOOLS=add_operator_bundle_to_cluster (adds bundles from this list), create_cluster + + I/O-bound operation - uses async def for external API calls. + + Retrieves details about operator bundles that can be optionally installed + during cluster deployment. + + Returns: + str: A JSON string containing available operator bundles with metadata + including bundle names, descriptions, and operator details. + """ + log.info("Retrieving available operator bundles") + client = InventoryClient(get_access_token_func()) + result = await client.get_operator_bundles() + log.info("Successfully retrieved %s operator bundles", len(result)) + return json.dumps(result) + + +@track_tool_usage() +async def add_operator_bundle_to_cluster( + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, Field(description="The unique identifier of the cluster to configure.") + ], + bundle_name: Annotated[ + str, + Field( + description="The name of the operator bundle to add. The available operator bundle names are 'virtualization' and 'openshift-ai'" + ), + ], +) -> str: + """Add an operator bundle to be installed with the cluster with comprehensive metadata. + + TOOL_NAME=add_operator_bundle_to_cluster + DISPLAY_NAME=Add Operator Bundle + USECASE=Add operator bundles to extend cluster functionality with virtualization, AI, and other capabilities + INSTRUCTIONS=1. Get bundle name from list_operator_bundles, 2. Provide cluster_id and bundle_name, 3. Receive updated cluster configuration + INPUT_DESCRIPTION=cluster_id (string): cluster UUID, bundle_name (string): operator bundle name ('virtualization' or 'openshift-ai') + OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration showing added operator bundle + EXAMPLES=add_operator_bundle_to_cluster("cluster-uuid", "virtualization") + PREREQUISITES=Valid cluster with status allowing operator addition, bundle name from list_operator_bundles + RELATED_TOOLS=list_operator_bundles (get available bundles), cluster_info (verify cluster state), create_cluster + + I/O-bound operation - uses async def for external API calls. + + Configures the specified operator bundle to be automatically installed + during cluster deployment. The bundle must be from the list of available + bundles returned by list_operator_bundles(). + + Args: + cluster_id (str): The unique identifier of the cluster to configure. + bundle_name (str): The name of the operator bundle to add. + The available operator bundle names are "virtualization" and "openshift-ai" + + Returns: + str: A formatted string containing the updated cluster configuration + showing the newly added operator bundle. + """ + log.info("Adding operator bundle '%s' to cluster %s", bundle_name, cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.add_operator_bundle_to_cluster(cluster_id, bundle_name) + log.info( + "Successfully added operator bundle '%s' to cluster %s", bundle_name, cluster_id + ) + return result.to_str() + diff --git a/assisted_service_mcp/utils/__init__.py b/assisted_service_mcp/utils/__init__.py new file mode 100644 index 0000000..8967ba0 --- /dev/null +++ b/assisted_service_mcp/utils/__init__.py @@ -0,0 +1,2 @@ +"""Utility functions for Assisted Service MCP Server.""" + diff --git a/assisted_service_mcp/utils/auth.py b/assisted_service_mcp/utils/auth.py new file mode 100644 index 0000000..cd49a91 --- /dev/null +++ b/assisted_service_mcp/utils/auth.py @@ -0,0 +1,96 @@ +"""Authentication utilities for Assisted Service MCP Server.""" + +import os +import requests +from service_client.logger import log + + +def get_offline_token(mcp) -> str: + """ + Retrieve the offline token from environment variables or request headers. + + This function attempts to get the Red Hat OpenShift Cluster Manager (OCM) offline token + first from the OFFLINE_TOKEN environment variable, then from the OCM-Offline-Token + request header. The token is required for authenticating with the Red Hat assisted + installer service. + + Args: + mcp: The FastMCP instance to get request context from. + + Returns: + str: The offline token string used for authentication. + + Raises: + RuntimeError: If no offline token is found in either environment variables + or request headers. + """ + log.debug("Attempting to retrieve offline token") + token = os.environ.get("OFFLINE_TOKEN") + if token: + log.debug("Found offline token in environment variables") + return token + + request = mcp.get_context().request_context.request + if request is not None: + token = request.headers.get("OCM-Offline-Token") + if token: + log.debug("Found offline token in request headers") + return token + + log.error("No offline token found in environment or request headers") + raise RuntimeError("No offline token found in environment or request headers") + + +def get_access_token(mcp, offline_token_func=None) -> str: + """ + Retrieve the access token. + + This function tries to get the Red Hat OpenShift Cluster Manager (OCM) access token. First + it tries to extract it from the authorization header, and if it isn't there then it tries + to generate a new one using the offline token. + + Args: + mcp: The FastMCP instance to get request context from. + offline_token_func: Optional function to get offline token. If not provided, + uses get_offline_token(mcp). + + Returns: + str: The access token. + + Raises: + RuntimeError: If it isn't possible to obtain or generate the access token. + """ + log.debug("Attempting to retrieve access token") + # First try to get the token from the authorization header: + request = mcp.get_context().request_context.request + if request is not None: + header = request.headers.get("Authorization") + if header is not None: + parts = header.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + log.debug("Found access token in authorization header") + return parts[1] + + # Now try to get the offline token, and generate a new access token from it: + log.debug("Generating new access token from offline token") + + # Use the provided offline token function or default to get_offline_token + if offline_token_func is None: + offline_token = get_offline_token(mcp) + else: + offline_token = offline_token_func() + + params = { + "client_id": "cloud-services", + "grant_type": "refresh_token", + "refresh_token": offline_token, + } + sso_url = os.environ.get( + "SSO_URL", + "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", + ) + response = requests.post(sso_url, data=params, timeout=30) + response.raise_for_status() + log.debug("Successfully generated new access token") + return response.json()["access_token"] + diff --git a/assisted_service_mcp/utils/client_factory.py b/assisted_service_mcp/utils/client_factory.py new file mode 100644 index 0000000..27b6a52 --- /dev/null +++ b/assisted_service_mcp/utils/client_factory.py @@ -0,0 +1,35 @@ +"""Client factory for creating InventoryClient instances. + +This module provides a centralized way to create InventoryClient instances, +making it easier to mock in tests. +""" + +import sys +from service_client import InventoryClient as _BaseInventoryClient + + +def InventoryClient(access_token: str): + """Create an InventoryClient with the given access token. + + This function checks if server.InventoryClient has been mocked (for testing) + and uses that if available, otherwise uses the real client. + + Args: + access_token: The access token for authentication. + + Returns: + InventoryClient: A new InventoryClient instance. + """ + # Check if we're being called from tests that have mocked server.InventoryClient + if 'server' in sys.modules: + server_module = sys.modules['server'] + if hasattr(server_module, 'InventoryClient'): + # Use the potentially-mocked version from server + server_client = server_module.InventoryClient + # If it's been mocked by tests, it will be a Mock/function that returns a mock + if callable(server_client) and server_client != InventoryClient: + return server_client(access_token) + + # Default: use the real client + return _BaseInventoryClient(access_token) + diff --git a/assisted_service_mcp/utils/helpers.py b/assisted_service_mcp/utils/helpers.py new file mode 100644 index 0000000..ecc32a0 --- /dev/null +++ b/assisted_service_mcp/utils/helpers.py @@ -0,0 +1,35 @@ +"""Helper utilities for Assisted Service MCP Server.""" + +from typing import Any +from assisted_service_client import models + + +def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: + r""" + Format a presigned URL object into a readable string. + + Args: + presigned_url: A PresignedUrl object with url and optional expires_at attributes. + + Returns: + dict: A dict containing URL and optional expiration time. + Format: + { + url: + expires_at: (if expiration exists) + } + """ + presigned_url_dict = { + "url": presigned_url.url, + } + + # Only include expiration time if it's a meaningful date (not a zero/default value) + if presigned_url.expires_at and not str(presigned_url.expires_at).startswith( + "0001-01-01" + ): + presigned_url_dict["expires_at"] = presigned_url.expires_at.isoformat().replace( + "+00:00", "Z" + ) + + return presigned_url_dict + diff --git a/server.py b/server.py index 6fc4953..ee0326e 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-lines """ MCP server for Red Hat Assisted Service API. @@ -48,803 +47,232 @@ def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: Format a presigned URL object into a readable string. Args: - presigned_url: A PresignedUrl object with url and optional expires_at attributes. - + access_token: The access token for authentication. + Returns: - dict: A dict containing URL and optional expiration time. - Format: - { - url: - expires_at: (if expiration exists) - } - """ - presigned_url_dict = { - "url": presigned_url.url, - } - - # Only include expiration time if it's a meaningful date (not a zero/default value) - if presigned_url.expires_at and not str(presigned_url.expires_at).startswith( - "0001-01-01" - ): - presigned_url_dict["expires_at"] = presigned_url.expires_at.isoformat().replace( - "+00:00", "Z" - ) - - return presigned_url_dict + InventoryClient instance. + + Tests can patch this function to return a mock client. + """ + return client_factory.InventoryClient(access_token) + +# Import all tool modules for re-export +from assisted_service_mcp.src.tools import ( + cluster_tools, + event_tools, + download_tools, + version_tools, + host_tools, + network_tools, +) +# For backwards compatibility with tests, create a module-level mcp instance +_server = AssistedServiceMCPServer() +mcp = _server.mcp +# Re-export auth helpers with wrappers that match the old signature def get_offline_token() -> str: - """ - Retrieve the offline token from environment variables or request headers. - - This function attempts to get the Red Hat OpenShift Cluster Manager (OCM) offline token - first from the OFFLINE_TOKEN environment variable, then from the OCM-Offline-Token - request header. The token is required for authenticating with the Red Hat assisted - installer service. - - Returns: - str: The offline token string used for authentication. - - Raises: - RuntimeError: If no offline token is found in either environment variables - or request headers. - """ - log.debug("Attempting to retrieve offline token") - token = os.environ.get("OFFLINE_TOKEN") - if token: - log.debug("Found offline token in environment variables") - return token - - request = mcp.get_context().request_context.request - if request is not None: - token = request.headers.get("OCM-Offline-Token") - if token: - log.debug("Found offline token in request headers") - return token - - log.error("No offline token found in environment or request headers") - raise RuntimeError("No offline token found in environment or request headers") + """Wrapper for backwards compatibility.""" + return _auth_module.get_offline_token(mcp) def get_access_token() -> str: - """ - Retrieve the access token. - - This function tries to get the Red Hat OpenShift Cluster Manager (OCM) access token. First - it tries to extract it from the authorization header, and if it isn't there then it tries - to generate a new one using the offline token. - - Returns: - str: The access token. + """Wrapper for backwards compatibility.""" + # Pass get_offline_token as a callback to break the dependency + return _auth_module.get_access_token(mcp, offline_token_func=get_offline_token) - Raises: - RuntimeError: If it isn't possible to obtain or generate the access token. - """ - log.debug("Attempting to retrieve access token") - # First try to get the token from the authorization header: - request = mcp.get_context().request_context.request - if request is not None: - header = request.headers.get("Authorization") - if header is not None: - parts = header.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - log.debug("Found access token in authorization header") - return parts[1] - - # Now try to get the offline token, and generate a new access token from it: - log.debug("Generating new access token from offline token") - params = { - "client_id": "cloud-services", - "grant_type": "refresh_token", - "refresh_token": get_offline_token(), - } - sso_url = os.environ.get( - "SSO_URL", - "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", - ) - response = requests.post(sso_url, data=params, timeout=30) - response.raise_for_status() - log.debug("Successfully generated new access token") - return response.json()["access_token"] +# Re-export all tool functions for backwards compatibility with tests +# These wrappers inject the mcp instance and auth function automatically -@mcp.tool() -@track_tool_usage() -async def cluster_info( - cluster_id: Annotated[ - str, - Field( - description="The unique identifier of the cluster to retrieve information for. This is typically a UUID string." - ), - ], -) -> str: - """ - Get comprehensive information about a specific assisted installer cluster. - - Retrieves detailed cluster information including configuration, status, hosts, - network settings, and installation progress for the specified cluster ID. - +async def cluster_info(cluster_id: str) -> str: + """Get comprehensive information about a specific cluster. + Args: - cluster_id (str): The unique identifier of the cluster to retrieve information for. - This is typically a UUID string. - + cluster_id: The unique identifier of the cluster. + Returns: - str: A formatted string containing detailed cluster information including: - - Cluster name, ID, and OpenShift version - - Installation status and progress - - Network configuration (VIPs, subnets) - - Host information and roles + str: Formatted cluster information. """ - log.info("Retrieving cluster information for cluster_id: %s", cluster_id) - client = InventoryClient(get_access_token()) - result = await client.get_cluster(cluster_id=cluster_id) - log.info("Successfully retrieved cluster information for %s", cluster_id) - return result.to_str() + return await cluster_tools.cluster_info(mcp, get_access_token, cluster_id) -@mcp.tool() -@track_tool_usage() async def list_clusters() -> str: - """ - List all assisted installer clusters for the current user. - - Retrieves a summary of all clusters associated with the current user's account. - This provides basic information about each cluster without detailed configuration. - Use cluster_info() to get comprehensive details about a specific cluster. - + """List all clusters for the current user. + Returns: - str: A JSON-formatted string containing an array of cluster objects. - Each cluster object includes: - - name (str): The cluster name - - id (str): The unique cluster identifier - - openshift_version (str): The OpenShift version being installed - - status (str): Current cluster status (e.g., 'ready', 'installing', 'error') + str: JSON string containing list of clusters. """ - log.info("Retrieving list of all clusters") - client = InventoryClient(get_access_token()) - clusters = await client.list_clusters() - resp = [ - { - "name": cluster["name"], - "id": cluster["id"], - "openshift_version": cluster.get("openshift_version", "Unknown"), - "status": cluster["status"], - } - for cluster in clusters - ] - log.info("Successfully retrieved %s clusters", len(resp)) - return json.dumps(resp) + return await cluster_tools.list_clusters(mcp, get_access_token) -@mcp.tool() -@track_tool_usage() -async def cluster_events( - cluster_id: Annotated[ - str, - Field(description="The unique identifier of the cluster to get events for."), - ], +async def create_cluster( + name: str, + version: str, + base_domain: str, + *args: Any, + **kwargs: Any ) -> str: - """ - Get the events related to a cluster with the given cluster id. - - Retrieves chronological events related to cluster installation, configuration - changes, and status updates. These events help track installation progress - and diagnose issues. - + """Create a new OpenShift cluster. + Args: - cluster_id (str): The unique identifier of the cluster to get events for. - + name: The name of the new cluster. + version: The OpenShift version to install. + base_domain: The base DNS domain for the cluster. + *args: Additional positional arguments. + **kwargs: Additional keyword arguments (e.g., ssh_public_key, cpu_architecture, platform). + Returns: - str: A JSON-formatted string containing cluster events with timestamps, - event types, and descriptive messages about cluster activities. + str: Formatted cluster information. """ - log.info("Retrieving events for cluster_id: %s", cluster_id) - client = InventoryClient(get_access_token()) - result = await client.get_events(cluster_id=cluster_id) - log.info("Successfully retrieved events for cluster %s", cluster_id) - return result + return await cluster_tools.create_cluster(mcp, get_access_token, name, version, base_domain, *args, **kwargs) -@mcp.tool() -@track_tool_usage() -async def host_events( - cluster_id: Annotated[ - str, - Field(description="The unique identifier of the cluster containing the host."), - ], - host_id: Annotated[ - str, - Field( - description="The unique identifier of the specific host to get events for." - ), - ], +async def set_cluster_vips( + cluster_id: str, + api_vip: str, + ingress_vip: str ) -> str: - """ - Get events specific to a particular host within a cluster. - - Retrieves events related to a specific host's installation progress, hardware - validation, role assignment, and any host-specific issues or status changes. - + """Set the VIPs (Virtual IPs) for a cluster. + Args: - cluster_id (str): The unique identifier of the cluster containing the host. - host_id (str): The unique identifier of the specific host to get events for. - + cluster_id: The unique identifier of the cluster. + api_vip: The IP address for the cluster API endpoint. + ingress_vip: The IP address for the cluster ingress endpoint. + Returns: - str: A JSON-formatted string containing host-specific events including - hardware validation results, installation steps, and error messages. + str: Formatted cluster information. """ - log.info("Retrieving events for host %s in cluster %s", host_id, cluster_id) - client = InventoryClient(get_access_token()) - result = await client.get_events(cluster_id=cluster_id, host_id=host_id) - log.info( - "Successfully retrieved events for host %s in cluster %s", host_id, cluster_id - ) - return result + return await cluster_tools.set_cluster_vips(mcp, get_access_token, cluster_id, api_vip, ingress_vip) -@mcp.tool() -@track_tool_usage() -async def cluster_iso_download_url( - cluster_id: Annotated[ - str, - Field( - description="The unique identifier of the cluster, whose ISO image URL has to be retrieved." - ), - ], -) -> str: - """ - Get ISO download URL(s) for a cluster. - +async def set_cluster_platform(cluster_id: str, platform: str) -> str: + """Set the platform for a cluster. + Args: - cluster_id (str): The unique identifier of the cluster. - + cluster_id: The unique identifier of the cluster. + platform: The platform type (e.g., 'baremetal', 'vsphere', 'none'). + Returns: - dict: A JSON containing ISO download URLs and optional - expiration times. Each ISO's information is formatted as: - [{ - url: - expires_at: (if available) - }] - """ - log.info("Retrieving InfraEnv ISO URLs for cluster_id: %s", cluster_id) - client = InventoryClient(get_access_token()) - infra_envs = await client.list_infra_envs(cluster_id) - - if not infra_envs: - log.info("No infrastructure environments found for cluster %s", cluster_id) - return "No ISO download URLs found for this cluster." - - log.info( - "Found %d infrastructure environments for cluster %s", - len(infra_envs), - cluster_id, - ) - - # Get presigned URLs for each infra env - iso_info = [] - for infra_env in infra_envs: - infra_env_id = infra_env.get("id", "unknown") - - # Use the new get_infra_env_download_url method - presigned_url = await client.get_infra_env_download_url(infra_env_id) - - if presigned_url.url: - iso_info.append(format_presigned_url(presigned_url)) - else: - log.warning( - "No ISO download URL found for infra env %s", - infra_env_id, - ) - - if not iso_info: - log.info( - "No ISO download URLs found in infrastructure environments for cluster %s", - cluster_id, - ) - return "No ISO download URLs found for this cluster." - - log.info("Returning %d ISO URLs for cluster %s", len(iso_info), cluster_id) - return json.dumps(iso_info) - - -@mcp.tool() -@track_tool_usage() -async def create_cluster( # pylint: disable=too-many-arguments,too-many-positional-arguments - name: Annotated[str, Field(description="The name of the new cluster.")], - version: Annotated[ - str, - Field( - description="The OpenShift version to install (e.g., '4.18.2', '4.17.1')." - ), - ], - base_domain: Annotated[ - str, - Field( - description="The base DNS domain for the cluster (e.g., 'example.com'). The cluster will be accessible at api..." - ), - ], - single_node: Annotated[ - bool, - Field( - description="Whether to create a single-node cluster.Set to True for edge deployments or resource-constrained environments. Set to False for production high-availability clusters with multiple control plane nodes." - ), - ], - ssh_public_key: Annotated[ - str | None, - Field(default=None, description="SSH public key for accessing cluster nodes."), - ] = None, - cpu_architecture: Annotated[ - str, - Field( - default="x86_64", - description="The CPU architecture for the cluster. Defaults to 'x86_64' if not specified. Valid options are: x86_64, aarch64, arm64, ppc64le, s390x.", - ), - ] = "x86_64", - platform: Annotated[ - Helpers.VALID_PLATFORMS | None, - Field( - default=None, - description="The platform of the cluster. Defaults to 'baremetal' if not specified and single_node is false, or 'none' if not specified and single_node is true. Valid options: baremetal, vsphere, oci, nutanix, none.", - ), - ] = None, -) -> str: + str: Formatted cluster information. """ - Create a new OpenShift cluster. + return await cluster_tools.set_cluster_platform(mcp, get_access_token, cluster_id, platform) - Creates a cluster definition. The cluster can be configured for high availability - (multi-node) or single-node deployment. +async def install_cluster(cluster_id: str) -> str: + """Start the installation process for a cluster. + Args: - name (str): The name for the new cluster. - version (str): The OpenShift version to install (e.g., "4.18.2", "4.17.1"). - Use list_versions() to see available versions. - base_domain (str): The base DNS domain for the cluster (e.g., "example.com"). - The cluster will be accessible at api.{name}.{base_domain}. - single_node (bool): Whether to create a single-node cluster. Set to True for - edge deployments or resource-constrained environments. Set to False for - production high-availability clusters with multiple control plane nodes. - ssh_public_key (str, optional): SSH public key for accessing cluster nodes. - Providing this key will allow ssh acces to the nodes during and after - cluster installation. - cpu_architecture (str, optional): The CPU architecture for the cluster. - Valid options are: - - 'x86_64': Intel/AMD 64-bit processors (default) - - 'aarch64': ARM 64-bit processors - - 'arm64': ARM 64-bit processors (alias for aarch64) - - 'ppc64le': IBM POWER little-endian 64-bit processors - - 's390x': IBM System z mainframe processors - Defaults to 'x86_64' if not specified. - platform (str, optional): The platform of the cluster. - Valid options for multi-node clusters are: - - 'baremetal': Bare metal platform - - 'vsphere': VMware vSphere platform - - 'oci': Oracle Cloud Infrastructure platform - - 'nutanix': Nutanix platform - - 'none': No platform - Valid options for single-node clusters are: - - 'none': No platform - Defaults to 'baremetal' if not specified and single_node is false or none if not specified and single_node is true. + cluster_id: The unique identifier of the cluster. + Returns: - str: The created cluster's id - """ - log.info( - "Creating cluster: name=%s, version=%s, base_domain=%s, single_node=%s, cpu_architecture=%s, ssh_key_provided=%s, platform=%s", - name, - version, - base_domain, - single_node, - cpu_architecture, - ssh_public_key is not None, - platform, - ) - - if platform: - # Check for invalid combination: single_node = true and platform is specified and not "none" - if single_node is True and platform != "none": - return "Platform must be set to 'none' for single-node clusters" - else: - platform = "baremetal" - if single_node is True: - platform = "none" - - client = InventoryClient(get_access_token()) - - # Prepare cluster parameters - cluster_params = { - "base_dns_domain": base_domain, - "tags": "chatbot", - "cpu_architecture": cpu_architecture, - "platform": platform, - } - if ssh_public_key: - cluster_params["ssh_public_key"] = ssh_public_key - - cluster = await client.create_cluster(name, version, single_node, **cluster_params) - if cluster.id is None: - log.error("Failed to create cluster %s: cluster ID is unset", name) - return f"Failed to create cluster {name}: cluster ID is unset" - - log.info("Successfully created cluster %s with ID: %s", name, cluster.id) - - # Prepare infra env parameters - infraenv_params = { - "cluster_id": cluster.id, - "openshift_version": cluster.openshift_version, - "cpu_architecture": cpu_architecture, - } - if ssh_public_key: - infraenv_params["ssh_authorized_key"] = ssh_public_key - - infraenv = await client.create_infra_env(name, **infraenv_params) - log.info( - "Successfully created InfraEnv for cluster %s with ID: %s", - cluster.id, - infraenv.id, - ) - return cluster.id - - -@mcp.tool() -@track_tool_usage() -async def validate_nmstate_yaml(nmstate_yaml: str) -> str: - """ - Validate an nmstate yaml document. - - The yaml should always be validated before submitted to the cluster. - """ - validate_and_parse_nmstate(nmstate_yaml) - return "YAML is valid" - - -@mcp.tool( - description=f""" - Generate an initial nmstate yaml. - - You should call this after gathering information from the user to generate the initial nmstate - yaml. Then you can tweak it as needed. Do not generate nmstate yaml from scratch without calling - this tool. - - Returns: the generated nmstate yaml - - Input param schema: - {NMStateTemplateParams.model_json_schema()} -""" -) -@track_tool_usage() -async def generate_nmstate_yaml(params: NMStateTemplateParams) -> str: - """ - Generate an initial nmstate yaml. - - See the mcp.tool description for more details (we have to use it since we dynamically generate - the input schema for the params). - """ - log.info("Generate nmstate yaml with params: %s", params.model_dump_json(indent=2)) - try: - generated = generate_nmstate_from_template(params) - log.debug("Generated yaml: %s", generated) - return generated - except TemplateError as e: - log.error("Failed to render nmstate template", exc_info=e) - return "ERROR: Failed to generate nmstate yaml" - except Exception as e: - log.error("Exception generating nmstate yaml", exc_info=e) - return "ERROR: Unknown error" - - -@mcp.tool() -@track_tool_usage() -async def alter_static_network_config_nmstate_for_host( - cluster_id: str, index: int | None, new_nmstate_yaml: str | None -) -> str: + str: Formatted cluster information. """ - Add, replace, or delete the nmstate yaml for a single host. + return await cluster_tools.install_cluster(mcp, get_access_token, cluster_id) - To add a new host, set index to None and the given config will be appended to the end of the - static network configs for the given cluster. - - To remove a host's config at a particular index, set new_nmstate_yaml to None. +async def set_cluster_ssh_key(cluster_id: str, ssh_public_key: str) -> str: + """Set the SSH public key for a cluster. + Args: - cluster_id (str): The unique identifier of the cluster - index (int | None): The index of the host in the existing static config to replace, or None to append a new host to the end of the config. - new_nmstate_yaml (str): The new nmstate YAML for a host. Leave this None to delete the config at the given index. - - Returns: the updated infra env with the new static network config + cluster_id: The unique identifier of the cluster. + ssh_public_key: The SSH public key to add. + + Returns: + str: Formatted cluster information or error message. """ - client = InventoryClient(get_access_token()) - infra_env_id = await _get_cluster_infra_env_id(client, cluster_id) - infra_env = await client.get_infra_env(infra_env_id) - - if new_nmstate_yaml is None: - if index is None: - raise ValueError("index cannot be null when removing a host yaml") - if not infra_env.static_network_config: - raise ValueError( - "cannot remove host yaml with empty existing static network config" - ) - static_network_config = remove_static_host_config_by_index( - existing_static_network_config=infra_env.static_network_config, index=index - ) - else: - static_network_config = add_or_replace_static_host_config_yaml( - existing_static_network_config=infra_env.static_network_config, - index=index, - new_nmstate_yaml=new_nmstate_yaml, - ) + return await cluster_tools.set_cluster_ssh_key(mcp, get_access_token, cluster_id, ssh_public_key) - result = await client.update_infra_env( - infra_env_id, static_network_config=static_network_config - ) - return result.to_str() - - -@mcp.tool() -@track_tool_usage() -async def list_static_network_config(cluster_id: str) -> str: - """ - List all of the host static network config associated with the given cluster_id. +async def cluster_events(cluster_id: str) -> str: + """Get events for a cluster. + Args: - cluster_id (str): The unique identifier of the cluster to configure. - + cluster_id: The unique identifier of the cluster. + Returns: - str: A JSON-formatted list of static network config or an error string - """ - client = InventoryClient(get_access_token()) - infra_envs = await client.list_infra_envs(cluster_id) - log.info("Found %d InfraEnvs for cluster %s", len(infra_envs), cluster_id) - - if len(infra_envs) != 1: - log.warning( - "cluster %s has %d infra_envs, expected 1", cluster_id, len(infra_envs) - ) - return "ERROR: this cluster doesn't have exactly 1 infra env, cannot manage static network config" - - return json.dumps(infra_envs[0].get("static_network_config", [])) - - -@mcp.tool() -@track_tool_usage() -async def set_cluster_vips( - cluster_id: Annotated[ - str, Field(description="The unique identifier of the cluster to configure.") - ], - api_vip: Annotated[ - str, - Field( - description="The IP address for the cluster API endpoint. This is where kubectl and other management tools will connect." - ), - ], - ingress_vip: Annotated[ - str, - Field( - description="The IP address for ingress traffic to applications running in the cluster." - ), - ], -) -> str: + str: JSON string containing cluster events. """ - Configure the virtual IP addresses (VIPs) for cluster API and ingress traffic. + return await event_tools.cluster_events(mcp, get_access_token, cluster_id) - VIPs are only required for clusters in the following platforms: baremetal, vsphere, nutanix. - Do not set VIPs for clusters in the following platforms: none, oci. - - Sets the API VIP (for cluster management) and Ingress VIP (for application traffic) - for the specified cluster. These VIPs must be available IP addresses within the - cluster's network subnet. +async def host_events(host_id: str, cluster_id: str) -> str: + """Get events for a specific host. + Args: - cluster_id (str): The unique identifier of the cluster to configure. - api_vip (str): The IP address for the cluster API endpoint. This is where - kubectl and other management tools will connect. - ingress_vip (str): The IP address for ingress traffic to applications - running in the cluster. - + host_id: The unique identifier of the host. + cluster_id: The unique identifier of the cluster containing the host. + Returns: - str: A formatted string containing the updated cluster configuration - showing the newly set VIP addresses. + str: JSON string containing host events. """ - log.info( - "Setting VIPs for cluster %s: api_vip=%s, ingress_vip=%s", - cluster_id, - api_vip, - ingress_vip, - ) - client = InventoryClient(get_access_token()) - result = await client.update_cluster( - cluster_id, api_vip=api_vip, ingress_vip=ingress_vip - ) - log.info("Successfully set VIPs for cluster %s", cluster_id) - return result.to_str() + return await event_tools.host_events(mcp, get_access_token, host_id, cluster_id) -@mcp.tool() -@track_tool_usage() -async def set_cluster_platform( - cluster_id: Annotated[ - str, Field(description="The unique identifier of the cluster to configure.") - ], - platform: Annotated[ - Helpers.VALID_PLATFORMS, - Field( - description="The platform for the cluster. Valid options are: baremetal, vsphere, oci, nutanix, or none." - ), - ], -) -> str: - """ - Set the platform for a cluster. - +async def cluster_iso_download_url(cluster_id: str) -> str: + """Get ISO download URL for a cluster. + Args: - cluster_id (str): The unique identifier of the cluster to configure. - platform (str): The platform for the cluster. - Valid options are: - - 'baremetal': Bare metal platform - - 'vsphere': VMware vSphere platform - - 'oci': Oracle Cloud Infrastructure platform - - 'nutanix': Nutanix platform - - 'none': No platform - + cluster_id: The unique identifier of the cluster. + Returns: - str: A formatted string containing the updated cluster configuration - showing the newly set platform. - """ - log.info( - "Setting platform for cluster %s: platform=%s", - cluster_id, - platform, - ) - client = InventoryClient(get_access_token()) - result = await client.update_cluster(cluster_id, platform=platform) - log.info("Successfully set platform for cluster %s", cluster_id) - return result.to_str() - - -@mcp.tool() -@track_tool_usage() -async def install_cluster( - cluster_id: Annotated[ - str, Field(description="The unique identifier of the cluster to install.") - ], -) -> str: + str: JSON string containing URL and optional expiration. """ - Trigger the installation process for a prepared cluster. + return await download_tools.cluster_iso_download_url(mcp, get_access_token, cluster_id) - Initiates the OpenShift installation on all discovered and validated hosts. - The cluster must have all prerequisites met including sufficient hosts, - network configuration, and any required validations. +async def cluster_credentials_download_url(cluster_id: str, file_name: str) -> str: + """Get credentials download URL for a cluster. + Args: - cluster_id (str): The unique identifier of the cluster to install. - + cluster_id: The unique identifier of the cluster. + file_name: The name of the credentials file to download. + Returns: - str: A formatted string containing the cluster status after installation - has been triggered, including installation progress information. - - Note: - Before calling this function, ensure: - - All required hosts are discovered and ready - - Network configuration is complete (VIPs set if required) - - All cluster validations pass + str: JSON string containing URL and optional expiration. """ - log.info("Initiating installation for cluster_id: %s", cluster_id) - client = InventoryClient(get_access_token()) - result = await client.install_cluster(cluster_id) - log.info("Successfully triggered installation for cluster %s", cluster_id) - return result.to_str() + return await download_tools.cluster_credentials_download_url(mcp, get_access_token, cluster_id, file_name) -@mcp.tool() -@track_tool_usage() async def list_versions() -> str: - """ - List all available OpenShift versions for installation. - - Retrieves the complete list of OpenShift versions that can be installed - using the assisted installer service, including release versions and - pre-release candidates. - + """List all available OpenShift versions. + Returns: - str: A JSON string containing available OpenShift versions with metadata - including version numbers, release dates, and support status. + str: JSON string containing available versions. """ - log.info("Retrieving available OpenShift versions") - client = InventoryClient(get_access_token()) - result = await client.get_openshift_versions(True) - log.info("Successfully retrieved OpenShift versions") - return json.dumps(result) + return await version_tools.list_versions(mcp, get_access_token) -@mcp.tool() -@track_tool_usage() async def list_operator_bundles() -> str: - """ - List available operator bundles for cluster installation. - - Retrieves details about operator bundles that can be optionally installed - during cluster deployment. - + """List all available operator bundles. + Returns: - str: A JSON string containing available operator bundles with metadata - including bundle names, descriptions, and operator details. + str: JSON string containing available operator bundles. """ - log.info("Retrieving available operator bundles") - client = InventoryClient(get_access_token()) - result = await client.get_operator_bundles() - log.info("Successfully retrieved %s operator bundles", len(result)) - return json.dumps(result) + return await version_tools.list_operator_bundles(mcp, get_access_token) -@mcp.tool() -@track_tool_usage() -async def add_operator_bundle_to_cluster( - cluster_id: Annotated[ - str, Field(description="The unique identifier of the cluster to configure.") - ], - bundle_name: Annotated[ - str, - Field( - description="The name of the operator bundle to add. The available operator bundle names are 'virtualization' and 'openshift-ai'" - ), - ], -) -> str: - """ - Add an operator bundle to be installed with the cluster. - - Configures the specified operator bundle to be automatically installed - during cluster deployment. The bundle must be from the list of available - bundles returned by list_operator_bundles(). - +async def add_operator_bundle_to_cluster(cluster_id: str, bundle_name: str) -> str: + """Add an operator bundle to a cluster. + Args: - cluster_id (str): The unique identifier of the cluster to configure. - bundle_name (str): The name of the operator bundle to add. - The available operator bundle names are "virtualization" and "openshift-ai" - + cluster_id: The unique identifier of the cluster. + bundle_name: The name of the operator bundle to add. + Returns: - str: A formatted string containing the updated cluster configuration - showing the newly added operator bundle. - """ - log.info("Adding operator bundle '%s' to cluster %s", bundle_name, cluster_id) - client = InventoryClient(get_access_token()) - result = await client.add_operator_bundle_to_cluster(cluster_id, bundle_name) - log.info( - "Successfully added operator bundle '%s' to cluster %s", bundle_name, cluster_id - ) - return result.to_str() - - -@mcp.tool() -@track_tool_usage() -async def cluster_credentials_download_url( - cluster_id: Annotated[ - str, - Field( - description="The unique identifier of the cluster to get credentials for." - ), - ], - file_name: Annotated[ - str, - Field( - description="The type of credential file to download. Valid options are: kubeconfig (Standard kubeconfig file for cluster access), kubeconfig-noingress (Kubeconfig without ingress configuration), kubeadmin-password (The kubeadmin user password file)." - ), - ], -) -> str: + str: Formatted cluster information. """ - Get presigned download URL for cluster credential files. + return await version_tools.add_operator_bundle_to_cluster(mcp, get_access_token, cluster_id, bundle_name) - Retrieves a presigned URL for downloading cluster credential files such as - kubeconfig, kubeadmin password, or kubeconfig without ingress configuration. - For a successfully installed cluster the kubeconfig file should always be used - over the kubeconfig-noingress file. - The URL is time-limited and provides secure access to sensitive cluster files. - Whenever a URL is returned provide the user with information on the expiration - of that URL if possible. +async def set_host_role(host_id: str, cluster_id: str, role: str) -> str: + """Set the role for a host. + Args: - cluster_id (str): The unique identifier of the cluster to get credentials for. - file_name (str): The type of credential file to download. Valid options are: - - "kubeconfig": Standard kubeconfig file for cluster access - - "kubeconfig-noingress": Kubeconfig without ingress configuration - - "kubeadmin-password": The kubeadmin user password file - + host_id: The unique identifier of the host. + cluster_id: The unique identifier of the cluster. + role: The role to assign (e.g., 'master', 'worker', 'auto-assign'). + Returns: str: A JSON containing the presigned URL and optional expiration time. The response format is: From fdefd7cfb28b0a6c716aaad115c63ca56bfd8cce Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Tue, 7 Oct 2025 13:41:34 +0200 Subject: [PATCH 2/4] config management and better tool descriptions --- assisted_service_mcp/src/api.py | 9 +- assisted_service_mcp/src/main.py | 6 +- assisted_service_mcp/src/mcp.py | 21 +- assisted_service_mcp/src/settings.py | 174 +++++++++++ .../src/tools/cluster_tools.py | 290 +++++++++--------- .../src/tools/download_tools.py | 76 ++--- assisted_service_mcp/src/tools/event_tools.py | 99 +++--- assisted_service_mcp/src/tools/host_tools.py | 43 ++- .../src/tools/network_tools.py | 182 ++++++----- .../src/tools/version_tools.py | 103 ++++--- assisted_service_mcp/utils/auth.py | 9 +- pyproject.toml | 2 + service_client/assisted_service_api.py | 13 +- service_client/logger.py | 15 +- tests/test_assisted_service_api.py | 19 +- tests/test_server.py | 6 +- 16 files changed, 638 insertions(+), 429 deletions(-) create mode 100644 assisted_service_mcp/src/settings.py diff --git a/assisted_service_mcp/src/api.py b/assisted_service_mcp/src/api.py index ca77539..9788533 100644 --- a/assisted_service_mcp/src/api.py +++ b/assisted_service_mcp/src/api.py @@ -4,18 +4,15 @@ with appropriate transport protocols. """ -import os from assisted_service_mcp.src.mcp import AssistedServiceMCPServer +from assisted_service_mcp.src.settings import settings from service_client.logger import log # Initialize the MCP server server = AssistedServiceMCPServer() -# Get transport configuration -transport_type = os.environ.get("TRANSPORT", "sse").lower() - -# Choose the appropriate transport protocol -if transport_type == "streamable-http": +# Choose the appropriate transport protocol based on settings +if settings.TRANSPORT.lower() == "streamable-http": app = server.mcp.streamable_http_app() log.info("Using StreamableHTTP transport (stateless)") else: diff --git a/assisted_service_mcp/src/main.py b/assisted_service_mcp/src/main.py index 9c97180..a7a3eab 100644 --- a/assisted_service_mcp/src/main.py +++ b/assisted_service_mcp/src/main.py @@ -2,6 +2,7 @@ import uvicorn from assisted_service_mcp.src.api import app, server +from assisted_service_mcp.src.settings import settings from metrics import metrics, initiate_metrics from service_client.logger import log @@ -13,6 +14,7 @@ def main() -> None: """ try: log.info("Starting Assisted Service MCP Server") + log.info(f"Configuration: TRANSPORT={settings.TRANSPORT}, HOST={settings.MCP_HOST}, PORT={settings.MCP_PORT}") # Initialize metrics with list of all tools tool_names = server.list_tools() @@ -23,8 +25,8 @@ def main() -> None: app.add_route("/metrics", metrics) log.info("Metrics endpoint available at /metrics") - # Start the server - uvicorn.run(app, host="0.0.0.0") + # Start the server using settings + uvicorn.run(app, host=settings.MCP_HOST, port=settings.MCP_PORT) except KeyboardInterrupt: log.info("Received keyboard interrupt, shutting down") diff --git a/assisted_service_mcp/src/mcp.py b/assisted_service_mcp/src/mcp.py index d9eadfe..211c63e 100644 --- a/assisted_service_mcp/src/mcp.py +++ b/assisted_service_mcp/src/mcp.py @@ -4,7 +4,6 @@ tools for MCP clients. It uses FastMCP to register and manage MCP capabilities. """ -import os import asyncio import inspect from functools import wraps @@ -13,6 +12,7 @@ # Import auth utilities from assisted_service_mcp.utils.auth import get_offline_token, get_access_token +from assisted_service_mcp.src.settings import settings # Import all tool modules from assisted_service_mcp.src.tools import ( @@ -35,25 +35,22 @@ class AssistedServiceMCPServer: def __init__(self): """Initialize the MCP server with assisted service tools.""" try: - # Get transport configuration - transport_type = os.environ.get("TRANSPORT", "sse").lower() - use_stateless_http = transport_type == "streamable-http" + # Get transport configuration from settings + use_stateless_http = settings.TRANSPORT.lower() == "streamable-http" # Initialize FastMCP server self.mcp = FastMCP( - "AssistedService", host="0.0.0.0", stateless_http=use_stateless_http + "AssistedService", host=settings.MCP_HOST, stateless_http=use_stateless_http ) - - # Create closures for auth functions that capture self.mcp + # Define auth helpers bound to this MCP instance self._get_offline_token = lambda: get_offline_token(self.mcp) - self._get_access_token = lambda: get_access_token(self.mcp) - + self._get_access_token = lambda: get_access_token( + self.mcp, offline_token_func=self._get_offline_token + ) self._register_mcp_tools() - log.info("Assisted Service MCP Server initialized successfully") - except Exception as e: - log.error(f"Failed to initialize Assisted Service MCP Server: {e}") + log.exception("Failed to initialize Assisted Service MCP Server: %s", e) raise def _register_mcp_tools(self) -> None: diff --git a/assisted_service_mcp/src/settings.py b/assisted_service_mcp/src/settings.py new file mode 100644 index 0000000..048bf30 --- /dev/null +++ b/assisted_service_mcp/src/settings.py @@ -0,0 +1,174 @@ +"""Settings for the Assisted Service MCP Server.""" + +from typing import Optional + +from dotenv import load_dotenv +from pydantic import Field, ConfigDict +from pydantic_settings import BaseSettings + +# Load environment variables with error handling +try: + load_dotenv() +except Exception: + # Silently ignore - environment variables might be set directly + pass + + +class Settings(BaseSettings): + """Configuration settings for the Assisted Service MCP Server. + + Uses Pydantic BaseSettings to load and validate configuration from environment variables. + Provides default values for optional settings and validation for required ones. + """ + + # MCP Server Configuration + MCP_HOST: str = Field( + default="0.0.0.0", + json_schema_extra={ + "env": "MCP_HOST", + "description": "Host address for the MCP server", + "example": "localhost", + }, + ) + MCP_PORT: int = Field( + default=8000, + ge=1024, + le=65535, + json_schema_extra={ + "env": "MCP_PORT", + "description": "Port number for the MCP server", + "example": 8000, + }, + ) + + # Transport Configuration + TRANSPORT: str = Field( + default="sse", + json_schema_extra={ + "env": "TRANSPORT", + "description": "Transport protocol for the MCP server", + "example": "sse", + "enum": ["sse", "streamable-http"], + }, + ) + + # Assisted Service API Configuration + INVENTORY_URL: str = Field( + default="https://api.openshift.com/api/assisted-install/v2", + json_schema_extra={ + "env": "INVENTORY_URL", + "description": "Assisted Service API base URL", + "example": "https://api.openshift.com/api/assisted-install/v2", + }, + ) + + PULL_SECRET_URL: str = Field( + default="https://api.openshift.com/api/accounts_mgmt/v1/access_token", + json_schema_extra={ + "env": "PULL_SECRET_URL", + "description": "URL for fetching pull secret", + "example": "https://api.openshift.com/api/accounts_mgmt/v1/access_token", + }, + ) + + CLIENT_DEBUG: bool = Field( + default=False, + json_schema_extra={ + "env": "CLIENT_DEBUG", + "description": "Enable debug mode for API client", + "example": False, + }, + ) + + # Authentication Configuration + OFFLINE_TOKEN: Optional[str] = Field( + default=None, + json_schema_extra={ + "env": "OFFLINE_TOKEN", + "description": "OCM offline token for authentication", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "sensitive": True, + }, + ) + + SSO_URL: str = Field( + default="https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", + json_schema_extra={ + "env": "SSO_URL", + "description": "SSO token endpoint URL", + "example": "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", + }, + ) + + # Logging Configuration + LOGGING_LEVEL: str = Field( + default="INFO", + json_schema_extra={ + "env": "LOGGING_LEVEL", + "description": "Logging level for the application", + "example": "INFO", + "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + }, + ) + + LOGGER_NAME: str = Field( + default="", + json_schema_extra={ + "env": "LOGGER_NAME", + "description": "Name for the logger", + "example": "assisted-service-mcp", + }, + ) + + LOG_TO_FILE: bool = Field( + default=True, + json_schema_extra={ + "env": "LOG_TO_FILE", + "description": "Enable logging to file (disable in containers)", + "example": True, + }, + ) + + model_config = ConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + ) + + +def validate_config(settings: Settings) -> None: + """Validate configuration settings. + + Performs validation to ensure required settings are present and values + are within acceptable ranges. + + Args: + settings: Settings instance to validate. + + Raises: + ValueError: If required configuration is missing or invalid. + """ + # Validate port range + if not (1024 <= settings.MCP_PORT <= 65535): + raise ValueError( + f"MCP_PORT must be between 1024 and 65535, got {settings.MCP_PORT}" + ) + + # Validate log level + valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if settings.LOGGING_LEVEL.upper() not in valid_log_levels: + raise ValueError( + f"LOGGING_LEVEL must be one of {valid_log_levels}, got {settings.LOGGING_LEVEL}" + ) + + # Validate transport protocol + valid_transports = ["sse", "streamable-http"] + if settings.TRANSPORT not in valid_transports: + raise ValueError( + f"TRANSPORT must be one of {valid_transports}, got {settings.TRANSPORT}" + ) + + +# Create config instance without validation (validation happens in main.py if needed) +settings = Settings() + diff --git a/assisted_service_mcp/src/tools/cluster_tools.py b/assisted_service_mcp/src/tools/cluster_tools.py index a4db746..a06b822 100644 --- a/assisted_service_mcp/src/tools/cluster_tools.py +++ b/assisted_service_mcp/src/tools/cluster_tools.py @@ -21,26 +21,26 @@ async def cluster_info( ), ], ) -> str: - """Get comprehensive information about a specific assisted installer cluster with comprehensive metadata. + """Get comprehensive information about a specific cluster. - TOOL_NAME=cluster_info - DISPLAY_NAME=Cluster Information - USECASE=Retrieve detailed configuration, status, network settings, and installation progress for a specific cluster - INSTRUCTIONS=1. Obtain cluster_id from list_clusters or previous cluster operations, 2. Call function with cluster_id, 3. Receive detailed cluster information - INPUT_DESCRIPTION=cluster_id (string): cluster UUID obtained from list_clusters or cluster creation - OUTPUT_DESCRIPTION=Formatted string with cluster name, ID, OpenShift version, installation status/progress, network configuration (VIPs, subnets), and host information/roles - EXAMPLES=cluster_info("550e8400-e29b-41d4-a716-446655440000") - PREREQUISITES=Valid cluster_id, OCM offline token for authentication - RELATED_TOOLS=list_clusters (get cluster IDs), cluster_events (view cluster history), install_cluster, set_cluster_vips + Retrieves detailed cluster information including configuration, status, network settings, + installation progress, and host information. Use this to check cluster state, verify + configuration, or monitor installation progress. - I/O-bound operation - uses async def for external API calls. + Examples: + - cluster_info("550e8400-e29b-41d4-a716-446655440000") + - After creating a cluster, use this to verify the configuration + - During installation, use this to check current status and progress - Retrieves detailed cluster information including configuration, status, hosts, - network settings, and installation progress for the specified cluster ID. + Prerequisites: + - Valid cluster UUID (from list_clusters or create_cluster) + - OCM offline token for authentication - Args: - cluster_id (str): The unique identifier of the cluster to retrieve information for. - This is typically a UUID string. + Related tools: + - list_clusters - Get cluster IDs + - cluster_events - View cluster installation history + - install_cluster - Start cluster installation + - set_cluster_vips - Configure network VIPs Returns: str: A formatted string containing detailed cluster information including: @@ -60,23 +60,24 @@ async def cluster_info( async def list_clusters( mcp, get_access_token_func # Positional args for consistency ) -> str: - """List all assisted installer clusters for the current user with comprehensive metadata. + """List all clusters for the current user. - TOOL_NAME=list_clusters - DISPLAY_NAME=List Clusters - USECASE=Retrieve summary of all OpenShift clusters associated with the current user's account - INSTRUCTIONS=1. Call function without parameters, 2. Receive list of cluster summaries - INPUT_DESCRIPTION=No parameters required - OUTPUT_DESCRIPTION=JSON array with cluster objects containing name, id, openshift_version, and status (e.g., 'ready', 'installing', 'error') - EXAMPLES=list_clusters() - PREREQUISITES=Valid OCM offline token for authentication - RELATED_TOOLS=cluster_info (get detailed cluster information), create_cluster (create new cluster), cluster_events (view cluster history) + Retrieves a summary of all OpenShift clusters associated with your account. This provides + basic information about each cluster (name, ID, version, status) without detailed + configuration. Use cluster_info() to get comprehensive details about a specific cluster. - I/O-bound operation - uses async def for external API calls. + Examples: + - list_clusters() + - Use at the start of a session to see all available clusters + - Check status of multiple clusters at once - Retrieves a summary of all clusters associated with the current user's account. - This provides basic information about each cluster without detailed configuration. - Use cluster_info() to get comprehensive details about a specific cluster. + Prerequisites: + - Valid OCM offline token for authentication + + Related tools: + - cluster_info - Get detailed information for a specific cluster + - create_cluster - Create a new cluster + - cluster_events - View installation history for a cluster Returns: str: A JSON-formatted string containing an array of cluster objects. @@ -110,7 +111,7 @@ async def create_cluster( # pylint: disable=too-many-arguments,too-many-positio version: Annotated[ str, Field( - description="The OpenShift version to install (e.g., '4.18.2', '4.17.1')." + description="The OpenShift version to install (e.g., '4.18.2', '4.17.1'). Use list_versions to see available versions." ), ], base_domain: Annotated[ @@ -122,65 +123,51 @@ async def create_cluster( # pylint: disable=too-many-arguments,too-many-positio single_node: Annotated[ bool, Field( - description="Whether to create a single-node cluster.Set to True for edge deployments or resource-constrained environments. Set to False for production high-availability clusters with multiple control plane nodes." + description="Whether to create a single-node cluster. Set to True for edge deployments or resource-constrained environments. Set to False for production high-availability clusters with multiple control plane nodes." ), ], ssh_public_key: Annotated[ str | None, - Field(default=None, description="SSH public key for accessing cluster nodes."), + Field(default=None, description="SSH public key for accessing cluster nodes. Allows SSH access to nodes during and after installation."), ] = None, cpu_architecture: Annotated[ str, Field( default="x86_64", - description="The CPU architecture for the cluster. Defaults to 'x86_64' if not specified. Valid options are: x86_64, aarch64, arm64, ppc64le, s390x.", + description="CPU architecture for the cluster. Valid options: x86_64 (default), aarch64, arm64, ppc64le, s390x.", ), ] = "x86_64", platform: Annotated[ Helpers.VALID_PLATFORMS | None, Field( default=None, - description="The platform of the cluster. Defaults to 'baremetal' if not specified and single_node is false, or 'none' if not specified and single_node is true. Valid options: baremetal, vsphere, oci, nutanix, none.", + description="Infrastructure platform. For multi-node: baremetal (default), vsphere, oci, nutanix, none. For single-node: must be 'none'. Auto-selected based on single_node if not specified.", ), ] = None, ) -> str: - """Create a new OpenShift cluster with comprehensive configuration options. - - TOOL_NAME=create_cluster - DISPLAY_NAME=Create OpenShift Cluster - USECASE=Create new OpenShift cluster for production HA or single-node edge deployments - INSTRUCTIONS=1. Get version from list_versions, 2. Choose single_node (True/False) and platform, 3. Provide name/domain/architecture, 4. Optionally add SSH key, 5. Receive cluster ID - INPUT_DESCRIPTION=name (string): cluster name, version (string): OpenShift version from list_versions, base_domain (string): DNS domain (e.g. 'example.com'), single_node (boolean): True for SNO/False for HA, ssh_public_key (string, optional): SSH public key, cpu_architecture (string, optional): x86_64/aarch64/arm64/ppc64le/s390x (default: x86_64), platform (string, optional): baremetal/vsphere/oci/nutanix/none (auto-selected based on single_node if not specified) - OUTPUT_DESCRIPTION=String containing the created cluster's UUID for use in subsequent operations - EXAMPLES=create_cluster("my-cluster", "4.18.2", "example.com", False, ssh_public_key="ssh-rsa AAAA...", platform="baremetal"), create_cluster("edge-cluster", "4.17.1", "edge.example.com", True) - PREREQUISITES=Valid OCM offline token, OpenShift version from list_versions, DNS domain configured - RELATED_TOOLS=list_versions (get available versions), cluster_info (view created cluster), set_cluster_vips (configure VIPs for HA clusters), install_cluster (start installation) - - I/O-bound operation - uses async def for external API calls. - - Creates a cluster definition and associated infrastructure environment. The cluster can be configured - for high availability (multi-node) or single-node deployment (SNO). For single-node clusters, platform - must be 'none'. For multi-node clusters, platform defaults to 'baremetal' but can be set to vsphere, - oci, or nutanix. - - Args: - name (str): The name for the new cluster. - version (str): The OpenShift version to install (e.g., "4.18.2", "4.17.1"). - Use list_versions() to see available versions. - base_domain (str): The base DNS domain for the cluster (e.g., "example.com"). - The cluster will be accessible at api.{name}.{base_domain}. - single_node (bool): Whether to create a single-node cluster. Set to True for - edge deployments or resource-constrained environments. Set to False for - production high-availability clusters with multiple control plane nodes. - ssh_public_key (str, optional): SSH public key for accessing cluster nodes. - Providing this key will allow SSH access to the nodes during and after - cluster installation. - cpu_architecture (str, optional): The CPU architecture for the cluster. - Valid options: x86_64 (default), aarch64, arm64, ppc64le, s390x. - platform (str, optional): The platform of the cluster. - For multi-node: baremetal (default), vsphere, oci, nutanix, none. - For single-node: must be 'none'. - Auto-selected if not specified. + """Create a new OpenShift cluster. + + Creates a cluster definition and infrastructure environment for either high-availability + (multi-node) or single-node (SNO) deployment. For single-node clusters, platform must be + 'none'. For multi-node clusters, platform defaults to 'baremetal' but can be vsphere, + oci, or nutanix. This creates the cluster configuration only; use install_cluster to + start the actual installation. + + Examples: + - create_cluster("prod-cluster", "4.18.2", "example.com", False, ssh_public_key="ssh-rsa AAAA...", platform="baremetal") + - create_cluster("edge-cluster", "4.17.1", "edge.local", True) # Single-node, platform='none' auto-selected + - create_cluster("vsphere-cluster", "4.18.2", "vsphere.com", False, platform="vsphere", cpu_architecture="x86_64") + + Prerequisites: + - Valid OCM offline token for authentication + - OpenShift version from list_versions + - Configured DNS domain + + Related tools: + - list_versions - Get available OpenShift versions + - cluster_info - View created cluster details + - set_cluster_vips - Configure VIPs (required for HA baremetal/vsphere/nutanix) + - install_cluster - Start the installation process Returns: str: The created cluster's UUID. @@ -264,28 +251,25 @@ async def set_cluster_vips( ) -> str: """Configure virtual IP addresses (VIPs) for cluster API and ingress traffic. - TOOL_NAME=set_cluster_vips - DISPLAY_NAME=Configure Cluster VIPs - USECASE=Configure virtual IPs for high-availability cluster API and ingress endpoints - INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Ensure platform is baremetal/vsphere/nutanix, 3. Provide two unused IPs from cluster subnet, 4. Receive updated cluster config - INPUT_DESCRIPTION=cluster_id (string): cluster UUID, api_vip (string): IP for API endpoint (kubectl/management tools), ingress_vip (string): IP for application ingress traffic - OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration showing configured VIP addresses - EXAMPLES=set_cluster_vips("cluster-uuid", "192.168.1.100", "192.168.1.101") - PREREQUISITES=Multi-node cluster on baremetal/vsphere/nutanix platform, IPs within cluster subnet and not assigned to any host, reachable from all cluster nodes - RELATED_TOOLS=create_cluster (create cluster first), cluster_info (verify VIP configuration), install_cluster - - I/O-bound operation - uses async def for external API calls. - - VIPs are only required for clusters on baremetal, vsphere, and nutanix platforms. - Do NOT set VIPs for clusters on 'none' or 'oci' platforms. - - The IP addresses must be within the cluster's network subnet, not assigned to any physical host, - and reachable from all cluster nodes. - - Args: - cluster_id (str): The unique identifier of the cluster to configure. - api_vip (str): The IP address for the cluster API endpoint where kubectl connects. - ingress_vip (str): The IP address for ingress traffic to applications. + Sets the API and ingress VIPs required for HA clusters on baremetal, vsphere, and nutanix + platforms. VIPs are NOT needed for single-node clusters or clusters on 'none' or 'oci' + platforms. The IP addresses must be within the cluster's network subnet, not assigned to + any physical host, and reachable from all cluster nodes. + + Examples: + - set_cluster_vips("cluster-uuid", "192.168.1.100", "192.168.1.101") + - After creating an HA baremetal cluster, set VIPs before installation + - Use consecutive IPs from your cluster subnet + + Prerequisites: + - Multi-node cluster on baremetal, vsphere, or nutanix platform + - Two unused IP addresses within the cluster subnet + - IPs must be reachable from all cluster nodes + + Related tools: + - create_cluster - Create the cluster first + - cluster_info - Verify VIP configuration + - install_cluster - Install after VIPs are configured Returns: str: Formatted string with updated cluster configuration including VIP addresses. @@ -318,27 +302,26 @@ async def set_cluster_platform( ), ], ) -> str: - """Set or update the platform type for a cluster. + """Set or update the infrastructure platform type for a cluster. - TOOL_NAME=set_cluster_platform - DISPLAY_NAME=Set Cluster Platform - USECASE=Configure or change the infrastructure platform type for cluster deployment - INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Choose platform type based on infrastructure, 3. Receive updated cluster config, 4. May need to reconfigure network settings - INPUT_DESCRIPTION=cluster_id (string): cluster UUID, platform (string): baremetal/vsphere/oci/nutanix/none - OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration showing new platform setting - EXAMPLES=set_cluster_platform("cluster-uuid", "vsphere"), set_cluster_platform("cluster-uuid", "none") - PREREQUISITES=Existing cluster, compatible platform choice for cluster type (single-node clusters require 'none') - RELATED_TOOLS=create_cluster (creates with default platform), set_cluster_vips (VIP configuration depends on platform), cluster_info + Changes the platform type which determines deployment method and available infrastructure + features. Single-node clusters require platform 'none'. Multi-node clusters can use + baremetal, vsphere, oci, or nutanix. Changing the platform may require reconfiguration + of network settings (VIPs) and other platform-specific parameters. - I/O-bound operation - uses async def for external API calls. + Examples: + - set_cluster_platform("cluster-uuid", "vsphere") # Change to vSphere deployment + - set_cluster_platform("cluster-uuid", "none") # Set for single-node or platformless + - set_cluster_platform("cluster-uuid", "baremetal") # Standard baremetal deployment - The platform type determines how the cluster will be deployed and what infrastructure-specific - features are available. Changing the platform may require reconfiguration of network settings - and other platform-specific parameters. + Prerequisites: + - Existing cluster (from create_cluster) + - Compatible platform choice for cluster type (single-node requires 'none') - Args: - cluster_id (str): The unique identifier of the cluster to configure. - platform (str): baremetal, vsphere, oci, nutanix, or none. + Related tools: + - create_cluster - Creates cluster with default platform + - set_cluster_vips - Configure VIPs (required for baremetal/vsphere/nutanix) + - cluster_info - Verify platform configuration Returns: str: Formatted string with updated cluster configuration and new platform setting. @@ -358,26 +341,30 @@ async def install_cluster( str, Field(description="The unique identifier of the cluster to install.") ], ) -> str: - """Trigger the installation process for a prepared cluster. - - TOOL_NAME=install_cluster - DISPLAY_NAME=Install Cluster - USECASE=Start OpenShift installation on validated and prepared cluster - INSTRUCTIONS=1. Ensure all hosts discovered and validated, 2. Verify network config complete (VIPs if needed), 3. Check validations pass, 4. Call with cluster_id, 5. Monitor via cluster_info/cluster_events - INPUT_DESCRIPTION=cluster_id (string): cluster UUID ready for installation - OUTPUT_DESCRIPTION=Formatted string with cluster status after installation triggered, includes progress information - EXAMPLES=install_cluster("cluster-uuid") - PREREQUISITES=All required hosts discovered and ready, network configuration complete (VIPs set if required), all cluster validations passing - RELATED_TOOLS=create_cluster (create first), cluster_info (check readiness and monitor progress), cluster_events (monitor installation), set_cluster_vips (configure network) - - I/O-bound operation - uses async def for external API calls. - - Initiates the OpenShift installation on all discovered and validated hosts. The cluster must - have all prerequisites met before installation can begin. Returns immediately - use cluster_info - and cluster_events to monitor progress. - - Args: - cluster_id (str): The unique identifier of the cluster to install. + """Start the OpenShift installation process for a prepared cluster. + + Initiates installation on all discovered and validated hosts. The cluster must have all + prerequisites met: required number of hosts discovered and ready, network configuration + complete (VIPs set if required), and all validations passing. This operation returns + immediately; use cluster_info and cluster_events to monitor installation progress. + + Examples: + - install_cluster("cluster-uuid") + - After all hosts are discovered and validated, trigger installation + - VIPs must be configured first for HA baremetal/vsphere/nutanix clusters + + Prerequisites: + - All required hosts discovered and in 'ready' state + - Network configuration complete (VIPs set if required by platform) + - All cluster validations passing (check with cluster_info) + - For HA: minimum 3 master nodes, VIPs configured + - For SNO: 1 node with sufficient resources + + Related tools: + - create_cluster - Create cluster first + - cluster_info - Check readiness and monitor installation progress + - cluster_events - View detailed installation events and logs + - set_cluster_vips - Configure VIPs before installation (HA clusters) Returns: str: Formatted string with cluster status and installation progress information. @@ -403,27 +390,26 @@ async def set_cluster_ssh_key( ), ], ) -> str: - """Set or update the SSH public key for a cluster and its boot images. - - TOOL_NAME=set_cluster_ssh_key - DISPLAY_NAME=Set Cluster SSH Key - USECASE=Configure SSH access to cluster nodes during and after installation - INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Provide SSH public key in OpenSSH format, 3. Download new ISO after update, 4. Boot/reboot hosts with new ISO to apply key - INPUT_DESCRIPTION=cluster_id (string): cluster UUID, ssh_public_key (string): SSH public key in OpenSSH format (e.g., 'ssh-rsa AAAAB3...') - OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration, or partial success message if boot image update fails - EXAMPLES=set_cluster_ssh_key("cluster-uuid", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host") - PREREQUISITES=Existing cluster, valid SSH public key in OpenSSH format - RELATED_TOOLS=create_cluster (can set SSH key at creation), cluster_iso_download_url (get new ISO with updated key), cluster_info - - I/O-bound operation - uses async def for external API calls. - - Updates both the cluster configuration and associated infrastructure environment boot images - with the SSH public key. Only ISO images downloaded after this update will include the new key. - Discovered hosts must be booted with a new ISO to get the updated key. - - Args: - cluster_id (str): The unique identifier of the cluster to update. - ssh_public_key (str): SSH public key in OpenSSH format (e.g., 'ssh-rsa AAAAB3...'). + """Set or update the SSH public key for a cluster. + + Updates both the cluster configuration and boot images with the SSH public key, enabling + SSH access to cluster nodes during and after installation. Only ISO images downloaded after + this update will include the new key. Hosts already booted need to be rebooted with a new + ISO to get the updated key. + + Examples: + - set_cluster_ssh_key("cluster-uuid", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host") + - Add SSH key to existing cluster that was created without one + - Update SSH key if the old key is compromised + + Prerequisites: + - Existing cluster (from create_cluster) + - Valid SSH public key in OpenSSH format (starts with ssh-rsa, ssh-ed25519, etc.) + + Related tools: + - create_cluster - Can set SSH key at creation time + - cluster_iso_download_url - Download new ISO with updated key + - cluster_info - Verify SSH key configuration Returns: str: Formatted string with updated cluster configuration, or error message if boot image update fails. diff --git a/assisted_service_mcp/src/tools/download_tools.py b/assisted_service_mcp/src/tools/download_tools.py index 6ce5640..059859a 100644 --- a/assisted_service_mcp/src/tools/download_tools.py +++ b/assisted_service_mcp/src/tools/download_tools.py @@ -17,29 +17,30 @@ async def cluster_iso_download_url( cluster_id: Annotated[ str, Field( - description="The unique identifier of the cluster, whose ISO image URL has to be retrieved." + description="The unique identifier of the cluster whose ISO image URL will be retrieved." ), ], ) -> str: """Get ISO download URL(s) for cluster boot images. - TOOL_NAME=cluster_iso_download_url - DISPLAY_NAME=Cluster ISO Download URL - USECASE=Get presigned URLs to download bootable ISO images for cluster host discovery and installation - INSTRUCTIONS=1. Get cluster_id from create_cluster, 2. Call function to get ISO URLs, 3. Download ISO from returned URL(s), 4. Boot hosts from ISO for discovery - INPUT_DESCRIPTION=cluster_id (string): cluster UUID - OUTPUT_DESCRIPTION=JSON array with ISO download information including presigned URLs and optional expiration timestamps for each infrastructure environment - EXAMPLES=cluster_iso_download_url("cluster-uuid") - PREREQUISITES=Cluster with created infrastructure environments - RELATED_TOOLS=create_cluster (creates cluster and infra env), set_cluster_ssh_key (update SSH key, requires new ISO download), cluster_info + Retrieves time-limited download URLs for all infrastructure environment ISOs + associated with the cluster. These bootable ISOs are used to boot hosts for automatic + discovery and installation. Download the ISO and boot your hosts from it (USB, virtual + media, PXE) to add them to the cluster. URLs are time-limited for security and will + expire after a period. - I/O-bound operation - uses async def for external API calls. + Examples: + - cluster_iso_download_url("cluster-uuid") + - After creating a cluster, get the ISO URL to boot your first host + - If you updated SSH key, download a new ISO with the updated key - Retrieves presigned download URLs for all infrastructure environment ISOs associated with the cluster. - These ISOs are used to boot hosts for discovery and installation. URLs are time-limited for security. + Prerequisites: + - Cluster with created infrastructure environment (automatically created by create_cluster) - Args: - cluster_id (str): The unique identifier of the cluster. + Related tools: + - create_cluster - Creates cluster and infrastructure environment + - set_cluster_ssh_key - Update SSH key (requires new ISO download) + - cluster_info - View cluster and infrastructure environment details Returns: str: JSON array with ISO URLs and optional expiration times, or message if no ISOs found. @@ -98,30 +99,33 @@ async def cluster_credentials_download_url( file_name: Annotated[ str, Field( - description="The type of credential file to download. Valid options are: kubeconfig (Standard kubeconfig file for cluster access), kubeconfig-noingress (Kubeconfig without ingress configuration), kubeadmin-password (The kubeadmin user password file)." + description="The type of credential file to download. Valid options: 'kubeconfig' (standard kubeconfig for cluster access - use this), 'kubeconfig-noingress' (kubeconfig without ingress), 'kubeadmin-password' (the kubeadmin user password)." ), ], ) -> str: - """Get presigned download URL for cluster credential files after successful installation. - - TOOL_NAME=cluster_credentials_download_url - DISPLAY_NAME=Cluster Credentials Download URL - USECASE=Get secure presigned URLs to download kubeconfig and kubeadmin password after cluster installation completes - INSTRUCTIONS=1. Ensure cluster installation completed successfully, 2. Get cluster_id, 3. Choose file_name (kubeconfig recommended), 4. Download credentials from returned URL before expiration - INPUT_DESCRIPTION=cluster_id (string): cluster UUID, file_name (string): kubeconfig (standard, use this)/kubeconfig-noingress (without ingress)/kubeadmin-password (admin password) - OUTPUT_DESCRIPTION=JSON object with presigned download URL and optional expiration timestamp for secure credential file access - EXAMPLES=cluster_credentials_download_url("cluster-uuid", "kubeconfig"), cluster_credentials_download_url("cluster-uuid", "kubeadmin-password") - PREREQUISITES=Successfully installed cluster (check with cluster_info) - RELATED_TOOLS=cluster_info (verify installation complete), install_cluster (start installation), cluster_events (monitor installation progress) - - I/O-bound operation - uses async def for external API calls. - - Retrieves a time-limited presigned URL for downloading cluster credential files. For successfully - installed clusters, always use "kubeconfig" over "kubeconfig-noingress". URLs expire for security. - - Args: - cluster_id (str): The unique identifier of the cluster to get credentials for. - file_name (str): kubeconfig, kubeconfig-noingress, or kubeadmin-password. + """Get presigned download URL for cluster credentials after installation completes. + + Retrieves a presigned URL for downloading cluster credential files such as + kubeconfig, kubeadmin password, or kubeconfig without ingress configuration. + For a successfully installed cluster the kubeconfig file should always be used + over the kubeconfig-noingress file. + The URL is time-limited and provides secure access to sensitive cluster files. + Whenever a URL is returned provide the user with information on the expiration + of that URL if possible. + + Examples: + - cluster_credentials_download_url("cluster-uuid", "kubeconfig") + - cluster_credentials_download_url("cluster-uuid", "kubeadmin-password") + - After installation completes, get kubeconfig to start using the cluster + - Get admin password if you need to log into the web console + + Prerequisites: + - Successfully completed cluster installation (check status with cluster_info) + + Related tools: + - cluster_info - Verify installation is complete + - install_cluster - Start the installation + - cluster_events - Monitor installation progress Returns: str: JSON with presigned URL and optional expiration timestamp. diff --git a/assisted_service_mcp/src/tools/event_tools.py b/assisted_service_mcp/src/tools/event_tools.py index 1e3f405..f0712c5 100644 --- a/assisted_service_mcp/src/tools/event_tools.py +++ b/assisted_service_mcp/src/tools/event_tools.py @@ -19,32 +19,39 @@ async def cluster_events( ) -> str: """Get chronological events for cluster installation progress and diagnostics. - TOOL_NAME=cluster_events - DISPLAY_NAME=Cluster Events - USECASE=Track cluster installation progress, configuration changes, and diagnose issues through event history - INSTRUCTIONS=1. Get cluster_id from create_cluster or list_clusters, 2. Call function to retrieve events, 3. Review chronological event log for progress and issues - INPUT_DESCRIPTION=cluster_id (string): cluster UUID - OUTPUT_DESCRIPTION=JSON string with timestamped events including event types, severity levels, and descriptive messages about cluster activities - EXAMPLES=cluster_events("cluster-uuid") - PREREQUISITES=Existing cluster with UUID - RELATED_TOOLS=cluster_info (current cluster state), host_events (host-specific events), install_cluster (triggers installation events), list_clusters - - I/O-bound operation - uses async def for external API calls. - - Retrieves chronological events related to cluster installation, configuration changes, and status updates. - Events help track installation progress and diagnose issues. - - Args: - cluster_id (str): The unique identifier of the cluster to get events for. + Retrieves timestamped events related to cluster installation, configuration changes, + and status updates. Use this to track installation progress, understand what actions + have been taken, and diagnose issues. Events include validation results, configuration + changes, and error messages. + + Examples: + - cluster_events("cluster-uuid") + - Monitor installation progress in real-time + - Investigate why a cluster installation failed + - Review configuration changes made to the cluster + + Prerequisites: + - Existing cluster with UUID (from list_clusters or create_cluster) + + Related tools: + - cluster_info - Current cluster state and status + - host_events - Events specific to individual hosts + - install_cluster - Triggers installation events + - list_clusters - Get cluster UUIDs Returns: str: JSON string with timestamped cluster events and descriptive messages. """ log.info("Retrieving events for cluster_id: %s", cluster_id) - client = InventoryClient(get_access_token_func()) - result = await client.get_events(cluster_id=cluster_id) - log.info("Successfully retrieved events for cluster %s", cluster_id) - return result + try: + access_token = get_access_token_func() + client = InventoryClient(access_token) + result = await client.get_events(cluster_id=cluster_id) + log.info("Successfully retrieved events for cluster %s", cluster_id) + return result + except Exception as e: + log.error("Failed to retrieve events for cluster %s: %s", cluster_id, str(e)) + raise @track_tool_usage() @@ -64,33 +71,39 @@ async def host_events( ) -> str: """Get events specific to a particular host for installation tracking and diagnostics. - TOOL_NAME=host_events - DISPLAY_NAME=Host Events - USECASE=Track host-specific installation progress, hardware validation, and diagnose host issues - INSTRUCTIONS=1. Get host_id from cluster_info host list, 2. Get cluster_id from create_cluster or list_clusters, 3. Call function to retrieve host events, 4. Review for validation results and issues - INPUT_DESCRIPTION=cluster_id (string): cluster UUID containing the host, host_id (string): host UUID - OUTPUT_DESCRIPTION=JSON string with host-specific events including hardware validation results, installation steps, role assignment, and error messages - EXAMPLES=host_events("cluster-uuid", "host-uuid") - PREREQUISITES=Existing cluster with discovered hosts - RELATED_TOOLS=cluster_events (cluster-wide events), cluster_info (get host list), set_host_role (configure host role) + Retrieves host-specific events including hardware validation results, installation steps, + role assignment, and error messages. Use this to diagnose host-specific issues like + hardware compatibility problems, network configuration issues, or installation failures + on a particular node. - I/O-bound operation - uses async def for external API calls. + Examples: + - host_events("cluster-uuid", "host-uuid") + - Debug why a specific host failed validation + - Monitor installation progress on a particular node + - Check hardware detection and compatibility results - Retrieves events related to a specific host's installation progress, hardware validation, - role assignment, and any host-specific issues or status changes. + Prerequisites: + - Existing cluster with discovered hosts + - Host ID (from cluster_info host list) - Args: - cluster_id (str): The unique identifier of the cluster containing the host. - host_id (str): The unique identifier of the specific host to get events for. + Related tools: + - cluster_events - Cluster-wide events + - cluster_info - Get host list and IDs + - set_host_role - Configure host role assignment Returns: str: JSON string with host-specific events including validation results and installation steps. """ - log.info("Retrieving events for host %s in cluster %s", host_id, cluster_id) - client = InventoryClient(get_access_token_func()) - result = await client.get_events(cluster_id=cluster_id, host_id=host_id) - log.info( - "Successfully retrieved events for host %s in cluster %s", host_id, cluster_id - ) - return result + try: + log.info("Retrieving events for host %s in cluster %s", host_id, cluster_id) + client = InventoryClient(get_access_token_func()) + result = await client.get_events(cluster_id=cluster_id, host_id=host_id) + log.info( + "Successfully retrieved events for host %s in cluster %s", host_id, cluster_id + ) + return result + except Exception as e: + log.error( + "Failed to retrieve events for host %s in cluster %s: %s", host_id, cluster_id, str(e)) + raise diff --git a/assisted_service_mcp/src/tools/host_tools.py b/assisted_service_mcp/src/tools/host_tools.py index 3148323..ced1fc7 100644 --- a/assisted_service_mcp/src/tools/host_tools.py +++ b/assisted_service_mcp/src/tools/host_tools.py @@ -23,31 +23,33 @@ async def set_host_role( role: Annotated[ str, Field( - description="The role to assign to the host. Valid options are: auto-assign (Let the installer automatically determine the role), master (Control plane node - API server, etcd, scheduler), worker (Compute node for running application workloads)." + description="The role to assign to the host. Valid options: 'auto-assign' (let installer decide), 'master' (control plane node with API server, etcd, scheduler), 'worker' (compute node for application workloads)." ), ], ) -> str: """Assign a specific role to a discovered host in the cluster. - TOOL_NAME=set_host_role - DISPLAY_NAME=Set Host Role - USECASE=Configure whether discovered host will be control plane (master) or compute (worker) node - INSTRUCTIONS=1. Boot hosts with cluster ISO, 2. Get host_id from cluster_info, 3. Get cluster_id, 4. Choose role (auto-assign/master/worker), 5. Receive updated host config - INPUT_DESCRIPTION=host_id (string): host UUID from discovered hosts, cluster_id (string): cluster UUID, role (string): auto-assign (automatic)/master (control plane)/worker (compute node) - OUTPUT_DESCRIPTION=Formatted string with updated host configuration showing newly assigned role - EXAMPLES=set_host_role("host-uuid", "cluster-uuid", "master"), set_host_role("host-uuid", "cluster-uuid", "worker") - PREREQUISITES=Host discovered after booting from cluster ISO (visible in cluster_info) - RELATED_TOOLS=cluster_info (get host list and IDs), cluster_iso_download_url (get ISO to boot hosts), host_events (view host-specific events) + Sets whether a host will be a control plane (master) node or worker node. Use 'master' + for nodes that will run the Kubernetes control plane (API server, etcd, scheduler). + Use 'worker' for nodes that will only run application workloads. Use 'auto-assign' to + let the installer choose based on cluster requirements. HA clusters require at least + 3 master nodes. - I/O-bound operation - uses async def for external API calls. + Examples: + - set_host_role("host-uuid", "cluster-uuid", "master") # Make this host a control plane node + - set_host_role("host-uuid", "cluster-uuid", "worker") # Make this host a worker node + - set_host_role("host-uuid", "cluster-uuid", "auto-assign") # Let installer decide + - For HA: assign 'master' to first 3 hosts, 'worker' to remaining hosts - Sets the role for a host that has been discovered through booting from the cluster ISO. - The role determines the host's function in the OpenShift cluster. + Prerequisites: + - Discovered host (boot from cluster ISO to discover) + - Host ID from cluster_info host list + - Cluster with infrastructure environment - Args: - host_id (str): The unique identifier of the host to configure. - cluster_id (str): The unique identifier of the cluster containing the host. - role (str): auto-assign, master (control plane), or worker (compute). + Related tools: + - cluster_info - Get list of discovered hosts with their IDs + - host_events - View host-specific events and validation results + - cluster_iso_download_url - Get ISO to boot hosts for discovery Returns: str: Formatted string with updated host configuration showing assigned role. @@ -60,11 +62,6 @@ async def set_host_role( # Update the host with the specified role result = await client.update_host(host_id, infra_env_id, host_role=role) - log.info( - "Successfully set role '%s' for host %s in cluster %s", - role, - host_id, - cluster_id, - ) + log.info("Successfully set role for host %s in cluster %s", host_id, cluster_id) return result.to_str() diff --git a/assisted_service_mcp/src/tools/network_tools.py b/assisted_service_mcp/src/tools/network_tools.py index 4a7353a..691990b 100644 --- a/assisted_service_mcp/src/tools/network_tools.py +++ b/assisted_service_mcp/src/tools/network_tools.py @@ -1,6 +1,8 @@ """Network configuration tools for Assisted Service MCP Server.""" import json +from typing import Annotated +from pydantic import Field from jinja2 import TemplateError from metrics import track_tool_usage @@ -17,25 +19,33 @@ @track_tool_usage() -async def validate_nmstate_yaml(mcp, get_access_token_func, nmstate_yaml: str) -> str: - """Validate an nmstate YAML document before submission. +async def validate_nmstate_yaml( + mcp, + get_access_token_func, + nmstate_yaml: Annotated[ + str, + Field(description="The NMState YAML document to validate. This defines static network configuration for a host."), + ], +) -> str: + """Validate an NMState YAML document before applying to hosts. - TOOL_NAME=validate_nmstate_yaml - DISPLAY_NAME=Validate NMState YAML - USECASE=Validate static network configuration YAML before applying to hosts - INSTRUCTIONS=1. Generate or obtain nmstate YAML, 2. Call function to validate, 3. Fix errors if validation fails, 4. Apply to hosts after validation succeeds - INPUT_DESCRIPTION=nmstate_yaml (string): NMState YAML document for static network configuration - OUTPUT_DESCRIPTION=String "YAML is valid" on success, or error message with validation failure details - EXAMPLES=validate_nmstate_yaml("interfaces:\\n- name: eth0\\n type: ethernet\\n state: up") - PREREQUISITES=NMState YAML document (from generate_nmstate_yaml or manually created) - RELATED_TOOLS=generate_nmstate_yaml (generate initial YAML), alter_static_network_config_nmstate_for_host (apply validated YAML) + Validates the YAML syntax and structure to ensure it's correct before submitting to the + cluster. Always validate YAML after generating or manually editing before applying it to + hosts. Invalid YAML will cause host configuration failures. - CPU-bound operation - uses def for validation logic. + Examples: + - validate_nmstate_yaml("interfaces:\\n- name: eth0\\n type: ethernet\\n state: up...") + - After generating YAML with generate_nmstate_yaml, validate it + - After manually editing YAML, validate before applying + - Catch syntax errors before they cause host configuration problems - The YAML must be validated before being submitted to the cluster to ensure correct network configuration. + Prerequisites: + - NMState YAML document (from generate_nmstate_yaml or manual creation) - Args: - nmstate_yaml (str): The nmstate YAML to validate. + Related tools: + - generate_nmstate_yaml - Generate initial YAML from parameters + - alter_static_network_config_nmstate_for_host - Apply validated YAML to hosts + - list_static_network_config - View currently applied configurations Returns: str: "YAML is valid" if successful, otherwise error message. @@ -46,27 +56,35 @@ async def validate_nmstate_yaml(mcp, get_access_token_func, nmstate_yaml: str) - @track_tool_usage() async def generate_nmstate_yaml( - mcp, get_access_token_func, params: NMStateTemplateParams + mcp, + get_access_token_func, + params: Annotated[ + NMStateTemplateParams, + Field( + description="Structured network configuration parameters including interface name, IP addresses (IPv4/IPv6), DNS servers, gateway, and routes. Use NMStateTemplateParams schema." + ), + ], ) -> str: - """Generate initial nmstate YAML from network configuration parameters. + """Generate NMState YAML from structured network parameters. - TOOL_NAME=generate_nmstate_yaml - DISPLAY_NAME=Generate NMState YAML - USECASE=Generate initial static network configuration YAML from structured parameters - INSTRUCTIONS=1. Gather network info from user (interface, IPs, DNS, gateway), 2. Call with NMStateTemplateParams, 3. Receive generated YAML, 4. Validate with validate_nmstate_yaml, 5. Apply with alter_static_network_config_nmstate_for_host - INPUT_DESCRIPTION=params (NMStateTemplateParams): structured network configuration including interface name, IP addresses, DNS servers, gateway, routes - OUTPUT_DESCRIPTION=Generated nmstate YAML string, or error message if generation fails - EXAMPLES=generate_nmstate_yaml(NMStateTemplateParams(interface_name="eth0", ipv4_address="192.168.1.10/24", ipv4_gateway="192.168.1.1")) - PREREQUISITES=Network configuration information from user - RELATED_TOOLS=validate_nmstate_yaml (validate generated YAML), alter_static_network_config_nmstate_for_host (apply to host) + Creates NMState YAML configuration from structured parameters rather than writing YAML + manually. Always use this to generate initial YAML from user requirements, then validate + and optionally tweak the result. Do not generate nmstate yaml from scratch without calling + this tool. - I/O-bound operation - uses async def for potential future API calls. + Examples: + - generate_nmstate_yaml(NMStateTemplateParams(interface_name="eth0", ipv4_address="192.168.1.10/24", ipv4_gateway="192.168.1.1", ipv4_dns=["8.8.8.8"])) + - Generate YAML for static IP configuration from user input + - Create YAML with both IPv4 and IPv6 configuration + - Generate YAML with multiple DNS servers and custom routes - Always use this tool to generate initial YAML from user input rather than creating YAML from scratch. - The generated YAML can be tweaked as needed before validation and application. + Prerequisites: + - Network information from user (interface, IPs, gateway, DNS) - Args: - params: NMStateTemplateParams object containing network configuration. + Related tools: + - validate_nmstate_yaml - Validate the generated YAML + - alter_static_network_config_nmstate_for_host - Apply generated YAML to hosts + - list_static_network_config - View applied configurations Returns: str: Generated nmstate YAML or error message. @@ -88,32 +106,45 @@ async def generate_nmstate_yaml( async def alter_static_network_config_nmstate_for_host( mcp, get_access_token_func, - cluster_id: str, - index: int | None, - new_nmstate_yaml: str | None, + cluster_id: Annotated[ + str, + Field(description="The unique identifier of the cluster to configure."), + ], + index: Annotated[ + int | None, + Field( + description="The position of the host in the static network configuration list. Use None to append a new host configuration. Use 0, 1, 2, etc. to replace or delete an existing host configuration." + ), + ], + new_nmstate_yaml: Annotated[ + str | None, + Field( + description="The validated NMState YAML to apply. Use None to delete the configuration at the specified index. Provide YAML to add or update a configuration." + ), + ], ) -> str: - """Add, replace, or delete nmstate YAML configuration for a specific host. + """Add, replace, or delete static network configuration for a host. - TOOL_NAME=alter_static_network_config_nmstate_for_host - DISPLAY_NAME=Alter Host Static Network Config - USECASE=Apply, update, or remove static network configuration for individual cluster hosts - INSTRUCTIONS=1. Generate/validate YAML, 2. Get cluster_id, 3. To add: set index=None, provide YAML, 4. To update: set index to host position, provide new YAML, 5. To remove: set index to host position, set YAML=None - INPUT_DESCRIPTION=cluster_id (string): cluster UUID, index (int or null): host position in config list (null to append new), new_nmstate_yaml (string or null): validated nmstate YAML (null to delete config at index) - OUTPUT_DESCRIPTION=Formatted string with updated infrastructure environment showing new static network configuration - EXAMPLES=alter_static_network_config_nmstate_for_host("cluster-uuid", None, "interfaces:\\n- name: eth0..."), alter_static_network_config_nmstate_for_host("cluster-uuid", 0, None) - PREREQUISITES=Validated nmstate YAML (from validate_nmstate_yaml), cluster with infrastructure environment - RELATED_TOOLS=generate_nmstate_yaml (generate YAML), validate_nmstate_yaml (validate before applying), list_static_network_config (view current configs) + Manages static network configurations for cluster hosts. To add a new host config, use + index=None and provide YAML. To update an existing host config, provide the index and + new YAML. To remove a host config, provide the index and set YAML=None. Each + configuration corresponds to one host in the order they boot from the ISO. - I/O-bound operation - uses async def for external API calls. + Examples: + - alter_static_network_config_nmstate_for_host("cluster-uuid", None, "interfaces:\\n- name: eth0...") # Add new host config + - alter_static_network_config_nmstate_for_host("cluster-uuid", 0, "interfaces:\\n- name: eth1...") # Update first host + - alter_static_network_config_nmstate_for_host("cluster-uuid", 1, None) # Delete second host config - Add new host: index=None, provide YAML (appends to end). - Replace host config: provide index and new YAML. - Delete host config: provide index, set YAML=None. + Prerequisites: + - Validated NMState YAML (from validate_nmstate_yaml) + - Cluster with infrastructure environment + - Know which host corresponds to which index (first boot = index 0, second = 1, etc.) - Args: - cluster_id (str): The unique identifier of the cluster. - index (int | None): Host position in config list, or None to append. - new_nmstate_yaml (str | None): New nmstate YAML, or None to delete. + Related tools: + - generate_nmstate_yaml - Create YAML from parameters + - validate_nmstate_yaml - Validate YAML before applying + - list_static_network_config - View current configurations and indices + - cluster_info - View cluster and infrastructure environment Returns: str: Updated infrastructure environment with new static network config. @@ -147,27 +178,34 @@ async def alter_static_network_config_nmstate_for_host( @track_tool_usage() async def list_static_network_config( - mcp, get_access_token_func, cluster_id: str + mcp, + get_access_token_func, + cluster_id: Annotated[ + str, + Field(description="The unique identifier of the cluster to query."), + ], ) -> str: - """List all host static network configurations for a cluster. - - TOOL_NAME=list_static_network_config - DISPLAY_NAME=List Static Network Configs - USECASE=View all static network configurations applied to cluster hosts - INSTRUCTIONS=1. Get cluster_id, 2. Call function, 3. Receive JSON array of host configs with indices - INPUT_DESCRIPTION=cluster_id (string): cluster UUID - OUTPUT_DESCRIPTION=JSON array of static network configurations (one per host), or error if cluster doesn't have exactly one infrastructure environment - EXAMPLES=list_static_network_config("cluster-uuid") - PREREQUISITES=Cluster with infrastructure environment - RELATED_TOOLS=alter_static_network_config_nmstate_for_host (modify configs), generate_nmstate_yaml (create new configs), cluster_info - - I/O-bound operation - uses async def for external API calls. - - Returns all host static network configurations for the cluster's infrastructure environment. - Each configuration corresponds to one host, indexed by position in the array. - - Args: - cluster_id (str): The unique identifier of the cluster. + """List all static network configurations for cluster hosts. + + Shows all static network configurations applied to the cluster's infrastructure + environment. Each configuration in the array corresponds to one host, in the order + they were added. Use the array index when updating or deleting specific host + configurations. + + Examples: + - list_static_network_config("cluster-uuid") + - Check which hosts have static network configs + - Find the index of a specific host's configuration + - Verify configurations after adding or updating + + Prerequisites: + - Cluster with infrastructure environment + + Related tools: + - alter_static_network_config_nmstate_for_host - Add, update, or remove configs + - generate_nmstate_yaml - Generate new configurations + - validate_nmstate_yaml - Validate configurations + - cluster_info - View cluster and infrastructure environment details Returns: str: JSON array of static network configs, or error message. diff --git a/assisted_service_mcp/src/tools/version_tools.py b/assisted_service_mcp/src/tools/version_tools.py index 26d1186..f414159 100644 --- a/assisted_service_mcp/src/tools/version_tools.py +++ b/assisted_service_mcp/src/tools/version_tools.py @@ -11,23 +11,24 @@ @track_tool_usage() async def list_versions(mcp, get_access_token_func) -> str: - """List all available OpenShift versions for installation with comprehensive metadata. + """List all available OpenShift versions for installation. - TOOL_NAME=list_versions - DISPLAY_NAME=OpenShift Version List - USECASE=Retrieve available OpenShift versions that can be installed using the assisted installer - INSTRUCTIONS=1. Call function without parameters, 2. Receive list of available versions - INPUT_DESCRIPTION=No parameters required - OUTPUT_DESCRIPTION=JSON string with available OpenShift versions including version numbers, release dates, and support status - EXAMPLES=list_versions() - PREREQUISITES=Valid OCM offline token for authentication - RELATED_TOOLS=create_cluster (uses version from this list), list_operator_bundles + Retrieves the complete list of OpenShift versions that can be installed using the + assisted installer service, including GA releases and pre-release candidates. Use + this before creating a cluster to see which versions are available. - I/O-bound operation - uses async def for external API calls. + Examples: + - list_versions() + - Check available versions before creating a new cluster + - See if a specific OpenShift version is available + - Find the latest stable release - Retrieves the complete list of OpenShift versions that can be installed - using the assisted installer service, including release versions and - pre-release candidates. + Prerequisites: + - Valid OCM offline token for authentication + + Related tools: + - create_cluster - Uses version from this list + - list_operator_bundles - See available operators for each version Returns: str: A JSON string containing available OpenShift versions with metadata @@ -42,22 +43,25 @@ async def list_versions(mcp, get_access_token_func) -> str: @track_tool_usage() async def list_operator_bundles(mcp, get_access_token_func) -> str: - """List available operator bundles for cluster installation with comprehensive metadata. + """List available operator bundles that can be added to clusters. + + Retrieves operator bundles that extend OpenShift cluster functionality with additional + capabilities like virtualization, AI/ML, monitoring, and storage. These bundles are + automatically installed during cluster deployment if added before installation. - TOOL_NAME=list_operator_bundles - DISPLAY_NAME=Operator Bundle List - USECASE=Retrieve available operator bundles that extend OpenShift cluster functionality - INSTRUCTIONS=1. Call function without parameters, 2. Receive list of available operator bundles - INPUT_DESCRIPTION=No parameters required - OUTPUT_DESCRIPTION=JSON string with available operator bundles including bundle names, descriptions, and operator details - EXAMPLES=list_operator_bundles() - PREREQUISITES=Valid OCM offline token for authentication - RELATED_TOOLS=add_operator_bundle_to_cluster (adds bundles from this list), create_cluster + Examples: + - list_operator_bundles() + - See available operators before creating a cluster + - Check if a specific operator bundle is available + - Find operators for a specific use case (e.g., virtualization, AI) - I/O-bound operation - uses async def for external API calls. + Prerequisites: + - Valid OCM offline token for authentication - Retrieves details about operator bundles that can be optionally installed - during cluster deployment. + Related tools: + - add_operator_bundle_to_cluster - Add bundles from this list to a cluster + - create_cluster - Operator bundles can be added to new clusters + - list_versions - See compatible OpenShift versions Returns: str: A JSON string containing available operator bundles with metadata @@ -80,32 +84,31 @@ async def add_operator_bundle_to_cluster( bundle_name: Annotated[ str, Field( - description="The name of the operator bundle to add. The available operator bundle names are 'virtualization' and 'openshift-ai'" + description="The name of the operator bundle to add. Use list_operator_bundles to see available bundles. Common bundles: 'virtualization', 'openshift-ai'." ), ], ) -> str: - """Add an operator bundle to be installed with the cluster with comprehensive metadata. - - TOOL_NAME=add_operator_bundle_to_cluster - DISPLAY_NAME=Add Operator Bundle - USECASE=Add operator bundles to extend cluster functionality with virtualization, AI, and other capabilities - INSTRUCTIONS=1. Get bundle name from list_operator_bundles, 2. Provide cluster_id and bundle_name, 3. Receive updated cluster configuration - INPUT_DESCRIPTION=cluster_id (string): cluster UUID, bundle_name (string): operator bundle name ('virtualization' or 'openshift-ai') - OUTPUT_DESCRIPTION=Formatted string with updated cluster configuration showing added operator bundle - EXAMPLES=add_operator_bundle_to_cluster("cluster-uuid", "virtualization") - PREREQUISITES=Valid cluster with status allowing operator addition, bundle name from list_operator_bundles - RELATED_TOOLS=list_operator_bundles (get available bundles), cluster_info (verify cluster state), create_cluster - - I/O-bound operation - uses async def for external API calls. - - Configures the specified operator bundle to be automatically installed - during cluster deployment. The bundle must be from the list of available - bundles returned by list_operator_bundles(). - - Args: - cluster_id (str): The unique identifier of the cluster to configure. - bundle_name (str): The name of the operator bundle to add. - The available operator bundle names are "virtualization" and "openshift-ai" + """Add an operator bundle to be automatically installed with the cluster. + + Configures the specified operator bundle to be installed during cluster deployment. + The operator will be installed automatically after the cluster installation completes. + Bundle must be from the list returned by list_operator_bundles(). Add operator bundles + before starting cluster installation. + + Examples: + - add_operator_bundle_to_cluster("cluster-uuid", "virtualization") + - add_operator_bundle_to_cluster("cluster-uuid", "openshift-ai") + + Prerequisites: + - Existing cluster (from create_cluster) + - Cluster not yet installed (check with cluster_info) + - Bundle name from list_operator_bundles + + Related tools: + - list_operator_bundles - Get available operator bundle names + - cluster_info - Verify cluster state and installed operators + - create_cluster - Create cluster first + - install_cluster - Start installation after adding bundles Returns: str: A formatted string containing the updated cluster configuration diff --git a/assisted_service_mcp/utils/auth.py b/assisted_service_mcp/utils/auth.py index cd49a91..9c1ed3b 100644 --- a/assisted_service_mcp/utils/auth.py +++ b/assisted_service_mcp/utils/auth.py @@ -1,8 +1,8 @@ """Authentication utilities for Assisted Service MCP Server.""" -import os import requests from service_client.logger import log +from assisted_service_mcp.src.settings import settings def get_offline_token(mcp) -> str: @@ -25,7 +25,7 @@ def get_offline_token(mcp) -> str: or request headers. """ log.debug("Attempting to retrieve offline token") - token = os.environ.get("OFFLINE_TOKEN") + token = settings.OFFLINE_TOKEN if token: log.debug("Found offline token in environment variables") return token @@ -85,10 +85,7 @@ def get_access_token(mcp, offline_token_func=None) -> str: "grant_type": "refresh_token", "refresh_token": offline_token, } - sso_url = os.environ.get( - "SSO_URL", - "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", - ) + sso_url = settings.SSO_URL response = requests.post(sso_url, data=params, timeout=30) response.raise_for_status() log.debug("Successfully generated new access token") diff --git a/pyproject.toml b/pyproject.toml index 3ed575e..942089c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "pyyaml>=6", "jinja2>=3.1", "pydantic>=2.12.1", + "pydantic-settings>=2.6.0", + "python-dotenv>=1.0.0", "nestedarchive>=0.2.4", "tabulate>=0.9.0", ] diff --git a/service_client/assisted_service_api.py b/service_client/assisted_service_api.py index 3fbc071..79e1595 100644 --- a/service_client/assisted_service_api.py +++ b/service_client/assisted_service_api.py @@ -6,7 +6,6 @@ environments, and host management. """ -import os import asyncio from typing import Any, Optional, cast, Callable, TypeVar from urllib.parse import urlparse @@ -20,6 +19,7 @@ from service_client.exceptions import sanitize_exceptions from service_client.helpers import Helpers from metrics.metrics import API_CALL_LATENCY +from assisted_service_mcp.src.settings import settings T = TypeVar("T") @@ -40,10 +40,8 @@ def __init__(self, access_token: str): """Initialize the InventoryClient with an access token.""" self.access_token = access_token self._pull_secret: Optional[str] = None - self.inventory_url = os.environ.get( - "INVENTORY_URL", "https://api.openshift.com/api/assisted-install/v2" - ) - self.client_debug = os.environ.get("CLIENT_DEBUG", "False").lower() == "true" + self.inventory_url = settings.INVENTORY_URL + self.client_debug = settings.CLIENT_DEBUG async def _api_call(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T: """ @@ -70,10 +68,7 @@ def pull_secret(self) -> str: return self._pull_secret def _get_pull_secret(self) -> str: - url = os.environ.get( - "PULL_SECRET_URL", - "https://api.openshift.com/api/accounts_mgmt/v1/access_token", - ) + url = settings.PULL_SECRET_URL headers = {"Authorization": f"Bearer {self.access_token}"} try: diff --git a/service_client/logger.py b/service_client/logger.py index 39f8b5d..9cf5686 100644 --- a/service_client/logger.py +++ b/service_client/logger.py @@ -73,12 +73,14 @@ def format(self, record: logging.LogRecord) -> str: def get_logging_level() -> int: """ - Get the logging level from environment variable. + Get the logging level from settings. Returns: int: The logging level (defaults to INFO if not set or invalid). """ - level = os.environ.get("LOGGING_LEVEL", "") + # Import here to avoid circular dependency at module load time + from assisted_service_mcp.src.settings import settings + level = settings.LOGGING_LEVEL return getattr(logging, level.upper(), logging.INFO) if level else logging.INFO @@ -116,7 +118,10 @@ def add_stream_handler(logger: logging.Logger) -> None: logger.addHandler(ch) -logger_name = os.environ.get("LOGGER_NAME", "") +# Import settings for logger configuration +from assisted_service_mcp.src.settings import settings + +logger_name = settings.LOGGER_NAME urllib3_logger = logging.getLogger("urllib3") urllib3_logger.handlers = [logging.NullHandler()] @@ -126,8 +131,8 @@ def add_stream_handler(logger: logging.Logger) -> None: log = logging.getLogger(logger_name) log.setLevel(get_logging_level()) -# Check if we should log to file (default: True, set to False in containers) -log_to_file = os.environ.get("LOG_TO_FILE", "true").lower() == "true" +# Check if we should log to file (from settings) +log_to_file = settings.LOG_TO_FILE if log_to_file: add_log_file_handler(log, "assisted-service-mcp.log") diff --git a/tests/test_assisted_service_api.py b/tests/test_assisted_service_api.py index 9fecced..e0f7163 100644 --- a/tests/test_assisted_service_api.py +++ b/tests/test_assisted_service_api.py @@ -59,15 +59,14 @@ def test_init_with_access_token(self, mock_access_token: str) -> None: def test_init_with_environment_variables(self, mock_access_token: str) -> None: """Test client initialization with environment variables.""" test_url = "https://custom-api.example.com/v2" - with patch.dict( - os.environ, {"INVENTORY_URL": test_url, "CLIENT_DEBUG": "true"} - ): - with patch.object( - InventoryClient, "_get_pull_secret", return_value="test-pull-secret" - ): - client = InventoryClient(mock_access_token) - assert client.inventory_url == test_url - assert client.client_debug is True + with patch("assisted_service_mcp.src.settings.settings.INVENTORY_URL", test_url): + with patch("assisted_service_mcp.src.settings.settings.CLIENT_DEBUG", True): + with patch.object( + InventoryClient, "_get_pull_secret", return_value="test-pull-secret" + ): + client = InventoryClient(mock_access_token) + assert client.inventory_url == test_url + assert client.client_debug is True @patch("requests.post") def test_get_pull_secret_success( @@ -113,7 +112,7 @@ def test_get_pull_secret_with_custom_url( mock_response.text = "pull-secret-content" mock_post.return_value = mock_response - with patch.dict(os.environ, {"PULL_SECRET_URL": custom_url}): + with patch("assisted_service_mcp.src.settings.settings.PULL_SECRET_URL", custom_url): client = InventoryClient(mock_access_token) # Access the pull_secret property to trigger lazy loading diff --git a/tests/test_server.py b/tests/test_server.py index 3509746..e988e2d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -40,7 +40,7 @@ def mock_mcp_get_context(self) -> Generator[Tuple[Mock, Mock], None, None]: def test_get_offline_token_from_environment(self) -> None: """Test retrieving offline token from environment variables.""" test_token = "test-offline-token" - with patch.dict(os.environ, {"OFFLINE_TOKEN": test_token}): + with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", test_token): result = server.get_offline_token() assert result == test_token @@ -55,7 +55,7 @@ def test_get_offline_token_environment_takes_precedence( # Set up both environment and header tokens mock_request.headers.get.return_value = header_token - with patch.dict(os.environ, {"OFFLINE_TOKEN": env_token}): + with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", env_token): result = server.get_offline_token() # Should return the environment token, not the header token @@ -190,7 +190,7 @@ def test_get_access_token_custom_sso_url( mock_response.json.return_value = {"access_token": access_token} mock_post.return_value = mock_response - with patch.dict(os.environ, {"SSO_URL": custom_sso_url}): + with patch("assisted_service_mcp.src.settings.settings.SSO_URL", custom_sso_url): with patch.object(server, "get_offline_token", return_value=offline_token): result = server.get_access_token() From 227b497fddc131fcf5339a0d88b86659b4e968a0 Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Fri, 10 Oct 2025 10:55:54 +0200 Subject: [PATCH 3/4] deprecating server.py --- Dockerfile | 7 +- Makefile | 4 +- README.md | 4 +- assisted_service_mcp/__init__.py | 1 - assisted_service_mcp/src/__init__.py | 1 - assisted_service_mcp/src/api.py | 9 +- assisted_service_mcp/src/logger.py | 178 +++ assisted_service_mcp/src/main.py | 20 +- assisted_service_mcp/src/mcp.py | 77 +- .../src/metrics}/__init__.py | 0 .../src/metrics}/metrics.py | 0 .../src/service_client}/__init__.py | 2 +- .../service_client}/assisted_service_api.py | 17 +- .../src/service_client}/exceptions.py | 2 +- .../src/service_client}/helpers.py | 2 +- assisted_service_mcp/src/settings.py | 86 +- assisted_service_mcp/src/tools/__init__.py | 1 - .../src/tools/cluster_tools.py | 97 +- .../src/tools/download_tools.py | 68 +- assisted_service_mcp/src/tools/event_tools.py | 39 +- assisted_service_mcp/src/tools/host_tools.py | 21 +- .../src/tools/network_tools.py | 50 +- .../src/tools/operator_tools.py | 96 ++ .../src/tools/shared_helpers.py | 5 +- .../src/tools/version_tools.py | 110 +- .../src/utils/log_analyzer}/__init__.py | 0 .../src/utils/log_analyzer}/log_analyzer.py | 0 .../src/utils/log_analyzer}/main.py | 2 +- .../log_analyzer}/signatures/__init__.py | 0 .../signatures/advanced_analysis.py | 5 +- .../utils/log_analyzer}/signatures/base.py | 0 .../log_analyzer}/signatures/basic_info.py | 0 .../signatures/error_detection.py | 5 +- .../log_analyzer}/signatures/networking.py | 7 +- .../log_analyzer}/signatures/performance.py | 0 .../signatures/platform_specific.py | 0 .../src/utils/static_net}/__init__.py | 0 .../src/utils/static_net}/config.py | 6 +- .../src/utils/static_net}/template.py | 12 +- assisted_service_mcp/utils/__init__.py | 1 - assisted_service_mcp/utils/auth.py | 78 +- assisted_service_mcp/utils/client_factory.py | 35 - assisted_service_mcp/utils/helpers.py | 9 +- integration_test/performance/README.md | 2 +- pyproject.toml | 16 +- pyrightconfig.json | 15 + server.py | 480 ------- service_client/logger.py | 142 --- tests/test_api.py | 66 + tests/test_assisted_service_api.py | 15 +- tests/test_auth.py | 107 ++ tests/test_helpers.py | 2 +- tests/test_integration_api.py | 0 tests/test_log_analyzer.py | 165 +++ tests/test_logger.py | 92 ++ tests/test_mcp.py | 37 + tests/test_metrics.py | 85 ++ tests/test_server.py | 1103 ----------------- tests/test_service_client_api.py | 61 + tests/test_settings.py | 73 ++ tests/test_shared_helpers.py | 28 + tests/test_static_net.py | 8 +- tests/test_tools_module.py | 567 +++++++++ uv.lock | 28 +- 64 files changed, 1970 insertions(+), 2179 deletions(-) create mode 100644 assisted_service_mcp/src/logger.py rename {metrics => assisted_service_mcp/src/metrics}/__init__.py (100%) rename {metrics => assisted_service_mcp/src/metrics}/metrics.py (100%) rename {service_client => assisted_service_mcp/src/service_client}/__init__.py (86%) rename {service_client => assisted_service_mcp/src/service_client}/assisted_service_api.py (97%) rename {service_client => assisted_service_mcp/src/service_client}/exceptions.py (97%) rename {service_client => assisted_service_mcp/src/service_client}/helpers.py (93%) create mode 100644 assisted_service_mcp/src/tools/operator_tools.py rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/__init__.py (100%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/log_analyzer.py (100%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/main.py (96%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/__init__.py (100%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/advanced_analysis.py (98%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/base.py (100%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/basic_info.py (100%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/error_detection.py (98%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/networking.py (98%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/performance.py (100%) rename {log_analyzer => assisted_service_mcp/src/utils/log_analyzer}/signatures/platform_specific.py (100%) rename {static_net => assisted_service_mcp/src/utils/static_net}/__init__.py (100%) rename {static_net => assisted_service_mcp/src/utils/static_net}/config.py (94%) rename {static_net => assisted_service_mcp/src/utils/static_net}/template.py (95%) delete mode 100644 assisted_service_mcp/utils/client_factory.py create mode 100644 pyrightconfig.json delete mode 100644 server.py delete mode 100644 service_client/logger.py create mode 100644 tests/test_api.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_integration_api.py create mode 100644 tests/test_log_analyzer.py create mode 100644 tests/test_logger.py create mode 100644 tests/test_mcp.py create mode 100644 tests/test_metrics.py delete mode 100644 tests/test_server.py create mode 100644 tests/test_service_client_api.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_shared_helpers.py create mode 100644 tests/test_tools_module.py diff --git a/Dockerfile b/Dockerfile index 77764f6..b551b0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,6 @@ COPY pyproject.toml . COPY uv.lock . RUN uv sync -COPY server.py . -COPY service_client ./service_client/ -COPY static_net ./static_net/ -COPY log_analyzer ./log_analyzer/ -COPY metrics ./metrics/ COPY assisted_service_mcp ./assisted_service_mcp/ RUN chown -R 1001:0 ${APP_HOME} @@ -27,4 +22,4 @@ ENV LOG_TO_FILE=false EXPOSE 8000 -CMD ["uv", "--cache-dir", "/tmp/uv-cache", "run", "server.py"] +CMD ["uv", "--cache-dir", "/tmp/uv-cache", "run", "python", "-m", "assisted_service_mcp.src.main"] diff --git a/Makefile b/Makefile index a05afb6..143a4af 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ run: .PHONY: run-local run-local: - uv run server.py + uv run python -m assisted_service_mcp.src.main .PHONY: run-mock-assisted run-mock-assisted: @@ -30,7 +30,7 @@ deploy-template: scripts/deploy_from_template.sh test-coverage: - uv run --group test pytest --cov=service_client --cov=server --cov-report=html --cov-report=term-missing + uv run --group test pytest --cov=assisted_service_mcp --cov-report=html --cov-report=term-missing test-verbose: uv run --group test pytest -v diff --git a/README.md b/README.md index 652c4ce..8fc3704 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ In VSCode for example: "run", "mcp", "run", - "/path/to/assisted-service-mcp/server.py" + "/path/to/assisted-service-mcp/assisted_service_mcp/src/main.py" ], "env": { "OFFLINE_TOKEN": @@ -43,7 +43,7 @@ For SSE (recommended): Start the server in a terminal: -`OFFLINE_TOKEN= uv run server.py` +`OFFLINE_TOKEN= uv run assisted_service_mcp.src.main` Configure the server in the client: diff --git a/assisted_service_mcp/__init__.py b/assisted_service_mcp/__init__.py index b66ebc3..3912e1f 100644 --- a/assisted_service_mcp/__init__.py +++ b/assisted_service_mcp/__init__.py @@ -4,4 +4,3 @@ """ __version__ = "0.1.0" - diff --git a/assisted_service_mcp/src/__init__.py b/assisted_service_mcp/src/__init__.py index 2d205c4..0b70939 100644 --- a/assisted_service_mcp/src/__init__.py +++ b/assisted_service_mcp/src/__init__.py @@ -1,2 +1 @@ """Source code for Assisted Service MCP Server.""" - diff --git a/assisted_service_mcp/src/api.py b/assisted_service_mcp/src/api.py index 9788533..7ba6514 100644 --- a/assisted_service_mcp/src/api.py +++ b/assisted_service_mcp/src/api.py @@ -6,16 +6,19 @@ from assisted_service_mcp.src.mcp import AssistedServiceMCPServer from assisted_service_mcp.src.settings import settings -from service_client.logger import log +from assisted_service_mcp.src.logger import log, configure_logging + +# Ensure logging is configured before any module-level log usage +configure_logging() # Initialize the MCP server server = AssistedServiceMCPServer() # Choose the appropriate transport protocol based on settings -if settings.TRANSPORT.lower() == "streamable-http": +TRANSPORT_VALUE = getattr(settings, "TRANSPORT", "sse") +if TRANSPORT_VALUE and str(TRANSPORT_VALUE).lower() == "streamable-http": app = server.mcp.streamable_http_app() log.info("Using StreamableHTTP transport (stateless)") else: app = server.mcp.sse_app() log.info("Using SSE transport (stateful)") - diff --git a/assisted_service_mcp/src/logger.py b/assisted_service_mcp/src/logger.py new file mode 100644 index 0000000..4c664be --- /dev/null +++ b/assisted_service_mcp/src/logger.py @@ -0,0 +1,178 @@ +""" +Logging utilities with sensitive information filtering. + +This module provides logging configuration and formatting utilities that +automatically filter sensitive information like pull secrets, SSH keys, +and vSphere credentials from log messages. +""" + +# -*- coding: utf-8 -*- +import logging +import re +import sys + + +class SensitiveFormatter(logging.Formatter): + """Formatter that removes sensitive info.""" + + # Default log format used by this formatter + DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)-8s - %(thread)d:%(process)d - %(message)s - (%(pathname)s:%(lineno)d)->%(funcName)s" + + def __init__(self, fmt: str | None = None) -> None: + """Initialize with default format if none provided.""" + if fmt is None: + fmt = self.DEFAULT_FORMAT + super().__init__(fmt) + + @staticmethod + def _filter(s: str) -> str: + # Dict filter + s = re.sub(r"('_pull_secret':\s+)'(.*?)'", r"\g<1>'*** PULL_SECRET ***'", s) + s = re.sub(r"('_ssh_public_key':\s+)'(.*?)'", r"\g<1>'*** SSH_KEY ***'", s) + s = re.sub( + r"('_vsphere_username':\s+)'(.*?)'", r"\g<1>'*** VSPHERE USER ***'", s + ) + s = re.sub( + r"('_vsphere_password':\s+)'(.*?)'", r"\g<1>'*** VSPHERE PASSWORD ***'", s + ) + + # Object filter + def _redact_value(text: str, key: str, placeholder: str) -> str: + # Match quoted (single or double) or unquoted values, preserving spacing and quotes + pattern = re.compile( + rf"({re.escape(key)})(\s*=\s*)(?:'([^']*)'|\"([^\"]*)\"|([^\s,}}]+))" + ) + + def _repl(m: re.Match) -> str: + key_part = m.group(1) + eq_spaces = m.group(2) + if m.group(3) is not None: # single-quoted + return f"{key_part}{eq_spaces}'{placeholder}'" + if m.group(4) is not None: # double-quoted + return f'{key_part}{eq_spaces}"{placeholder}"' + # unquoted + return f"{key_part}{eq_spaces}{placeholder}" + + return pattern.sub(_repl, text) + + s = _redact_value(s, "pull_secret", "*** PULL_SECRET ***") + s = _redact_value(s, "ssh_public_key", "*** SSH_KEY ***") + s = _redact_value(s, "vsphere_username", "*** VSPHERE_USER ***") + s = _redact_value(s, "vsphere_password", "*** VSPHERE_PASSWORD ***") + + return s + + def format(self, record: logging.LogRecord) -> str: + """ + Format log record while filtering sensitive information. + + Args: + record: The LogRecord instance to be formatted. + + Returns: + str: The formatted log message with sensitive info redacted. + """ + original = logging.Formatter.format(self, record) + return self._filter(original) + + +def get_logging_level() -> int: + """ + Get the logging level from settings. + + Returns: + int: The logging level (defaults to INFO if not set or invalid). + """ + # Import here to avoid circular dependency at module load time + from assisted_service_mcp.src.settings import settings + + level = settings.LOGGING_LEVEL + return getattr(logging, str(level).upper(), logging.INFO) if level else logging.INFO + + +logging.getLogger("requests").setLevel(logging.ERROR) +logging.getLogger("urllib3").setLevel(logging.ERROR) +logging.getLogger("asyncio").setLevel(logging.ERROR) + + +def add_log_file_handler(logger: logging.Logger, filename: str) -> logging.FileHandler: + """ + Add a file handler to the logger with sensitive information filtering. + + Args: + logger: The logger instance to add the handler to. + filename: The path to the log file. + + Returns: + logging.FileHandler: The created file handler. + """ + fh = logging.FileHandler(filename) + fh.setFormatter(SensitiveFormatter()) + logger.addHandler(fh) + return fh + + +def add_stream_handler(logger: logging.Logger) -> None: + """ + Add a stream handler to the logger with sensitive information filtering. + + Args: + logger: The logger instance to add the handler to. + """ + ch = logging.StreamHandler(sys.stderr) + ch.setFormatter(SensitiveFormatter()) + logger.addHandler(ch) + + +# Export a module-level logger with a safe default name to avoid circular imports. +# Configuration should be done by calling configure_logging() after settings are ready. +log = logging.getLogger("assisted-service-mcp") + + +def configure_logging() -> logging.Logger: + """ + Configure logging after settings are available. + + This sets logger names/levels, third-party logger levels, and attaches + file/stream handlers with the SensitiveFormatter. Importing settings here + avoids circular imports at module load time. + + Returns: + logging.Logger: The configured application logger. + """ + # Import inside function to avoid circular dependency + from assisted_service_mcp.src.settings import settings + + # Resolve logger name, falling back to a stable default + logger_name = settings.LOGGER_NAME or "assisted-service-mcp" + target_logger = logging.getLogger(logger_name) + + # Configure third-party loggers + logging.getLogger("requests").setLevel(logging.ERROR) + urllib3_logger = logging.getLogger("urllib3") + + # Reset handlers to prevent duplicates on reconfiguration + for handler in target_logger.handlers: + handler.close() + target_logger.handlers = [] + for handler in urllib3_logger.handlers: + handler.close() + urllib3_logger.handlers = [] + + # Set levels + urllib3_logger.setLevel(logging.ERROR) + target_logger.setLevel(get_logging_level()) + + # Optional file logging + if settings.LOG_TO_FILE: + add_log_file_handler(target_logger, "assisted-service-mcp.log") + add_log_file_handler(urllib3_logger, "assisted-service-mcp.log") + + # Always add stream handlers + add_stream_handler(target_logger) + add_stream_handler(urllib3_logger) + + # Ensure modules using `from ...logger import log` get the configured logger + global log # pylint: disable=global-statement + log = target_logger + return target_logger diff --git a/assisted_service_mcp/src/main.py b/assisted_service_mcp/src/main.py index a7a3eab..96af2e4 100644 --- a/assisted_service_mcp/src/main.py +++ b/assisted_service_mcp/src/main.py @@ -3,23 +3,28 @@ import uvicorn from assisted_service_mcp.src.api import app, server from assisted_service_mcp.src.settings import settings -from metrics import metrics, initiate_metrics -from service_client.logger import log +from assisted_service_mcp.src.metrics import metrics, initiate_metrics +from assisted_service_mcp.src.logger import log def main() -> None: - """Main entry point for the MCP server. + """Start the MCP server. Initializes the server, sets up metrics, and starts the uvicorn server. """ try: log.info("Starting Assisted Service MCP Server") - log.info(f"Configuration: TRANSPORT={settings.TRANSPORT}, HOST={settings.MCP_HOST}, PORT={settings.MCP_PORT}") + log.info( + "Configuration: TRANSPORT=%s, HOST=%s, PORT=%s", + settings.TRANSPORT, + settings.MCP_HOST, + settings.MCP_PORT, + ) # Initialize metrics with list of all tools - tool_names = server.list_tools() + tool_names = server.list_tools_sync() initiate_metrics(tool_names) - log.info(f"Initialized metrics for {len(tool_names)} tools") + log.info("Initialized metrics for %s tools", len(tool_names)) # Add metrics endpoint app.add_route("/metrics", metrics) @@ -31,7 +36,7 @@ def main() -> None: except KeyboardInterrupt: log.info("Received keyboard interrupt, shutting down") except Exception as e: - log.error(f"Server failed to start: {e}", exc_info=True) + log.error("Server failed to start: %s", e, exc_info=True) raise finally: log.info("Assisted Service MCP server shutting down") @@ -39,4 +44,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/assisted_service_mcp/src/mcp.py b/assisted_service_mcp/src/mcp.py index 211c63e..0c7d1d6 100644 --- a/assisted_service_mcp/src/mcp.py +++ b/assisted_service_mcp/src/mcp.py @@ -1,18 +1,16 @@ -"""Assisted Service MCP Server implementation. - -This module contains the main Assisted Service MCP Server class that provides -tools for MCP clients. It uses FastMCP to register and manage MCP capabilities. -""" +"""Assisted Service MCP server implementation.""" import asyncio import inspect from functools import wraps +from typing import Any, Awaitable, Callable + from mcp.server.fastmcp import FastMCP -from service_client.logger import log +from assisted_service_mcp.src.logger import log # Import auth utilities from assisted_service_mcp.utils.auth import get_offline_token, get_access_token -from assisted_service_mcp.src.settings import settings +from assisted_service_mcp.src.settings import settings, get_setting # Import all tool modules from assisted_service_mcp.src.tools import ( @@ -20,6 +18,7 @@ event_tools, download_tools, version_tools, + operator_tools, host_tools, network_tools, ) @@ -32,15 +31,17 @@ class AssistedServiceMCPServer: Red Hat Assisted Installer API. """ - def __init__(self): + def __init__(self) -> None: """Initialize the MCP server with assisted service tools.""" try: # Get transport configuration from settings - use_stateless_http = settings.TRANSPORT.lower() == "streamable-http" + use_stateless_http = (settings.TRANSPORT or "").lower() == "streamable-http" # Initialize FastMCP server self.mcp = FastMCP( - "AssistedService", host=settings.MCP_HOST, stateless_http=use_stateless_http + "AssistedService", + host=settings.MCP_HOST, + stateless_http=use_stateless_http, ) # Define auth helpers bound to this MCP instance self._get_offline_token = lambda: get_offline_token(self.mcp) @@ -73,6 +74,8 @@ def _register_mcp_tools(self) -> None: self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_platform)) self.mcp.tool()(self._wrap_tool(cluster_tools.install_cluster)) self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_ssh_key)) + if get_setting("ENABLE_TROUBLESHOOTING_TOOLS"): + self.mcp.tool()(self._wrap_tool(cluster_tools.analyze_cluster_logs)) # Register event monitoring tools self.mcp.tool()(self._wrap_tool(event_tools.cluster_events)) @@ -84,10 +87,12 @@ def _register_mcp_tools(self) -> None: self._wrap_tool(download_tools.cluster_credentials_download_url) ) - # Register version and operator tools + # Register version tools self.mcp.tool()(self._wrap_tool(version_tools.list_versions)) - self.mcp.tool()(self._wrap_tool(version_tools.list_operator_bundles)) - self.mcp.tool()(self._wrap_tool(version_tools.add_operator_bundle_to_cluster)) + + # Register operator bundle tools + self.mcp.tool()(self._wrap_tool(operator_tools.list_operator_bundles)) + self.mcp.tool()(self._wrap_tool(operator_tools.add_operator_bundle_to_cluster)) # Register host management tools self.mcp.tool()(self._wrap_tool(host_tools.set_host_role)) @@ -113,7 +118,9 @@ def _register_mcp_tools(self) -> None: ) self.mcp.tool()(self._wrap_tool(network_tools.list_static_network_config)) - def _wrap_tool(self, tool_func): + def _wrap_tool( + self, tool_func: Callable[..., Awaitable[Any]] + ) -> Callable[..., Awaitable[Any]]: """Wrap a tool function to inject mcp and auth dependencies. Args: @@ -124,34 +131,38 @@ def _wrap_tool(self, tool_func): """ @wraps(tool_func) - async def wrapped(*args, **kwargs): - # Inject mcp instance and auth function as first two parameters - return await tool_func(self.mcp, self._get_access_token, *args, **kwargs) + async def wrapped(*args: Any, **kwargs: Any) -> Any: + # Inject the access token provider as the first parameter + return await tool_func(self._get_access_token, *args, **kwargs) # Get the original function signature sig = inspect.signature(tool_func) params = list(sig.parameters.values()) - # Remove the first two parameters (mcp and get_access_token_func) - # since they're injected by the wrapper - if len(params) >= 2 and params[0].name == "mcp" and params[1].name == "get_access_token_func": - params = params[2:] + # Remove the first parameter (auth token provider) since it's injected by the wrapper + if len(params) >= 1: + params = params[1:] # Create new signature with remaining parameters new_sig = sig.replace(parameters=params) - wrapped.__signature__ = new_sig + wrapped.__signature__ = new_sig # type: ignore[attr-defined] return wrapped - def list_tools(self) -> list[str]: - """List all registered MCP tools. - - Returns: - list[str]: List of tool names. - """ - - async def mcp_list_tools() -> list[str]: - return [t.name for t in await self.mcp.list_tools()] - - return asyncio.run(mcp_list_tools()) + async def list_tools(self) -> list[str]: + """List all registered MCP tools (async).""" + return [t.name for t in await self.mcp.list_tools()] + def list_tools_sync(self) -> list[str]: + """Synchronize tool listing with a safe sync wrapper.""" + try: + asyncio.get_running_loop() + except RuntimeError: + # No running loop -> safe to use asyncio.run + return asyncio.run(self.list_tools()) + + # A loop is already running in this thread – do not nest. + raise RuntimeError( + "list_tools_sync() cannot be called from within a running event loop. " + "Use 'await list_tools()' in async contexts." + ) diff --git a/metrics/__init__.py b/assisted_service_mcp/src/metrics/__init__.py similarity index 100% rename from metrics/__init__.py rename to assisted_service_mcp/src/metrics/__init__.py diff --git a/metrics/metrics.py b/assisted_service_mcp/src/metrics/metrics.py similarity index 100% rename from metrics/metrics.py rename to assisted_service_mcp/src/metrics/metrics.py diff --git a/service_client/__init__.py b/assisted_service_mcp/src/service_client/__init__.py similarity index 86% rename from service_client/__init__.py rename to assisted_service_mcp/src/service_client/__init__.py index a48c4f1..0686a74 100644 --- a/service_client/__init__.py +++ b/assisted_service_mcp/src/service_client/__init__.py @@ -5,7 +5,7 @@ Red Hat's Assisted Service API to manage OpenShift cluster installations. """ +from assisted_service_mcp.src.logger import log from .assisted_service_api import InventoryClient -from .logger import log __all__ = ["InventoryClient", "log"] diff --git a/service_client/assisted_service_api.py b/assisted_service_mcp/src/service_client/assisted_service_api.py similarity index 97% rename from service_client/assisted_service_api.py rename to assisted_service_mcp/src/service_client/assisted_service_api.py index 79e1595..ee45f8c 100644 --- a/service_client/assisted_service_api.py +++ b/assisted_service_mcp/src/service_client/assisted_service_api.py @@ -15,11 +15,11 @@ from requests.exceptions import RequestException from assisted_service_client import ApiClient, Configuration, PresignedUrl, api, models -from service_client.logger import log -from service_client.exceptions import sanitize_exceptions -from service_client.helpers import Helpers -from metrics.metrics import API_CALL_LATENCY -from assisted_service_mcp.src.settings import settings +from assisted_service_mcp.src.logger import log +from assisted_service_mcp.src.metrics.metrics import API_CALL_LATENCY +from assisted_service_mcp.src.settings import get_setting +from .exceptions import sanitize_exceptions +from .helpers import Helpers T = TypeVar("T") @@ -40,8 +40,9 @@ def __init__(self, access_token: str): """Initialize the InventoryClient with an access token.""" self.access_token = access_token self._pull_secret: Optional[str] = None - self.inventory_url = settings.INVENTORY_URL - self.client_debug = settings.CLIENT_DEBUG + # Read configuration at construction time so tests can patch settings + self.inventory_url: str = get_setting("INVENTORY_URL") + self.client_debug: bool = bool(get_setting("CLIENT_DEBUG")) async def _api_call(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T: """ @@ -68,7 +69,7 @@ def pull_secret(self) -> str: return self._pull_secret def _get_pull_secret(self) -> str: - url = settings.PULL_SECRET_URL + url = get_setting("PULL_SECRET_URL") headers = {"Authorization": f"Bearer {self.access_token}"} try: diff --git a/service_client/exceptions.py b/assisted_service_mcp/src/service_client/exceptions.py similarity index 97% rename from service_client/exceptions.py rename to assisted_service_mcp/src/service_client/exceptions.py index 63af21e..d1cb2bb 100644 --- a/service_client/exceptions.py +++ b/assisted_service_mcp/src/service_client/exceptions.py @@ -9,7 +9,7 @@ from functools import wraps from assisted_service_client.rest import ApiException -from service_client.logger import log +from assisted_service_mcp.src.logger import log class AssistedServiceAPIError(Exception): diff --git a/service_client/helpers.py b/assisted_service_mcp/src/service_client/helpers.py similarity index 93% rename from service_client/helpers.py rename to assisted_service_mcp/src/service_client/helpers.py index 58dacb4..825f161 100644 --- a/service_client/helpers.py +++ b/assisted_service_mcp/src/service_client/helpers.py @@ -16,7 +16,7 @@ def get_platform_model(platform: Optional[str]) -> models.Platform: Get the platform object from a platform type string. Args: - platform (str): The platform type string + platform (Optional[str]): The platform type string, or None for default (baremetal) Returns: models.Platform: The platform object diff --git a/assisted_service_mcp/src/settings.py b/assisted_service_mcp/src/settings.py index 048bf30..9f4eaf9 100644 --- a/assisted_service_mcp/src/settings.py +++ b/assisted_service_mcp/src/settings.py @@ -1,17 +1,24 @@ """Settings for the Assisted Service MCP Server.""" -from typing import Optional +from typing import Optional, ClassVar +from typing import Literal +from typing import Any from dotenv import load_dotenv -from pydantic import Field, ConfigDict -from pydantic_settings import BaseSettings +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict # Load environment variables with error handling try: load_dotenv() -except Exception: - # Silently ignore - environment variables might be set directly +except FileNotFoundError: + # Expected when .env doesn't exist pass +except Exception as e: + # Log unexpected errors but don't fail + import warnings + + warnings.warn(f"Failed to load .env file: {e}") class Settings(BaseSettings): @@ -40,15 +47,14 @@ class Settings(BaseSettings): "example": 8000, }, ) - + # Transport Configuration - TRANSPORT: str = Field( + TRANSPORT: Literal["sse", "streamable-http"] = Field( default="sse", json_schema_extra={ "env": "TRANSPORT", "description": "Transport protocol for the MCP server", "example": "sse", - "enum": ["sse", "streamable-http"], }, ) @@ -61,7 +67,7 @@ class Settings(BaseSettings): "example": "https://api.openshift.com/api/assisted-install/v2", }, ) - + PULL_SECRET_URL: str = Field( default="https://api.openshift.com/api/accounts_mgmt/v1/access_token", json_schema_extra={ @@ -70,7 +76,7 @@ class Settings(BaseSettings): "example": "https://api.openshift.com/api/accounts_mgmt/v1/access_token", }, ) - + CLIENT_DEBUG: bool = Field( default=False, json_schema_extra={ @@ -90,7 +96,7 @@ class Settings(BaseSettings): "sensitive": True, }, ) - + SSO_URL: str = Field( default="https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", json_schema_extra={ @@ -101,16 +107,15 @@ class Settings(BaseSettings): ) # Logging Configuration - LOGGING_LEVEL: str = Field( + LOGGING_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field( default="INFO", json_schema_extra={ "env": "LOGGING_LEVEL", "description": "Logging level for the application", "example": "INFO", - "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, ) - + LOGGER_NAME: str = Field( default="", json_schema_extra={ @@ -119,7 +124,7 @@ class Settings(BaseSettings): "example": "assisted-service-mcp", }, ) - + LOG_TO_FILE: bool = Field( default=True, json_schema_extra={ @@ -129,46 +134,69 @@ class Settings(BaseSettings): }, ) - model_config = ConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=True, + ENABLE_TROUBLESHOOTING_TOOLS: int = Field( + default=0, + ge=0, + le=1, + json_schema_extra={ + "env": "ENABLE_TROUBLESHOOTING_TOOLS", + "description": "Whether the troubleshooting tool call(s) should be enabled", + "example": 0, + }, ) + model_config: ClassVar[SettingsConfigDict] = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": True, + # Enable runtime assignment so tests can patch settings fields + "validate_assignment": True, + "frozen": False, + } + -def validate_config(settings: Settings) -> None: +def validate_config(cfg: Settings) -> None: """Validate configuration settings. Performs validation to ensure required settings are present and values are within acceptable ranges. Args: - settings: Settings instance to validate. + cfg: Settings instance to validate. Raises: ValueError: If required configuration is missing or invalid. """ # Validate port range - if not (1024 <= settings.MCP_PORT <= 65535): - raise ValueError( - f"MCP_PORT must be between 1024 and 65535, got {settings.MCP_PORT}" - ) + if not 1024 <= cfg.MCP_PORT <= 65535: + raise ValueError(f"MCP_PORT must be between 1024 and 65535, got {cfg.MCP_PORT}") # Validate log level valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - if settings.LOGGING_LEVEL.upper() not in valid_log_levels: + if cfg.LOGGING_LEVEL.upper() not in valid_log_levels: raise ValueError( - f"LOGGING_LEVEL must be one of {valid_log_levels}, got {settings.LOGGING_LEVEL}" + f"LOGGING_LEVEL must be one of {valid_log_levels}, got {cfg.LOGGING_LEVEL}" ) # Validate transport protocol valid_transports = ["sse", "streamable-http"] - if settings.TRANSPORT not in valid_transports: + if cfg.TRANSPORT not in valid_transports: raise ValueError( - f"TRANSPORT must be one of {valid_transports}, got {settings.TRANSPORT}" + f"TRANSPORT must be one of {valid_transports}, got {cfg.TRANSPORT}" ) # Create config instance without validation (validation happens in main.py if needed) settings = Settings() + +def get_setting(name: str) -> Any: + """Return setting value, honoring runtime test patches. + + unittest.mock.patch may set attributes directly on the instance which can + bypass pydantic's internal field store. Prefer a direct __dict__ lookup + first, then fall back to normal attribute access. + """ + if name in settings.__dict__: + return settings.__dict__[name] + return getattr(settings, name) diff --git a/assisted_service_mcp/src/tools/__init__.py b/assisted_service_mcp/src/tools/__init__.py index 0182e4b..3da628c 100644 --- a/assisted_service_mcp/src/tools/__init__.py +++ b/assisted_service_mcp/src/tools/__init__.py @@ -1,2 +1 @@ """MCP tools for Assisted Service operations.""" - diff --git a/assisted_service_mcp/src/tools/cluster_tools.py b/assisted_service_mcp/src/tools/cluster_tools.py index a06b822..7da483a 100644 --- a/assisted_service_mcp/src/tools/cluster_tools.py +++ b/assisted_service_mcp/src/tools/cluster_tools.py @@ -1,19 +1,19 @@ """Cluster management tools for Assisted Service MCP Server.""" import json -from typing import Annotated +from typing import Annotated, Callable from pydantic import Field -from metrics import track_tool_usage -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.helpers import Helpers -from service_client.logger import log +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.service_client.helpers import Helpers +from assisted_service_mcp.src.logger import log +from assisted_service_mcp.src.utils.log_analyzer.main import analyze_cluster @track_tool_usage() async def cluster_info( - mcp, # FastMCP instance passed from mcp.py - get_access_token_func, # Auth function passed from mcp.py + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field( @@ -27,11 +27,6 @@ async def cluster_info( installation progress, and host information. Use this to check cluster state, verify configuration, or monitor installation progress. - Examples: - - cluster_info("550e8400-e29b-41d4-a716-446655440000") - - After creating a cluster, use this to verify the configuration - - During installation, use this to check current status and progress - Prerequisites: - Valid cluster UUID (from list_clusters or create_cluster) - OCM offline token for authentication @@ -57,20 +52,13 @@ async def cluster_info( @track_tool_usage() -async def list_clusters( - mcp, get_access_token_func # Positional args for consistency -) -> str: +async def list_clusters(get_access_token_func: Callable[[], str]) -> str: """List all clusters for the current user. Retrieves a summary of all OpenShift clusters associated with your account. This provides basic information about each cluster (name, ID, version, status) without detailed configuration. Use cluster_info() to get comprehensive details about a specific cluster. - Examples: - - list_clusters() - - Use at the start of a session to see all available clusters - - Check status of multiple clusters at once - Prerequisites: - Valid OCM offline token for authentication @@ -92,10 +80,10 @@ async def list_clusters( clusters = await client.list_clusters() resp = [ { - "name": cluster["name"], - "id": cluster["id"], - "openshift_version": cluster.get("openshift_version", "Unknown"), - "status": cluster["status"], + "name": cluster.name, + "id": cluster.id, + "openshift_version": getattr(cluster, "openshift_version", "Unknown"), + "status": cluster.status, } for cluster in clusters ] @@ -105,8 +93,7 @@ async def list_clusters( @track_tool_usage() async def create_cluster( # pylint: disable=too-many-arguments,too-many-positional-arguments - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], name: Annotated[str, Field(description="The name of the new cluster.")], version: Annotated[ str, @@ -128,7 +115,10 @@ async def create_cluster( # pylint: disable=too-many-arguments,too-many-positio ], ssh_public_key: Annotated[ str | None, - Field(default=None, description="SSH public key for accessing cluster nodes. Allows SSH access to nodes during and after installation."), + Field( + default=None, + description="SSH public key for accessing cluster nodes. Allows SSH access to nodes during and after installation.", + ), ] = None, cpu_architecture: Annotated[ str, @@ -161,7 +151,6 @@ async def create_cluster( # pylint: disable=too-many-arguments,too-many-positio Prerequisites: - Valid OCM offline token for authentication - OpenShift version from list_versions - - Configured DNS domain Related tools: - list_versions - Get available OpenShift versions @@ -231,8 +220,7 @@ async def create_cluster( # pylint: disable=too-many-arguments,too-many-positio @track_tool_usage() async def set_cluster_vips( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to configure.") ], @@ -253,15 +241,11 @@ async def set_cluster_vips( Sets the API and ingress VIPs required for HA clusters on baremetal, vsphere, and nutanix platforms. VIPs are NOT needed for single-node clusters or clusters on 'none' or 'oci' - platforms. The IP addresses must be within the cluster's network subnet, not assigned to - any physical host, and reachable from all cluster nodes. - - Examples: - - set_cluster_vips("cluster-uuid", "192.168.1.100", "192.168.1.101") - - After creating an HA baremetal cluster, set VIPs before installation - - Use consecutive IPs from your cluster subnet + platforms. The IP addresses must be within the cluster's machine network subnet, not assigned + to any physical host, and reachable from all cluster nodes. Prerequisites: + - Valid OCM offline token for authentication - Multi-node cluster on baremetal, vsphere, or nutanix platform - Two unused IP addresses within the cluster subnet - IPs must be reachable from all cluster nodes @@ -290,8 +274,7 @@ async def set_cluster_vips( @track_tool_usage() async def set_cluster_platform( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to configure.") ], @@ -309,12 +292,8 @@ async def set_cluster_platform( baremetal, vsphere, oci, or nutanix. Changing the platform may require reconfiguration of network settings (VIPs) and other platform-specific parameters. - Examples: - - set_cluster_platform("cluster-uuid", "vsphere") # Change to vSphere deployment - - set_cluster_platform("cluster-uuid", "none") # Set for single-node or platformless - - set_cluster_platform("cluster-uuid", "baremetal") # Standard baremetal deployment - Prerequisites: + - Valid OCM offline token for authentication - Existing cluster (from create_cluster) - Compatible platform choice for cluster type (single-node requires 'none') @@ -335,8 +314,7 @@ async def set_cluster_platform( @track_tool_usage() async def install_cluster( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to install.") ], @@ -348,12 +326,8 @@ async def install_cluster( complete (VIPs set if required), and all validations passing. This operation returns immediately; use cluster_info and cluster_events to monitor installation progress. - Examples: - - install_cluster("cluster-uuid") - - After all hosts are discovered and validated, trigger installation - - VIPs must be configured first for HA baremetal/vsphere/nutanix clusters - Prerequisites: + - Valid OCM offline token for authentication - All required hosts discovered and in 'ready' state - Network configuration complete (VIPs set if required by platform) - All cluster validations passing (check with cluster_info) @@ -378,8 +352,7 @@ async def install_cluster( @track_tool_usage() async def set_cluster_ssh_key( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to update.") ], @@ -397,12 +370,8 @@ async def set_cluster_ssh_key( this update will include the new key. Hosts already booted need to be rebooted with a new ISO to get the updated key. - Examples: - - set_cluster_ssh_key("cluster-uuid", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host") - - Add SSH key to existing cluster that was created without one - - Update SSH key if the old key is compromised - Prerequisites: + - Valid OCM offline token for authentication - Existing cluster (from create_cluster) - Valid SSH public key in OpenSSH format (starts with ssh-rsa, ssh-ed25519, etc.) @@ -443,3 +412,15 @@ async def set_cluster_ssh_key( ) return result.to_str() + +@track_tool_usage() +async def analyze_cluster_logs( + get_access_token_func: Callable[[], str], + cluster_id: Annotated[str, Field(description="The ID of the cluster")], +) -> str: + """ + Analyze the cluster logs for the given cluster_id and return the results. + """ + client = InventoryClient(get_access_token_func()) + results = await analyze_cluster(cluster_id=cluster_id, api_client=client) + return "\n\n".join([str(r) for r in results]) diff --git a/assisted_service_mcp/src/tools/download_tools.py b/assisted_service_mcp/src/tools/download_tools.py index 059859a..728b905 100644 --- a/assisted_service_mcp/src/tools/download_tools.py +++ b/assisted_service_mcp/src/tools/download_tools.py @@ -1,19 +1,18 @@ """Download URL tools for Assisted Service MCP Server.""" import json -from typing import Annotated +from typing import Annotated, Callable from pydantic import Field -from metrics import track_tool_usage -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.logger import log +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log from assisted_service_mcp.utils.helpers import format_presigned_url @track_tool_usage() async def cluster_iso_download_url( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field( @@ -29,12 +28,8 @@ async def cluster_iso_download_url( media, PXE) to add them to the cluster. URLs are time-limited for security and will expire after a period. - Examples: - - cluster_iso_download_url("cluster-uuid") - - After creating a cluster, get the ISO URL to boot your first host - - If you updated SSH key, download a new ISO with the updated key - Prerequisites: + - Valid OCM offline token for authentication - Cluster with created infrastructure environment (automatically created by create_cluster) Related tools: @@ -46,8 +41,13 @@ async def cluster_iso_download_url( str: JSON array with ISO URLs and optional expiration times, or message if no ISOs found. """ log.info("Retrieving InfraEnv ISO URLs for cluster_id: %s", cluster_id) - client = InventoryClient(get_access_token_func()) - infra_envs = await client.list_infra_envs(cluster_id) + try: + token = get_access_token_func() + client = InventoryClient(token) + infra_envs = await client.list_infra_envs(cluster_id) + except Exception as e: + log.error("Failed to retrieve infrastructure environments: %s", e) + return f"Error retrieving ISO URLs: {str(e)}" if not infra_envs: log.info("No infrastructure environments found for cluster %s", cluster_id) @@ -64,10 +64,15 @@ async def cluster_iso_download_url( for infra_env in infra_envs: infra_env_id = infra_env.get("id", "unknown") - # Use the new get_infra_env_download_url method - presigned_url = await client.get_infra_env_download_url(infra_env_id) + try: + presigned_url = await client.get_infra_env_download_url(infra_env_id) + except Exception as e: + log.error( + "Failed to get download URL for infra env %s: %s", infra_env_id, e + ) + continue - if presigned_url.url: + if presigned_url and presigned_url.url: iso_info.append(format_presigned_url(presigned_url)) else: log.warning( @@ -88,8 +93,7 @@ async def cluster_iso_download_url( @track_tool_usage() async def cluster_credentials_download_url( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field( @@ -112,14 +116,9 @@ async def cluster_credentials_download_url( The URL is time-limited and provides secure access to sensitive cluster files. Whenever a URL is returned provide the user with information on the expiration of that URL if possible. - - Examples: - - cluster_credentials_download_url("cluster-uuid", "kubeconfig") - - cluster_credentials_download_url("cluster-uuid", "kubeadmin-password") - - After installation completes, get kubeconfig to start using the cluster - - Get admin password if you need to log into the web console Prerequisites: + - Valid OCM offline token for authentication - Successfully completed cluster installation (check status with cluster_info) Related tools: @@ -135,14 +134,25 @@ async def cluster_credentials_download_url( cluster_id, file_name, ) - client = InventoryClient(get_access_token_func()) - result = await client.get_presigned_for_cluster_credentials(cluster_id, file_name) + try: + client = InventoryClient(get_access_token_func()) + result = await client.get_presigned_for_cluster_credentials( + cluster_id, file_name + ) + except Exception as e: + log.error("Failed to retrieve credentials URL: %s", e) + return json.dumps({"error": f"Failed to retrieve credentials URL: {str(e)}"}) + + if not result: + log.warning( + "No presigned URL returned for cluster %s file %s", cluster_id, file_name + ) + return json.dumps({"error": "No credentials URL available"}) + log.info( - "Successfully retrieved presigned URL for cluster %s credentials file %s - %s", + "Successfully retrieved presigned URL for cluster %s credentials file %s", cluster_id, file_name, - result, ) return json.dumps(format_presigned_url(result)) - diff --git a/assisted_service_mcp/src/tools/event_tools.py b/assisted_service_mcp/src/tools/event_tools.py index f0712c5..9e0eff9 100644 --- a/assisted_service_mcp/src/tools/event_tools.py +++ b/assisted_service_mcp/src/tools/event_tools.py @@ -1,17 +1,16 @@ """Event management tools for Assisted Service MCP Server.""" -from typing import Annotated +from typing import Annotated, Callable from pydantic import Field -from metrics import track_tool_usage -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.logger import log +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log @track_tool_usage() async def cluster_events( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to get events for."), @@ -24,13 +23,8 @@ async def cluster_events( have been taken, and diagnose issues. Events include validation results, configuration changes, and error messages. - Examples: - - cluster_events("cluster-uuid") - - Monitor installation progress in real-time - - Investigate why a cluster installation failed - - Review configuration changes made to the cluster - Prerequisites: + - Valid OCM offline token for authentication - Existing cluster with UUID (from list_clusters or create_cluster) Related tools: @@ -56,8 +50,7 @@ async def cluster_events( @track_tool_usage() async def host_events( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster containing the host."), @@ -76,13 +69,8 @@ async def host_events( hardware compatibility problems, network configuration issues, or installation failures on a particular node. - Examples: - - host_events("cluster-uuid", "host-uuid") - - Debug why a specific host failed validation - - Monitor installation progress on a particular node - - Check hardware detection and compatibility results - Prerequisites: + - Valid OCM offline token for authentication - Existing cluster with discovered hosts - Host ID (from cluster_info host list) @@ -99,11 +87,16 @@ async def host_events( client = InventoryClient(get_access_token_func()) result = await client.get_events(cluster_id=cluster_id, host_id=host_id) log.info( - "Successfully retrieved events for host %s in cluster %s", host_id, cluster_id + "Successfully retrieved events for host %s in cluster %s", + host_id, + cluster_id, ) return result except Exception as e: log.error( - "Failed to retrieve events for host %s in cluster %s: %s", host_id, cluster_id, str(e)) + "Failed to retrieve events for host %s in cluster %s: %s", + host_id, + cluster_id, + str(e), + ) raise - diff --git a/assisted_service_mcp/src/tools/host_tools.py b/assisted_service_mcp/src/tools/host_tools.py index ced1fc7..ff39481 100644 --- a/assisted_service_mcp/src/tools/host_tools.py +++ b/assisted_service_mcp/src/tools/host_tools.py @@ -1,18 +1,17 @@ """Host management tools for Assisted Service MCP Server.""" -from typing import Annotated +from typing import Annotated, Callable, Literal from pydantic import Field -from metrics import track_tool_usage -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.logger import log +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log from assisted_service_mcp.src.tools.shared_helpers import _get_cluster_infra_env_id @track_tool_usage() async def set_host_role( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], host_id: Annotated[ str, Field(description="The unique identifier of the host to configure.") ], @@ -21,7 +20,7 @@ async def set_host_role( Field(description="The unique identifier of the cluster containing the host."), ], role: Annotated[ - str, + Literal["auto-assign", "master", "worker"], Field( description="The role to assign to the host. Valid options: 'auto-assign' (let installer decide), 'master' (control plane node with API server, etcd, scheduler), 'worker' (compute node for application workloads)." ), @@ -35,13 +34,8 @@ async def set_host_role( let the installer choose based on cluster requirements. HA clusters require at least 3 master nodes. - Examples: - - set_host_role("host-uuid", "cluster-uuid", "master") # Make this host a control plane node - - set_host_role("host-uuid", "cluster-uuid", "worker") # Make this host a worker node - - set_host_role("host-uuid", "cluster-uuid", "auto-assign") # Let installer decide - - For HA: assign 'master' to first 3 hosts, 'worker' to remaining hosts - Prerequisites: + - Valid OCM offline token for authentication - Discovered host (boot from cluster ISO to discover) - Host ID from cluster_info host list - Cluster with infrastructure environment @@ -64,4 +58,3 @@ async def set_host_role( result = await client.update_host(host_id, infra_env_id, host_role=role) log.info("Successfully set role for host %s in cluster %s", host_id, cluster_id) return result.to_str() - diff --git a/assisted_service_mcp/src/tools/network_tools.py b/assisted_service_mcp/src/tools/network_tools.py index 691990b..5a879ed 100644 --- a/assisted_service_mcp/src/tools/network_tools.py +++ b/assisted_service_mcp/src/tools/network_tools.py @@ -1,14 +1,14 @@ """Network configuration tools for Assisted Service MCP Server.""" import json -from typing import Annotated +from typing import Annotated, Callable from pydantic import Field from jinja2 import TemplateError -from metrics import track_tool_usage -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.logger import log -from static_net import ( +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log +from assisted_service_mcp.src.utils.static_net import ( NMStateTemplateParams, add_or_replace_static_host_config_yaml, generate_nmstate_from_template, @@ -20,25 +20,20 @@ @track_tool_usage() async def validate_nmstate_yaml( - mcp, - get_access_token_func, + _get_access_token_func: Callable[[], str], nmstate_yaml: Annotated[ str, - Field(description="The NMState YAML document to validate. This defines static network configuration for a host."), + Field( + description="The NMState YAML document to validate. This defines static network configuration for a host." + ), ], ) -> str: - """Validate an NMState YAML document before applying to hosts. + r"""Validate an NMState YAML document before applying to hosts. Validates the YAML syntax and structure to ensure it's correct before submitting to the cluster. Always validate YAML after generating or manually editing before applying it to hosts. Invalid YAML will cause host configuration failures. - Examples: - - validate_nmstate_yaml("interfaces:\\n- name: eth0\\n type: ethernet\\n state: up...") - - After generating YAML with generate_nmstate_yaml, validate it - - After manually editing YAML, validate before applying - - Catch syntax errors before they cause host configuration problems - Prerequisites: - NMState YAML document (from generate_nmstate_yaml or manual creation) @@ -56,8 +51,7 @@ async def validate_nmstate_yaml( @track_tool_usage() async def generate_nmstate_yaml( - mcp, - get_access_token_func, + _get_access_token_func: Callable[[], str], params: Annotated[ NMStateTemplateParams, Field( @@ -72,12 +66,6 @@ async def generate_nmstate_yaml( and optionally tweak the result. Do not generate nmstate yaml from scratch without calling this tool. - Examples: - - generate_nmstate_yaml(NMStateTemplateParams(interface_name="eth0", ipv4_address="192.168.1.10/24", ipv4_gateway="192.168.1.1", ipv4_dns=["8.8.8.8"])) - - Generate YAML for static IP configuration from user input - - Create YAML with both IPv4 and IPv6 configuration - - Generate YAML with multiple DNS servers and custom routes - Prerequisites: - Network information from user (interface, IPs, gateway, DNS) @@ -104,8 +92,7 @@ async def generate_nmstate_yaml( @track_tool_usage() async def alter_static_network_config_nmstate_for_host( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to configure."), @@ -123,7 +110,7 @@ async def alter_static_network_config_nmstate_for_host( ), ], ) -> str: - """Add, replace, or delete static network configuration for a host. + r"""Add, replace, or delete static network configuration for a host. Manages static network configurations for cluster hosts. To add a new host config, use index=None and provide YAML. To update an existing host config, provide the index and @@ -136,6 +123,7 @@ async def alter_static_network_config_nmstate_for_host( - alter_static_network_config_nmstate_for_host("cluster-uuid", 1, None) # Delete second host config Prerequisites: + - Valid OCM offline token for authentication - Validated NMState YAML (from validate_nmstate_yaml) - Cluster with infrastructure environment - Know which host corresponds to which index (first boot = index 0, second = 1, etc.) @@ -178,8 +166,7 @@ async def alter_static_network_config_nmstate_for_host( @track_tool_usage() async def list_static_network_config( - mcp, - get_access_token_func, + get_access_token_func: Callable[[], str], cluster_id: Annotated[ str, Field(description="The unique identifier of the cluster to query."), @@ -192,12 +179,6 @@ async def list_static_network_config( they were added. Use the array index when updating or deleting specific host configurations. - Examples: - - list_static_network_config("cluster-uuid") - - Check which hosts have static network configs - - Find the index of a specific host's configuration - - Verify configurations after adding or updating - Prerequisites: - Cluster with infrastructure environment @@ -221,4 +202,3 @@ async def list_static_network_config( return "ERROR: this cluster doesn't have exactly 1 infra env, cannot manage static network config" return json.dumps(infra_envs[0].get("static_network_config", [])) - diff --git a/assisted_service_mcp/src/tools/operator_tools.py b/assisted_service_mcp/src/tools/operator_tools.py new file mode 100644 index 0000000..67b1bfb --- /dev/null +++ b/assisted_service_mcp/src/tools/operator_tools.py @@ -0,0 +1,96 @@ +"""Version and operator management tools for Assisted Service MCP Server.""" + +import json +from typing import Annotated, Callable +from pydantic import Field + +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log + + +@track_tool_usage() +async def list_operator_bundles(get_access_token_func: Callable[[], str]) -> str: + """List available operator bundles that can be added to clusters. + + Retrieves operator bundles that extend OpenShift cluster functionality with additional + capabilities like virtualization, AI/ML, monitoring, and storage. These bundles are + automatically installed during cluster deployment if added before installation. + + Prerequisites: + - Valid OCM offline token for authentication + + Related tools: + - add_operator_bundle_to_cluster - Add bundles from this list to a cluster + - create_cluster - Operator bundles can be added to new clusters + - list_versions - See compatible OpenShift versions + + Returns: + str: A JSON string containing available operator bundles with metadata + including bundle names, descriptions, and operator details. + """ + log.info("Retrieving available operator bundles") + client = InventoryClient(get_access_token_func()) + try: + result = await client.get_operator_bundles() + log.info("Successfully retrieved %s operator bundles", len(result)) + return json.dumps(result) + except Exception as e: + log.error("Failed to retrieve operator bundles: %s", str(e)) + raise + + +@track_tool_usage() +async def add_operator_bundle_to_cluster( + get_access_token_func: Callable[[], str], + cluster_id: Annotated[ + str, Field(description="The unique identifier of the cluster to configure.") + ], + bundle_name: Annotated[ + str, + Field( + description="The name of the operator bundle to add. Use list_operator_bundles to see available bundles. Common bundles: 'virtualization', 'openshift-ai'." + ), + ], +) -> str: + """Add an operator bundle to be automatically installed with the cluster. + + Configures the specified operator bundle to be installed during cluster deployment. + The operator will be installed automatically after the cluster installation completes. + Bundle must be from the list returned by list_operator_bundles(). Add operator bundles + before starting cluster installation. + + Prerequisites: + - Valid OCM offline token for authentication + - Existing cluster (from create_cluster) + - Cluster not yet installed (check with cluster_info) + - Bundle name from list_operator_bundles + + Related tools: + - list_operator_bundles - Get available operator bundle names + - cluster_info - Verify cluster state and installed operators + - create_cluster - Create cluster first + - install_cluster - Start installation after adding bundles + + Returns: + str: A formatted string containing the updated cluster configuration + showing the newly added operator bundle. + """ + log.info("Adding operator bundle '%s' to cluster %s", bundle_name, cluster_id) + client = InventoryClient(get_access_token_func()) + try: + result = await client.add_operator_bundle_to_cluster(cluster_id, bundle_name) + log.info( + "Successfully added operator bundle '%s' to cluster %s", + bundle_name, + cluster_id, + ) + return result.to_str() + except Exception as e: + log.error( + "Failed to add operator bundle '%s' to cluster %s: %s", + bundle_name, + cluster_id, + str(e), + ) + raise diff --git a/assisted_service_mcp/src/tools/shared_helpers.py b/assisted_service_mcp/src/tools/shared_helpers.py index 1285a33..9322ee5 100644 --- a/assisted_service_mcp/src/tools/shared_helpers.py +++ b/assisted_service_mcp/src/tools/shared_helpers.py @@ -1,7 +1,7 @@ """Shared helper functions used across multiple tool modules.""" -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.logger import log +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log async def _get_cluster_infra_env_id(client: InventoryClient, cluster_id: str) -> str: @@ -39,4 +39,3 @@ async def _get_cluster_infra_env_id(client: InventoryClient, cluster_id: str) -> log.info("Using InfraEnv %s for cluster %s", infra_env_id, cluster_id) return infra_env_id - diff --git a/assisted_service_mcp/src/tools/version_tools.py b/assisted_service_mcp/src/tools/version_tools.py index f414159..c23a895 100644 --- a/assisted_service_mcp/src/tools/version_tools.py +++ b/assisted_service_mcp/src/tools/version_tools.py @@ -1,28 +1,21 @@ """Version and operator management tools for Assisted Service MCP Server.""" import json -from typing import Annotated -from pydantic import Field +from typing import Callable -from metrics import track_tool_usage -from assisted_service_mcp.utils.client_factory import InventoryClient -from service_client.logger import log +from assisted_service_mcp.src.metrics import track_tool_usage +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.logger import log @track_tool_usage() -async def list_versions(mcp, get_access_token_func) -> str: +async def list_versions(get_access_token_func: Callable[[], str]) -> str: """List all available OpenShift versions for installation. Retrieves the complete list of OpenShift versions that can be installed using the assisted installer service, including GA releases and pre-release candidates. Use this before creating a cluster to see which versions are available. - Examples: - - list_versions() - - Check available versions before creating a new cluster - - See if a specific OpenShift version is available - - Find the latest stable release - Prerequisites: - Valid OCM offline token for authentication @@ -36,89 +29,10 @@ async def list_versions(mcp, get_access_token_func) -> str: """ log.info("Retrieving available OpenShift versions") client = InventoryClient(get_access_token_func()) - result = await client.get_openshift_versions(True) - log.info("Successfully retrieved OpenShift versions") - return json.dumps(result) - - -@track_tool_usage() -async def list_operator_bundles(mcp, get_access_token_func) -> str: - """List available operator bundles that can be added to clusters. - - Retrieves operator bundles that extend OpenShift cluster functionality with additional - capabilities like virtualization, AI/ML, monitoring, and storage. These bundles are - automatically installed during cluster deployment if added before installation. - - Examples: - - list_operator_bundles() - - See available operators before creating a cluster - - Check if a specific operator bundle is available - - Find operators for a specific use case (e.g., virtualization, AI) - - Prerequisites: - - Valid OCM offline token for authentication - - Related tools: - - add_operator_bundle_to_cluster - Add bundles from this list to a cluster - - create_cluster - Operator bundles can be added to new clusters - - list_versions - See compatible OpenShift versions - - Returns: - str: A JSON string containing available operator bundles with metadata - including bundle names, descriptions, and operator details. - """ - log.info("Retrieving available operator bundles") - client = InventoryClient(get_access_token_func()) - result = await client.get_operator_bundles() - log.info("Successfully retrieved %s operator bundles", len(result)) - return json.dumps(result) - - -@track_tool_usage() -async def add_operator_bundle_to_cluster( - mcp, - get_access_token_func, - cluster_id: Annotated[ - str, Field(description="The unique identifier of the cluster to configure.") - ], - bundle_name: Annotated[ - str, - Field( - description="The name of the operator bundle to add. Use list_operator_bundles to see available bundles. Common bundles: 'virtualization', 'openshift-ai'." - ), - ], -) -> str: - """Add an operator bundle to be automatically installed with the cluster. - - Configures the specified operator bundle to be installed during cluster deployment. - The operator will be installed automatically after the cluster installation completes. - Bundle must be from the list returned by list_operator_bundles(). Add operator bundles - before starting cluster installation. - - Examples: - - add_operator_bundle_to_cluster("cluster-uuid", "virtualization") - - add_operator_bundle_to_cluster("cluster-uuid", "openshift-ai") - - Prerequisites: - - Existing cluster (from create_cluster) - - Cluster not yet installed (check with cluster_info) - - Bundle name from list_operator_bundles - - Related tools: - - list_operator_bundles - Get available operator bundle names - - cluster_info - Verify cluster state and installed operators - - create_cluster - Create cluster first - - install_cluster - Start installation after adding bundles - - Returns: - str: A formatted string containing the updated cluster configuration - showing the newly added operator bundle. - """ - log.info("Adding operator bundle '%s' to cluster %s", bundle_name, cluster_id) - client = InventoryClient(get_access_token_func()) - result = await client.add_operator_bundle_to_cluster(cluster_id, bundle_name) - log.info( - "Successfully added operator bundle '%s' to cluster %s", bundle_name, cluster_id - ) - return result.to_str() - + try: + result = await client.get_openshift_versions(True) + log.info("Successfully retrieved OpenShift versions") + return json.dumps(result) + except Exception as e: + log.error("Failed to retrieve OpenShift versions: %s", str(e)) + raise diff --git a/log_analyzer/__init__.py b/assisted_service_mcp/src/utils/log_analyzer/__init__.py similarity index 100% rename from log_analyzer/__init__.py rename to assisted_service_mcp/src/utils/log_analyzer/__init__.py diff --git a/log_analyzer/log_analyzer.py b/assisted_service_mcp/src/utils/log_analyzer/log_analyzer.py similarity index 100% rename from log_analyzer/log_analyzer.py rename to assisted_service_mcp/src/utils/log_analyzer/log_analyzer.py diff --git a/log_analyzer/main.py b/assisted_service_mcp/src/utils/log_analyzer/main.py similarity index 96% rename from log_analyzer/main.py rename to assisted_service_mcp/src/utils/log_analyzer/main.py index 1947323..580eafb 100644 --- a/log_analyzer/main.py +++ b/assisted_service_mcp/src/utils/log_analyzer/main.py @@ -5,7 +5,7 @@ import logging from typing import List, Optional -from service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient from .log_analyzer import LogAnalyzer from .signatures import ALL_SIGNATURES, SignatureResult diff --git a/log_analyzer/signatures/__init__.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/__init__.py similarity index 100% rename from log_analyzer/signatures/__init__.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/__init__.py diff --git a/log_analyzer/signatures/advanced_analysis.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/advanced_analysis.py similarity index 98% rename from log_analyzer/signatures/advanced_analysis.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/advanced_analysis.py index 6b29cef..0be4f39 100644 --- a/log_analyzer/signatures/advanced_analysis.py +++ b/assisted_service_mcp/src/utils/log_analyzer/signatures/advanced_analysis.py @@ -8,7 +8,10 @@ import re from typing import Any, Generator, Optional, Callable -from log_analyzer.log_analyzer import NEW_LOG_BUNDLE_PATH, OLD_LOG_BUNDLE_PATH +from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import ( + NEW_LOG_BUNDLE_PATH, + OLD_LOG_BUNDLE_PATH, +) from .base import Signature, SignatureResult diff --git a/log_analyzer/signatures/base.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/base.py similarity index 100% rename from log_analyzer/signatures/base.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/base.py diff --git a/log_analyzer/signatures/basic_info.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/basic_info.py similarity index 100% rename from log_analyzer/signatures/basic_info.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/basic_info.py diff --git a/log_analyzer/signatures/error_detection.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/error_detection.py similarity index 98% rename from log_analyzer/signatures/error_detection.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/error_detection.py index 3c47c98..b2d8277 100644 --- a/log_analyzer/signatures/error_detection.py +++ b/assisted_service_mcp/src/utils/log_analyzer/signatures/error_detection.py @@ -10,7 +10,10 @@ from typing import Optional import yaml -from log_analyzer.log_analyzer import NEW_LOG_BUNDLE_PATH, OLD_LOG_BUNDLE_PATH +from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import ( + NEW_LOG_BUNDLE_PATH, + OLD_LOG_BUNDLE_PATH, +) from .base import ErrorSignature, SignatureResult diff --git a/log_analyzer/signatures/networking.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/networking.py similarity index 98% rename from log_analyzer/signatures/networking.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/networking.py index b1993cd..b6e096f 100644 --- a/log_analyzer/signatures/networking.py +++ b/assisted_service_mcp/src/utils/log_analyzer/signatures/networking.py @@ -10,8 +10,11 @@ import re from typing import Optional -from log_analyzer.log_analyzer import NEW_LOG_BUNDLE_PATH, OLD_LOG_BUNDLE_PATH -from log_analyzer.signatures.advanced_analysis import ( +from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import ( + NEW_LOG_BUNDLE_PATH, + OLD_LOG_BUNDLE_PATH, +) +from assisted_service_mcp.src.utils.log_analyzer.signatures.advanced_analysis import ( operator_statuses_from_controller_logs, filter_operators, ) diff --git a/log_analyzer/signatures/performance.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/performance.py similarity index 100% rename from log_analyzer/signatures/performance.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/performance.py diff --git a/log_analyzer/signatures/platform_specific.py b/assisted_service_mcp/src/utils/log_analyzer/signatures/platform_specific.py similarity index 100% rename from log_analyzer/signatures/platform_specific.py rename to assisted_service_mcp/src/utils/log_analyzer/signatures/platform_specific.py diff --git a/static_net/__init__.py b/assisted_service_mcp/src/utils/static_net/__init__.py similarity index 100% rename from static_net/__init__.py rename to assisted_service_mcp/src/utils/static_net/__init__.py diff --git a/static_net/config.py b/assisted_service_mcp/src/utils/static_net/config.py similarity index 94% rename from static_net/config.py rename to assisted_service_mcp/src/utils/static_net/config.py index 516fea3..397f88a 100644 --- a/static_net/config.py +++ b/assisted_service_mcp/src/utils/static_net/config.py @@ -31,7 +31,7 @@ def remove_static_host_config_by_index( if index < 0: raise IndexError("negative indexes are not allowed") if index >= len(config): - raise ValueError( + raise IndexError( f"static network config only has {len(config)} elements, cannot delete index {index}" ) del config[index] @@ -73,13 +73,15 @@ def add_or_replace_static_host_config_yaml( def _generate_host_static_config(nmstate_yaml: str) -> HostStaticNetworkConfig: nmstate = validate_and_parse_nmstate(nmstate_yaml) interfaces = nmstate.get("interfaces") + if interfaces is None: + raise ValueError("nmstate YAML must contain an 'interfaces' key") name_and_mac_list: list[MacInterfaceMap] = [ { "mac_address": i.get("mac-address"), "logical_nic_name": i.get("name"), } for i in interfaces - if i.get("mac-address") + if i.get("mac-address") and i.get("name") ] if not name_and_mac_list: raise ValueError("At least one interface must be associated to a MAC Address") diff --git a/static_net/template.py b/assisted_service_mcp/src/utils/static_net/template.py similarity index 95% rename from static_net/template.py rename to assisted_service_mcp/src/utils/static_net/template.py index 1e51258..c1e9e11 100644 --- a/static_net/template.py +++ b/assisted_service_mcp/src/utils/static_net/template.py @@ -12,7 +12,8 @@ class RouteParams(BaseModel): """The routes config in nmstate yaml""" destination: str = Field( - "0.0.0.0/0", description="The destination addreses for which this route applies" + "0.0.0.0/0", + description="The destination addresses for which this route applies", ) next_hop_address: str = Field( description="The IP address to which to route traffic for this route" @@ -63,7 +64,8 @@ class BondInterfaceParams(BaseModel): "balance-alb", ] = "active-backup" port_interface_names: list[str] = Field( - description="The interface names that are aggregated for this bond." + min_length=2, + description="The interface names that are aggregated for this bond.", ) options: dict[str, Any] | None = Field( None, description="Link aggregation options for the bond interface" @@ -80,7 +82,7 @@ class VLANInterfaceParams(BaseModel): None, description="If the user supplies an IP address for the vlan interface, don't reuse that same address on the base ethernet interface", ) - vlan_id: int + vlan_id: int = Field(ge=1, le=4094, description="VLAN ID (1-4094)") base_interface_name: str = Field( description="If there is only one other ethernet interface configured for this host, use that interface name. Generally this base interface will not have an ip address configured, only the vlan interface." ) @@ -119,7 +121,7 @@ class NMStateTemplateParams(BaseModel): def generate_nmstate_from_template(params: NMStateTemplateParams) -> str: """Generate the nmstate yaml based on the params""" # Go through a serialization loop to standardize indentation and whitespace. - return yaml.dump(yaml.safe_load(NMSTATE_TEMPLATE.render(params))) + return yaml.dump(yaml.safe_load(NMSTATE_TEMPLATE.render(**params.model_dump()))) NMSTATE_TEMPLATE = Template( @@ -213,7 +215,7 @@ def generate_nmstate_from_template(params: NMStateTemplateParams) -> str: enabled: false link-aggregation: mode: {{i.mode}} - port: + port: {% for p in i.port_interface_names %} - {{p}} {% endfor %} diff --git a/assisted_service_mcp/utils/__init__.py b/assisted_service_mcp/utils/__init__.py index 8967ba0..b5d7109 100644 --- a/assisted_service_mcp/utils/__init__.py +++ b/assisted_service_mcp/utils/__init__.py @@ -1,2 +1 @@ """Utility functions for Assisted Service MCP Server.""" - diff --git a/assisted_service_mcp/utils/auth.py b/assisted_service_mcp/utils/auth.py index 9c1ed3b..3016b67 100644 --- a/assisted_service_mcp/utils/auth.py +++ b/assisted_service_mcp/utils/auth.py @@ -1,11 +1,13 @@ """Authentication utilities for Assisted Service MCP Server.""" +from typing import Any, Callable + import requests -from service_client.logger import log -from assisted_service_mcp.src.settings import settings +from assisted_service_mcp.src.logger import log +from assisted_service_mcp.src.settings import get_setting -def get_offline_token(mcp) -> str: +def get_offline_token(mcp: Any) -> str: """ Retrieve the offline token from environment variables or request headers. @@ -25,23 +27,27 @@ def get_offline_token(mcp) -> str: or request headers. """ log.debug("Attempting to retrieve offline token") - token = settings.OFFLINE_TOKEN + token = get_setting("OFFLINE_TOKEN") if token: log.debug("Found offline token in environment variables") return token - request = mcp.get_context().request_context.request - if request is not None: - token = request.headers.get("OCM-Offline-Token") - if token: - log.debug("Found offline token in request headers") - return token + context = mcp.get_context() + if context and context.request_context: + request = context.request_context.request + if request is not None: + token = request.headers.get("OCM-Offline-Token") + if token: + log.debug("Found offline token in request headers") + return token log.error("No offline token found in environment or request headers") raise RuntimeError("No offline token found in environment or request headers") -def get_access_token(mcp, offline_token_func=None) -> str: +def get_access_token( + mcp: Any, offline_token_func: Callable[[], str] | None = None +) -> str: """ Retrieve the access token. @@ -62,32 +68,50 @@ def get_access_token(mcp, offline_token_func=None) -> str: """ log.debug("Attempting to retrieve access token") # First try to get the token from the authorization header: - request = mcp.get_context().request_context.request - if request is not None: - header = request.headers.get("Authorization") - if header is not None: - parts = header.split() - if len(parts) == 2 and parts[0].lower() == "bearer": - log.debug("Found access token in authorization header") - return parts[1] + context = mcp.get_context() + if context and context.request_context: + request = context.request_context.request + if request is not None: + header = request.headers.get("Authorization") + if header is not None: + parts = header.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + log.debug("Found access token in authorization header") + return parts[1] # Now try to get the offline token, and generate a new access token from it: log.debug("Generating new access token from offline token") - - # Use the provided offline token function or default to get_offline_token + + # Use the provided offline token function or default to get_offline_token(mcp) if offline_token_func is None: offline_token = get_offline_token(mcp) else: offline_token = offline_token_func() - + params = { "client_id": "cloud-services", "grant_type": "refresh_token", "refresh_token": offline_token, } - sso_url = settings.SSO_URL - response = requests.post(sso_url, data=params, timeout=30) - response.raise_for_status() - log.debug("Successfully generated new access token") - return response.json()["access_token"] + sso_url = get_setting("SSO_URL") + if not sso_url: + log.error("SSO_URL is not configured") + raise RuntimeError("SSO_URL is not configured") + try: + response = requests.post(sso_url, data=params, timeout=30) + response.raise_for_status() + except requests.exceptions.RequestException as e: + log.error("Failed to exchange offline token for access token: %s", e) + raise RuntimeError(f"Failed to obtain access token from SSO: {e}") from e + + try: + response_data = response.json() + access_token = response_data["access_token"] + except (KeyError, ValueError) as e: + log.error("Invalid SSO response format: %s", e) + raise RuntimeError( + "Invalid SSO response: missing or malformed access_token" + ) from e + log.debug("Successfully generated new access token") + return access_token diff --git a/assisted_service_mcp/utils/client_factory.py b/assisted_service_mcp/utils/client_factory.py deleted file mode 100644 index 27b6a52..0000000 --- a/assisted_service_mcp/utils/client_factory.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Client factory for creating InventoryClient instances. - -This module provides a centralized way to create InventoryClient instances, -making it easier to mock in tests. -""" - -import sys -from service_client import InventoryClient as _BaseInventoryClient - - -def InventoryClient(access_token: str): - """Create an InventoryClient with the given access token. - - This function checks if server.InventoryClient has been mocked (for testing) - and uses that if available, otherwise uses the real client. - - Args: - access_token: The access token for authentication. - - Returns: - InventoryClient: A new InventoryClient instance. - """ - # Check if we're being called from tests that have mocked server.InventoryClient - if 'server' in sys.modules: - server_module = sys.modules['server'] - if hasattr(server_module, 'InventoryClient'): - # Use the potentially-mocked version from server - server_client = server_module.InventoryClient - # If it's been mocked by tests, it will be a Mock/function that returns a mock - if callable(server_client) and server_client != InventoryClient: - return server_client(access_token) - - # Default: use the real client - return _BaseInventoryClient(access_token) - diff --git a/assisted_service_mcp/utils/helpers.py b/assisted_service_mcp/utils/helpers.py index ecc32a0..f77e5fd 100644 --- a/assisted_service_mcp/utils/helpers.py +++ b/assisted_service_mcp/utils/helpers.py @@ -1,8 +1,12 @@ """Helper utilities for Assisted Service MCP Server.""" from typing import Any +from datetime import datetime, timezone from assisted_service_client import models +# Define a constant for zero datetime +ZERO_DATETIME = datetime(1, 1, 1, tzinfo=timezone.utc) + def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: r""" @@ -24,12 +28,9 @@ def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: } # Only include expiration time if it's a meaningful date (not a zero/default value) - if presigned_url.expires_at and not str(presigned_url.expires_at).startswith( - "0001-01-01" - ): + if presigned_url.expires_at and presigned_url.expires_at != ZERO_DATETIME: presigned_url_dict["expires_at"] = presigned_url.expires_at.isoformat().replace( "+00:00", "Z" ) return presigned_url_dict - diff --git a/integration_test/performance/README.md b/integration_test/performance/README.md index c0f2c55..15ec91c 100644 --- a/integration_test/performance/README.md +++ b/integration_test/performance/README.md @@ -19,7 +19,7 @@ This directory contains performance testing tools for the Assisted Service MCP S 3. **Running MCP Server**: ```bash - TRANSPORT=streamable-http INVENTORY_URL="http://localhost:8080/api/assisted-install/v2" uv run server.py + TRANSPORT=streamable-http INVENTORY_URL="http://localhost:8080/api/assisted-install/v2" uv run python -m assisted_service_mcp.src.main ``` ### Running Tests diff --git a/pyproject.toml b/pyproject.toml index 942089c..9ba6f29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "netaddr>=1.3.0", "requests>=2.32.3", "retry>=0.9.2", - "types-requests>=2.32.4.20250611", "prometheus_client>=0.22.1", "pyyaml>=6", "jinja2>=3.1", @@ -19,6 +18,7 @@ dependencies = [ "python-dotenv>=1.0.0", "nestedarchive>=0.2.4", "tabulate>=0.9.0", + "fastapi>=0.115.0", ] [dependency-groups] @@ -30,6 +30,8 @@ dev = [ "pyright>=1.1.402", "ruff>=0.12.1", "types-pyyaml>=6", + "types-requests>=2.32.4.20250611", + "pytest>=8.0.0", ] test = [ "pytest>=8.0.0", @@ -50,6 +52,7 @@ ignore-paths = [ ".pytest_cache", "build", "dist", + "integration_test", ] [tool.pylint.messages_control] @@ -60,6 +63,7 @@ disable = [ "too-few-public-methods", # Common in utility classes "line-too-long", # Handled by black "broad-exception-caught", # Sometimes necessary + "import-outside-toplevel", # Allowed in tests and some runtime imports ] [tool.pytest.ini_options] @@ -67,20 +71,26 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] -addopts = "--strict-markers --strict-config --verbose" +addopts = "--strict-markers --strict-config --verbose --cov=assisted_service_mcp --cov-report=term-missing" markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", ] asyncio_mode = "auto" +[tool.coverage.run] +omit = [ + "assisted_service_mcp/src/main.py", + "assisted_service_mcp/src/utils/log_analyzer/main.py", +] + [tool.pydocstyle] add-ignore=[ "D400" # Stop requiring periods after non-sentences. ] [tool.mypy] -exclude = "log_analyzer/" +exclude = "assisted_service_mcp/src/utils/log_analyzer/" follow_imports = "skip" explicit_package_bases = true disallow_untyped_calls = true diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..5b7c748 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/pyright/main/packages/pyright/schema/pyrightconfig.schema.json", + "exclude": [ + "integration_test/performance", + ".venv", + "venv" + ], + "ignore": [ + "integration_test/performance" + ], + "excludeTests": false, + "useLibraryCodeForTypes": false +} + + diff --git a/server.py b/server.py deleted file mode 100644 index ee0326e..0000000 --- a/server.py +++ /dev/null @@ -1,480 +0,0 @@ -""" -MCP server for Red Hat Assisted Service API. - -This module provides Model Context Protocol (MCP) tools for interacting with -Red Hat's Assisted Service API to manage OpenShift cluster installations. -""" - -import json -import os -import asyncio -from typing import Any, Annotated - -from jinja2 import TemplateError -import requests -import uvicorn -from pydantic import Field -from assisted_service_client import models -from mcp.server.fastmcp import FastMCP -from log_analyzer.main import analyze_cluster - -from metrics import metrics, track_tool_usage, initiate_metrics -from service_client import InventoryClient -from service_client.helpers import Helpers -from service_client.logger import log -from static_net import ( - NMStateTemplateParams, - add_or_replace_static_host_config_yaml, - generate_nmstate_from_template, - remove_static_host_config_by_index, - validate_and_parse_nmstate, -) - - -transport_type = os.environ.get("TRANSPORT", "sse").lower() -use_stateless_http = transport_type == "streamable-http" - -mcp = FastMCP("AssistedService", host="0.0.0.0", stateless_http=use_stateless_http) - - -TROUBLESHOOTING_ENABLED = ( - os.environ.get("ENABLE_TROUBLESHOOTING_TOOLS", "0").lower() == "1" -) - - -def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: - r""" - Format a presigned URL object into a readable string. - - Args: - access_token: The access token for authentication. - - Returns: - InventoryClient instance. - - Tests can patch this function to return a mock client. - """ - return client_factory.InventoryClient(access_token) - -# Import all tool modules for re-export -from assisted_service_mcp.src.tools import ( - cluster_tools, - event_tools, - download_tools, - version_tools, - host_tools, - network_tools, -) - -# For backwards compatibility with tests, create a module-level mcp instance -_server = AssistedServiceMCPServer() -mcp = _server.mcp - -# Re-export auth helpers with wrappers that match the old signature -def get_offline_token() -> str: - """Wrapper for backwards compatibility.""" - return _auth_module.get_offline_token(mcp) - - -def get_access_token() -> str: - """Wrapper for backwards compatibility.""" - # Pass get_offline_token as a callback to break the dependency - return _auth_module.get_access_token(mcp, offline_token_func=get_offline_token) - - -# Re-export all tool functions for backwards compatibility with tests -# These wrappers inject the mcp instance and auth function automatically - -async def cluster_info(cluster_id: str) -> str: - """Get comprehensive information about a specific cluster. - - Args: - cluster_id: The unique identifier of the cluster. - - Returns: - str: Formatted cluster information. - """ - return await cluster_tools.cluster_info(mcp, get_access_token, cluster_id) - - -async def list_clusters() -> str: - """List all clusters for the current user. - - Returns: - str: JSON string containing list of clusters. - """ - return await cluster_tools.list_clusters(mcp, get_access_token) - - -async def create_cluster( - name: str, - version: str, - base_domain: str, - *args: Any, - **kwargs: Any -) -> str: - """Create a new OpenShift cluster. - - Args: - name: The name of the new cluster. - version: The OpenShift version to install. - base_domain: The base DNS domain for the cluster. - *args: Additional positional arguments. - **kwargs: Additional keyword arguments (e.g., ssh_public_key, cpu_architecture, platform). - - Returns: - str: Formatted cluster information. - """ - return await cluster_tools.create_cluster(mcp, get_access_token, name, version, base_domain, *args, **kwargs) - - -async def set_cluster_vips( - cluster_id: str, - api_vip: str, - ingress_vip: str -) -> str: - """Set the VIPs (Virtual IPs) for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - api_vip: The IP address for the cluster API endpoint. - ingress_vip: The IP address for the cluster ingress endpoint. - - Returns: - str: Formatted cluster information. - """ - return await cluster_tools.set_cluster_vips(mcp, get_access_token, cluster_id, api_vip, ingress_vip) - - -async def set_cluster_platform(cluster_id: str, platform: str) -> str: - """Set the platform for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - platform: The platform type (e.g., 'baremetal', 'vsphere', 'none'). - - Returns: - str: Formatted cluster information. - """ - return await cluster_tools.set_cluster_platform(mcp, get_access_token, cluster_id, platform) - - -async def install_cluster(cluster_id: str) -> str: - """Start the installation process for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - - Returns: - str: Formatted cluster information. - """ - return await cluster_tools.install_cluster(mcp, get_access_token, cluster_id) - - -async def set_cluster_ssh_key(cluster_id: str, ssh_public_key: str) -> str: - """Set the SSH public key for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - ssh_public_key: The SSH public key to add. - - Returns: - str: Formatted cluster information or error message. - """ - return await cluster_tools.set_cluster_ssh_key(mcp, get_access_token, cluster_id, ssh_public_key) - - -async def cluster_events(cluster_id: str) -> str: - """Get events for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - - Returns: - str: JSON string containing cluster events. - """ - return await event_tools.cluster_events(mcp, get_access_token, cluster_id) - - -async def host_events(host_id: str, cluster_id: str) -> str: - """Get events for a specific host. - - Args: - host_id: The unique identifier of the host. - cluster_id: The unique identifier of the cluster containing the host. - - Returns: - str: JSON string containing host events. - """ - return await event_tools.host_events(mcp, get_access_token, host_id, cluster_id) - - -async def cluster_iso_download_url(cluster_id: str) -> str: - """Get ISO download URL for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - - Returns: - str: JSON string containing URL and optional expiration. - """ - return await download_tools.cluster_iso_download_url(mcp, get_access_token, cluster_id) - - -async def cluster_credentials_download_url(cluster_id: str, file_name: str) -> str: - """Get credentials download URL for a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - file_name: The name of the credentials file to download. - - Returns: - str: JSON string containing URL and optional expiration. - """ - return await download_tools.cluster_credentials_download_url(mcp, get_access_token, cluster_id, file_name) - - -async def list_versions() -> str: - """List all available OpenShift versions. - - Returns: - str: JSON string containing available versions. - """ - return await version_tools.list_versions(mcp, get_access_token) - - -async def list_operator_bundles() -> str: - """List all available operator bundles. - - Returns: - str: JSON string containing available operator bundles. - """ - return await version_tools.list_operator_bundles(mcp, get_access_token) - - -async def add_operator_bundle_to_cluster(cluster_id: str, bundle_name: str) -> str: - """Add an operator bundle to a cluster. - - Args: - cluster_id: The unique identifier of the cluster. - bundle_name: The name of the operator bundle to add. - - Returns: - str: Formatted cluster information. - """ - return await version_tools.add_operator_bundle_to_cluster(mcp, get_access_token, cluster_id, bundle_name) - - -async def set_host_role(host_id: str, cluster_id: str, role: str) -> str: - """Set the role for a host. - - Args: - host_id: The unique identifier of the host. - cluster_id: The unique identifier of the cluster. - role: The role to assign (e.g., 'master', 'worker', 'auto-assign'). - - Returns: - str: A JSON containing the presigned URL and optional - expiration time. The response format is: - { - url: - expires_at: (if available) - } - """ - log.info( - "Getting presigned URL for cluster %s credentials file %s", - cluster_id, - file_name, - ) - client = InventoryClient(get_access_token()) - result = await client.get_presigned_for_cluster_credentials(cluster_id, file_name) - log.info( - "Successfully retrieved presigned URL for cluster %s credentials file %s - %s", - cluster_id, - file_name, - result, - ) - - return json.dumps(format_presigned_url(result)) - - -async def _get_cluster_infra_env_id(client: InventoryClient, cluster_id: str) -> str: - """ - Get the InfraEnv ID for a cluster (expecting a single InfraEnv). - - This is shared code used by both set_host_role and set_cluster_ssh_key. - - Args: - client: The InventoryClient instance. - cluster_id: The cluster ID to get InfraEnv ID for. - - Returns: - str: The InfraEnv ID (first valid one if multiple exist). - - Raises: - ValueError: If no InfraEnv is found or InfraEnv doesn't have a valid ID. - """ - log.info("Getting InfraEnv for cluster %s", cluster_id) - infra_envs = await client.list_infra_envs(cluster_id) - - if not infra_envs: - raise ValueError(f"No InfraEnv found for cluster {cluster_id}") - - if len(infra_envs) > 1: - log.warning( - "Found %d InfraEnvs for cluster %s, using the first valid one", - len(infra_envs), - cluster_id, - ) - - infra_env_id = infra_envs[0].get("id") - if not infra_env_id: - raise ValueError(f"No InfraEnv with valid ID found for cluster {cluster_id}") - - log.info("Using InfraEnv %s for cluster %s", infra_env_id, cluster_id) - return infra_env_id - - -@mcp.tool() -@track_tool_usage() -async def set_host_role( - host_id: Annotated[ - str, Field(description="The unique identifier of the host to configure.") - ], - cluster_id: Annotated[ - str, - Field(description="The unique identifier of the cluster containing the host."), - ], - role: Annotated[ - str, - Field( - description="The role to assign to the host. Valid options are: auto-assign (Let the installer automatically determine the role), master (Control plane node - API server, etcd, scheduler), worker (Compute node for running application workloads)." - ), - ], -) -> str: - """ - Assign a specific role to a discovered host in the cluster. - - Sets the role for a host that has been discovered through the cluster's hosts boot process. - The role determines the host's function in the OpenShift cluster. - - Args: - host_id (str): The unique identifier of the host to configure. - cluster_id (str): The unique identifier of the cluster containing the host. - role (str): The role to assign to the host. Valid options are: - - 'auto-assign': Let the installer automatically determine the role - - 'master': Control plane node (API server, etcd, scheduler) - - 'worker': Compute node for running application workloads - - Returns: - str: A formatted string containing the updated host configuration - showing the newly assigned role. - """ - log.info("Setting role '%s' for host %s in cluster %s", role, host_id, cluster_id) - client = InventoryClient(get_access_token()) - - # Get the InfraEnv ID for the cluster - infra_env_id = await _get_cluster_infra_env_id(client, cluster_id) - - # Update the host with the specified role - result = await client.update_host(host_id, infra_env_id, host_role=role) - log.info( - "Successfully set role '%s' for host %s in cluster %s", - role, - host_id, - cluster_id, - ) - return result.to_str() - - -@mcp.tool() -@track_tool_usage() -async def set_cluster_ssh_key( - cluster_id: Annotated[ - str, Field(description="The unique identifier of the cluster to update.") - ], - ssh_public_key: Annotated[ - str, - Field( - description="The SSH public key to set for the cluster. This should be a valid SSH public key in OpenSSH format." - ), - ], -) -> str: - """ - Set or update the SSH public key for a cluster. - - This allows SSH access to cluster nodes during and after installation. - Only ISO images downloaded after the update will include the updated key. - Discovered hosts should be booted with a new ISO in order to get the new key. - - Args: - cluster_id (str): The unique identifier of the cluster to update. - ssh_public_key (str): The SSH public key to set for the cluster. - This should be a valid SSH public key in OpenSSH format - (e.g., 'ssh-rsa AAAAB3NzaC1yc2E... user@host'). - - Returns: - str: A formatted string containing the updated cluster configuration. - """ - log.info("Setting SSH public key for cluster %s", cluster_id) - client = InventoryClient(get_access_token()) - - # Update the cluster with the new SSH public key - result = await client.update_cluster(cluster_id, ssh_public_key=ssh_public_key) - log.info("Successfully updated cluster %s with new SSH key", cluster_id) - - # Get the InfraEnv ID and update it - try: - infra_env_id = await _get_cluster_infra_env_id(client, cluster_id) - except ValueError as e: - log.error("Failed to get InfraEnv ID: %s", str(e)) - return f"Cluster key updated, but failed to get InfraEnv ID: {str(e)}. New cluster: {result.to_str()}" - - try: - await client.update_infra_env(infra_env_id, ssh_authorized_key=ssh_public_key) - log.info("Successfully updated InfraEnv %s with new SSH key", infra_env_id) - except Exception as e: - log.error("Failed to update InfraEnv %s: %s", infra_env_id, str(e)) - return f"Cluster key updated, but boot image key update failed. New cluster: {result.to_str()}" - - log.info( - "Successfully updated SSH key for cluster %s and its InfraEnvs", cluster_id - ) - return result.to_str() - - -@track_tool_usage() -async def analyze_cluster_logs( - cluster_id: Annotated[str, Field(description="The ID of the cluster")], -) -> str: - """ - Analyze the cluster logs for the given cluster_id and return the results. - """ - client = InventoryClient(get_access_token()) - results = await analyze_cluster(cluster_id=cluster_id, api_client=client) - return "\n\n".join([str(r) for r in results]) - - -def list_tools() -> list[str]: - """List all MCP tools.""" - - async def mcp_list_tools() -> list[str]: - return [t.name for t in await mcp.list_tools()] - - return asyncio.run(mcp_list_tools()) - - -if __name__ == "__main__": - if transport_type == "streamable-http": - app = mcp.streamable_http_app() - log.info("Using StreamableHTTP transport (stateless)") - else: - app = mcp.sse_app() - log.info("Using SSE transport (stateful)") - - if TROUBLESHOOTING_ENABLED: - mcp.add_tool(analyze_cluster_logs) - - initiate_metrics(list_tools()) - app.add_route("/metrics", metrics) - uvicorn.run(app, host="0.0.0.0") diff --git a/service_client/logger.py b/service_client/logger.py deleted file mode 100644 index 9cf5686..0000000 --- a/service_client/logger.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Logging utilities with sensitive information filtering. - -This module provides logging configuration and formatting utilities that -automatically filter sensitive information like pull secrets, SSH keys, -and vSphere credentials from log messages. -""" - -# -*- coding: utf-8 -*- -import logging -import os -import re -import sys - - -class SensitiveFormatter(logging.Formatter): - """Formatter that removes sensitive info.""" - - # Default log format used by this formatter - DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)-8s - %(thread)d:%(process)d - %(message)s - (%(pathname)s:%(lineno)d)->%(funcName)s" - - def __init__(self, fmt: str | None = None) -> None: - """Initialize with default format if none provided.""" - if fmt is None: - fmt = self.DEFAULT_FORMAT - super().__init__(fmt) - - @staticmethod - def _filter(s: str) -> str: - # Dict filter - s = re.sub(r"('_pull_secret':\s+)'(.*?)'", r"\g<1>'*** PULL_SECRET ***'", s) - s = re.sub(r"('_ssh_public_key':\s+)'(.*?)'", r"\g<1>'*** SSH_KEY ***'", s) - s = re.sub( - r"('_vsphere_username':\s+)'(.*?)'", r"\g<1>'*** VSPHERE_USER ***'", s - ) - s = re.sub( - r"('_vsphere_password':\s+)'(.*?)'", r"\g<1>'*** VSPHERE_PASSWORD ***'", s - ) - - # Object filter - s = re.sub( - r"(pull_secret='[^']*(?=')')", "pull_secret = *** PULL_SECRET ***", s - ) - s = re.sub( - r"(ssh_public_key='[^']*(?=')')", "ssh_public_key = *** SSH_KEY ***", s - ) - s = re.sub( - r"(vsphere_username='[^']*(?=')')", - "vsphere_username = *** VSPHERE_USER ***", - s, - ) - s = re.sub( - r"(vsphere_password='[^']*(?=')')", - "vsphere_password = *** VSPHERE_PASSWORD ***", - s, - ) - - return s - - def format(self, record: logging.LogRecord) -> str: - """ - Format log record while filtering sensitive information. - - Args: - record: The LogRecord instance to be formatted. - - Returns: - str: The formatted log message with sensitive info redacted. - """ - original = logging.Formatter.format(self, record) - return self._filter(original) - - -def get_logging_level() -> int: - """ - Get the logging level from settings. - - Returns: - int: The logging level (defaults to INFO if not set or invalid). - """ - # Import here to avoid circular dependency at module load time - from assisted_service_mcp.src.settings import settings - level = settings.LOGGING_LEVEL - return getattr(logging, level.upper(), logging.INFO) if level else logging.INFO - - -logging.getLogger("requests").setLevel(logging.ERROR) -logging.getLogger("urllib3").setLevel(logging.ERROR) -logging.getLogger("asyncio").setLevel(logging.ERROR) - - -def add_log_file_handler(logger: logging.Logger, filename: str) -> logging.FileHandler: - """ - Add a file handler to the logger with sensitive information filtering. - - Args: - logger: The logger instance to add the handler to. - filename: The path to the log file. - - Returns: - logging.FileHandler: The created file handler. - """ - fh = logging.FileHandler(filename) - fh.setFormatter(SensitiveFormatter()) - logger.addHandler(fh) - return fh - - -def add_stream_handler(logger: logging.Logger) -> None: - """ - Add a stream handler to the logger with sensitive information filtering. - - Args: - logger: The logger instance to add the handler to. - """ - ch = logging.StreamHandler(sys.stderr) - ch.setFormatter(SensitiveFormatter()) - logger.addHandler(ch) - - -# Import settings for logger configuration -from assisted_service_mcp.src.settings import settings - -logger_name = settings.LOGGER_NAME -urllib3_logger = logging.getLogger("urllib3") -urllib3_logger.handlers = [logging.NullHandler()] - -logging.getLogger("requests").setLevel(logging.ERROR) -urllib3_logger.setLevel(logging.ERROR) - -log = logging.getLogger(logger_name) -log.setLevel(get_logging_level()) - -# Check if we should log to file (from settings) -log_to_file = settings.LOG_TO_FILE - -if log_to_file: - add_log_file_handler(log, "assisted-service-mcp.log") - add_log_file_handler(urllib3_logger, "assisted-service-mcp.log") - -add_stream_handler(log) -add_stream_handler(urllib3_logger) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..361127f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,66 @@ +import importlib +import sys +from typing import Any +import pytest +from fastapi.testclient import TestClient + + +def load_app_with_transport(transport: str) -> Any: + # Reload settings with desired transport + settings_mod = importlib.import_module("assisted_service_mcp.src.settings") + with pytest.MonkeyPatch().context() as mp: + mp.setenv("TRANSPORT", transport) + importlib.reload(settings_mod) + # Reload api module to pick up new settings + if "assisted_service_mcp.src.api" in sys.modules: + del sys.modules["assisted_service_mcp.src.api"] + api_mod = importlib.import_module("assisted_service_mcp.src.api") + return api_mod + + +def test_api_uses_sse_when_configured() -> None: + api_mod = load_app_with_transport("sse") + assert hasattr(api_mod, "app") + assert hasattr(api_mod, "server") + + +def test_api_uses_streamable_http_when_configured() -> None: + api_mod = load_app_with_transport("streamable-http") + assert hasattr(api_mod, "app") + assert hasattr(api_mod, "server") + + +def ensure_metrics_route(app) -> None: # type: ignore[no-untyped-def] + # Attach /metrics route for the test (normally added in main()) + from assisted_service_mcp.src.metrics import ( + metrics as metrics_endpoint, + ) # pylint: disable=import-outside-toplevel + + existing_paths = { + getattr(r, "path") for r in getattr(app, "routes", []) if hasattr(r, "path") + } + if "/metrics" not in existing_paths: + app.add_route("/metrics", metrics_endpoint) + + +@pytest.mark.parametrize("transport", ["sse", "streamable-http"]) +def test_metrics_endpoint_present_and_exposes_prometheus(transport: str) -> None: + api_mod = load_app_with_transport(transport) + app = api_mod.app + ensure_metrics_route(app) + + with TestClient(app) as client: + resp = client.get("/metrics") + assert resp.status_code == 200 + # Prometheus exposition format typically includes HELP/TYPE lines + assert "HELP" in resp.text or "# HELP" in resp.text + + +@pytest.mark.parametrize("transport", ["sse", "streamable-http"]) +def test_basic_liveness_returns_response(transport: str) -> None: + api_mod = load_app_with_transport(transport) + app = api_mod.app + + with TestClient(app) as client: + resp = client.get("/") + assert resp.status_code in (200, 404, 405) diff --git a/tests/test_assisted_service_api.py b/tests/test_assisted_service_api.py index e0f7163..e030975 100644 --- a/tests/test_assisted_service_api.py +++ b/tests/test_assisted_service_api.py @@ -2,7 +2,6 @@ Unit tests for the assisted_service_api module. """ -import os from unittest.mock import Mock, patch import pytest @@ -10,8 +9,8 @@ from assisted_service_client.rest import ApiException from assisted_service_client import Configuration, models -from service_client.assisted_service_api import InventoryClient -from service_client.exceptions import AssistedServiceAPIError +from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient +from assisted_service_mcp.src.service_client.exceptions import AssistedServiceAPIError from tests.test_utils import ( create_test_cluster, create_test_installing_cluster, @@ -59,7 +58,9 @@ def test_init_with_access_token(self, mock_access_token: str) -> None: def test_init_with_environment_variables(self, mock_access_token: str) -> None: """Test client initialization with environment variables.""" test_url = "https://custom-api.example.com/v2" - with patch("assisted_service_mcp.src.settings.settings.INVENTORY_URL", test_url): + with patch( + "assisted_service_mcp.src.settings.settings.INVENTORY_URL", test_url + ): with patch("assisted_service_mcp.src.settings.settings.CLIENT_DEBUG", True): with patch.object( InventoryClient, "_get_pull_secret", return_value="test-pull-secret" @@ -112,7 +113,9 @@ def test_get_pull_secret_with_custom_url( mock_response.text = "pull-secret-content" mock_post.return_value = mock_response - with patch("assisted_service_mcp.src.settings.settings.PULL_SECRET_URL", custom_url): + with patch( + "assisted_service_mcp.src.settings.settings.PULL_SECRET_URL", custom_url + ): client = InventoryClient(mock_access_token) # Access the pull_secret property to trigger lazy loading @@ -135,7 +138,7 @@ def test_get_host_url_parsing(self, client: InventoryClient) -> None: # The method replaces the netloc and scheme but keeps the original path assert result == "https://custom.example.com/api/v1" - @patch("service_client.assisted_service_api.ApiClient") + @patch("assisted_service_mcp.src.service_client.assisted_service_api.ApiClient") def test_get_client_configuration( self, mock_api_client_class: Mock, client: InventoryClient ) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..6635013 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,107 @@ +import types +import importlib +from unittest.mock import Mock, patch, MagicMock +import requests + +import pytest + +from assisted_service_mcp.utils import auth as auth_mod + + +class _ReqCtx: + def __init__(self, headers: dict[str, str] | None) -> None: + self.request_context = types.SimpleNamespace( + request=( + types.SimpleNamespace(headers=headers) if headers is not None else None + ) + ) + + +class _MCP: + def __init__(self, headers: dict[str, str] | None) -> None: + self._ctx = _ReqCtx(headers) + + def get_context(self) -> object: # noqa: D401 + return self._ctx + + +def test_get_offline_token_prefers_settings_env() -> None: + with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", "env-token"): + mcp = _MCP(headers={"OCM-Offline-Token": "header-token"}) + assert auth_mod.get_offline_token(mcp) == "env-token" + + +def test_get_offline_token_from_header_when_no_env() -> None: + with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", None): + mcp = _MCP(headers={"OCM-Offline-Token": "header-token"}) + assert auth_mod.get_offline_token(mcp) == "header-token" + + +def test_get_offline_token_raises_when_missing() -> None: + with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", None): + mcp = _MCP(headers={}) + with pytest.raises(RuntimeError): + auth_mod.get_offline_token(mcp) + + +def test_get_access_token_from_authorization_header() -> None: + mcp = _MCP(headers={"Authorization": "Bearer abc"}) + assert auth_mod.get_access_token(mcp) == "abc" + + +@patch("requests.post") +def test_get_access_token_via_offline_token(mock_post: Mock) -> None: # type: ignore[no-untyped-def] + mcp = _MCP(headers={}) + + with ( + patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", "offline"), + patch( + "assisted_service_mcp.src.settings.settings.SSO_URL", "https://sso/token" + ), + ): + mock_resp = Mock() + mock_resp.json.return_value = {"access_token": "new-token"} + mock_post.return_value = mock_resp + + token = auth_mod.get_access_token(mcp) + assert token == "new-token" + mock_post.assert_called_once() + + +def test_get_access_token_sso_request_exception() -> None: + mod = importlib.import_module("assisted_service_mcp.utils.auth") + mcp = MagicMock() + mcp.get_context.return_value = MagicMock(request_context=None) + + with ( + patch("assisted_service_mcp.utils.auth.requests.post") as mock_post, + patch( + "assisted_service_mcp.utils.auth.get_setting", + side_effect=lambda k: "https://sso.example.com" if k == "SSO_URL" else "", + ), + ): + mock_post.side_effect = requests.exceptions.RequestException("network error") + with pytest.raises( + RuntimeError, match="Failed to obtain access token from SSO" + ): + mod.get_access_token(mcp, offline_token_func=lambda: "offline") + + +def test_get_access_token_invalid_json_response() -> None: + mod = importlib.import_module("assisted_service_mcp.utils.auth") + mcp = MagicMock() + mcp.get_context.return_value = MagicMock(request_context=None) + + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {} + + with ( + patch("assisted_service_mcp.utils.auth.requests.post", return_value=mock_resp), + patch( + "assisted_service_mcp.utils.auth.get_setting", + side_effect=lambda k: "https://sso.example.com" if k == "SSO_URL" else "", + ), + ): + with pytest.raises(RuntimeError, match="Invalid SSO response"): + mod.get_access_token(mcp, offline_token_func=lambda: "offline") diff --git a/tests/test_helpers.py b/tests/test_helpers.py index e898fb3..23ce784 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -3,7 +3,7 @@ """ import pytest -from service_client.helpers import Helpers +from assisted_service_mcp.src.service_client.helpers import Helpers class TestHelpers: diff --git a/tests/test_integration_api.py b/tests/test_integration_api.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_log_analyzer.py b/tests/test_log_analyzer.py new file mode 100644 index 0000000..1ef1033 --- /dev/null +++ b/tests/test_log_analyzer.py @@ -0,0 +1,165 @@ +import asyncio +from typing import Any, Mapping +from unittest.mock import MagicMock + + +def make_archive(get_map: Mapping[str, object]) -> Any: + """Create a fake archive with a .get(path) API from a mapping.""" + + class _A: + def get( + self, path: str, **kwargs: Any # pylint: disable=unused-argument + ) -> object: + if path in get_map: + return get_map[path] + raise FileNotFoundError(path) + + return _A() + + +def test_log_analyzer_metadata_and_events_partitioning() -> None: + from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import LogAnalyzer + + # minimal metadata and events + md = { + "cluster": { + "install_started_at": "2025-01-01T00:00:00Z", + "hosts": [ + {"id": "h1", "deleted_at": "2024-12-31T23:00:00Z"}, + {"id": "h2"}, + ], + } + } + events = [ + {"name": "something"}, + {"name": "cluster_installation_reset"}, + {"name": "something_else", "host_id": "h2"}, + ] + + archive = make_archive( + { + "cluster_metadata.json": "{}", # will be overridden below to inject md + "cluster_events.json": "[]", # overridden to inject events + } + ) + + # monkeypatch the archive.get to return our JSON strings + def _get(path: str, **kwargs: Any) -> str: # pylint: disable=unused-argument + if path == "cluster_metadata.json": + import json + + return json.dumps(md["cluster"]) # analyzer wraps it into {"cluster":...} + if path == "cluster_events.json": + import json + + return json.dumps(events) + raise FileNotFoundError(path) + + archive.get = _get # type: ignore[attr-defined] + + la = LogAnalyzer(archive) # type: ignore[arg-type] + m = la.metadata + assert m is not None + assert "deleted_hosts" in m["cluster"] and len(m["cluster"]["deleted_hosts"]) == 1 + # last partition should only include post-reset events + last_events = la.get_last_install_cluster_events() + assert last_events and last_events[0]["name"] == "something_else" + # grouped by host + by_host = la.get_events_by_host() + assert "h2" in by_host and by_host["h2"][0]["name"] == "something_else" + + +def test_log_analyzer_host_log_paths() -> None: + from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import LogAnalyzer + + # first simulate new-path miss, then old-path hit + def _get(path: str, **kwargs: Any) -> str: # pylint: disable=unused-argument + # New format contains ".tar/.tar.gz/..." pattern + if ".tar/.tar.gz/logs_host_hX/agent.logs" in path: + raise FileNotFoundError(path) + # Old format ends with ".tar.gz/logs_host_hX/agent.logs" + if path.endswith(".tar.gz/logs_host_hX/agent.logs"): + return "old" + raise FileNotFoundError(path) + + archive = make_archive({}) + archive.get = _get # type: ignore[attr-defined] + la = LogAnalyzer(archive) # type: ignore[arg-type] + content = la.get_host_log_file("hX", "agent.logs") + assert content == "old" + + +def test_main_analyze_cluster_runs_signatures() -> None: + from assisted_service_mcp.src.utils.log_analyzer import main as main_mod + + fake_archive = MagicMock() + fake_archive.get.return_value = "{}" # minimal content to keep signatures no-op + + fake_client = MagicMock() + + async def _get_logs(cid: str) -> Any: # pylint: disable=unused-argument + return fake_archive + + fake_client.get_cluster_logs = _get_logs # type: ignore[attr-defined] + + # Run with an empty signature list to ensure happy path + async def run() -> None: + results = await main_mod.analyze_cluster( + "cid", fake_client, specific_signatures=[] + ) + assert isinstance(results, list) + + asyncio.run(run()) + + +def test_basic_info_signature_runs() -> None: + from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import LogAnalyzer + from assisted_service_mcp.src.utils.log_analyzer.signatures.basic_info import ( + ComponentsVersionSignature, + ) + + archive = make_archive( + { + "cluster_metadata.json": "{}", + "cluster_events.json": "[]", + } + ) + la = LogAnalyzer(archive) # type: ignore[arg-type] + sig = ComponentsVersionSignature() + # Should not raise, may return SignatureResult or None + _ = sig.analyze(la) + + +def test_error_detection_signature_no_crash() -> None: + from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import LogAnalyzer + from assisted_service_mcp.src.utils.log_analyzer.signatures.error_detection import ( + SNOHostnameHasEtcd, + ) + + archive = make_archive( + { + "cluster_metadata.json": "{}", + "cluster_events.json": "[]", + # controller logs not needed for this quick smoke; absence should be handled + } + ) + la = LogAnalyzer(archive) # type: ignore[arg-type] + sig = SNOHostnameHasEtcd() + _ = sig.analyze(la) + + +def test_networking_signature_no_crash() -> None: + from assisted_service_mcp.src.utils.log_analyzer.log_analyzer import LogAnalyzer + from assisted_service_mcp.src.utils.log_analyzer.signatures.networking import ( + SNOMachineCidrSignature, + ) + + archive = make_archive( + { + "cluster_metadata.json": "{}", + "cluster_events.json": "[]", + } + ) + la = LogAnalyzer(archive) # type: ignore[arg-type] + sig = SNOMachineCidrSignature() + _ = sig.analyze(la) diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..3d0d312 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,92 @@ +import importlib +import sys +import logging +import os + +from assisted_service_mcp.src.logger import SensitiveFormatter + + +def _reload_settings(env: dict[str, str]) -> None: # type: ignore[no-untyped-def] + os.environ.update(env) + mod = "assisted_service_mcp.src.settings" + if mod in sys.modules: + del sys.modules[mod] + importlib.import_module(mod) + + +def test_configure_logging_stream_only() -> None: + _reload_settings( + { + "LOGGER_NAME": "assisted-mcp-test", + "LOGGING_LEVEL": "DEBUG", + "LOG_TO_FILE": "false", + } + ) + + logger_mod = importlib.import_module("assisted_service_mcp.src.logger") + logger = logger_mod.configure_logging() + + assert logger.name == "assisted-mcp-test" + assert logger.level == logging.DEBUG + # At least one StreamHandler present + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + # No FileHandler when LOG_TO_FILE is false + assert not any(isinstance(h, logging.FileHandler) for h in logger.handlers) + + +def test_configure_logging_with_file() -> None: + _reload_settings( + { + "LOGGER_NAME": "assisted-mcp-test-file", + "LOGGING_LEVEL": "INFO", + "LOG_TO_FILE": "true", + } + ) + + logger_mod = importlib.import_module("assisted_service_mcp.src.logger") + logger = logger_mod.configure_logging() + + assert logger.name == "assisted-mcp-test-file" + assert logger.level == logging.INFO + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + assert any(isinstance(h, logging.FileHandler) for h in logger.handlers) + + +def filter_text(text: str) -> str: + return SensitiveFormatter._filter(text) # pylint: disable=protected-access + + +def test_redact_object_style_single_quotes() -> None: + original = "pull_secret='abc123' ssh_public_key='ssh-rsa AAA' vsphere_username='user' vsphere_password='pass'" + redacted = filter_text(original) + assert "pull_secret='*** PULL_SECRET ***'" in redacted + assert "ssh_public_key='*** SSH_KEY ***'" in redacted + assert "vsphere_username='*** VSPHERE_USER ***'" in redacted + assert "vsphere_password='*** VSPHERE_PASSWORD ***'" in redacted + + +def test_redact_object_style_double_quotes() -> None: + original = 'pull_secret="abc123" ssh_public_key="ssh-rsa AAA" vsphere_username="user" vsphere_password="pass"' + redacted = filter_text(original) + assert 'pull_secret="*** PULL_SECRET ***"' in redacted + assert 'ssh_public_key="*** SSH_KEY ***"' in redacted + assert 'vsphere_username="*** VSPHERE_USER ***"' in redacted + assert 'vsphere_password="*** VSPHERE_PASSWORD ***"' in redacted + + +def test_redact_object_style_unquoted() -> None: + original = "pull_secret=abc123 ssh_public_key=ssh-rsaAAA vsphere_username=user vsphere_password=pass" + redacted = filter_text(original) + assert "pull_secret=*** PULL_SECRET ***" in redacted + assert "ssh_public_key=*** SSH_KEY ***" in redacted + assert "vsphere_username=*** VSPHERE_USER ***" in redacted + assert "vsphere_password=*** VSPHERE_PASSWORD ***" in redacted + + +def test_preserve_spaces_around_equals() -> None: + original = "pull_secret = 'abc123' ssh_public_key=\t\t\"k\" vsphere_username= user vsphere_password =pass" + redacted = filter_text(original) + assert "pull_secret = '*** PULL_SECRET ***'" in redacted + assert 'ssh_public_key=\t\t"*** SSH_KEY ***"' in redacted + assert "vsphere_username= *** VSPHERE_USER ***" in redacted + assert "vsphere_password =*** VSPHERE_PASSWORD ***" in redacted diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..9d3260e --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,37 @@ +import importlib + + +def test_mcp_registers_tools_and_auth_closures() -> None: + mod = importlib.import_module("assisted_service_mcp.src.mcp") + server = mod.AssistedServiceMCPServer() + + # Check closures exist + assert hasattr(server, "_get_offline_token") + assert hasattr(server, "_get_access_token") + + # List tools + tool_names = server.list_tools_sync() + assert isinstance(tool_names, list) + # Expect at least all core tools + expected = { + "cluster_info", + "list_clusters", + "create_cluster", + "set_cluster_vips", + "set_cluster_platform", + "install_cluster", + "set_cluster_ssh_key", + "cluster_events", + "host_events", + "cluster_iso_download_url", + "cluster_credentials_download_url", + "list_versions", + "list_operator_bundles", + "add_operator_bundle_to_cluster", + "set_host_role", + "validate_nmstate_yaml", + "generate_nmstate_yaml", + "alter_static_network_config_nmstate_for_host", + "list_static_network_config", + } + assert expected.issubset(set(tool_names)) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..db5f07f --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,85 @@ +import asyncio +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from prometheus_client import REGISTRY, generate_latest +from assisted_service_mcp.src.metrics import initiate_metrics, metrics, track_tool_usage + + +def test_metrics_endpoint_returns_prometheus() -> None: + app = FastAPI() + app.add_route("/metrics", metrics) + with TestClient(app) as client: + resp = client.get("/metrics") + assert resp.status_code == 200 + assert "# HELP" in resp.text or "HELP" in resp.text + + +def test_track_tool_usage_decorator_counts_and_times() -> None: + tool_name = "test_track_tool_usage_decorator_counts_and_times_unique_tool" + initiate_metrics([tool_name]) # ensure labeled series exists + + async def _impl(x: int) -> int: + await asyncio.sleep(0) + return x + 1 + + # Ensure the decorator labels with our tool_name + _impl.__name__ = tool_name # type: ignore[attr-defined] + wrapped = track_tool_usage()(_impl) + + def _read_values() -> tuple[float | None, float | None]: + c_val = None + h_val = None + for metric in REGISTRY.collect(): + for sample in metric.samples: + if ( + sample.name == "assisted_service_mcp_tool_request_count_total" + and sample.labels.get("tool") == tool_name + ): + c_val = sample.value + if ( + sample.name == "assisted_service_mcp_tool_request_duration_count" + and sample.labels.get("tool") == tool_name + ): + h_val = sample.value + return c_val, h_val + + before_counter, before_hist = _read_values() + + # Two calls should increase counters by exactly 2 for this label + assert asyncio.run(wrapped(1)) == 2 + assert asyncio.run(wrapped(2)) == 3 + + after_counter, after_hist = _read_values() + + assert ( + after_counter is not None and (after_counter - (before_counter or 0.0)) == 2.0 + ) + assert after_hist is not None and (after_hist - (before_hist or 0.0)) == 2.0 + + +def test_initiate_metrics_idempotent() -> None: + tool = "test_initiate_metrics_idempotent_tool" + # First call should register label and create initial observation + initiate_metrics([tool]) + _ = generate_latest() # force scrape to register series + + async def _impl(x: int) -> int: + await asyncio.sleep(0) + return x + 1 + + _impl.__name__ = tool # type: ignore[attr-defined] + wrapped = track_tool_usage()(_impl) + + # Exercise decorator; then assert label appears in scrape output + assert asyncio.run(wrapped(1)) == 2 + output = generate_latest().decode() + assert f'tool="{tool}"' in output + + # Calling initiate_metrics again should be harmless (idempotent in the sense of no error) + # Note: current implementation also adds an observation (0), so count may increase by 1. + initiate_metrics([tool]) + _ = generate_latest() + assert asyncio.run(wrapped(2)) == 3 + output2 = generate_latest().decode() + assert f'tool="{tool}"' in output2 diff --git a/tests/test_server.py b/tests/test_server.py deleted file mode 100644 index e988e2d..0000000 --- a/tests/test_server.py +++ /dev/null @@ -1,1103 +0,0 @@ -""" -Unit tests for the server module. -""" - -# pylint: disable=too-many-lines - -import json -import os -from copy import deepcopy -from typing import Generator, Tuple -from unittest.mock import Mock, patch, call - -import pytest -from requests.exceptions import RequestException - -from service_client import InventoryClient -import server -from tests.test_utils import ( - create_test_cluster, - create_test_installing_cluster, - create_test_host, - create_test_infra_env, - create_test_presigned_url, -) - - -class TestTokenFunctions: - """Test cases for token handling functions.""" - - @pytest.fixture - def mock_mcp_get_context(self) -> Generator[Tuple[Mock, Mock], None, None]: - """Mock MCP context for testing.""" - mock_context = Mock() - mock_request = Mock() - mock_context.request_context.request = mock_request - - with patch.object(server.mcp, "get_context", return_value=mock_context): - yield mock_context, mock_request - - def test_get_offline_token_from_environment(self) -> None: - """Test retrieving offline token from environment variables.""" - test_token = "test-offline-token" - with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", test_token): - result = server.get_offline_token() - assert result == test_token - - def test_get_offline_token_environment_takes_precedence( - self, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test that environment token takes precedence over request header token.""" - _mock_context, mock_request = mock_mcp_get_context - env_token = "environment-token" - header_token = "header-token" - - # Set up both environment and header tokens - mock_request.headers.get.return_value = header_token - - with patch("assisted_service_mcp.src.settings.settings.OFFLINE_TOKEN", env_token): - result = server.get_offline_token() - - # Should return the environment token, not the header token - assert result == env_token - - # Should not even check the request headers since env token was found - mock_request.headers.get.assert_not_called() - - def test_get_offline_token_from_headers( - self, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test retrieving offline token from request headers.""" - _mock_context, mock_request = mock_mcp_get_context - test_token = "test-offline-token-header" - mock_request.headers.get.return_value = test_token - - # Ensure environment variable is not set - with patch.dict(os.environ, {}, clear=True): - result = server.get_offline_token() - assert result == test_token - mock_request.headers.get.assert_called_once_with("OCM-Offline-Token") - - def test_get_offline_token_not_found( - self, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test error when offline token is not found.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(RuntimeError) as exc_info: - server.get_offline_token() - assert "No offline token found" in str(exc_info.value) - - def test_get_offline_token_no_request(self) -> None: - """Test offline token retrieval when no request is available.""" - mock_context = Mock() - mock_context.request_context.request = None - - with patch.object(server.mcp, "get_context", return_value=mock_context): - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(RuntimeError) as exc_info: - server.get_offline_token() - assert "No offline token found" in str(exc_info.value) - - def test_get_access_token_from_authorization_header( - self, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test retrieving access token from Authorization header.""" - _mock_context, mock_request = mock_mcp_get_context - test_token = "test-access-token" - mock_request.headers.get.return_value = f"Bearer {test_token}" - - result = server.get_access_token() - assert result == test_token - mock_request.headers.get.assert_called_once_with("Authorization") - - def test_get_access_token_invalid_authorization_header( - self, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test access token retrieval with invalid Authorization header.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = "Invalid header format" - - with patch.object(server, "get_offline_token", return_value="offline-token"): - with patch("requests.post") as mock_post: - mock_response = Mock() - mock_response.json.return_value = {"access_token": "new-token"} - mock_post.return_value = mock_response - - result = server.get_access_token() - assert result == "new-token" - - def test_get_access_token_no_authorization_header( - self, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test access token retrieval without Authorization header.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - - with patch.object(server, "get_offline_token", return_value="offline-token"): - with patch("requests.post") as mock_post: - mock_response = Mock() - mock_response.json.return_value = {"access_token": "new-token"} - mock_post.return_value = mock_response - - result = server.get_access_token() - assert result == "new-token" - - @patch("requests.post") - def test_get_access_token_generate_from_offline_token( - self, mock_post: Mock, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test generating access token from offline token.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - - offline_token = "test-offline-token" - access_token = "generated-access-token" - - mock_response = Mock() - mock_response.json.return_value = {"access_token": access_token} - mock_post.return_value = mock_response - - with patch.object(server, "get_offline_token", return_value=offline_token): - result = server.get_access_token() - - assert result == access_token - mock_post.assert_called_once_with( - "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", - data={ - "client_id": "cloud-services", - "grant_type": "refresh_token", - "refresh_token": offline_token, - }, - timeout=30, - ) - - @patch("requests.post") - def test_get_access_token_custom_sso_url( - self, mock_post: Mock, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test access token generation with custom SSO URL.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - - custom_sso_url = "https://custom-sso.example.com/token" - offline_token = "test-offline-token" - access_token = "generated-access-token" - - mock_response = Mock() - mock_response.json.return_value = {"access_token": access_token} - mock_post.return_value = mock_response - - with patch("assisted_service_mcp.src.settings.settings.SSO_URL", custom_sso_url): - with patch.object(server, "get_offline_token", return_value=offline_token): - result = server.get_access_token() - - assert result == access_token - mock_post.assert_called_once_with( - custom_sso_url, - data={ - "client_id": "cloud-services", - "grant_type": "refresh_token", - "refresh_token": offline_token, - }, - timeout=30, - ) - - @patch("requests.post") - def test_get_access_token_request_failure( - self, mock_post: Mock, mock_mcp_get_context: Tuple[Mock, Mock] - ) -> None: - """Test access token generation request failure.""" - _mock_context, mock_request = mock_mcp_get_context - mock_request.headers.get.return_value = None - - mock_post.side_effect = RequestException("Network error") - - with patch.object(server, "get_offline_token", return_value="offline-token"): - with pytest.raises(RequestException): - server.get_access_token() - - def test_get_access_token_no_request_context(self) -> None: - """Test access token retrieval when no request context is available.""" - mock_context = Mock() - mock_context.request_context.request = None - - with patch.object(server.mcp, "get_context", return_value=mock_context): - with patch.object( - server, "get_offline_token", return_value="offline-token" - ): - with patch("requests.post") as mock_post: - mock_response = Mock() - mock_response.json.return_value = {"access_token": "new-token"} - mock_post.return_value = mock_response - - result = server.get_access_token() - assert result == "new-token" - - -class TestMCPToolFunctions: # pylint: disable=too-many-public-methods - """Test cases for MCP tool functions.""" - - @pytest.fixture - def mock_inventory_client(self) -> Mock: - """Mock InventoryClient for testing.""" - return Mock(spec=InventoryClient) - - @pytest.fixture - def mock_get_access_token(self) -> Generator[None, None, None]: - """Mock get_access_token function.""" - with patch.object(server, "get_access_token", return_value="test-access-token"): - yield - - @pytest.mark.asyncio - async def test_cluster_info_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful cluster_info function.""" - cluster_id = "test-cluster-id" - cluster = create_test_cluster(cluster_id=cluster_id) - mock_inventory_client.get_cluster.return_value = cluster - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_info(cluster_id) - - assert result == cluster.to_str() - mock_inventory_client.get_cluster.assert_called_once_with( - cluster_id=cluster_id - ) - - @pytest.mark.asyncio - async def test_list_clusters_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful list_clusters function.""" - mock_clusters = [ - { - "name": "cluster1", - "id": "id1", - "openshift_version": "4.18.2", - "status": "ready", - }, - { - "name": "cluster2", - "id": "id2", - "openshift_version": "4.17.1", - "status": "installing", - }, - { - "name": "cluster3", - "id": "id3", - # Missing openshift version - "status": "installing", - }, - ] - mock_inventory_client.list_clusters.return_value = mock_clusters - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.list_clusters() - - expected_clusters = deepcopy(mock_clusters) - expected_clusters[2]["openshift_version"] = "Unknown" - assert json.loads(result) == expected_clusters - mock_inventory_client.list_clusters.assert_called_once() - - @pytest.mark.asyncio - async def test_cluster_events_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful cluster_events function.""" - cluster_id = "test-cluster-id" - mock_events = '{"events": ["event1", "event2"]}' - mock_inventory_client.get_events.return_value = mock_events - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_events(cluster_id) - - assert result == mock_events - mock_inventory_client.get_events.assert_called_once_with( - cluster_id=cluster_id - ) - - @pytest.mark.asyncio - async def test_host_events_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful host_events function.""" - cluster_id = "test-cluster-id" - host_id = "test-host-id" - mock_events = '{"events": ["host-event1", "host-event2"]}' - mock_inventory_client.get_events.return_value = mock_events - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.host_events(cluster_id, host_id) - - assert result == mock_events - mock_inventory_client.get_events.assert_called_once_with( - cluster_id=cluster_id, host_id=host_id - ) - - @pytest.mark.asyncio - async def test_cluster_iso_download_url_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful cluster_iso_download_url function with single infraenv.""" - cluster_id = "test-cluster-id" - mock_infraenv = { - "name": "test-infraenv", - "id": "test-infraenv-id", - "cluster_id": cluster_id, - "openshift_version": "4.18.2", - } - mock_inventory_client.list_infra_envs.return_value = [mock_infraenv] - mock_inventory_client.get_infra_env_download_url.return_value = create_test_presigned_url( - url="https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id/downloads/image", - expires_at="2023-12-31T23:59:59Z", - ) - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_iso_download_url(cluster_id) - - expected_result = json.dumps( - [ - { - "url": "https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id/downloads/image", - "expires_at": "2023-12-31T23:59:59Z", - } - ] - ) - assert result == expected_result - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - mock_inventory_client.get_infra_env_download_url.assert_called_once_with( - "test-infraenv-id" - ) - - @pytest.mark.asyncio - async def test_cluster_iso_download_url_multiple_infraenvs( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful cluster_iso_download_url function with multiple infraenvs.""" - cluster_id = "test-cluster-id" - - # First infraenv - mock_infraenv1 = { - "name": "test-infraenv-1", - "id": "test-infraenv-id-1", - "cluster_id": cluster_id, - "openshift_version": "4.18.2", - } - - # Second infraenv with different characteristics - mock_infraenv2 = { - "name": "test-infraenv-2", - "id": "test-infraenv-id-2", - "cluster_id": cluster_id, - "openshift_version": "4.18.2", - } - - mock_inventory_client.list_infra_envs.return_value = [ - mock_infraenv1, - mock_infraenv2, - ] - - # Mock return values for each infra env - mock_inventory_client.get_infra_env_download_url.side_effect = [ - create_test_presigned_url( - url="https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id-1/downloads/image", - expires_at="2023-12-31T23:59:59Z", - ), - create_test_presigned_url( - url="https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id-2/downloads/image", - expires_at="2024-01-15T12:00:00Z", - ), - ] - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_iso_download_url(cluster_id) - - expected_result = json.dumps( - [ - { - "url": "https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id-1/downloads/image", - "expires_at": "2023-12-31T23:59:59Z", - }, - { - "url": "https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id-2/downloads/image", - "expires_at": "2024-01-15T12:00:00Z", - }, - ] - ) - assert result == expected_result - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - mock_inventory_client.get_infra_env_download_url.assert_has_calls( - [ - call("test-infraenv-id-1"), - call("test-infraenv-id-2"), - ] - ) - - @pytest.mark.asyncio - async def test_cluster_iso_download_url_no_expiration( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test cluster_iso_download_url function when no expiration date is provided.""" - cluster_id = "test-cluster-id" - mock_infraenv = { - "name": "test-infraenv", - "id": "test-infraenv-id", - "cluster_id": cluster_id, - "openshift_version": "4.18.2", - } - mock_inventory_client.list_infra_envs.return_value = [mock_infraenv] - mock_inventory_client.get_infra_env_download_url.return_value = create_test_presigned_url( - url="https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id/downloads/image", - expires_at=None, - ) - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_iso_download_url(cluster_id) - - expected_result = json.dumps( - [ - { - "url": "https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id/downloads/image" - } - ] - ) - assert result == expected_result - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - mock_inventory_client.get_infra_env_download_url.assert_called_once_with( - "test-infraenv-id" - ) - - @pytest.mark.asyncio - async def test_cluster_iso_download_url_zero_expiration( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test cluster_iso_download_url function when expiration is a zero/default date.""" - cluster_id = "test-cluster-id" - mock_infraenv = { - "name": "test-infraenv", - "id": "test-infraenv-id", - "cluster_id": cluster_id, - "openshift_version": "4.18.2", - } - mock_inventory_client.list_infra_envs.return_value = [mock_infraenv] - mock_inventory_client.get_infra_env_download_url.return_value = create_test_presigned_url( - url="https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id/downloads/image", - expires_at="0001-01-01 00:00:00+00:00", - ) - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_iso_download_url(cluster_id) - - # Should not include expiration time since it's a zero/default value - expected_result = json.dumps( - [ - { - "url": "https://api.openshift.com/api/assisted-install/v2/infra-envs/test-id/downloads/image" - } - ] - ) - assert result == expected_result - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - mock_inventory_client.get_infra_env_download_url.assert_called_once_with( - "test-infraenv-id" - ) - - @pytest.mark.asyncio - async def test_cluster_iso_download_url_no_infraenvs( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test cluster_iso_download_url function when no infraenvs are found.""" - cluster_id = "test-cluster-id" - mock_inventory_client.list_infra_envs.return_value = [] - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_iso_download_url(cluster_id) - - assert result == "No ISO download URLs found for this cluster." - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - - @pytest.mark.asyncio - async def test_create_cluster_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful create_cluster function.""" - name = "test-cluster" - version = "4.18.2" - base_domain = "example.com" - single_node = False - - cluster = create_test_cluster( - cluster_id="cluster-id", - name=name, - openshift_version=version, - ) - infraenv = create_test_infra_env( - infra_env_id="infraenv-id", - name=name, - ) - - mock_inventory_client.create_cluster.return_value = cluster - mock_inventory_client.create_infra_env.return_value = infraenv - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.create_cluster( - name, version, base_domain, single_node - ) - assert result == cluster.id - - mock_inventory_client.create_cluster.assert_called_once_with( - name, - version, - single_node, - base_dns_domain=base_domain, - tags="chatbot", - cpu_architecture="x86_64", - platform="baremetal", - ) - mock_inventory_client.create_infra_env.assert_called_once_with( - name, - cluster_id="cluster-id", - openshift_version=version, - cpu_architecture="x86_64", - ) - - @pytest.mark.asyncio - async def test_create_cluster_with_ssh_key_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful create_cluster function with SSH public key.""" - name = "test-cluster" - version = "4.18.2" - base_domain = "example.com" - single_node = False - ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC test@example.com" - - cluster = create_test_cluster( - cluster_id="cluster-id", - name=name, - openshift_version=version, - ) - infraenv = create_test_infra_env( - infra_env_id="infraenv-id", - name=name, - ) - - mock_inventory_client.create_cluster.return_value = cluster - mock_inventory_client.create_infra_env.return_value = infraenv - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.create_cluster( - name, version, base_domain, single_node, ssh_public_key - ) - assert result == cluster.id - - mock_inventory_client.create_cluster.assert_called_once_with( - name, - version, - single_node, - base_dns_domain=base_domain, - tags="chatbot", - ssh_public_key=ssh_public_key, - cpu_architecture="x86_64", - platform="baremetal", - ) - mock_inventory_client.create_infra_env.assert_called_once_with( - name, - cluster_id="cluster-id", - openshift_version=version, - ssh_authorized_key=ssh_public_key, - cpu_architecture="x86_64", - ) - - @pytest.mark.asyncio - async def test_create_cluster_with_cpu_architecture_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful create_cluster function with specific CPU architecture.""" - name = "test-cluster" - version = "4.18.2" - base_domain = "example.com" - single_node = False - cpu_architecture = "aarch64" - - cluster = create_test_cluster( - cluster_id="cluster-id", - name=name, - openshift_version=version, - ) - infraenv = create_test_infra_env( - infra_env_id="infraenv-id", - name=name, - ) - - mock_inventory_client.create_cluster.return_value = cluster - mock_inventory_client.create_infra_env.return_value = infraenv - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.create_cluster( - name, - version, - base_domain, - single_node, - None, - cpu_architecture, - None, - ) - assert result == cluster.id - - mock_inventory_client.create_cluster.assert_called_once_with( - name, - version, - single_node, - base_dns_domain=base_domain, - tags="chatbot", - cpu_architecture=cpu_architecture, - platform="baremetal", - ) - mock_inventory_client.create_infra_env.assert_called_once_with( - name, - cluster_id="cluster-id", - openshift_version=version, - cpu_architecture=cpu_architecture, - ) - - @pytest.mark.asyncio - async def test_create_cluster_with_platform_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful create_cluster function with specific platform.""" - name = "test-cluster" - version = "4.18.2" - base_domain = "example.com" - single_node = False - cpu_architecture = "x86_64" - platform = "nutanix" - - cluster = create_test_cluster( - cluster_id="cluster-id", - name=name, - openshift_version=version, - ) - infraenv = create_test_infra_env( - infra_env_id="infraenv-id", - name=name, - ) - - mock_inventory_client.create_cluster.return_value = cluster - mock_inventory_client.create_infra_env.return_value = infraenv - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.create_cluster( - name, - version, - base_domain, - single_node, - None, - cpu_architecture, - platform, - ) - assert result == cluster.id - - mock_inventory_client.create_cluster.assert_called_once_with( - name, - version, - single_node, - base_dns_domain=base_domain, - tags="chatbot", - cpu_architecture=cpu_architecture, - platform=platform, - ) - mock_inventory_client.create_infra_env.assert_called_once_with( - name, - cluster_id="cluster-id", - openshift_version=version, - cpu_architecture=cpu_architecture, - ) - - @pytest.mark.asyncio - async def test_set_cluster_vips_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful set_cluster_vips function.""" - cluster_id = "test-cluster-id" - api_vip = "192.168.1.100" - ingress_vip = "192.168.1.101" - - cluster = create_test_cluster(cluster_id=cluster_id) - mock_inventory_client.update_cluster.return_value = cluster - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.set_cluster_vips(cluster_id, api_vip, ingress_vip) - - assert result == cluster.to_str() - mock_inventory_client.update_cluster.assert_called_once_with( - cluster_id, api_vip=api_vip, ingress_vip=ingress_vip - ) - - @pytest.mark.asyncio - async def test_set_cluster_platform_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful set_cluster_platform function.""" - cluster_id = "test-cluster-id" - platform = "vsphere" - - cluster = create_test_cluster(cluster_id=cluster_id) - mock_inventory_client.update_cluster.return_value = cluster - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.set_cluster_platform(cluster_id, platform) - - assert result == cluster.to_str() - mock_inventory_client.update_cluster.assert_called_once_with( - cluster_id, platform=platform - ) - - @pytest.mark.asyncio - async def test_install_cluster_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful install_cluster function.""" - cluster_id = "test-cluster-id" - cluster = create_test_installing_cluster(cluster_id=cluster_id) - mock_inventory_client.install_cluster.return_value = cluster - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.install_cluster(cluster_id) - - assert result == cluster.to_str() - mock_inventory_client.install_cluster.assert_called_once_with(cluster_id) - - @pytest.mark.asyncio - async def test_list_versions_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful list_versions function.""" - mock_versions = {"versions": ["4.18.2", "4.17.1"]} - mock_inventory_client.get_openshift_versions.return_value = mock_versions - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.list_versions() - - expected_result = json.dumps(mock_versions) - assert result == expected_result - mock_inventory_client.get_openshift_versions.assert_called_once_with(True) - - @pytest.mark.asyncio - async def test_list_operator_bundles_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful list_operator_bundles function.""" - mock_bundles = [ - {"name": "bundle1", "operators": ["op1"]}, - {"name": "bundle2", "operators": ["op2"]}, - ] - mock_inventory_client.get_operator_bundles.return_value = mock_bundles - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.list_operator_bundles() - - expected_result = json.dumps(mock_bundles) - assert result == expected_result - mock_inventory_client.get_operator_bundles.assert_called_once() - - @pytest.mark.asyncio - async def test_add_operator_bundle_to_cluster_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful add_operator_bundle_to_cluster function.""" - cluster_id = "test-cluster-id" - bundle_name = "test-bundle" - - cluster = create_test_cluster(cluster_id=cluster_id) - mock_inventory_client.add_operator_bundle_to_cluster.return_value = cluster - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.add_operator_bundle_to_cluster( - cluster_id, bundle_name - ) - - assert result == cluster.to_str() - mock_inventory_client.add_operator_bundle_to_cluster.assert_called_once_with( - cluster_id, bundle_name - ) - - @pytest.mark.asyncio - async def test_set_host_role_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful set_host_role function.""" - host_id = "test-host-id" - infraenv_id = "test-infraenv-id" - role = "master" - - host = create_test_host(host_id=host_id, role=role) - mock_inventory_client.update_host.return_value = host - - # Mock InfraEnvs list - mock_infra_envs = [ - {"id": infraenv_id, "name": "infraenv"}, - ] - mock_inventory_client.list_infra_envs.return_value = mock_infra_envs - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.set_host_role(host_id, infraenv_id, role) - - assert result == host.to_str() - mock_inventory_client.update_host.assert_called_once_with( - host_id, infraenv_id, host_role=role - ) - - @pytest.mark.asyncio - async def test_cluster_credentials_download_url_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful cluster_credentials_download_url function.""" - cluster_id = "test-cluster-id" - file_name = "kubeconfig" - - presigned_url = create_test_presigned_url() - mock_inventory_client.get_presigned_for_cluster_credentials.return_value = ( - presigned_url - ) - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_credentials_download_url( - cluster_id, file_name - ) - - expected_result = json.dumps( - { - "url": "https://example.com/presigned-url", - "expires_at": "2023-12-31T23:59:59Z", - } - ) - assert result == expected_result - mock_inventory_client.get_presigned_for_cluster_credentials.assert_called_once_with( - cluster_id, file_name - ) - - @pytest.mark.asyncio - async def test_cluster_credentials_download_url_no_expiration( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test cluster_credentials_download_url function when no expiration is provided.""" - cluster_id = "test-cluster-id" - file_name = "kubeconfig" - - presigned_url = create_test_presigned_url(expires_at=None) - mock_inventory_client.get_presigned_for_cluster_credentials.return_value = ( - presigned_url - ) - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_credentials_download_url( - cluster_id, file_name - ) - - expected_result = json.dumps({"url": "https://example.com/presigned-url"}) - assert result == expected_result - mock_inventory_client.get_presigned_for_cluster_credentials.assert_called_once_with( - cluster_id, file_name - ) - - @pytest.mark.asyncio - async def test_cluster_credentials_download_url_zero_expiration( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test cluster_credentials_download_url function when expiration is a zero/default date.""" - cluster_id = "test-cluster-id" - file_name = "kubeconfig" - - presigned_url = create_test_presigned_url( - expires_at="0001-01-01 00:00:00+00:00", - ) - mock_inventory_client.get_presigned_for_cluster_credentials.return_value = ( - presigned_url - ) - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.cluster_credentials_download_url( - cluster_id, file_name - ) - - # Should not include expiration time since it's a zero/default value - expected_result = json.dumps({"url": "https://example.com/presigned-url"}) - assert result == expected_result - mock_inventory_client.get_presigned_for_cluster_credentials.assert_called_once_with( - cluster_id, file_name - ) - - @pytest.mark.asyncio - async def test_set_cluster_ssh_key_success( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test successful set_cluster_ssh_key function with cluster and InfraEnvs updated.""" - cluster_id = "test-cluster-id" - ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC test@example.com" - - # Mock cluster update response - cluster = create_test_cluster(cluster_id=cluster_id) - mock_inventory_client.update_cluster.return_value = cluster - - # Mock InfraEnvs list - mock_infra_envs = [ - {"id": "infraenv-id", "name": "infraenv"}, - ] - mock_inventory_client.list_infra_envs.return_value = mock_infra_envs - - # Mock successful InfraEnv updates - mock_inventory_client.update_infra_env.return_value = None - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.set_cluster_ssh_key(cluster_id, ssh_public_key) - assert result == cluster.to_str() - - # Verify all expected calls were made - mock_inventory_client.update_cluster.assert_called_once_with( - cluster_id, ssh_public_key=ssh_public_key - ) - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - mock_inventory_client.update_infra_env.assert_called_once_with( - "infraenv-id", ssh_authorized_key=ssh_public_key - ) - - @pytest.mark.asyncio - async def test_set_cluster_ssh_key_infraenv_failure( - self, - mock_inventory_client: Mock, - mock_get_access_token: None, # pylint: disable=unused-argument - ) -> None: - """Test set_cluster_ssh_key function when InfraEnv update fails.""" - cluster_id = "test-cluster-id" - ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC test@example.com" - - # Mock cluster update response - cluster = create_test_cluster(cluster_id=cluster_id) - mock_inventory_client.update_cluster.return_value = cluster - - # Mock InfraEnvs list - mock_infra_envs = [ - {"id": "infraenv-id", "name": "infraenv"}, - ] - mock_inventory_client.list_infra_envs.return_value = mock_infra_envs - - # Mock all InfraEnv updates to fail - mock_inventory_client.update_infra_env.side_effect = Exception("Update failed") - - with patch.object( - server, "InventoryClient", return_value=mock_inventory_client - ): - result = await server.set_cluster_ssh_key(cluster_id, ssh_public_key) - # Should return partial failure message - assert "Cluster key updated, but boot image key update failed" in result - assert cluster.to_str() in result - - # Verify all expected calls were made - mock_inventory_client.update_cluster.assert_called_once_with( - cluster_id, ssh_public_key=ssh_public_key - ) - mock_inventory_client.list_infra_envs.assert_called_once_with(cluster_id) - mock_inventory_client.update_infra_env.assert_called_once_with( - "infraenv-id", ssh_authorized_key=ssh_public_key - ) diff --git a/tests/test_service_client_api.py b/tests/test_service_client_api.py new file mode 100644 index 0000000..348d713 --- /dev/null +++ b/tests/test_service_client_api.py @@ -0,0 +1,61 @@ +import importlib +from unittest.mock import patch, MagicMock + + +def test_get_host_overrides_scheme_and_netloc() -> None: + sc_mod = importlib.import_module( + "assisted_service_mcp.src.service_client.assisted_service_api" + ) + configs = sc_mod.Configuration() + configs.host = "http://placeholder.local" + + with ( + patch( + "assisted_service_mcp.src.service_client.assisted_service_api.get_setting", + side_effect=lambda k: ( + "https://real.example.com" if k == "INVENTORY_URL" else "" + ), + ), + patch.object(sc_mod, "Configuration", return_value=configs), + ): + client = sc_mod.InventoryClient("token") + # ensure inventory_url set from patched get_setting + client.inventory_url = "https://real.example.com" + host = client._get_host(configs) # pylint: disable=protected-access + assert host.startswith("https://real.example.com") + + +def test_update_cluster_vips_and_platform_mapping() -> None: + sc_mod = importlib.import_module( + "assisted_service_mcp.src.service_client.assisted_service_api" + ) + client = sc_mod.InventoryClient("token") + + with ( + patch.object(client, "_installer_api") as mock_api, + patch( + "assisted_service_mcp.src.service_client.assisted_service_api.Helpers.get_platform_model", + return_value="platform_model", + ), + ): + api_instance = MagicMock() + mock_api.return_value = api_instance + api_instance.v2_update_cluster = MagicMock() + + async def run() -> None: + await client.update_cluster( + cluster_id="cid", + api_vip="1.2.3.4", + ingress_vip="5.6.7.8", + platform={"type": "vsphere"}, + ) + + import asyncio + + asyncio.run(run()) + + # Ensure vips were set and API called + called_kwargs = api_instance.v2_update_cluster.call_args.kwargs + params = called_kwargs["cluster_update_params"] + assert params.api_vips and params.api_vips[0].ip == "1.2.3.4" + assert params.ingress_vips and params.ingress_vips[0].ip == "5.6.7.8" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..9d75e4d --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,73 @@ +import importlib +import sys +import pytest + + +def reload_settings_with_env(env: dict[str, str]): # type: ignore[no-untyped-def] + module_name = "assisted_service_mcp.src.settings" + if module_name in sys.modules: + importlib.reload(importlib.import_module(module_name)) + with pytest.MonkeyPatch().context() as mp: + for k, v in env.items(): + mp.setenv(k, v) + # Re-import to apply env overrides + settings_mod = importlib.import_module(module_name) + importlib.reload(settings_mod) + return settings_mod.settings + + +def test_settings_defaults() -> None: + settings = reload_settings_with_env({}) + assert settings.MCP_HOST == "0.0.0.0" + assert settings.MCP_PORT == 8000 + assert settings.TRANSPORT in {"sse", "streamable-http"} + assert settings.INVENTORY_URL.endswith("/api/assisted-install/v2") + assert settings.PULL_SECRET_URL.endswith("/api/accounts_mgmt/v1/access_token") + assert settings.CLIENT_DEBUG is False + assert settings.SSO_URL.startswith("https://") + + +def test_settings_env_overrides() -> None: + settings = reload_settings_with_env( + { + "MCP_HOST": "127.0.0.1", + "MCP_PORT": "9000", + "TRANSPORT": "streamable-http", + "INVENTORY_URL": "https://custom.example.com/v2", + "CLIENT_DEBUG": "true", + } + ) + assert settings.MCP_HOST == "127.0.0.1" + assert settings.MCP_PORT == 9000 + assert settings.TRANSPORT == "streamable-http" + assert settings.INVENTORY_URL == "https://custom.example.com/v2" + assert settings.CLIENT_DEBUG is True + + +def test_settings_validation_invalid_transport() -> None: + from pydantic import ValidationError # pylint: disable=import-outside-toplevel + + with pytest.raises( + ValidationError, match="Input should be 'sse' or 'streamable-http'" + ): + _settings = reload_settings_with_env({"TRANSPORT": "invalid"}) + + +def test_validate_config_invalid_port_low() -> None: + with pytest.raises(Exception): + reload_settings_with_env({"MCP_PORT": "1023"}) + + +def test_validate_config_invalid_port_high() -> None: + with pytest.raises(Exception): + reload_settings_with_env({"MCP_PORT": "70000"}) + + +def test_validate_config_invalid_log_level() -> None: + with pytest.raises(Exception): + reload_settings_with_env({"LOGGING_LEVEL": "VERBOSE"}) + + +def test_validate_config_invalid_transport() -> None: + with pytest.raises(Exception): + reload_settings_with_env({"TRANSPORT": "http2"}) diff --git a/tests/test_shared_helpers.py b/tests/test_shared_helpers.py new file mode 100644 index 0000000..e5f6166 --- /dev/null +++ b/tests/test_shared_helpers.py @@ -0,0 +1,28 @@ +from unittest.mock import AsyncMock +import pytest + +from assisted_service_mcp.src.tools.shared_helpers import _get_cluster_infra_env_id + + +@pytest.mark.asyncio +async def test_get_cluster_infra_env_id_success() -> None: + client = AsyncMock() + client.list_infra_envs.return_value = [{"id": "ie-1"}] + res = await _get_cluster_infra_env_id(client, "cid") + assert res == "ie-1" + + +@pytest.mark.asyncio +async def test_get_cluster_infra_env_id_no_infra_envs() -> None: + client = AsyncMock() + client.list_infra_envs.return_value = [] + with pytest.raises(ValueError): + await _get_cluster_infra_env_id(client, "cid") + + +@pytest.mark.asyncio +async def test_get_cluster_infra_env_id_missing_id() -> None: + client = AsyncMock() + client.list_infra_envs.return_value = [{}] + with pytest.raises(ValueError): + await _get_cluster_infra_env_id(client, "cid") diff --git a/tests/test_static_net.py b/tests/test_static_net.py index 8dfa0df..416e42e 100644 --- a/tests/test_static_net.py +++ b/tests/test_static_net.py @@ -9,7 +9,7 @@ import pytest import yaml -from static_net import ( +from assisted_service_mcp.src.utils.static_net import ( remove_static_host_config_by_index, add_or_replace_static_host_config_yaml, validate_and_parse_nmstate, @@ -17,7 +17,7 @@ NMStateTemplateParams, ) -from static_net.template import ( +from assisted_service_mcp.src.utils.static_net.template import ( EthernetInterfaceParams, IPV4AddressWithSubnet, RouteParams, @@ -82,7 +82,7 @@ def test_remove_invalid_index_too_high(self): existing_config = json.dumps(config_data) with pytest.raises( - ValueError, + IndexError, match="static network config only has 1 elements, cannot delete index 5", ): remove_static_host_config_by_index(existing_config, 5) @@ -92,7 +92,7 @@ def test_remove_from_empty_config(self): existing_config = json.dumps([]) with pytest.raises( - ValueError, + IndexError, match="static network config only has 0 elements, cannot delete index 0", ): remove_static_host_config_by_index(existing_config, 0) diff --git a/tests/test_tools_module.py b/tests/test_tools_module.py new file mode 100644 index 0000000..b9412b1 --- /dev/null +++ b/tests/test_tools_module.py @@ -0,0 +1,567 @@ +import asyncio +import json +import datetime as _dt +from unittest.mock import patch, Mock, MagicMock, AsyncMock +import pytest + +from assisted_service_mcp.src.tools.version_tools import list_versions +from assisted_service_mcp.src.tools.operator_tools import ( + list_operator_bundles, + add_operator_bundle_to_cluster, +) + + +@pytest.mark.asyncio +async def test_tool_set_cluster_platform_module() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.update_cluster = AsyncMock( + return_value=type("_R", (), {"to_str": lambda self=None: "UPDATED"})() + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await cluster_tools.set_cluster_platform( + lambda: "test-access-token", "cid", "vsphere" + ) + assert resp == "UPDATED" + + +@pytest.mark.asyncio +async def test_tool_set_cluster_vips_module() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.update_cluster = AsyncMock( + return_value=type("_R", (), {"to_str": lambda self=None: "VIPS-UPDATED"})() + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await cluster_tools.set_cluster_vips( + lambda: "test-access-token", "cid", "10.0.0.2", "10.0.0.3" + ) + assert resp == "VIPS-UPDATED" + + +@pytest.mark.asyncio +async def test_create_cluster_invalid_platform_for_sno_returns_message() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + AssistedServiceMCPServer() + resp = await cluster_tools.create_cluster( + lambda: "t", + name="n", + version="4.18.0", + base_domain="example.com", + single_node=True, + ssh_public_key=None, + cpu_architecture="x86_64", + platform="baremetal", + ) + assert resp == "Platform must be set to 'none' for single-node clusters" + + +@pytest.mark.asyncio +async def test_tool_install_cluster_module() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.install_cluster = AsyncMock( + return_value=type("_R", (), {"to_str": lambda self=None: "INSTALL-TRIGGERED"})() + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await cluster_tools.install_cluster(lambda: "test-access-token", "cid") + assert resp == "INSTALL-TRIGGERED" + + +@pytest.mark.asyncio +async def test_tool_cluster_events_module() -> None: + from assisted_service_mcp.src.tools import event_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.get_events = AsyncMock(return_value='{"events": ["e1", "e2"]}') + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.event_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await event_tools.cluster_events(lambda: "test-access-token", "cid") + assert json.loads(resp)["events"] == ["e1", "e2"] + + +@pytest.mark.asyncio +async def test_tool_host_events_module() -> None: + from assisted_service_mcp.src.tools import event_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.get_events = AsyncMock(return_value='{"events": ["h1"]}') + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.event_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await event_tools.host_events(lambda: "test-access-token", "cid", "hid") + assert json.loads(resp)["events"] == ["h1"] + + +@pytest.mark.asyncio +async def test_tool_cluster_iso_download_url_module() -> None: + from assisted_service_mcp.src.tools import download_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + class _Presigned: + def __init__(self, url: str, expires_at: str | None = None) -> None: + self.url = url + self.expires_at = ( + _dt.datetime.fromisoformat("2025-01-01T00:00:00+00:00") + if expires_at + else None + ) + + mock_client = Mock() + mock_client.list_infra_envs = AsyncMock(return_value=[{"id": "ie1"}]) + mock_client.get_infra_env_download_url = AsyncMock( + return_value=_Presigned("https://u/iso", "2025-01-01T00:00:00Z") + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.download_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await download_tools.cluster_iso_download_url( + lambda: "test-access-token", "cid" + ) + data = json.loads(resp) + assert data[0]["url"] == "https://u/iso" + assert data[0]["expires_at"] == "2025-01-01T00:00:00Z" + + +@pytest.mark.asyncio +async def test_tool_cluster_credentials_download_url_module() -> None: + from assisted_service_mcp.src.tools import download_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + class _Presigned: + def __init__(self, url: str, expires_at: str | None = None) -> None: + self.url = url + self.expires_at = expires_at + + mock_client = Mock() + mock_client.get_presigned_for_cluster_credentials = AsyncMock( + return_value=_Presigned("https://u/kubeconfig", None) + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.download_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await download_tools.cluster_credentials_download_url( + lambda: "test-access-token", "cid", "kubeconfig" + ) + data = json.loads(resp) + assert data["url"] == "https://u/kubeconfig" + + +@pytest.mark.asyncio +async def test_tool_set_host_role_module() -> None: + from assisted_service_mcp.src.tools import host_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.update_host = AsyncMock( + return_value=type("_R", (), {"to_str": lambda self=None: "HOST-UPDATED"})() + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.host_tools._get_cluster_infra_env_id", + new=AsyncMock(return_value="ie1"), + ), + patch( + "assisted_service_mcp.src.tools.host_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await host_tools.set_host_role( + lambda: "test-access-token", "hid", "cid", "worker" + ) + assert resp == "HOST-UPDATED" + + +@pytest.mark.asyncio +async def test_tool_add_operator_bundle_module() -> None: + from assisted_service_mcp.src.tools import operator_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.add_operator_bundle_to_cluster = AsyncMock( + return_value=type("_R", (), {"to_str": lambda self=None: "OP-ADDED"})() + ) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.operator_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await operator_tools.add_operator_bundle_to_cluster( + lambda: "test-access-token", "cid", "virtualization" + ) + assert resp == "OP-ADDED" + + +@pytest.mark.asyncio +async def test_tool_list_operator_bundles_module() -> None: + from assisted_service_mcp.src.tools import operator_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + bundles = [{"name": "virtualization"}] + mock_client = Mock() + mock_client.get_operator_bundles = AsyncMock(return_value=bundles) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.operator_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await operator_tools.list_operator_bundles(lambda: "test-access-token") + assert json.loads(resp) == bundles + + +@pytest.mark.asyncio +async def test_tool_network_validate_nmstate_yaml_module() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + with patch( + "assisted_service_mcp.src.tools.network_tools.validate_and_parse_nmstate" + ): + AssistedServiceMCPServer() + resp = await network_tools.validate_nmstate_yaml( + lambda: "test-access-token", "interfaces: []\n" + ) + assert resp == "YAML is valid" + + +@pytest.mark.asyncio +async def test_alter_static_network_remove_with_none_index_raises() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + AssistedServiceMCPServer() + mock_client = Mock() + mock_client.get_infra_env = AsyncMock( + return_value=type("_I", (), {"static_network_config": "[]"})() + ) + with ( + patch( + "assisted_service_mcp.src.tools.network_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.src.tools.network_tools._get_cluster_infra_env_id", + new=AsyncMock(return_value="ie1"), + ), + ): + with pytest.raises(ValueError, match="index cannot be null"): + await network_tools.alter_static_network_config_nmstate_for_host( + lambda: "t", "cid", None, None + ) + + +@pytest.mark.asyncio +async def test_alter_static_network_remove_with_empty_existing_raises() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + AssistedServiceMCPServer() + mock_client = Mock() + mock_client.get_infra_env = AsyncMock( + return_value=type("_I", (), {"static_network_config": None})() + ) + with ( + patch( + "assisted_service_mcp.src.tools.network_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.src.tools.network_tools._get_cluster_infra_env_id", + new=AsyncMock(return_value="ie1"), + ), + ): + with pytest.raises(ValueError, match="empty existing static network config"): + await network_tools.alter_static_network_config_nmstate_for_host( + lambda: "t", "cid", 0, None + ) + + +@pytest.mark.asyncio +async def test_list_static_network_config_invalid_infraenv_count() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + AssistedServiceMCPServer() + mock_client = Mock() + mock_client.list_infra_envs = AsyncMock(return_value=[{"id": "a"}, {"id": "b"}]) + with patch( + "assisted_service_mcp.src.tools.network_tools.InventoryClient", + return_value=mock_client, + ): + resp = await network_tools.list_static_network_config(lambda: "t", "cid") + assert resp.startswith("ERROR:") + + +@pytest.mark.asyncio +async def test_list_versions_happy_path() -> None: + from assisted_service_mcp.src.tools import version_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + AssistedServiceMCPServer() + mock_client = Mock() + mock_client.get_openshift_versions = AsyncMock(return_value=[{"version": "4.18.1"}]) + with patch( + "assisted_service_mcp.src.tools.version_tools.InventoryClient", + return_value=mock_client, + ): + resp = await version_tools.list_versions(lambda: "t") + assert json.loads(resp)[0]["version"] == "4.18.1" + + +@pytest.mark.asyncio +async def test_tool_network_generate_nmstate_yaml_module() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.utils.static_net.template import ( + NMStateTemplateParams, + EthernetInterfaceParams, + ) + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + params = NMStateTemplateParams( + routes=None, + bond_ifaces=None, + vlan_ifaces=None, + ethernet_ifaces=[ + EthernetInterfaceParams(mac_address="00:11:22:33:44:55", name="eth0") + ], + ) + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.network_tools.generate_nmstate_from_template", + return_value="yaml", + ): + resp = await network_tools.generate_nmstate_yaml( + lambda: "test-access-token", params + ) + assert resp == "yaml" + + +@pytest.mark.asyncio +async def test_tool_create_cluster_module() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + # Simulate returned API objects' to_str() and attributes + class _Cluster: + def __init__(self, cid: str, ver: str) -> None: + self.id = cid + self.openshift_version = ver + + def to_str(self) -> str: + return f"CLUSTER:{self.id}:{self.openshift_version}" + + class _Infra: + def __init__(self, iid: str) -> None: + self.id = iid + + mock_client = Mock() + mock_client.create_cluster = AsyncMock(return_value=_Cluster("cid-1", "4.18.2")) + mock_client.create_infra_env = AsyncMock(return_value=_Infra("ie-1")) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await cluster_tools.create_cluster( + lambda: "test-access-token", + name="n", + version="4.18.2", + base_domain="example.com", + single_node=False, + ssh_public_key=None, + cpu_architecture="x86_64", + platform="baremetal", + ) + assert resp == "cid-1" + + +@pytest.mark.asyncio +async def test_tool_set_cluster_ssh_key_partial_failure_module() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + class _Cluster: + def __init__(self, cid: str) -> None: + self.id = cid + + def to_str(self) -> str: + return f"CLUSTER:{self.id}" + + mock_client = Mock() + mock_client.update_cluster = AsyncMock(return_value=_Cluster("cid")) + mock_client.list_infra_envs = AsyncMock(return_value=[{"id": "infraenv-id"}]) + mock_client.update_infra_env = AsyncMock(side_effect=Exception("Update failed")) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.shared_helpers._get_cluster_infra_env_id", + new=AsyncMock(return_value="infraenv-id"), + ), + patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ), + patch( + "assisted_service_mcp.utils.auth.get_access_token", + return_value="test-access-token", + ), + ): + resp = await cluster_tools.set_cluster_ssh_key( + lambda: "test-access-token", "cid", "ssh-rsa AAAA" + ) + assert "Cluster key updated, but boot image key update failed" in resp + + +def test_list_versions_error_branch() -> None: # type: ignore[no-untyped-def] + async def run() -> None: + with patch( + "assisted_service_mcp.src.service_client.assisted_service_api.InventoryClient.get_openshift_versions", + side_effect=Exception("boom"), + ): + try: + await list_versions(lambda: "token") + except Exception as e: # noqa: BLE001 + assert "boom" in str(e) + + asyncio.run(run()) + + +def test_list_operator_bundles_error_branch() -> None: # type: ignore[no-untyped-def] + async def run() -> None: + with patch( + "assisted_service_mcp.src.service_client.assisted_service_api.InventoryClient.get_operator_bundles", + side_effect=Exception("boom2"), + ): + try: + await list_operator_bundles(lambda: "token") + except Exception as e: # noqa: BLE001 + assert "boom2" in str(e) + + asyncio.run(run()) + + +def test_add_operator_bundle_to_cluster_happy() -> None: # type: ignore[no-untyped-def] + async def run() -> None: + mock_client = MagicMock() + mock_cluster = MagicMock() + mock_client.add_operator_bundle_to_cluster = AsyncMock( + return_value=mock_cluster + ) + mock_cluster.to_str.return_value = "cluster-str" + # Patch where it's used: operator_tools imports InventoryClient into its namespace + with patch( + "assisted_service_mcp.src.tools.operator_tools.InventoryClient", + return_value=mock_client, + ): + s = await add_operator_bundle_to_cluster(lambda: "token", "cid", "bundle") + assert s == "cluster-str" + + asyncio.run(run()) diff --git a/uv.lock b/uv.lock index 2d22bac..e3d3dc0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -134,17 +134,19 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "assisted-service-client" }, + { name = "fastapi" }, { name = "jinja2" }, { name = "mcp" }, { name = "nestedarchive" }, { name = "netaddr" }, { name = "prometheus-client" }, { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, { name = "retry" }, { name = "tabulate" }, - { name = "types-requests" }, ] [package.dev-dependencies] @@ -154,8 +156,10 @@ dev = [ { name = "pydocstyle" }, { name = "pylint" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, { name = "types-pyyaml" }, + { name = "types-requests" }, ] performance = [ { name = "aiohttp" }, @@ -170,17 +174,19 @@ test = [ [package.metadata] requires-dist = [ { name = "assisted-service-client", specifier = ">=2.41.0.post3" }, + { name = "fastapi", specifier = ">=0.115.0" }, { name = "jinja2", specifier = ">=3.1" }, { name = "mcp", specifier = ">=1.15.0" }, { name = "nestedarchive", specifier = ">=0.2.4" }, { name = "netaddr", specifier = ">=1.3.0" }, { name = "prometheus-client", specifier = ">=0.22.1" }, { name = "pydantic", specifier = ">=2.12.1" }, + { name = "pydantic-settings", specifier = ">=2.6.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6" }, { name = "requests", specifier = ">=2.32.3" }, { name = "retry", specifier = ">=0.9.2" }, { name = "tabulate", specifier = ">=0.9.0" }, - { name = "types-requests", specifier = ">=2.32.4.20250611" }, ] [package.metadata.requires-dev] @@ -190,8 +196,10 @@ dev = [ { name = "pydocstyle", specifier = ">=6.3.0" }, { name = "pylint", specifier = ">=3.3.7" }, { name = "pyright", specifier = ">=1.1.402" }, + { name = "pytest", specifier = ">=8.0.0" }, { name = "ruff", specifier = ">=0.12.1" }, { name = "types-pyyaml", specifier = ">=6" }, + { name = "types-requests", specifier = ">=2.32.4.20250611" }, ] performance = [{ name = "aiohttp", specifier = ">=3.8.0" }] test = [ @@ -390,6 +398,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, ] +[[package]] +name = "fastapi" +version = "0.119.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/f9/5c5bcce82a7997cc0eb8c47b7800f862f6b56adc40486ed246e5010d443b/fastapi-0.119.0.tar.gz", hash = "sha256:451082403a2c1f0b99c6bd57c09110ed5463856804c8078d38e5a1f1035dbbb7", size = 336756, upload-time = "2025-10-11T17:13:40.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/70/584c4d7cad80f5e833715c0a29962d7c93b4d18eed522a02981a6d1b6ee5/fastapi-0.119.0-py3-none-any.whl", hash = "sha256:90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2", size = 107095, upload-time = "2025-10-11T17:13:39.048Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" From 3d600ec9864ff552be1e50109a6d27fa0939bf2d Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Thu, 16 Oct 2025 10:34:50 +0200 Subject: [PATCH 4/4] normalizing tool descriptions --- .env.template | 39 +++ assisted_service_mcp/src/api.py | 3 +- assisted_service_mcp/src/mcp.py | 11 +- assisted_service_mcp/src/settings.py | 8 +- .../src/tools/cluster_tools.py | 74 ++---- .../src/tools/download_tools.py | 50 ++-- assisted_service_mcp/src/tools/event_tools.py | 13 - assisted_service_mcp/src/tools/host_tools.py | 6 - .../src/tools/network_tools.py | 32 +-- .../src/tools/operator_tools.py | 17 +- .../src/tools/version_tools.py | 13 +- assisted_service_mcp/utils/helpers.py | 36 --- tests/test_integration_api.py | 0 tests/test_metrics.py | 13 +- tests/test_settings.py | 6 + tests/test_tools_module.py | 222 ++++++++++++++++++ 16 files changed, 343 insertions(+), 200 deletions(-) create mode 100644 .env.template delete mode 100644 assisted_service_mcp/utils/helpers.py delete mode 100644 tests/test_integration_api.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..3beb3bf --- /dev/null +++ b/.env.template @@ -0,0 +1,39 @@ +# Assisted Service MCP Server - .env.template +# Copy to `.env` and adjust values as needed. All variables are optional unless noted. + +# --- Server --- +# Bind host for the HTTP/SSE server +MCP_HOST=0.0.0.0 +# Port for the HTTP/SSE server (1024-65535) +MCP_PORT=8000 +# Transport protocol for MCP server: sse | streamable-http +TRANSPORT=sse + +# --- Assisted Service API --- +# Assisted Service API base URL +INVENTORY_URL=https://api.openshift.com/api/assisted-install/v2 +# Enable verbose client logging (true/false) +CLIENT_DEBUG=false +# URL endpoint used to fetch the pull secret +PULL_SECRET_URL=https://api.openshift.com/api/accounts_mgmt/v1/access_token + +# --- Authentication --- +# OCM offline token used to mint access tokens. +# You may omit this and instead provide the token per-request via the +# "OCM-Offline-Token" HTTP header from your MCP client configuration. +# OFFLINE_TOKEN= + +# Red Hat SSO token endpoint used to exchange the offline token +SSO_URL=https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token + +# --- Logging --- +# Logging level: DEBUG | INFO | WARNING | ERROR | CRITICAL +LOGGING_LEVEL=INFO +# Logger name (leave empty for default) +# LOGGER_NAME=assisted-service-mcp +# Write logs to file (true/false). Consider disabling in containers. +LOG_TO_FILE=true + +# --- Features --- +# Enable troubleshooting tool calls: 0 (disabled) | 1 (enabled) +ENABLE_TROUBLESHOOTING_TOOLS=0 diff --git a/assisted_service_mcp/src/api.py b/assisted_service_mcp/src/api.py index 7ba6514..955d9a1 100644 --- a/assisted_service_mcp/src/api.py +++ b/assisted_service_mcp/src/api.py @@ -15,8 +15,7 @@ server = AssistedServiceMCPServer() # Choose the appropriate transport protocol based on settings -TRANSPORT_VALUE = getattr(settings, "TRANSPORT", "sse") -if TRANSPORT_VALUE and str(TRANSPORT_VALUE).lower() == "streamable-http": +if settings.TRANSPORT == "streamable-http": app = server.mcp.streamable_http_app() log.info("Using StreamableHTTP transport (stateless)") else: diff --git a/assisted_service_mcp/src/mcp.py b/assisted_service_mcp/src/mcp.py index 0c7d1d6..53ac9ec 100644 --- a/assisted_service_mcp/src/mcp.py +++ b/assisted_service_mcp/src/mcp.py @@ -10,7 +10,7 @@ # Import auth utilities from assisted_service_mcp.utils.auth import get_offline_token, get_access_token -from assisted_service_mcp.src.settings import settings, get_setting +from assisted_service_mcp.src.settings import settings # Import all tool modules from assisted_service_mcp.src.tools import ( @@ -35,7 +35,7 @@ def __init__(self) -> None: """Initialize the MCP server with assisted service tools.""" try: # Get transport configuration from settings - use_stateless_http = (settings.TRANSPORT or "").lower() == "streamable-http" + use_stateless_http = settings.TRANSPORT == "streamable-http" # Initialize FastMCP server self.mcp = FastMCP( @@ -74,7 +74,7 @@ def _register_mcp_tools(self) -> None: self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_platform)) self.mcp.tool()(self._wrap_tool(cluster_tools.install_cluster)) self.mcp.tool()(self._wrap_tool(cluster_tools.set_cluster_ssh_key)) - if get_setting("ENABLE_TROUBLESHOOTING_TOOLS"): + if settings.ENABLE_TROUBLESHOOTING_TOOLS: self.mcp.tool()(self._wrap_tool(cluster_tools.analyze_cluster_logs)) # Register event monitoring tools @@ -132,8 +132,9 @@ def _wrap_tool( @wraps(tool_func) async def wrapped(*args: Any, **kwargs: Any) -> Any: - # Inject the access token provider as the first parameter - return await tool_func(self._get_access_token, *args, **kwargs) + # Generate token off the event loop; pass a cheap closure to tools + token = await asyncio.to_thread(self._get_access_token) + return await tool_func(lambda: token, *args, **kwargs) # Get the original function signature sig = inspect.signature(tool_func) diff --git a/assisted_service_mcp/src/settings.py b/assisted_service_mcp/src/settings.py index 9f4eaf9..f871ddf 100644 --- a/assisted_service_mcp/src/settings.py +++ b/assisted_service_mcp/src/settings.py @@ -5,7 +5,7 @@ from typing import Any from dotenv import load_dotenv -from pydantic import Field +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Load environment variables with error handling @@ -116,6 +116,12 @@ class Settings(BaseSettings): }, ) + # Accept lower/any-case input from env (e.g., "debug") and normalize + @field_validator("LOGGING_LEVEL", mode="before") + @classmethod + def _normalize_logging_level(cls, v): # type: ignore[no-untyped-def] + return v.upper() if isinstance(v, str) else v + LOGGER_NAME: str = Field( default="", json_schema_extra={ diff --git a/assisted_service_mcp/src/tools/cluster_tools.py b/assisted_service_mcp/src/tools/cluster_tools.py index 7da483a..a0c820e 100644 --- a/assisted_service_mcp/src/tools/cluster_tools.py +++ b/assisted_service_mcp/src/tools/cluster_tools.py @@ -17,7 +17,7 @@ async def cluster_info( cluster_id: Annotated[ str, Field( - description="The unique identifier of the cluster to retrieve information for. This is typically a UUID string." + description="The unique identifier of the cluster to retrieve information for." ), ], ) -> str: @@ -29,13 +29,6 @@ async def cluster_info( Prerequisites: - Valid cluster UUID (from list_clusters or create_cluster) - - OCM offline token for authentication - - Related tools: - - list_clusters - Get cluster IDs - - cluster_events - View cluster installation history - - install_cluster - Start cluster installation - - set_cluster_vips - Configure network VIPs Returns: str: A formatted string containing detailed cluster information including: @@ -59,14 +52,6 @@ async def list_clusters(get_access_token_func: Callable[[], str]) -> str: basic information about each cluster (name, ID, version, status) without detailed configuration. Use cluster_info() to get comprehensive details about a specific cluster. - Prerequisites: - - Valid OCM offline token for authentication - - Related tools: - - cluster_info - Get detailed information for a specific cluster - - create_cluster - Create a new cluster - - cluster_events - View installation history for a cluster - Returns: str: A JSON-formatted string containing an array of cluster objects. Each cluster object includes: @@ -80,10 +65,10 @@ async def list_clusters(get_access_token_func: Callable[[], str]) -> str: clusters = await client.list_clusters() resp = [ { - "name": cluster.name, - "id": cluster.id, - "openshift_version": getattr(cluster, "openshift_version", "Unknown"), - "status": cluster.status, + "name": cluster["name"], + "id": cluster["id"], + "openshift_version": cluster.get("openshift_version", "Unknown"), + "status": cluster["status"], } for cluster in clusters ] @@ -149,15 +134,8 @@ async def create_cluster( # pylint: disable=too-many-arguments,too-many-positio - create_cluster("vsphere-cluster", "4.18.2", "vsphere.com", False, platform="vsphere", cpu_architecture="x86_64") Prerequisites: - - Valid OCM offline token for authentication - OpenShift version from list_versions - Related tools: - - list_versions - Get available OpenShift versions - - cluster_info - View created cluster details - - set_cluster_vips - Configure VIPs (required for HA baremetal/vsphere/nutanix) - - install_cluster - Start the installation process - Returns: str: The created cluster's UUID. """ @@ -245,15 +223,8 @@ async def set_cluster_vips( to any physical host, and reachable from all cluster nodes. Prerequisites: - - Valid OCM offline token for authentication - Multi-node cluster on baremetal, vsphere, or nutanix platform - Two unused IP addresses within the cluster subnet - - IPs must be reachable from all cluster nodes - - Related tools: - - create_cluster - Create the cluster first - - cluster_info - Verify VIP configuration - - install_cluster - Install after VIPs are configured Returns: str: Formatted string with updated cluster configuration including VIP addresses. @@ -293,15 +264,9 @@ async def set_cluster_platform( of network settings (VIPs) and other platform-specific parameters. Prerequisites: - - Valid OCM offline token for authentication - Existing cluster (from create_cluster) - Compatible platform choice for cluster type (single-node requires 'none') - Related tools: - - create_cluster - Creates cluster with default platform - - set_cluster_vips - Configure VIPs (required for baremetal/vsphere/nutanix) - - cluster_info - Verify platform configuration - Returns: str: Formatted string with updated cluster configuration and new platform setting. """ @@ -327,19 +292,11 @@ async def install_cluster( immediately; use cluster_info and cluster_events to monitor installation progress. Prerequisites: - - Valid OCM offline token for authentication - All required hosts discovered and in 'ready' state - Network configuration complete (VIPs set if required by platform) - All cluster validations passing (check with cluster_info) - - For HA: minimum 3 master nodes, VIPs configured - For SNO: 1 node with sufficient resources - Related tools: - - create_cluster - Create cluster first - - cluster_info - Check readiness and monitor installation progress - - cluster_events - View detailed installation events and logs - - set_cluster_vips - Configure VIPs before installation (HA clusters) - Returns: str: Formatted string with cluster status and installation progress information. """ @@ -371,15 +328,9 @@ async def set_cluster_ssh_key( ISO to get the updated key. Prerequisites: - - Valid OCM offline token for authentication - Existing cluster (from create_cluster) - Valid SSH public key in OpenSSH format (starts with ssh-rsa, ssh-ed25519, etc.) - Related tools: - - create_cluster - Can set SSH key at creation time - - cluster_iso_download_url - Download new ISO with updated key - - cluster_info - Verify SSH key configuration - Returns: str: Formatted string with updated cluster configuration, or error message if boot image update fails. """ @@ -418,8 +369,19 @@ async def analyze_cluster_logs( get_access_token_func: Callable[[], str], cluster_id: Annotated[str, Field(description="The ID of the cluster")], ) -> str: - """ - Analyze the cluster logs for the given cluster_id and return the results. + """Analyze Assisted Installer logs for a cluster and summarize findings. + + Runs a set of built‑in log analysis signatures against the cluster’s collected + logs (controller logs, bootstrap/control‑plane logs, and must‑gather content + when available). The results highlight common misconfigurations and known + error patterns to speed up triage of failed or degraded installations. + + Prerequisites: + - Logs are available for the target cluster (downloadable via the API) + + Returns: + str: Human‑readable report of signature results. Returns an empty + string if no issues were found by the analyzer. """ client = InventoryClient(get_access_token_func()) results = await analyze_cluster(cluster_id=cluster_id, api_client=client) diff --git a/assisted_service_mcp/src/tools/download_tools.py b/assisted_service_mcp/src/tools/download_tools.py index 728b905..63a9a35 100644 --- a/assisted_service_mcp/src/tools/download_tools.py +++ b/assisted_service_mcp/src/tools/download_tools.py @@ -1,13 +1,45 @@ """Download URL tools for Assisted Service MCP Server.""" import json -from typing import Annotated, Callable +from datetime import datetime, timezone +from typing import Annotated, Callable, Any from pydantic import Field +from assisted_service_client import models from assisted_service_mcp.src.metrics import track_tool_usage from assisted_service_mcp.src.service_client.assisted_service_api import InventoryClient from assisted_service_mcp.src.logger import log -from assisted_service_mcp.utils.helpers import format_presigned_url + +# Define a constant for zero datetime +ZERO_DATETIME = datetime(1, 1, 1, tzinfo=timezone.utc) + + +def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: + r""" + Format a presigned URL object into a readable string. + + Args: + presigned_url: A PresignedUrl object with url and optional expires_at attributes. + + Returns: + dict: A dict containing URL and optional expiration time. + Format: + { + url: + expires_at: (if expiration exists) + } + """ + presigned_url_dict = { + "url": presigned_url.url, + } + + # Only include expiration time if it's a meaningful date (not a zero/default value) + if presigned_url.expires_at and presigned_url.expires_at != ZERO_DATETIME: + presigned_url_dict["expires_at"] = presigned_url.expires_at.isoformat().replace( + "+00:00", "Z" + ) + + return presigned_url_dict @track_tool_usage() @@ -25,18 +57,12 @@ async def cluster_iso_download_url( Retrieves time-limited download URLs for all infrastructure environment ISOs associated with the cluster. These bootable ISOs are used to boot hosts for automatic discovery and installation. Download the ISO and boot your hosts from it (USB, virtual - media, PXE) to add them to the cluster. URLs are time-limited for security and will + media) to add them to the cluster. URLs are time-limited for security and will expire after a period. Prerequisites: - - Valid OCM offline token for authentication - Cluster with created infrastructure environment (automatically created by create_cluster) - Related tools: - - create_cluster - Creates cluster and infrastructure environment - - set_cluster_ssh_key - Update SSH key (requires new ISO download) - - cluster_info - View cluster and infrastructure environment details - Returns: str: JSON array with ISO URLs and optional expiration times, or message if no ISOs found. """ @@ -118,14 +144,8 @@ async def cluster_credentials_download_url( of that URL if possible. Prerequisites: - - Valid OCM offline token for authentication - Successfully completed cluster installation (check status with cluster_info) - Related tools: - - cluster_info - Verify installation is complete - - install_cluster - Start the installation - - cluster_events - Monitor installation progress - Returns: str: JSON with presigned URL and optional expiration timestamp. """ diff --git a/assisted_service_mcp/src/tools/event_tools.py b/assisted_service_mcp/src/tools/event_tools.py index 9e0eff9..c4ceedc 100644 --- a/assisted_service_mcp/src/tools/event_tools.py +++ b/assisted_service_mcp/src/tools/event_tools.py @@ -24,15 +24,8 @@ async def cluster_events( changes, and error messages. Prerequisites: - - Valid OCM offline token for authentication - Existing cluster with UUID (from list_clusters or create_cluster) - Related tools: - - cluster_info - Current cluster state and status - - host_events - Events specific to individual hosts - - install_cluster - Triggers installation events - - list_clusters - Get cluster UUIDs - Returns: str: JSON string with timestamped cluster events and descriptive messages. """ @@ -70,15 +63,9 @@ async def host_events( on a particular node. Prerequisites: - - Valid OCM offline token for authentication - Existing cluster with discovered hosts - Host ID (from cluster_info host list) - Related tools: - - cluster_events - Cluster-wide events - - cluster_info - Get host list and IDs - - set_host_role - Configure host role assignment - Returns: str: JSON string with host-specific events including validation results and installation steps. """ diff --git a/assisted_service_mcp/src/tools/host_tools.py b/assisted_service_mcp/src/tools/host_tools.py index ff39481..e0af31e 100644 --- a/assisted_service_mcp/src/tools/host_tools.py +++ b/assisted_service_mcp/src/tools/host_tools.py @@ -35,16 +35,10 @@ async def set_host_role( 3 master nodes. Prerequisites: - - Valid OCM offline token for authentication - Discovered host (boot from cluster ISO to discover) - Host ID from cluster_info host list - Cluster with infrastructure environment - Related tools: - - cluster_info - Get list of discovered hosts with their IDs - - host_events - View host-specific events and validation results - - cluster_iso_download_url - Get ISO to boot hosts for discovery - Returns: str: Formatted string with updated host configuration showing assigned role. """ diff --git a/assisted_service_mcp/src/tools/network_tools.py b/assisted_service_mcp/src/tools/network_tools.py index 5a879ed..7ff21fd 100644 --- a/assisted_service_mcp/src/tools/network_tools.py +++ b/assisted_service_mcp/src/tools/network_tools.py @@ -37,11 +37,6 @@ async def validate_nmstate_yaml( Prerequisites: - NMState YAML document (from generate_nmstate_yaml or manual creation) - Related tools: - - generate_nmstate_yaml - Generate initial YAML from parameters - - alter_static_network_config_nmstate_for_host - Apply validated YAML to hosts - - list_static_network_config - View currently applied configurations - Returns: str: "YAML is valid" if successful, otherwise error message. """ @@ -69,11 +64,6 @@ async def generate_nmstate_yaml( Prerequisites: - Network information from user (interface, IPs, gateway, DNS) - Related tools: - - validate_nmstate_yaml - Validate the generated YAML - - alter_static_network_config_nmstate_for_host - Apply generated YAML to hosts - - list_static_network_config - View applied configurations - Returns: str: Generated nmstate YAML or error message. """ @@ -84,10 +74,10 @@ async def generate_nmstate_yaml( return generated except TemplateError as e: log.error("Failed to render nmstate template", exc_info=e) - return "ERROR: Failed to generate nmstate yaml" + return f"ERROR: Failed to generate nmstate yaml: {str(e)}" except Exception as e: log.error("Exception generating nmstate yaml", exc_info=e) - return "ERROR: Unknown error" + return f"ERROR: Failed to generate nmstate yaml: {str(e)}" @track_tool_usage() @@ -114,8 +104,7 @@ async def alter_static_network_config_nmstate_for_host( Manages static network configurations for cluster hosts. To add a new host config, use index=None and provide YAML. To update an existing host config, provide the index and - new YAML. To remove a host config, provide the index and set YAML=None. Each - configuration corresponds to one host in the order they boot from the ISO. + new YAML. To remove a host config, provide the index and set YAML=None. Examples: - alter_static_network_config_nmstate_for_host("cluster-uuid", None, "interfaces:\\n- name: eth0...") # Add new host config @@ -123,17 +112,10 @@ async def alter_static_network_config_nmstate_for_host( - alter_static_network_config_nmstate_for_host("cluster-uuid", 1, None) # Delete second host config Prerequisites: - - Valid OCM offline token for authentication - - Validated NMState YAML (from validate_nmstate_yaml) + - Validated NMState YAML (from validate_nmstate_yaml), when adding or updating hosts - Cluster with infrastructure environment - Know which host corresponds to which index (first boot = index 0, second = 1, etc.) - Related tools: - - generate_nmstate_yaml - Create YAML from parameters - - validate_nmstate_yaml - Validate YAML before applying - - list_static_network_config - View current configurations and indices - - cluster_info - View cluster and infrastructure environment - Returns: str: Updated infrastructure environment with new static network config. """ @@ -182,12 +164,6 @@ async def list_static_network_config( Prerequisites: - Cluster with infrastructure environment - Related tools: - - alter_static_network_config_nmstate_for_host - Add, update, or remove configs - - generate_nmstate_yaml - Generate new configurations - - validate_nmstate_yaml - Validate configurations - - cluster_info - View cluster and infrastructure environment details - Returns: str: JSON array of static network configs, or error message. """ diff --git a/assisted_service_mcp/src/tools/operator_tools.py b/assisted_service_mcp/src/tools/operator_tools.py index 67b1bfb..47953d6 100644 --- a/assisted_service_mcp/src/tools/operator_tools.py +++ b/assisted_service_mcp/src/tools/operator_tools.py @@ -17,14 +17,6 @@ async def list_operator_bundles(get_access_token_func: Callable[[], str]) -> str capabilities like virtualization, AI/ML, monitoring, and storage. These bundles are automatically installed during cluster deployment if added before installation. - Prerequisites: - - Valid OCM offline token for authentication - - Related tools: - - add_operator_bundle_to_cluster - Add bundles from this list to a cluster - - create_cluster - Operator bundles can be added to new clusters - - list_versions - See compatible OpenShift versions - Returns: str: A JSON string containing available operator bundles with metadata including bundle names, descriptions, and operator details. @@ -49,7 +41,7 @@ async def add_operator_bundle_to_cluster( bundle_name: Annotated[ str, Field( - description="The name of the operator bundle to add. Use list_operator_bundles to see available bundles. Common bundles: 'virtualization', 'openshift-ai'." + description="The name of the operator bundle to add. The available operator bundle names are 'virtualization' and 'openshift-ai'" ), ], ) -> str: @@ -61,17 +53,10 @@ async def add_operator_bundle_to_cluster( before starting cluster installation. Prerequisites: - - Valid OCM offline token for authentication - Existing cluster (from create_cluster) - Cluster not yet installed (check with cluster_info) - Bundle name from list_operator_bundles - Related tools: - - list_operator_bundles - Get available operator bundle names - - cluster_info - Verify cluster state and installed operators - - create_cluster - Create cluster first - - install_cluster - Start installation after adding bundles - Returns: str: A formatted string containing the updated cluster configuration showing the newly added operator bundle. diff --git a/assisted_service_mcp/src/tools/version_tools.py b/assisted_service_mcp/src/tools/version_tools.py index c23a895..ae7a924 100644 --- a/assisted_service_mcp/src/tools/version_tools.py +++ b/assisted_service_mcp/src/tools/version_tools.py @@ -12,16 +12,9 @@ async def list_versions(get_access_token_func: Callable[[], str]) -> str: """List all available OpenShift versions for installation. - Retrieves the complete list of OpenShift versions that can be installed using the - assisted installer service, including GA releases and pre-release candidates. Use - this before creating a cluster to see which versions are available. - - Prerequisites: - - Valid OCM offline token for authentication - - Related tools: - - create_cluster - Uses version from this list - - list_operator_bundles - See available operators for each version + Retrieves the latest OpenShift versions that can be installed using the assisted + installer service, including GA releases and pre-release candidates. Use this + before creating a cluster to see which versions are currently available. Returns: str: A JSON string containing available OpenShift versions with metadata diff --git a/assisted_service_mcp/utils/helpers.py b/assisted_service_mcp/utils/helpers.py deleted file mode 100644 index f77e5fd..0000000 --- a/assisted_service_mcp/utils/helpers.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Helper utilities for Assisted Service MCP Server.""" - -from typing import Any -from datetime import datetime, timezone -from assisted_service_client import models - -# Define a constant for zero datetime -ZERO_DATETIME = datetime(1, 1, 1, tzinfo=timezone.utc) - - -def format_presigned_url(presigned_url: models.PresignedUrl) -> dict[str, Any]: - r""" - Format a presigned URL object into a readable string. - - Args: - presigned_url: A PresignedUrl object with url and optional expires_at attributes. - - Returns: - dict: A dict containing URL and optional expiration time. - Format: - { - url: - expires_at: (if expiration exists) - } - """ - presigned_url_dict = { - "url": presigned_url.url, - } - - # Only include expiration time if it's a meaningful date (not a zero/default value) - if presigned_url.expires_at and presigned_url.expires_at != ZERO_DATETIME: - presigned_url_dict["expires_at"] = presigned_url.expires_at.isoformat().replace( - "+00:00", "Z" - ) - - return presigned_url_dict diff --git a/tests/test_integration_api.py b/tests/test_integration_api.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_metrics.py b/tests/test_metrics.py index db5f07f..843c4ef 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,18 +1,7 @@ import asyncio -from fastapi import FastAPI -from fastapi.testclient import TestClient from prometheus_client import REGISTRY, generate_latest -from assisted_service_mcp.src.metrics import initiate_metrics, metrics, track_tool_usage - - -def test_metrics_endpoint_returns_prometheus() -> None: - app = FastAPI() - app.add_route("/metrics", metrics) - with TestClient(app) as client: - resp = client.get("/metrics") - assert resp.status_code == 200 - assert "# HELP" in resp.text or "HELP" in resp.text +from assisted_service_mcp.src.metrics import initiate_metrics, track_tool_usage def test_track_tool_usage_decorator_counts_and_times() -> None: diff --git a/tests/test_settings.py b/tests/test_settings.py index 9d75e4d..c4c9d20 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -44,6 +44,12 @@ def test_settings_env_overrides() -> None: assert settings.CLIENT_DEBUG is True +def test_logging_level_case_insensitive() -> None: + # lower-case should be accepted and normalized to upper-case + settings = reload_settings_with_env({"LOGGING_LEVEL": "debug"}) + assert settings.LOGGING_LEVEL == "DEBUG" + + def test_settings_validation_invalid_transport() -> None: from pydantic import ValidationError # pylint: disable=import-outside-toplevel diff --git a/tests/test_tools_module.py b/tests/test_tools_module.py index b9412b1..dd138d8 100644 --- a/tests/test_tools_module.py +++ b/tests/test_tools_module.py @@ -565,3 +565,225 @@ async def run() -> None: assert s == "cluster-str" asyncio.run(run()) + + +@pytest.mark.asyncio +async def test_tool_cluster_info_success() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + from tests.test_utils import create_test_cluster + + cluster = create_test_cluster(cluster_id="cid-123") + mock_client = Mock() + mock_client.get_cluster = AsyncMock(return_value=cluster) + + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ): + resp = await cluster_tools.cluster_info(lambda: "t", "cid-123") + assert resp == cluster.to_str() + + +@pytest.mark.asyncio +async def test_tool_list_clusters_formats_fields_and_defaults_version() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + # Two clusters with version, one without (defaults to "Unknown") + c1 = { + "name": "cluster1", + "id": "id1", + "openshift_version": "4.18.2", + "status": "ready", + } + c2 = { + "name": "cluster2", + "id": "id2", + "openshift_version": "4.17.1", + "status": "installing", + } + c3 = {"name": "cluster3", "id": "id3", "status": "installing"} + + mock_client = Mock() + mock_client.list_clusters = AsyncMock(return_value=[c1, c2, c3]) + + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ): + resp = await cluster_tools.list_clusters(lambda: "t") + arr = json.loads(resp) + assert arr[0]["openshift_version"] == "4.18.2" + assert arr[2]["openshift_version"] == "Unknown" + + +@pytest.mark.asyncio +async def test_tool_cluster_iso_download_url_multiple_and_zero_expiration() -> None: + from assisted_service_mcp.src.tools import download_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + class _Presigned: + def __init__(self, url: str, expires_at: _dt.datetime | None) -> None: + self.url = url + self.expires_at = expires_at + + mock_client = Mock() + mock_client.list_infra_envs = AsyncMock(return_value=[{"id": "ie1"}, {"id": "ie2"}]) + mock_client.get_infra_env_download_url = AsyncMock( + side_effect=[ + _Presigned( + "https://u/iso1", + _dt.datetime( + 1, 1, 1, tzinfo=_dt.timezone.utc + ), # zero/default -> omitted + ), + _Presigned( + "https://u/iso2", + _dt.datetime.fromisoformat("2025-01-15T12:00:00+00:00"), + ), + ] + ) + + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.download_tools.InventoryClient", + return_value=mock_client, + ): + resp = await download_tools.cluster_iso_download_url(lambda: "t", "cid") + data = json.loads(resp) + assert data[0]["url"] == "https://u/iso1" + assert "expires_at" not in data[0] + assert data[1]["url"] == "https://u/iso2" + assert data[1]["expires_at"].startswith("2025-01-15T12:00:00") + + +@pytest.mark.asyncio +async def test_tool_cluster_iso_download_url_no_infraenvs_returns_message() -> None: + from assisted_service_mcp.src.tools import download_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.list_infra_envs = AsyncMock(return_value=[]) + + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.download_tools.InventoryClient", + return_value=mock_client, + ): + resp = await download_tools.cluster_iso_download_url(lambda: "t", "cid") + assert resp == "No ISO download URLs found for this cluster." + + +@pytest.mark.asyncio +async def test_tool_cluster_credentials_download_url_error_shaping() -> None: + from assisted_service_mcp.src.tools import download_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.get_presigned_for_cluster_credentials = AsyncMock( + side_effect=Exception("boom") + ) + + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.download_tools.InventoryClient", + return_value=mock_client, + ): + resp = await download_tools.cluster_credentials_download_url( + lambda: "t", "cid", "kubeconfig" + ) + data = json.loads(resp) + assert "error" in data + assert "boom" in data["error"] + + +@pytest.mark.asyncio +async def test_tool_set_cluster_ssh_key_success_path() -> None: + from assisted_service_mcp.src.tools import cluster_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + from tests.test_utils import create_test_cluster + + cluster = create_test_cluster(cluster_id="cid") + mock_client = Mock() + mock_client.update_cluster = AsyncMock(return_value=cluster) + mock_client.update_infra_env = AsyncMock(return_value=None) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.shared_helpers._get_cluster_infra_env_id", + new=AsyncMock(return_value="ie1"), + ), + patch( + "assisted_service_mcp.src.tools.cluster_tools.InventoryClient", + return_value=mock_client, + ), + ): + resp = await cluster_tools.set_cluster_ssh_key( + lambda: "t", "cid", "ssh-rsa AAAA" + ) + assert resp == cluster.to_str() + + +@pytest.mark.asyncio +async def test_tool_list_static_network_config_success() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + mock_client = Mock() + mock_client.list_infra_envs = AsyncMock( + return_value=[{"static_network_config": ["cfg1", "cfg2"]}] + ) + + AssistedServiceMCPServer() + with patch( + "assisted_service_mcp.src.tools.network_tools.InventoryClient", + return_value=mock_client, + ): + resp = await network_tools.list_static_network_config(lambda: "t", "cid") + arr = json.loads(resp) + assert arr == ["cfg1", "cfg2"] + + +@pytest.mark.asyncio +async def test_tool_alter_static_network_add_or_replace_success() -> None: + from assisted_service_mcp.src.tools import network_tools + from assisted_service_mcp.src.mcp import AssistedServiceMCPServer + + class _Infra: + def __init__(self) -> None: + self.static_network_config: list[str] = [] + + class _Result: + def to_str(self) -> str: # noqa: D401 + return "UPDATED-INFRA" + + mock_client = Mock() + mock_client.get_infra_env = AsyncMock(return_value=_Infra()) + mock_client.update_infra_env = AsyncMock(return_value=_Result()) + + AssistedServiceMCPServer() + with ( + patch( + "assisted_service_mcp.src.tools.network_tools._get_cluster_infra_env_id", + new=AsyncMock(return_value="ie1"), + ), + patch( + "assisted_service_mcp.src.tools.network_tools.add_or_replace_static_host_config_yaml", + return_value="NEWCFG", + ), + patch( + "assisted_service_mcp.src.tools.network_tools.InventoryClient", + return_value=mock_client, + ), + ): + resp = await network_tools.alter_static_network_config_nmstate_for_host( + lambda: "t", "cid", None, "interfaces: []\n" + ) + assert resp == "UPDATED-INFRA" + mock_client.update_infra_env.assert_awaited_once_with( + "ie1", static_network_config="NEWCFG" + )