diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..bcc306e --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,30 @@ +name: "Pre-commit checks" + +on: + push: + branches: + - main + + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + +jobs: + pre-commit: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + + - name: Run pre-commit + uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6ee1600 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.14 + hooks: + - id: ruff-check + - id: ruff-format diff --git a/Containerfile b/Containerfile index 470de0c..af1c592 100644 --- a/Containerfile +++ b/Containerfile @@ -42,4 +42,4 @@ EXPOSE 8080 USER 1001 -ENTRYPOINT ["rhos-ls-mcps"] +ENTRYPOINT ["rhos-ls-mcps", "--ip", "0.0.0.0"] diff --git a/README.md b/README.md index 4a749fa..eaba7ea 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The configuration file has 4 sections: - MCP Security ## General -- `ip`: IP address the server will bind to. Default `0.0.0.0`. +- `ip`: IP address the server will bind to. Default `127.0.0.1`. - `port`: TCP port the server will bind to. Default `8080`. - `debug`: Default `false`. - `workers`: Number of different uvicorn workers. Default `1`. diff --git a/config.yaml.sample b/config.yaml.sample index 47aa2c3..29c969a 100644 --- a/config.yaml.sample +++ b/config.yaml.sample @@ -1,4 +1,4 @@ -ip: 0.0.0.0 +ip: 127.0.0.1 port: 8901 debug: true workers: 1 diff --git a/pyproject.toml b/pyproject.toml index 5591941..a6c2494 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,19 @@ requires-python = ">=3.12,<3.13" readme = "README.md" license = {text = "Apache-2.0"} +[dependency-groups] +dev = [ + "cliff>=4.14.0", + "ruff>=0.15.14", + "pre-commit>=4.6.0" +] + [build-system] requires = ["uv_build>=0.11.16,<0.12"] build-backend = "uv_build" [project.scripts] rhos-ls-mcps = "rhos_ls_mcps.main:main" + +[tool.ruff.lint] +extend-select = ["S"] diff --git a/scripts/allow-deny-list.py b/scripts/allow-deny-list.py index 8320bfc..af36552 100755 --- a/scripts/allow-deny-list.py +++ b/scripts/allow-deny-list.py @@ -15,6 +15,7 @@ The accept commands list is generated using the rhros_ls_mcps package itself, where the reject and ignore list of commands come from this script. """ + from importlib.metadata import entry_points import sys @@ -28,31 +29,128 @@ # - export: # - cp: REJECT_COMMANDS: set[str] = { - "create", "delete", "update", "set", "unset", "remove", "add", "abort", - "complete", "revoke", "issue", "cleanup", "migrate", "resize", "cleanup", - "shelve", "unshelve", "reboot", "restart", "rebuild", "stop", "restore", - "import", "failover", "associate", "revert", "run", "save", "shrink", - "reset", "del", "onboard", "commit", "unrescue", "adopt", "on", "off", - "forcedown", "detach", "edit", "lock", "unlock", "purge", "rerun", - "attach", "resume", "start", "pause", "create-from-file", "request-refresh", - "rename", "post", "clear", "move", "manage", "enable", "register", "rescue", - "deploy", "unpause", "disable", "benchmark metric create", "abandon", - "renew", "ssh", "export", "replace", "alarm create", "alarm update", - "alarm quota set", "alarm state set", "recover", "cancel", "unhold", "accept", - "pull", "exec", "upgrade", "suspend", "disassociate", "undeploy", "grow", - "scale", "execute", "grant", "confirm", "kill", "mark", "eject", "op", - "verification", "reprocess", "expand", "evacuate", "signed", "axfr", - "unregister", "clean", "download", "authorize", "cp", "submit", "stage", - "promote", "configure", "inject", "signal", "release", - + "create", + "delete", + "update", + "set", + "unset", + "remove", + "add", + "abort", + "complete", + "revoke", + "issue", + "cleanup", + "migrate", + "resize", + "cleanup", + "shelve", + "unshelve", + "reboot", + "restart", + "rebuild", + "stop", + "restore", + "import", + "failover", + "associate", + "revert", + "run", + "save", + "shrink", + "reset", + "del", + "onboard", + "commit", + "unrescue", + "adopt", + "on", + "off", + "forcedown", + "detach", + "edit", + "lock", + "unlock", + "purge", + "rerun", + "attach", + "resume", + "start", + "pause", + "create-from-file", + "request-refresh", + "rename", + "post", + "clear", + "move", + "manage", + "enable", + "register", + "rescue", + "deploy", + "unpause", + "disable", + "benchmark metric create", + "abandon", + "renew", + "ssh", + "export", + "replace", + "alarm create", + "alarm update", + "alarm quota set", + "alarm state set", + "recover", + "cancel", + "unhold", + "accept", + "pull", + "exec", + "upgrade", + "suspend", + "disassociate", + "undeploy", + "grow", + "scale", + "execute", + "grant", + "confirm", + "kill", + "mark", + "eject", + "op", + "verification", + "reprocess", + "expand", + "evacuate", + "signed", + "axfr", + "unregister", + "clean", + "download", + "authorize", + "cp", + "submit", + "stage", + "promote", + "configure", + "inject", + "signal", + "release", # These are full names - "secret_store", "baremetal_node_inspect", "baremetal_node_service", - "baremetal_node_provide", "aggregate_cache_image", "alarm delete", - "cached_image_queue", "baremetal_driver_passthru_call", - "baremetal_node_passthru_call", "static-action_call", - "metric_benchmark measures add", "metric_measures_batch-metrics", + "secret_store", + "baremetal_node_inspect", + "baremetal_node_service", + "baremetal_node_provide", + "aggregate_cache_image", + "alarm delete", + "cached_image_queue", + "baremetal_driver_passthru_call", + "baremetal_node_passthru_call", + "static-action_call", + "metric_benchmark measures add", + "metric_measures_batch-metrics", "metric_measures_batch-resources-metrics", - # This sounds intrusive: https://docs.openstack.org/senlin/rocky/user/nodes.html#checking-a-node "cluster_node_check", } @@ -60,11 +158,30 @@ # These must be full names with the "_" suffix, and they are not really commands but artifacts # from the arg parsing mechanism IGNORE_COMMANDS: set[str] = { - "database_", "infra_optim_", "load_balancer_", "identity_", "neutronclient_", - "rca_", "object_store_", "compute_", "container_", "dns_", "key_manager_", - "application_catalog_", "congressclient_", "messaging_", "baremetal_", "image_", - "volume_", "network_", "clustering_", "metric_", "baremetal-introspection_", - "cluster_profile_type_ops_", "workflow_engine_", "data_processing_", + "database_", + "infra_optim_", + "load_balancer_", + "identity_", + "neutronclient_", + "rca_", + "object_store_", + "compute_", + "container_", + "dns_", + "key_manager_", + "application_catalog_", + "congressclient_", + "messaging_", + "baremetal_", + "image_", + "volume_", + "network_", + "clustering_", + "metric_", + "baremetal-introspection_", + "cluster_profile_type_ops_", + "workflow_engine_", + "data_processing_", "orchestration_", } @@ -92,13 +209,17 @@ def osp_list_commands(verbs: set[str]) -> tuple[list[str], list[str]]: def get_openstackclient_version() -> str | None: import openstackclient + return openstackclient.__version__ + def main() -> None: accept_commands, non_accept_commands = osc.osp_list_commands(osc.ACCEPT_COMMANDS) reject_commands, non_reject_commands = osc.osp_list_commands(REJECT_COMMANDS) - undefined_commands: list[str] = list(set(non_accept_commands).intersection(non_reject_commands) - IGNORE_COMMANDS) + undefined_commands: list[str] = list( + set(non_accept_commands).intersection(non_reject_commands) - IGNORE_COMMANDS + ) result = { "undefined_commands": undefined_commands, @@ -109,5 +230,6 @@ def main() -> None: } yaml.dump(result, sys.stdout) + if __name__ == "__main__": main() diff --git a/scripts/diff-allow-deny.py b/scripts/diff-allow-deny.py index 48581e1..b54188c 100755 --- a/scripts/diff-allow-deny.py +++ b/scripts/diff-allow-deny.py @@ -18,7 +18,9 @@ UNDEFINED_COMMANDS_MSG = "check ACCEPT_COMMANDS, REJECT_COMMANDS, and IGNORE_COMMANDS" -def show_diff(list_name: str, data_1: list[str], data_2: list[str], change_msg: str) -> None: +def show_diff( + list_name: str, data_1: list[str], data_2: list[str], change_msg: str +) -> None: in_list_1_not_in_list_2 = set(data_1[list_name]) - set(data_2[list_name]) in_list_2_not_in_list_1 = set(data_2[list_name]) - set(data_1[list_name]) @@ -37,7 +39,9 @@ def show_diff(list_name: str, data_1: list[str], data_2: list[str], change_msg: def main() -> None: if len(sys.argv) != 3: - print("Usage: diff-allow-deny.py ") + print( + "Usage: diff-allow-deny.py " + ) sys.exit(1) file_name_1 = sys.argv[1] @@ -48,17 +52,23 @@ def main() -> None: with open(file_name_2, "r") as f: list_2 = yaml.safe_load(f) - print("Differences between python-openstackclient version " - f"{list_1['python_osc_version']} and version {list_2['python_osc_version']}") + print( + "Differences between python-openstackclient version " + f"{list_1['python_osc_version']} and version {list_2['python_osc_version']}" + ) changes = show_diff("allow_commands", list_1, list_2, RIGHT_GROUP_MSG) changes |= show_diff("deny_commands", list_1, list_2, RIGHT_GROUP_MSG) - undefined_changes = show_diff("undefined_commands", list_1, list_2, UNDEFINED_COMMANDS_MSG) + undefined_changes = show_diff( + "undefined_commands", list_1, list_2, UNDEFINED_COMMANDS_MSG + ) changes |= undefined_changes if not undefined_changes and list_2["undefined_commands"]: - print("Undefined commands have not changed, but there are undefined commands, " - "so ACCEPT_COMMANDS, REJECT_COMMANDS, and IGNORE_COMMANDS need to be " - f"revised to include them: {sorted(list_2['undefined_commands'])}") + print( + "Undefined commands have not changed, but there are undefined commands, " + "so ACCEPT_COMMANDS, REJECT_COMMANDS, and IGNORE_COMMANDS need to be " + f"revised to include them: {sorted(list_2['undefined_commands'])}" + ) sys.exit(1) if not changes: diff --git a/src/rhos_ls_mcps/auth.py b/src/rhos_ls_mcps/auth.py index 0ad0124..6f7c907 100644 --- a/src/rhos_ls_mcps/auth.py +++ b/src/rhos_ls_mcps/auth.py @@ -3,7 +3,11 @@ from pydantic import AnyHttpUrl from mcp.server.auth.settings import AuthSettings -from mcp.server.auth.provider import AccessToken, TokenVerifier, OAuthAuthorizationServerProvider +from mcp.server.auth.provider import ( + AccessToken, + TokenVerifier, + OAuthAuthorizationServerProvider, +) from mcp.server.transport_security import TransportSecuritySettings from rhos_ls_mcps.settings import Settings @@ -19,11 +23,13 @@ def __init__(self, token: str, read_only: bool = True): async def verify_token(self, token: str) -> AccessToken | None: if self.token != token: return None - return AccessToken(token=token, - client_id="", - scopes=self.scopes, - expires_at=None, - resource=None) + return AccessToken( + token=token, + client_id="", + scopes=self.scopes, + expires_at=None, + resource=None, + ) @dataclass @@ -41,7 +47,7 @@ def get_auth_settings(config: Settings) -> SecurityConfig: """ auth_server_provider = None - transport_security=TransportSecuritySettings( + transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=config.mcp_transport_security.enable_dns_rebinding_protection, allowed_hosts=config.mcp_transport_security.allowed_hosts, allowed_origins=config.mcp_transport_security.allowed_origins, @@ -51,8 +57,10 @@ def get_auth_settings(config: Settings) -> SecurityConfig: issuer_url=AnyHttpUrl("http://localhost:8080"), resource_server_url=AnyHttpUrl("http://localhost:8080"), ) - token_verifier=StaticTokenVerifier(config.mcp_transport_security.token, - read_only=not config.openstack.allow_write) + token_verifier = StaticTokenVerifier( + config.mcp_transport_security.token, + read_only=not config.openstack.allow_write, + ) else: auth = None token_verifier = None @@ -63,4 +71,4 @@ def get_auth_settings(config: Settings) -> SecurityConfig: auth_server_provider=auth_server_provider, transport_security=transport_security, ) - return res \ No newline at end of file + return res diff --git a/src/rhos_ls_mcps/logging.py b/src/rhos_ls_mcps/logging.py index 3c221dc..2c2312d 100644 --- a/src/rhos_ls_mcps/logging.py +++ b/src/rhos_ls_mcps/logging.py @@ -10,6 +10,7 @@ logger = logging.getLogger(__name__) + @dataclass class LoggerContext: request_id: str = "-" @@ -44,6 +45,7 @@ def init_logging(config) -> None: def tool_logger(func: Callable[..., Any]) -> Callable[..., Any]: """Logger for MCP tools.""" + @wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: # ctx.request_id is always 2, so it's useless @@ -64,4 +66,5 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: logger.error(traceback.format_exc()) raise return result - return wrapper \ No newline at end of file + + return wrapper diff --git a/src/rhos_ls_mcps/mcp_base.py b/src/rhos_ls_mcps/mcp_base.py index d51cbb1..7a8bdf2 100644 --- a/src/rhos_ls_mcps/mcp_base.py +++ b/src/rhos_ls_mcps/mcp_base.py @@ -22,4 +22,4 @@ def __init__(self, args: argparse.Namespace) -> None: def add_tools(mcp: FastMCP) -> None: """Add the module's MCP tools to the server.""" # mcp.add_tool(method, name="toolname", title="short description") - pass \ No newline at end of file + pass diff --git a/src/rhos_ls_mcps/oc.py b/src/rhos_ls_mcps/oc.py index 48d7926..e4da06d 100644 --- a/src/rhos_ls_mcps/oc.py +++ b/src/rhos_ls_mcps/oc.py @@ -19,28 +19,45 @@ # These are global arguments that the user cannot pass # You can see the full list of available global arguments by running `oc options` REJECT_GLOBAL_ARGS: list[str] = [ - "--cache-dir", "--certificate-authority", "--client-certificate", "--client-key", - "--cluster", "--context", "--insecure-skip-tls-verify", "--kubeconfig", - "--match-server-version", "--profile-output", "--profile", "-s", "--server", - "--tls-server-name", "--token", "--user", + "--cache-dir", + "--certificate-authority", + "--client-certificate", + "--client-key", + "--cluster", + "--context", + "--insecure-skip-tls-verify", + "--kubeconfig", + "--match-server-version", + "--profile-output", + "--profile", + "-s", + "--server", + "--tls-server-name", + "--token", + "--user", ] ########## # METHODS AND CLASSES CALLED FROM main.py + def initialize(mcp_ocp: FastMCP): global OC_PARAMS, MAX_ALLOW_COMMAND_WORDS, MAX_BLOCK_COMMAND_WORDS - mcp_ocp.add_tool(openshift_cli_mcp_tool, - name="openshift-cli", - title="OpenShift Client MCP Tool") + mcp_ocp.add_tool( + openshift_cli_mcp_tool, name="openshift-cli", title="OpenShift Client MCP Tool" + ) if settings.CONFIG.openshift.insecure: OC_PARAMS.append("--insecure-skip-tls-verify=true") - MAX_ALLOW_COMMAND_WORDS = max_command_words(settings.CONFIG.openshift.allowed_commands) - MAX_BLOCK_COMMAND_WORDS = max_command_words(settings.CONFIG.openshift.blocked_commands) + MAX_ALLOW_COMMAND_WORDS = max_command_words( + settings.CONFIG.openshift.allowed_commands + ) + MAX_BLOCK_COMMAND_WORDS = max_command_words( + settings.CONFIG.openshift.blocked_commands + ) def max_command_words(commands: list[str]) -> int: @@ -50,6 +67,7 @@ def max_command_words(commands: list[str]) -> int: ########## # MCP TOOLS AND SUPPORTING METHODS + @tool_logger async def openshift_cli_mcp_tool(command_str: str, ctx: Context) -> str: """Run an OpenShift CLI command @@ -63,10 +81,7 @@ async def openshift_cli_mcp_tool(command_str: str, ctx: Context) -> str: str: The stdout or stderr of the command. """ # Build the command arguments list for the openstack command - mcp_argv = ( - OC_PARAMS + - get_ocp_credentials_args(ctx) - ) + mcp_argv = OC_PARAMS + get_ocp_credentials_args(ctx) user_argv = validate_command(command_str) returncode, stdout, stderr = await utils.EXECUTOR.run_command(mcp_argv + user_argv) if returncode: @@ -92,9 +107,11 @@ def validate_command(command_str: str) -> list[str]: return argv -def _is_in_command_list(command: list[str], command_list: list[str], max_words: int) -> bool: +def _is_in_command_list( + command: list[str], command_list: list[str], max_words: int +) -> bool: for i in range(1, max_words + 1): - if ' '.join(command[:i]) in command_list: + if " ".join(command[:i]) in command_list: return True return False @@ -116,22 +133,26 @@ def _is_command_allowed(argv: list[str]) -> bool: i += 1 if settings.CONFIG.openshift.allow_write: - return not _is_in_command_list(argv_without_global_args, - settings.CONFIG.openshift.blocked_commands, - MAX_BLOCK_COMMAND_WORDS) - - return _is_in_command_list(argv_without_global_args, - settings.CONFIG.openshift.allowed_commands, - MAX_ALLOW_COMMAND_WORDS) + return not _is_in_command_list( + argv_without_global_args, + settings.CONFIG.openshift.blocked_commands, + MAX_BLOCK_COMMAND_WORDS, + ) + + return _is_in_command_list( + argv_without_global_args, + settings.CONFIG.openshift.allowed_commands, + MAX_ALLOW_COMMAND_WORDS, + ) def get_ocp_credentials_args(ctx: Context) -> list[str]: """Get OpenShift credentials arguments.""" headers = ctx.request_context.request.headers logger.debug(f"Headers: {headers}") - token_header = utils.strip_bearer_prefix(headers.get('OCP_TOKEN', '')) + token_header = utils.strip_bearer_prefix(headers.get("OCP_TOKEN", "")) result = ["--token", token_header] if token_header else [] - url_header = headers.get('OCP_URL') + url_header = headers.get("OCP_URL") if url_header: result.extend(["--server", url_header]) return result diff --git a/src/rhos_ls_mcps/osc.py b/src/rhos_ls_mcps/osc.py index cba1021..72886b3 100644 --- a/src/rhos_ls_mcps/osc.py +++ b/src/rhos_ls_mcps/osc.py @@ -27,7 +27,10 @@ import logging import os import shlex -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from cliff import interactive from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.exceptions import ToolError @@ -41,6 +44,7 @@ logger = logging.getLogger(__name__) +# fmt: off ACCEPT_COMMANDS: set[str] = { # These are just verbs "get", "show", "list", "history", "alarm-history show", "alarm-history search", @@ -59,27 +63,59 @@ "metric_server_version", "messaging_health", "database_cluster_modules", "class-schema", } +# fmt: on # These are global arguments that the user nor us can pass, so we remove them. DELETE_GLOBAL_ARGS: list[str] = [ - "--os-cloud", "--os-cert", "--os-key", "--verify", "--os-interface", "--os-profile", - "--murano-url", "--glare-url", "--inspector-url", "--os-data-processing-url", - "--os-username", "--os-password", "--os-endpoint", "--os-trust-id", - "--os-identity-provider", "--os-client-secret", "--os-openid-scope", - "--os-access-token-endpoint", "--os-discovery-endpoint", "--os-access-token-type", - "--os-redirect-uri", "--os-aodh-endpoint", "--os-application-credential-secret", - "--os-application-credential-id", "--os-application-credential-name", - "--os-code-challenge-method", "--os-access-token", "--os-consumer-key", - "--os-consumer-secret", "--os-idp-otp-key", "--os-realm-name", "--os-openid-client-id", - "--os-auth-type", "--os-oauth2-endpoint", "--os-oauth2-client-id", - "--os-oauth2-client-secret", "--os-device-authorization-endpoint", - "--os-auth-methods", "--os-user", "--os-passcode", + "--os-cloud", + "--os-cert", + "--os-key", + "--verify", + "--os-interface", + "--os-profile", + "--murano-url", + "--glare-url", + "--inspector-url", + "--os-data-processing-url", + "--os-username", + "--os-password", + "--os-endpoint", + "--os-trust-id", + "--os-identity-provider", + "--os-client-secret", + "--os-openid-scope", + "--os-access-token-endpoint", + "--os-discovery-endpoint", + "--os-access-token-type", + "--os-redirect-uri", + "--os-aodh-endpoint", + "--os-application-credential-secret", + "--os-application-credential-id", + "--os-application-credential-name", + "--os-code-challenge-method", + "--os-access-token", + "--os-consumer-key", + "--os-consumer-secret", + "--os-idp-otp-key", + "--os-realm-name", + "--os-openid-client-id", + "--os-auth-type", + "--os-oauth2-endpoint", + "--os-oauth2-client-id", + "--os-oauth2-client-secret", + "--os-device-authorization-endpoint", + "--os-auth-methods", + "--os-user", + "--os-passcode", ] # These are global arguments that the user cannot pass but that we cannot remove because # we use them in the code. REJECT_GLOBAL_ARGS: list[str] = [ - "--os-auth-url", "--os-token", "--insecure", "--os-cacert", + "--os-auth-url", + "--os-token", + "--insecure", + "--os-cacert", ] SHELL = None @@ -90,12 +126,13 @@ ########## # METHODS AND CLASSES CALLED FROM main.py + def initialize(mcp_osp: FastMCP): global ALLOWED_COMMANDS, OSC_PARAMS - mcp_osp.add_tool(openstack_cli_mcp_tool, - name="openstack-cli", - title="OpenStack Client MCP Tool") + mcp_osp.add_tool( + openstack_cli_mcp_tool, name="openstack-cli", title="OpenStack Client MCP Tool" + ) if settings.CONFIG.openstack.ca_cert: OSC_PARAMS.extend(["--os-cacert", settings.CONFIG.openstack.ca_cert]) @@ -109,6 +146,7 @@ def initialize(mcp_osp: FastMCP): ########## # MCP TOOLS AND SUPPORTING METHODS + def _clean_response(response: str) -> str: """Clear the response to remove 0x00 characters at the start.""" return response.lstrip("\x00") @@ -159,10 +197,7 @@ async def openstack_cli_mcp_tool(command_str: str, ctx: Context) -> str: SHELL = MyOpenStackShell() # Build the command arguments list for the openstack command - mcp_argv = ( - OSC_PARAMS + - get_osp_credentials_args(ctx) - ) + mcp_argv = OSC_PARAMS + get_osp_credentials_args(ctx) user_argv = split_command(command_str, ctx) ret_value, stdout, stderr = await SHELL.run(mcp_argv, user_argv) @@ -174,7 +209,9 @@ async def openstack_cli_mcp_tool(command_str: str, ctx: Context) -> str: } if ret_value: - raise ToolError("openstack failed with error code {}: {}".format(ret_value, result)) + raise ToolError( + "openstack failed with error code {}: {}".format(ret_value, result) + ) return stdout or stderr @@ -188,6 +225,7 @@ class MyOpenStackShell(osc_shell.OpenStackShell): Also ensures that plugins and commands are loaded only once. """ + # Class variables shared by all instances initialized: bool = False # TODO: Figure out why we need to reload everytime otherwise the commands dissapear and we fail @@ -198,7 +236,7 @@ def __init__( self, description: str | None = None, version: str | None = None, - interactive_app_factory: type['interactive.InteractiveApp'] | None = None, + interactive_app_factory: type["interactive.InteractiveApp"] | None = None, deferred_help: Optional[bool] = None, ) -> None: stderr: io.StringIO = io.StringIO() @@ -207,19 +245,18 @@ def __init__( description = description or osc_shell.__doc__.strip() version = version or osc_shell.openstackclient.__version__ # Our custom command manager blocks commands that are not allowed - command_manager = MyCommandManager('openstack.cli', - stderr=stderr) + command_manager = MyCommandManager("openstack.cli", stderr=stderr) deferred_help = True if deferred_help is None else deferred_help super(osc_shell.OpenStackShell, self).__init__( - description=description, - version=version, - command_manager=command_manager, - stdin=None, - stdout=stdout, - stderr=stderr, - interactive_app_factory=interactive_app_factory, - deferred_help=deferred_help, + description=description, + version=version, + command_manager=command_manager, + stdin=None, + stdout=stdout, + stderr=stderr, + interactive_app_factory=interactive_app_factory, + deferred_help=deferred_help, ) self.NAME = "openstack" @@ -231,7 +268,7 @@ def __init__( # ignore warnings from openstacksdk since our users can't do anything # about them - osc_shell.warnings.filterwarnings('ignore', module='openstack') + osc_shell.warnings.filterwarnings("ignore", module="openstack") self.lock = asyncio.Lock() @@ -246,10 +283,9 @@ def configure_logging(self) -> None: # We don't use self.CONSOLE_MESSAGE_FORMAT so we don't include the python module in the description: formatter = logging.Formatter("%(levelname)s %(message)s") console.setFormatter(formatter) - self.LOG = logging.getLogger('cliff.app') + self.LOG = logging.getLogger("cliff.app") self.LOG.addHandler(console) - # TODO: Figure out why we need to reload everytime otherwise the commands dissapear and we fail def _load_plugins(self) -> None: """Only load plugins once.""" @@ -293,7 +329,9 @@ def _clean_stds(self) -> None: self.stderr.seek(0) self.stderr.truncate(0) - async def _initialize_parser(self, mcp_argv: list[str], user_argv: list[str]) -> None: + async def _initialize_parser( + self, mcp_argv: list[str], user_argv: list[str] + ) -> None: if self.initialized: return @@ -322,7 +360,9 @@ async def _initialize_global_args(self, user_argv: list[str]) -> None: delete_global_args.remove(option) if delete_global_args: - logger.warning(f"The following global arguments were not removed: {delete_global_args}") + logger.warning( + f"The following global arguments were not removed: {delete_global_args}" + ) async def _initialize_api_versions(self, mcp_argv: list[str]) -> None: """Initialize the api_version dictionary with the latest API version for each service. @@ -336,7 +376,9 @@ async def _initialize_api_versions(self, mcp_argv: list[str]) -> None: # Run in this process to later on share the loaded plugins and commands with command runs response, stdout, stderr = self._do_run(mcp_argv + versions_varg) if response: - raise ToolError(f"Failed to get API versions ({response}):\n{stdout}\n{stderr}") + raise ToolError( + f"Failed to get API versions ({response}):\n{stdout}\n{stderr}" + ) # For some reason stdout has 0x00 characters at the start, clean it api_versions = json.loads(_clean_response(stdout)) @@ -346,10 +388,15 @@ async def _initialize_api_versions(self, mcp_argv: list[str]) -> None: # We only care about the latest API version if version_info["Status"] == "CURRENT": # Some services reportt microversions, others only report the version - arg_name = self._get_version_arg_name_from_service_type(version_info["Service Type"]) + arg_name = self._get_version_arg_name_from_service_type( + version_info["Service Type"] + ) version = version_info["Max Microversion"] or version_info["Version"] # Keystone is weird, it reports 3.14 but doesn't accept it :-( - if arg_name in ("os_identity_api_version", "os_key_manager_api_version"): + if arg_name in ( + "os_identity_api_version", + "os_key_manager_api_version", + ): version = version.split(".")[0] version_defaults[arg_name] = version @@ -363,16 +410,20 @@ def _do_run(self, cmd: list[str]) -> tuple[int, str, str]: try: return_code = super().run(cmd) except (SystemExit, Exception) as e: - return_code = getattr(e, 'code', 1) - msg = getattr(e, 'msg', str(e)) - logger.debug(f"Failure running command: {cmd} with code: {return_code} and message: {msg}") + return_code = getattr(e, "code", 1) + msg = getattr(e, "msg", str(e)) + logger.debug( + f"Failure running command: {cmd} with code: {return_code} and message: {msg}" + ) finally: stdout = self.stdout.getvalue() stderr = self.stderr.getvalue() self._clean_stds() return return_code, stdout, stderr - async def run(self, mcp_argv: list[str], user_argv: list[str]) -> tuple[int, str, str]: + async def run( + self, mcp_argv: list[str], user_argv: list[str] + ) -> tuple[int, str, str]: """Run the OpenStack shell. Ensures that the API versions are initialized to the latest version for each service. @@ -385,9 +436,13 @@ async def run(self, mcp_argv: list[str], user_argv: list[str]) -> tuple[int, str await self._initialize_parser(mcp_argv, user_argv) utils.reject_arguments(user_argv, REJECT_GLOBAL_ARGS) # Run in a separate process to allow concurrency - return await utils.EXECUTOR.run_function(run_shell_cmd, mcp_argv + user_argv) + return await utils.EXECUTOR.run_function( + run_shell_cmd, mcp_argv + user_argv + ) except SystemExit as e: - raise ToolError(f"OpenStack failed {e.code}: {self.stdout.getvalue() or self.stderr.getvalue()}") + raise ToolError( + f"OpenStack failed {e.code}: {self.stdout.getvalue() or self.stderr.getvalue()}" + ) def run_shell_cmd(cmd: list[str]) -> tuple[int, str, str]: @@ -414,16 +469,26 @@ def get_osp_credentials_args(ctx: Context) -> list[str]: headers = ctx.request_context.request.headers logger.debug(f"Headers: {headers}") - token_header = utils.strip_bearer_prefix(headers.get('OS_TOKEN', '')) - url_header = headers.get('OS_URL') + token_header = utils.strip_bearer_prefix(headers.get("OS_TOKEN", "")) + url_header = headers.get("OS_URL") if token_header and url_header: - logger.debug(f"Using token and URL from request headers for credentials: {url_header}") + logger.debug( + f"Using token and URL from request headers for credentials: {url_header}" + ) return ["--os-token", token_header, "--os-url", url_header] # Check that we actually have the credential files in a known location - for config_dir in ["./", os.path.expanduser("~/.config/openstack"), "/etc/openstack"]: - if os.path.exists(os.path.join(config_dir, "clouds.yaml")) and os.path.exists(os.path.join(config_dir, "secure.yaml")): - logger.debug(f"Using clouds.yaml and secure.yaml from {config_dir} for credentials") + for config_dir in [ + "./", + os.path.expanduser("~/.config/openstack"), + "/etc/openstack", + ]: + if os.path.exists(os.path.join(config_dir, "clouds.yaml")) and os.path.exists( + os.path.join(config_dir, "secure.yaml") + ): + logger.debug( + f"Using clouds.yaml and secure.yaml from {config_dir} for credentials" + ) return [] raise ToolError("Missing OpenStack credentials") @@ -487,33 +552,37 @@ def osp_list_commands(verbs: set[str]) -> tuple[list[str], list[str]]: # since we don't have to check the command on each request. class RejectedEntryPoint(EntryPoint): """Entry point that rejects the request.""" + # Parent is inmutable, so we have to define our additiona slots and then # bypass the protections on the immutable base to set additional # attributes using the __setattr__ method. - __slots__ = ('stderr',) + __slots__ = ("stderr",) def __init__(self, name, value, group, stderr: io.StringIO): super().__init__(name, value, group) # Bypass protections on the immutable base to set additional attributes - object.__setattr__(self, 'stderr', stderr) + object.__setattr__(self, "stderr", stderr) def load(self) -> Any: """Load the entrypoint command replacing the action.""" # Raise it on load instead of take_action to avoid concatenating exceptions (don't know why it happens) - self.stderr.write(f"Command {self.name} is currently blocked for LLM use as it could modify the deployment.") + self.stderr.write( + f"Command {self.name} is currently blocked for LLM use as it could modify the deployment." + ) raise SystemError(3) def __repr__(self): return ( - f'RejectedEntryPoint(name={self.name!r}, value={self.value!r}, ' - f'group={self.group!r})' + f"RejectedEntryPoint(name={self.name!r}, value={self.value!r}, " + f"group={self.group!r})" ) + class MyCommandManager(osc_shell.commandmanager.CommandManager): """Custom command manager to replace entry points for commands that are not allowed.""" def __init__(self, *args, **kwargs): - self.stderr: Optional[io.StringIO] = kwargs.pop('stderr', None) + self.stderr: Optional[io.StringIO] = kwargs.pop("stderr", None) if not self.stderr: raise ToolError("stderr is required to initialize the command manager") super().__init__(*args, **kwargs) @@ -529,7 +598,9 @@ def load_commands(self, namespace: str) -> None: for command, ep in self.commands.items(): # Check agains EntryPoint instead of not being RejectedEntryPoint # because there's also EntryPointWrapper for commands such as help - if isinstance(ep, EntryPoint) and not self._is_command_allowed(command.split()): + if isinstance(ep, EntryPoint) and not self._is_command_allowed( + command.split() + ): # Using `self.commands.pop(command)` would be simpler, but wouldn't let us differentiate # between blocked and wrong commands entry_point = self.commands[command] @@ -537,10 +608,11 @@ def load_commands(self, namespace: str) -> None: name=entry_point.name, value=entry_point.value, group=entry_point.group, - stderr=self.stderr) + stderr=self.stderr, + ) def _is_command_allowed(self, argv: list[str]) -> bool: if settings.CONFIG.openstack.allow_write: return True - user_cmd = '_'.join(argv) + "_" + user_cmd = "_".join(argv) + "_" return any(user_cmd.startswith(cmd) for cmd in ALLOWED_COMMANDS) diff --git a/src/rhos_ls_mcps/settings.py b/src/rhos_ls_mcps/settings.py index 37d8cd5..86eefa3 100644 --- a/src/rhos_ls_mcps/settings.py +++ b/src/rhos_ls_mcps/settings.py @@ -11,39 +11,74 @@ logger = logging.getLogger(__name__) + class OpenStackSettings(BaseSettings): enabled: bool = Field(default=True, description="Enable OpenStack MCP tools") - allow_write: bool = Field(default=False, description="Allow write operations (default: false)") - ca_cert: Optional[str] = Field(default=None, description="CA certificate bundle file (Env: OS_CACERT)") - insecure: bool = Field(default=False, description="Allow insecure SSL connections (Env: OS_INSECURE)") + allow_write: bool = Field( + default=False, description="Allow write operations (default: false)" + ) + ca_cert: Optional[str] = Field( + default=None, description="CA certificate bundle file (Env: OS_CACERT)" + ) + insecure: bool = Field( + default=False, description="Allow insecure SSL connections (Env: OS_INSECURE)" + ) class OpenShiftSettings(BaseSettings): enabled: bool = Field(default=True, description="Enable OpenShift MCP tools") - allow_write: bool = Field(default=False, description="Allow write operations (default: false)") + allow_write: bool = Field( + default=False, description="Allow write operations (default: false)" + ) insecure: bool = Field(default=False, description="Allow insecure SSL connections") - allowed_commands: list[str] = Field(default=oc_defaults.DEFAULT_ALLOWED_COMMANDS, description="Allowed commands") - blocked_commands: list[str] = Field(default=oc_defaults.DEFAULT_BLOCKED_COMMANDS, description="Explicitly blocked commands") + allowed_commands: list[str] = Field( + default=oc_defaults.DEFAULT_ALLOWED_COMMANDS, description="Allowed commands" + ) + blocked_commands: list[str] = Field( + default=oc_defaults.DEFAULT_BLOCKED_COMMANDS, + description="Explicitly blocked commands", + ) class TransportSecuritySettings(BaseSettings): - token: Optional[str] = Field(default=os.environ.get("MCP_SECURITY_TOKEN"), description="Token to use for basic authentication (Env: MCP_SECURITY_TOKEN)") - enable_dns_rebinding_protection: bool = Field(default=False, description="Enable DNS rebinding protection") + token: Optional[str] = Field( + default=os.environ.get("MCP_SECURITY_TOKEN"), + description="Token to use for basic authentication (Env: MCP_SECURITY_TOKEN)", + ) + enable_dns_rebinding_protection: bool = Field( + default=False, description="Enable DNS rebinding protection" + ) allowed_hosts: list[str] = Field(default=["*:*"], description="Allowed hosts") - allowed_origins: list[str] = Field(default=["http://*:*"], description="Allowed origins") + allowed_origins: list[str] = Field( + default=["http://*:*"], description="Allowed origins" + ) class Settings(BaseSettings): - ip: str = Field(default="0.0.0.0", description="IP address to bind to") + ip: str = Field(default="127.0.0.1", description="IP address to bind to") port: int = Field(default=8080, description="Port to bind to") debug: bool = Field(default=False, description="Enable debug logging") workers: int = Field(default=1, description="Number of workers to use") - processes_pool_size: int = Field(default=10, description="Process pool size for each worker") - log_format: str = Field(default="%(asctime)s.%(msecs)03d %(process)d \033[32m%(levelname)s:\033[0m [%(request_id)s|%(client_id)s] %(name)s %(message)s", description="Log format") - uvicorn_log_format: str = Field(default="%(asctime)s.%(msecs)03d %(process)d \033[32m%(levelname)s:\033[0m [-|-] %(name)s %(message)s", description="Uvicorn log format") - openstack: OpenStackSettings = Field(default=OpenStackSettings(), description="OpenStack settings") - openshift: OpenShiftSettings = Field(default=OpenShiftSettings(), description="OpenShift settings") - mcp_transport_security: TransportSecuritySettings = Field(default=TransportSecuritySettings(), description="Transport security settings") + processes_pool_size: int = Field( + default=10, description="Process pool size for each worker" + ) + log_format: str = Field( + default="%(asctime)s.%(msecs)03d %(process)d \033[32m%(levelname)s:\033[0m [%(request_id)s|%(client_id)s] %(name)s %(message)s", + description="Log format", + ) + uvicorn_log_format: str = Field( + default="%(asctime)s.%(msecs)03d %(process)d \033[32m%(levelname)s:\033[0m [-|-] %(name)s %(message)s", + description="Uvicorn log format", + ) + openstack: OpenStackSettings = Field( + default=OpenStackSettings(), description="OpenStack settings" + ) + openshift: OpenShiftSettings = Field( + default=OpenShiftSettings(), description="OpenShift settings" + ) + mcp_transport_security: TransportSecuritySettings = Field( + default=TransportSecuritySettings(), description="Transport security settings" + ) def load_config(): diff --git a/src/rhos_ls_mcps/utils.py b/src/rhos_ls_mcps/utils.py index 5641b78..4a8845d 100644 --- a/src/rhos_ls_mcps/utils.py +++ b/src/rhos_ls_mcps/utils.py @@ -14,15 +14,16 @@ class ProcessPool: def __init__(self, pool_size: int): self.pool_size = pool_size - self.pool = ProcessPoolExecutor(max_workers=pool_size, mp_context=multiprocessing.get_context('fork')) + self.pool = ProcessPoolExecutor( + max_workers=pool_size, mp_context=multiprocessing.get_context("fork") + ) self.loop = asyncio.get_running_loop() # To limit total number of concurrent commands: run_function + run_command self.semaphore = asyncio.Semaphore(pool_size) async def run_function(self, func: Callable[..., Any], *args: Any) -> Any: async with self.semaphore: - result = await self.loop.run_in_executor( - self.pool, func, *args) + result = await self.loop.run_in_executor(self.pool, func, *args) return result async def run_command(self, cmd: list[str]) -> tuple[int, str, str]: @@ -54,9 +55,10 @@ def reject_arguments(user_argv: list[str], reject_args: list[str]) -> None: if user_arg.strip().startswith(reject_arg): raise ToolError(f"Global argument {user_arg} is not allowed") + def strip_bearer_prefix(header: str) -> str: """Auxiliary function that removes the 'Bearer' prefix from OAuth header""" - bearer, _, token = header.partition(' ') + bearer, _, token = header.partition(" ") if bearer.lower() != "bearer": return header diff --git a/uv.lock b/uv.lock index d20136c..04ef69b 100644 --- a/uv.lock +++ b/uv.lock @@ -159,6 +159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -297,6 +306,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "docker" version = "7.1.0" @@ -342,6 +360,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/ac/e5d886f892666d2d1e5cb8c1a41146e1d79ae8896477b1153a21711d3b44/fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7", size = 18702, upload-time = "2025-08-11T10:19:35.716Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -453,6 +480,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.16" @@ -689,6 +725,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -948,6 +993,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "prettytable" version = "0.7.2" @@ -1227,6 +1288,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/95/ba594de62553b094ddc471f20badcba993f806635d113f8112e86be12e12/python_designateclient-6.4.0-py3-none-any.whl", hash = "sha256:ee9c87a6d5fd5ebe04d2e89650f9994836e2f4795d233e12281a58558e870b05", size = 96287, upload-time = "2026-02-24T14:35:10.906Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1730,6 +1804,13 @@ dependencies = [ { name = "stevedore" }, ] +[package.dev-dependencies] +dev = [ + { name = "cliff" }, + { name = "pre-commit" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "kubernetes", specifier = ">=35.0.0" }, @@ -1740,6 +1821,13 @@ requires-dist = [ { name = "stevedore", specifier = ">=5.6.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "cliff", specifier = ">=4.14.0" }, + { name = "pre-commit", specifier = ">=4.6.0" }, + { name = "ruff", specifier = ">=0.15.14" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1788,6 +1876,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, ] +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + [[package]] name = "semantic-version" version = "2.10.0" @@ -1915,6 +2028,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + [[package]] name = "warlock" version = "2.1.0"