Skip to content

Commit 04d3ced

Browse files
committed
ci: add e2e tests
1 parent 8be7a8e commit 04d3ced

File tree

5 files changed

+227
-1
lines changed

5 files changed

+227
-1
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ testpaths = [
4343
"tests",
4444
"integration",
4545
]
46+
asyncio_mode = "auto"
47+
markers = [
48+
"e2e: marks tests as end-to-end tests",
49+
]

tests/e2e/data/test.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
apiVersion: v1
2+
kind: Pod
3+
metadata:
4+
name: my-pod
5+
spec:
6+
containers:
7+
- name: my-container
8+
image: nginx
9+
securityContext:
10+
privileged: true

tests/e2e/test_tools.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
from __future__ import annotations
2+
import os
3+
import json
4+
import pytest
5+
from typing import Callable, cast
6+
from fastmcp.client import Client
7+
from fastmcp.client.transports import StdioTransport
8+
9+
# Define a type for JSON-like objects to avoid using Any
10+
JsonValue = str | int | float | bool | None | dict[str, "JsonValue"] | list["JsonValue"]
11+
JsonObject = dict[str, JsonValue]
12+
13+
14+
# E2E tests for the Sysdig MCP Server tools.
15+
#
16+
# This script is designed to run in a CI/CD environment and requires the following prerequisites:
17+
# - Docker installed and running.
18+
# - The `sysdig-cli-scanner` binary installed and available in the system's PATH.
19+
# - The following environment variables set with valid Sysdig credentials:
20+
# - SYSDIG_MCP_API_SECURE_TOKEN
21+
# - SYSDIG_MCP_API_HOST
22+
#
23+
# The script will start the MCP server in a separate process, run a series of tests against it,
24+
# and then shut it down. If any of the tests fail, the script will exit with a non-zero status code.
25+
26+
27+
async def run_test(tool_name: str, tool_args: JsonObject, check: str | Callable[[JsonObject], None]):
28+
"""
29+
Runs a test by starting the MCP server, sending a request to it, and checking its stdout.
30+
"""
31+
transport = StdioTransport(
32+
"uv",
33+
["run", "sysdig-mcp-server"],
34+
env=dict(os.environ, **{"SYSDIG_MCP_LOGLEVEL": "DEBUG"}),
35+
)
36+
client = Client(transport)
37+
38+
async with client:
39+
result = await client.call_tool(tool_name, tool_args)
40+
41+
# Extract text content from the result
42+
output = ""
43+
if result.content:
44+
for content_block in result.content:
45+
output += getattr(content_block, "text", "")
46+
47+
print(f"--- STDOUT ---\n{output}")
48+
49+
if isinstance(check, str):
50+
assert check in output
51+
elif callable(check):
52+
try:
53+
json_output = cast(JsonObject, json.loads(output))
54+
check(json_output)
55+
except json.JSONDecodeError:
56+
pytest.fail(f"Output is not a valid JSON: {output}")
57+
58+
59+
@pytest.mark.e2e
60+
async def test_cli_scanner_tool_vulnerability_scan():
61+
"""
62+
Tests the CliScannerTool's vulnerability scan.
63+
"""
64+
def assert_vulns(output: JsonObject):
65+
assert output["exit_code"] == 0
66+
output_str = output.get("output", "")
67+
assert isinstance(output_str, str)
68+
assert "vulnerabilities found" in output_str
69+
70+
await run_test(
71+
"run_sysdig_cli_scanner",
72+
{"image": "ubuntu:18.04", "mode": "vulnerability", "standalone": True, "offline_analyser": True},
73+
assert_vulns,
74+
)
75+
76+
77+
@pytest.mark.e2e
78+
async def test_cli_scanner_tool_iac_scan():
79+
"""
80+
Tests the CliScannerTool's IaC scan.
81+
"""
82+
def assert_iac(output: JsonObject):
83+
assert output["exit_code"] == 0
84+
output_str = output.get("output", "")
85+
assert isinstance(output_str, str)
86+
assert "OK: no resources found" in output_str
87+
88+
await run_test(
89+
"run_sysdig_cli_scanner",
90+
{"path_to_scan": "tests/e2e/data/", "mode": "iac"},
91+
assert_iac,
92+
)
93+
94+
95+
@pytest.mark.e2e
96+
async def test_events_feed_tools_list_runtime_events():
97+
"""
98+
Tests the EventsFeedTools' list_runtime_events.
99+
"""
100+
def assert_events(output: JsonObject):
101+
assert output["status_code"] == 200
102+
results = output.get("results")
103+
assert isinstance(results, dict)
104+
assert isinstance(results.get("data"), list)
105+
assert isinstance(results.get("page"), dict)
106+
107+
await run_test("list_runtime_events", {"scope_hours": 1}, assert_events)
108+
109+
110+
@pytest.mark.e2e
111+
async def test_events_feed_tools_get_event_info():
112+
"""
113+
Tests the EventsFeedTools' get_event_info by first getting a valid event ID.
114+
"""
115+
event_id = None
116+
117+
def get_event_id(output: JsonObject):
118+
nonlocal event_id
119+
if output.get("results", {}).get("data"):
120+
event_id = output["results"]["data"][0].get("id")
121+
122+
await run_test("list_runtime_events", {"scope_hours": 24, "limit": 1}, get_event_id)
123+
124+
if not event_id:
125+
pytest.skip("No runtime events in the last 24 hours to test get_event_info.")
126+
127+
def assert_event_info(output: JsonObject):
128+
assert output["status_code"] == 200
129+
assert isinstance(output.get("results"), dict)
130+
assert output["results"].get("id") == event_id
131+
132+
await run_test("get_event_info", {"event_id": event_id}, assert_event_info)
133+
134+
135+
@pytest.mark.e2e
136+
async def test_events_feed_tools_get_event_process_tree():
137+
"""
138+
Tests the EventsFeedTools' get_event_process_tree by first getting a valid event ID.
139+
"""
140+
event_id = None
141+
142+
def get_event_id(output: JsonObject):
143+
nonlocal event_id
144+
if output.get("results", {}).get("data"):
145+
event_id = output["results"]["data"][0].get("id")
146+
147+
await run_test("list_runtime_events", {"scope_hours": 24, "limit": 1}, get_event_id)
148+
149+
if not event_id:
150+
pytest.skip("No runtime events in the last 24 hours to test get_event_process_tree.")
151+
152+
def assert_process_tree(output: JsonObject):
153+
assert isinstance(output.get("branches"), dict)
154+
assert isinstance(output.get("tree"), dict)
155+
assert isinstance(output.get("metadata"), dict)
156+
157+
await run_test("get_event_process_tree", {"event_id": event_id}, assert_process_tree)
158+
159+
160+
@pytest.mark.e2e
161+
async def test_sysql_tools_generate_and_run_sysql_query():
162+
"""
163+
Tests the SysQLTools' generate_and_run_sysql.
164+
"""
165+
def assert_sysql(output: JsonObject):
166+
assert output["status_code"] == 200
167+
results = output.get("results")
168+
assert isinstance(results, dict)
169+
assert isinstance(results.get("entities"), dict)
170+
assert isinstance(results.get("items"), list)
171+
172+
metadata = output.get("metadata")
173+
assert isinstance(metadata, dict)
174+
175+
metadata_kwargs = metadata.get("metadata_kwargs")
176+
assert isinstance(metadata_kwargs, dict)
177+
178+
sysql = metadata_kwargs.get("sysql")
179+
assert isinstance(sysql, str)
180+
assert "MATCH CloudResource AFFECTED_BY Vulnerability" in sysql
181+
182+
await run_test(
183+
"generate_and_run_sysql",
184+
{"question": "Match Cloud Resource affected by Critical Vulnerability"},
185+
assert_sysql,
186+
)
187+
188+
189+
@pytest.mark.e2e
190+
async def test_sysql_tools_run_sysql_query():
191+
"""
192+
Tests the SysQLTools' run_sysql.
193+
"""
194+
def assert_sysql(output: JsonObject):
195+
assert output["status_code"] == 200
196+
results = output.get("results")
197+
assert isinstance(results, dict)
198+
assert isinstance(results.get("entities"), dict)
199+
assert isinstance(results.get("items"), list)
200+
201+
metadata = output.get("metadata")
202+
assert isinstance(metadata, dict)
203+
204+
await run_test(
205+
"run_sysql",
206+
{"sysql_query": "MATCH CloudResource AFFECTED_BY Vulnerability"},
207+
assert_sysql,
208+
)

tools/cli_scanner/tool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ def run_sysdig_cli_scanner(
150150
# Run the command
151151
with open(tmp_result_file.name, "w") as output_file:
152152
result = subprocess.run(cmd, text=True, check=True, stdout=output_file, stderr=subprocess.PIPE)
153+
with open(tmp_result_file.name, "rt") as output_file:
153154
output_result = output_file.read()
154-
output_file.close()
155155
return {
156156
"exit_code": result.returncode,
157157
"output": output_result + result.stderr.strip(),

tools/sysql/tool.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ async def tool_run_sysql(self, ctx: Context, sysql_query: str) -> dict:
118118
if not sysql_query:
119119
raise ToolError("No SysQL query provided. Please provide a valid SysQL query string.")
120120

121+
# Ensure the query ends with a semicolon
122+
if not sysql_query.strip().endswith(";"):
123+
sysql_query += ";"
124+
121125
try:
122126
self.log.debug(f"Executing SysQL query: {sysql_query}")
123127
results = legacy_api_client.execute_sysql_query(sysql_query)

0 commit comments

Comments
 (0)