diff --git a/kcidev/api.py b/kcidev/api.py index 075c731b..3d354afd 100644 --- a/kcidev/api.py +++ b/kcidev/api.py @@ -46,6 +46,12 @@ resolve_kcidb_config, submit_to_kcidb, ) +from kcidev.libs.maestro_common import ( + maestro_get_node, + maestro_get_nodes, + send_checkout_full, + send_jobretry, +) from kcidev.main import get_cli @@ -88,6 +94,8 @@ def _as_library_error(action, func, *args, **kwargs): raise KciDevError(f"{action}: {exc.message}") from exc except click.Abort as exc: raise KciDevError(action) from exc + except SystemExit as exc: + raise KciDevError(action) from exc class KernelCIClient: @@ -470,3 +478,85 @@ def get_tree_report( max_age_in_hours, min_age_in_hours, ) + + def _instance_setting(self, key): + return ((self.cfg or {}).get(self.instance) or {}).get(key) + + def _require_maestro_setting(self, value, key): + if not value: + raise KciDevError( + f"No Maestro {key} configured; pass it explicitly or set it " + "in the instance config" + ) + return value + + def get_node(self, node_id, api_url=None): + """Fetch a single Maestro node by id.""" + url = self._require_maestro_setting( + api_url or self._instance_setting("api"), "api URL" + ) + return _as_library_error( + "Maestro node request failed", maestro_get_node, url, node_id + ) + + def get_nodes(self, limit=50, offset=0, filters=None, api_url=None): + """List Maestro nodes with 'field=value' filters and pagination.""" + url = self._require_maestro_setting( + api_url or self._instance_setting("api"), "api URL" + ) + return _as_library_error( + "Maestro nodes request failed", + maestro_get_nodes, + url, + limit, + offset, + filters or [], + True, + ) + + def retry_job(self, node_id, pipeline_url=None, token=None): + """Retry a failed or incomplete job by Maestro node id.""" + url = self._require_maestro_setting( + pipeline_url or self._instance_setting("pipeline"), "pipeline URL" + ) + token = self._require_maestro_setting( + token or self._instance_setting("token"), "token" + ) + result = _as_library_error( + "Maestro job retry failed", send_jobretry, url, node_id, token + ) + if result is None: + raise KciDevError(f"Maestro job retry failed for node {node_id}") + return result + + def trigger_checkout( + self, + giturl, + branch, + commit, + job_filter, + platform_filter=None, + pipeline_url=None, + token=None, + ): + """Trigger a pipeline checkout of a tree/branch/commit with a job filter.""" + url = self._require_maestro_setting( + pipeline_url or self._instance_setting("pipeline"), "pipeline URL" + ) + token = self._require_maestro_setting( + token or self._instance_setting("token"), "token" + ) + kwargs = { + "giturl": giturl, + "branch": branch, + "commit": commit, + "job_filter": job_filter, + } + if platform_filter: + kwargs["platform_filter"] = platform_filter + result = _as_library_error( + "Maestro checkout failed", send_checkout_full, url, token, **kwargs + ) + if result is None: + raise KciDevError(f"Maestro checkout failed for {giturl} at {commit}") + return result diff --git a/kcidev/libs/maestro_common.py b/kcidev/libs/maestro_common.py index 374b86b8..4479362a 100644 --- a/kcidev/libs/maestro_common.py +++ b/kcidev/libs/maestro_common.py @@ -326,3 +326,76 @@ def maestro_watch_jobs(baseurl, token, treeid, job_filter, test): kci_msg_nonl(f"\rRunning job...") previous_nodes = nodes time.sleep(30) + + +def send_jobretry(baseurl, jobid, token): + url = baseurl + "api/jobretry" + headers = { + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"{token}", + } + data = {"nodeid": jobid} + jdata = json.dumps(data) + + logging.info(f"Sending job retry request for node: {jobid}") + logging.debug(f"Retry URL: {url}") + maestro_print_api_call(url, data) + + try: + logging.debug("Sending POST request for job retry") + response = kcidev_session.post(url, headers=headers, data=jdata) + logging.debug(f"Response status: {response.status_code}") + except requests.exceptions.RequestException as e: + logging.error(f"Failed to send job retry request: {e}") + kci_err(f"API connection error: {e}") + return + + if response.status_code != 200: + logging.error(f"Job retry failed with status {response.status_code}") + maestro_api_error(response) + return None + + result = response.json() + logging.info(f"Job retry request successful: {result.get('message', 'No message')}") + return result + + +def send_checkout_full(baseurl, token, **kwargs): + url = baseurl + "api/checkout" + headers = { + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"{token}", + } + data = { + "url": kwargs["giturl"], + "branch": kwargs["branch"], + "commit": kwargs["commit"], + "jobfilter": kwargs["job_filter"], + } + if "platform_filter" in kwargs: + data["platformfilter"] = kwargs["platform_filter"] + + logging.info( + f"Sending checkout request for {kwargs['giturl']} branch {kwargs['branch']} commit {kwargs['commit']}" + ) + logging.debug(f"Checkout data: {json.dumps(data, indent=2)}") + + jdata = json.dumps(data) + maestro_print_api_call(url, data) + try: + logging.debug(f"POST request to: {url}") + response = kcidev_session.post(url, headers=headers, data=jdata, timeout=30) + logging.debug(f"Checkout response status: {response.status_code}") + except requests.exceptions.RequestException as e: + logging.error(f"Checkout API request failed: {e}") + kci_err(f"API connection error: {e}") + return None + + if response.status_code != 200: + logging.error(f"Checkout failed with status {response.status_code}") + maestro_api_error(response) + return None + + result = response.json() + logging.info(f"Checkout successful - tree ID: {result.get('treeid', 'unknown')}") + return result diff --git a/kcidev/mcp/__init__.py b/kcidev/mcp/__init__.py index 255b2d2d..85e30a45 100644 --- a/kcidev/mcp/__init__.py +++ b/kcidev/mcp/__init__.py @@ -3,6 +3,7 @@ from mcp.server.fastmcp import FastMCP +from kcidev.api import KernelCIClient from kcidev.libs.common import kcidev_version from kcidev.mcp import tools_dashboard, tools_maestro @@ -20,8 +21,9 @@ def create_server(cfg=None, instance=None, host="127.0.0.1", port=8000): server = FastMCP("kci-dev", instructions=SERVER_INSTRUCTIONS, host=host, port=port) server._mcp_server.version = kcidev_version tools_dashboard.register_tools(server) + client = KernelCIClient(cfg=cfg, instance=instance) icfg = (cfg or {}).get(instance) or {} tools_maestro.register_tools( - server, icfg.get("api"), icfg.get("pipeline"), icfg.get("token") + server, client, icfg.get("api"), icfg.get("pipeline"), icfg.get("token") ) return server diff --git a/kcidev/mcp/tools_maestro.py b/kcidev/mcp/tools_maestro.py index acb4b8e7..b727d960 100644 --- a/kcidev/mcp/tools_maestro.py +++ b/kcidev/mcp/tools_maestro.py @@ -1,37 +1,10 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from kcidev.libs.maestro_common import maestro_get_node, maestro_get_nodes -from kcidev.mcp.errors import ToolExecutionError, tool_errors -from kcidev.subcommands.checkout import send_checkout_full -from kcidev.subcommands.testretry import send_jobretry +from kcidev.mcp.errors import tool_errors -def _retry_job(pipeline_url, token, node_id): - result = send_jobretry(pipeline_url, node_id, token) - if result is None: - raise ToolExecutionError(f"Maestro job retry failed for node {node_id}") - return result - - -def _trigger_checkout( - pipeline_url, token, giturl, branch, commit, job_filter, platform_filter -): - kwargs = { - "giturl": giturl, - "branch": branch, - "commit": commit, - "job_filter": job_filter, - } - if platform_filter: - kwargs["platform_filter"] = platform_filter - result = send_checkout_full(pipeline_url, token, **kwargs) - if result is None: - raise ToolExecutionError(f"Maestro checkout failed for {giturl} at {commit}") - return result - - -def register_tools(server, api_url, pipeline_url, token): +def register_tools(server, client, api_url, pipeline_url, token): from mcp.types import ToolAnnotations read_only = ToolAnnotations(readOnlyHint=True) @@ -49,7 +22,7 @@ def get_node(node_id: str): this to poll a job started with trigger_checkout or retry_job. Node ids are 24-character hex strings. """ - return maestro_get_node(api_url, node_id) + return client.get_node(node_id) @server.tool(annotations=read_only) @tool_errors @@ -67,7 +40,7 @@ def list_nodes( than paginating from the start. Use limit and offset to paginate within the window. """ - return maestro_get_nodes(api_url, limit, offset, filters or [], True) + return client.get_nodes(limit=limit, offset=offset, filters=filters or []) if pipeline_url and token: @@ -79,7 +52,7 @@ def retry_job(node_id: str): Creates a new job for the given Maestro node id. Use list_nodes or the dashboard tools to find the node id of the failed job. """ - return _retry_job(pipeline_url, token, node_id) + return client.retry_job(node_id) @server.tool(annotations=action) @tool_errors @@ -98,6 +71,6 @@ def trigger_checkout( platform_filter. Returns a tree id; poll progress with list_nodes using 'treeid='. """ - return _trigger_checkout( - pipeline_url, token, giturl, branch, commit, job_filter, platform_filter + return client.trigger_checkout( + giturl, branch, commit, job_filter, platform_filter ) diff --git a/kcidev/subcommands/checkout.py b/kcidev/subcommands/checkout.py index 92bae0cc..436822d9 100644 --- a/kcidev/subcommands/checkout.py +++ b/kcidev/subcommands/checkout.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- import datetime -import json import logging import re import subprocess @@ -10,54 +9,12 @@ import time import click -import requests from git import Repo from kcidev.libs.common import * from kcidev.libs.maestro_common import * -def send_checkout_full(baseurl, token, **kwargs): - url = baseurl + "api/checkout" - headers = { - "Content-Type": "application/json; charset=utf-8", - "Authorization": f"{token}", - } - data = { - "url": kwargs["giturl"], - "branch": kwargs["branch"], - "commit": kwargs["commit"], - "jobfilter": kwargs["job_filter"], - } - if "platform_filter" in kwargs: - data["platformfilter"] = kwargs["platform_filter"] - - logging.info( - f"Sending checkout request for {kwargs['giturl']} branch {kwargs['branch']} commit {kwargs['commit']}" - ) - logging.debug(f"Checkout data: {json.dumps(data, indent=2)}") - - jdata = json.dumps(data) - maestro_print_api_call(url, data) - try: - logging.debug(f"POST request to: {url}") - response = kcidev_session.post(url, headers=headers, data=jdata, timeout=30) - logging.debug(f"Checkout response status: {response.status_code}") - except requests.exceptions.RequestException as e: - logging.error(f"Checkout API request failed: {e}") - kci_err(f"API connection error: {e}") - return None - - if response.status_code != 200: - logging.error(f"Checkout failed with status {response.status_code}") - maestro_api_error(response) - return None - - result = response.json() - logging.info(f"Checkout successful - tree ID: {result.get('treeid', 'unknown')}") - return result - - def retrieve_tot_commit(repourl, branch): """ Retrieve the latest commit on a branch diff --git a/kcidev/subcommands/testretry.py b/kcidev/subcommands/testretry.py index dc152a79..3a478561 100644 --- a/kcidev/subcommands/testretry.py +++ b/kcidev/subcommands/testretry.py @@ -1,49 +1,15 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import json import logging import click -import requests from git import Repo from kcidev.libs.common import * from kcidev.libs.maestro_common import * -def send_jobretry(baseurl, jobid, token): - url = baseurl + "api/jobretry" - headers = { - "Content-Type": "application/json; charset=utf-8", - "Authorization": f"{token}", - } - data = {"nodeid": jobid} - jdata = json.dumps(data) - - logging.info(f"Sending job retry request for node: {jobid}") - logging.debug(f"Retry URL: {url}") - maestro_print_api_call(url, data) - - try: - logging.debug("Sending POST request for job retry") - response = kcidev_session.post(url, headers=headers, data=jdata) - logging.debug(f"Response status: {response.status_code}") - except requests.exceptions.RequestException as e: - logging.error(f"Failed to send job retry request: {e}") - kci_err(f"API connection error: {e}") - return - - if response.status_code != 200: - logging.error(f"Job retry failed with status {response.status_code}") - maestro_api_error(response) - return None - - result = response.json() - logging.info(f"Job retry request successful: {result.get('message', 'No message')}") - return result - - @click.command( help="""Retry a failed or incomplete test job on KernelCI. diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..89d7ecb0 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,118 @@ +import json +from unittest.mock import Mock + +import pytest +import requests + +from kcidev import KciDevError, KernelCIClient +from kcidev.libs import maestro_common + +CFG = { + "test": { + "api": "https://api.example.org/", + "pipeline": "https://pipeline.example.org/", + "token": "secret", + } +} + + +def _client(): + return KernelCIClient(cfg=CFG, instance="test") + + +def test_get_node_uses_configured_api_url(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"id": "n1", "state": "done"} + get = Mock(return_value=response) + monkeypatch.setattr(maestro_common.kcidev_session, "get", get) + assert _client().get_node("n1") == {"id": "n1", "state": "done"} + assert get.call_args[0][0] == "https://api.example.org/latest/node/n1" + + +def test_get_node_without_api_url_raises(): + with pytest.raises(KciDevError, match="api URL"): + KernelCIClient().get_node("n1") + + +def test_get_node_http_error_raises_library_error(monkeypatch): + response = Mock(status_code=404) + response.json.return_value = {"detail": "Node not found"} + response.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=response + ) + monkeypatch.setattr( + maestro_common.kcidev_session, "get", Mock(return_value=response) + ) + with pytest.raises(KciDevError, match="Maestro node request failed"): + _client().get_node("n1") + + +def test_get_nodes_passes_pagination_and_filters(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = [] + get = Mock(return_value=response) + monkeypatch.setattr(maestro_common.kcidev_session, "get", get) + _client().get_nodes(limit=5, offset=10, filters=["name=checkout"]) + url = get.call_args[0][0] + assert "limit=5" in url + assert "offset=10" in url + assert "name=checkout" in url + + +def test_retry_job_posts_with_token(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"message": "OK"} + post = Mock(return_value=response) + monkeypatch.setattr(maestro_common.kcidev_session, "post", post) + assert _client().retry_job("n1") == {"message": "OK"} + assert post.call_args[0][0] == "https://pipeline.example.org/api/jobretry" + assert post.call_args.kwargs["headers"]["Authorization"] == "secret" + + +def test_retry_job_failure_raises(monkeypatch): + post = Mock(side_effect=requests.exceptions.ConnectionError("no route")) + monkeypatch.setattr(maestro_common.kcidev_session, "post", post) + with pytest.raises(KciDevError, match="retry failed"): + _client().retry_job("n1") + + +def test_trigger_checkout_posts_payload(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"treeid": "t1"} + post = Mock(return_value=response) + monkeypatch.setattr(maestro_common.kcidev_session, "post", post) + result = _client().trigger_checkout( + "https://git.example.org/linux.git", "master", "deadbeef", ["baseline-arm64"] + ) + assert result == {"treeid": "t1"} + body = json.loads(post.call_args.kwargs["data"]) + assert body == { + "url": "https://git.example.org/linux.git", + "branch": "master", + "commit": "deadbeef", + "jobfilter": ["baseline-arm64"], + } + + +def test_trigger_checkout_includes_platform_filter(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"treeid": "t1"} + post = Mock(return_value=response) + monkeypatch.setattr(maestro_common.kcidev_session, "post", post) + _client().trigger_checkout( + "https://git.example.org/linux.git", + "master", + "deadbeef", + ["baseline-arm64"], + platform_filter=["qemu-arm64"], + ) + body = json.loads(post.call_args.kwargs["data"]) + assert body["platformfilter"] == ["qemu-arm64"] + + +def test_trigger_checkout_requires_token(): + cfg = {"test": {"pipeline": "https://pipeline.example.org/"}} + with pytest.raises(KciDevError, match="token"): + KernelCIClient(cfg=cfg, instance="test").trigger_checkout( + "https://git.example.org/linux.git", "master", "deadbeef", ["baseline"] + ) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 5e9b8ece..5168dd65 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -126,3 +126,30 @@ def test_server_reports_kcidev_version(): from kcidev.libs.common import kcidev_version assert create_server()._mcp_server.version == kcidev_version + + +def test_retry_job_calls_pipeline_with_token(monkeypatch): + from kcidev.libs import maestro_common + + response = Mock(status_code=200) + response.json.return_value = {"message": "OK"} + post = Mock(return_value=response) + monkeypatch.setattr(maestro_common.kcidev_session, "post", post) + + result = _call_tool(create_server(CFG, "test"), "retry_job", {"node_id": "n1"}) + assert result.isError is False + assert post.call_args[0][0] == "https://pipeline.example.org/api/jobretry" + assert post.call_args.kwargs["headers"]["Authorization"] == "secret" + + +def test_retry_job_failure_returns_tool_error(monkeypatch): + from kcidev.libs import maestro_common + + monkeypatch.setattr( + maestro_common.kcidev_session, + "post", + Mock(side_effect=requests.exceptions.ConnectionError("no route")), + ) + result = _call_tool(create_server(CFG, "test"), "retry_job", {"node_id": "n1"}) + assert result.isError is True + assert "retry failed" in result.content[0].text diff --git a/tests/test_mcp_tools_maestro.py b/tests/test_mcp_tools_maestro.py deleted file mode 100644 index 7c17f6c5..00000000 --- a/tests/test_mcp_tools_maestro.py +++ /dev/null @@ -1,74 +0,0 @@ -from unittest.mock import Mock - -import pytest - -pytest.importorskip("mcp") - -from kcidev.mcp import tools_maestro -from kcidev.mcp.errors import ToolExecutionError - - -def test_retry_job_returns_result(monkeypatch): - send = Mock(return_value={"message": "OK"}) - monkeypatch.setattr(tools_maestro, "send_jobretry", send) - result = tools_maestro._retry_job("https://pipeline/", "tok", "node1") - assert result == {"message": "OK"} - send.assert_called_once_with("https://pipeline/", "node1", "tok") - - -def test_retry_job_failure_raises(monkeypatch): - monkeypatch.setattr(tools_maestro, "send_jobretry", Mock(return_value=None)) - with pytest.raises(ToolExecutionError, match="node1"): - tools_maestro._retry_job("https://pipeline/", "tok", "node1") - - -def test_trigger_checkout_passes_kwargs(monkeypatch): - send = Mock(return_value={"treeid": "t1"}) - monkeypatch.setattr(tools_maestro, "send_checkout_full", send) - result = tools_maestro._trigger_checkout( - "https://pipeline/", - "tok", - "https://git.example.org/linux.git", - "master", - "deadbeef", - ["baseline-arm64"], - None, - ) - assert result == {"treeid": "t1"} - send.assert_called_once_with( - "https://pipeline/", - "tok", - giturl="https://git.example.org/linux.git", - branch="master", - commit="deadbeef", - job_filter=["baseline-arm64"], - ) - - -def test_trigger_checkout_includes_platform_filter(monkeypatch): - send = Mock(return_value={"treeid": "t1"}) - monkeypatch.setattr(tools_maestro, "send_checkout_full", send) - tools_maestro._trigger_checkout( - "https://pipeline/", - "tok", - "https://git.example.org/linux.git", - "master", - "deadbeef", - ["baseline-arm64"], - ["qemu-arm64"], - ) - assert send.call_args.kwargs["platform_filter"] == ["qemu-arm64"] - - -def test_trigger_checkout_failure_raises(monkeypatch): - monkeypatch.setattr(tools_maestro, "send_checkout_full", Mock(return_value=None)) - with pytest.raises(ToolExecutionError, match="deadbeef"): - tools_maestro._trigger_checkout( - "https://pipeline/", - "tok", - "https://git.example.org/linux.git", - "master", - "deadbeef", - ["baseline-arm64"], - None, - )