chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# HertzBeat 日志 MCP
|
||||
|
||||
该 MCP 服务基于 GreptimeDB 查询 HertzBeat 自身运行日志。使用前需要启用 GreptimeDB 日志写入。
|
||||
|
||||
## 结构化只读查询
|
||||
|
||||
服务只暴露 `query_logs`,不再接收或执行调用方提供的 SQL。服务端生成的查询固定为:
|
||||
|
||||
```sql
|
||||
SELECT timestamp, severity_text, body
|
||||
FROM hzb_logs
|
||||
WHERE <结构化过滤条件>
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT <1-100>
|
||||
```
|
||||
|
||||
`query_logs` 支持以下可选参数:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `severity` | 字符串 | `TRACE`、`DEBUG`、`INFO`、`WARN`、`ERROR` 或 `FATAL` |
|
||||
| `keyword` | 字符串 | 日志正文关键词,最长 256 个字符 |
|
||||
| `startTime` | 整数 | 开始时间,Unix 毫秒时间戳 |
|
||||
| `endTime` | 整数 | 结束时间,Unix 毫秒时间戳 |
|
||||
| `limit` | 整数 | 返回条数,默认为 20,范围为 1~100 |
|
||||
|
||||
调用示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"severity": "ERROR",
|
||||
"keyword": "connection refused",
|
||||
"startTime": 1783785600000,
|
||||
"endTime": 1783872000000,
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
当前 `hzb_logs` 表没有 `monitorId` 字段,因此本接口不提供无效的监控 ID 过滤。如果后续需要该能力,应先在 OpenTelemetry 日志写入链中定义并提取统一的监控 ID 字段。
|
||||
|
||||
## GreptimeDB 账号
|
||||
|
||||
服务支持通过 `greptime.username` 和 `greptime.password` 发送 HTTP Basic Authentication。用户名和密码必须同时配置;建议配合 HTTPS 或可信内网使用。
|
||||
|
||||
项目当前 Docker Compose 使用 GreptimeDB `v0.14.3`,该版本只提供身份认证,不能限制用户为只读权限。因此当前真正生效的安全边界是“删除原始 SQL参数并固定生成单条 `SELECT`”。使用 GreptimeDB 1.0 及以上版本时,应为该 MCP 配置独立的 `ro`/`readonly` 账号。
|
||||
|
||||
## Claude Desktop 集成(stdio)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hertzbeat-mcp": {
|
||||
"command": "java",
|
||||
"args": [
|
||||
"-Dspring.ai.mcp.server.stdio=true",
|
||||
"-Dspring.main.web-application-type=none",
|
||||
"-Dlogging.pattern.console=",
|
||||
"-jar",
|
||||
"${PATH}/hertzbeat-mcp-2.0-SNAPSHOT.jar"
|
||||
],
|
||||
"env": {
|
||||
"GREPTIME_URL": "http://${IP}:4000",
|
||||
"GREPTIME_DATABASE": "public",
|
||||
"GREPTIME_USERNAME": "${READ_ONLY_USERNAME}",
|
||||
"GREPTIME_PASSWORD": "${READ_ONLY_PASSWORD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不启用 GreptimeDB 认证时,可以省略 `GREPTIME_USERNAME` 和 `GREPTIME_PASSWORD`;不能只配置其中一个。
|
||||
|
||||
旧的 `getHertzbeatLog({"querySql": "..."})` Tool 已直接移除,不保留兼容入口,以免继续暴露任意 SQL执行能力。
|
||||
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
~ contributor license agreements. See the NOTICE file distributed with
|
||||
~ this work for additional information regarding copyright ownership.
|
||||
~ The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
~ (the "License"); you may not use this file except in compliance with
|
||||
~ the License. You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.apache.hertzbeat</groupId>
|
||||
<artifactId>hertzbeat</artifactId>
|
||||
<version>2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>hertzbeat-mcp</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<spring-ai.version>1.0.0-M6</spring-ai.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>3.4.2</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-mcp-server-webflux-spring-boot-starter</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
</dependency>
|
||||
<!-- json path parser-->
|
||||
<dependency>
|
||||
<groupId>com.jayway.jsonpath</groupId>
|
||||
<artifactId>json-path</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven-compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
<compilerArgs>
|
||||
<compilerArg>-parameters</compilerArg>
|
||||
</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.mcp.server;
|
||||
|
||||
import org.apache.hertzbeat.mcp.server.service.LogService;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* MCP Server Application
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class McpServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(McpServerApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ToolCallbackProvider tools(
|
||||
LogService logService) {
|
||||
return MethodToolCallbackProvider.builder()
|
||||
.toolObjects(logService)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.mcp.server.service;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.jayway.jsonpath.ReadContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Log query service.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LogService {
|
||||
|
||||
private static final String BASE_QUERY = "SELECT timestamp, severity_text, body FROM hzb_logs";
|
||||
private static final String TIMESTAMP_COLUMN = "timestamp";
|
||||
private static final String SEVERITY_TEXT_COLUMN = "severity_text";
|
||||
private static final String BODY_COLUMN = "body";
|
||||
private static final int DEFAULT_LIMIT = 20;
|
||||
private static final int MAX_LIMIT = 100;
|
||||
private static final int MAX_KEYWORD_LENGTH = 256;
|
||||
private static final long NANOS_PER_MILLISECOND = 1_000_000L;
|
||||
private static final Set<String> SUPPORTED_SEVERITIES =
|
||||
Set.of("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL");
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String database;
|
||||
|
||||
/**
|
||||
* Creates a log query service.
|
||||
*
|
||||
* @param greptimeUrl GreptimeDB URL
|
||||
* @param database GreptimeDB database name
|
||||
* @param username GreptimeDB username
|
||||
* @param password GreptimeDB password
|
||||
*/
|
||||
@Autowired
|
||||
public LogService(@Value("${greptime.url}") String greptimeUrl,
|
||||
@Value("${greptime.database:public}") String database,
|
||||
@Value("${greptime.username:}") String username,
|
||||
@Value("${greptime.password:}") String password) {
|
||||
this(RestClient.builder(), greptimeUrl, database, username, password);
|
||||
}
|
||||
|
||||
LogService(RestClient.Builder restClientBuilder, String greptimeUrl, String database,
|
||||
String username, String password) {
|
||||
boolean hasUsername = username != null && !username.isBlank();
|
||||
boolean hasPassword = password != null && !password.isBlank();
|
||||
if (hasUsername != hasPassword) {
|
||||
throw new IllegalArgumentException("GreptimeDB username and password must be configured together");
|
||||
}
|
||||
|
||||
RestClient.Builder builder = restClientBuilder
|
||||
.baseUrl(greptimeUrl)
|
||||
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
|
||||
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
|
||||
if (hasUsername) {
|
||||
builder.defaultHeaders(headers -> headers.setBasicAuth(username, password));
|
||||
}
|
||||
this.restClient = builder.build();
|
||||
this.database = database == null || database.isBlank() ? "public" : database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries HertzBeat system logs using structured filters.
|
||||
*
|
||||
* @param severity log severity
|
||||
* @param keyword log body keyword
|
||||
* @param startTime start time as a Unix timestamp in milliseconds
|
||||
* @param endTime end time as a Unix timestamp in milliseconds
|
||||
* @param limit maximum number of records to return
|
||||
* @return formatted log query results
|
||||
*/
|
||||
@Tool(name = "query_logs", description = "Query HertzBeat system logs with structured read-only filters")
|
||||
public String queryLogs(
|
||||
@ToolParam(required = false,
|
||||
description = "Log severity; supported values: TRACE, DEBUG, INFO, WARN, ERROR, FATAL")
|
||||
String severity,
|
||||
@ToolParam(required = false, description = "Keyword to search in the log body; maximum 256 characters")
|
||||
String keyword,
|
||||
@ToolParam(required = false, description = "Start time as a Unix timestamp in milliseconds")
|
||||
Long startTime,
|
||||
@ToolParam(required = false, description = "End time as a Unix timestamp in milliseconds")
|
||||
Long endTime,
|
||||
@ToolParam(required = false,
|
||||
description = "Maximum number of records to return; defaults to 20 and cannot exceed 100")
|
||||
Integer limit) {
|
||||
try {
|
||||
String response = executeQuery(buildQuery(severity, keyword, startTime, endTime, limit));
|
||||
return formatQueryResults(response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "Invalid query parameters: " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to query logs", e);
|
||||
return "Failed to query logs";
|
||||
}
|
||||
}
|
||||
|
||||
static String buildQuery(String severity, String keyword, Long startTime, Long endTime, Integer limit) {
|
||||
if (startTime != null && startTime < 0) {
|
||||
throw new IllegalArgumentException("Start time must not be negative");
|
||||
}
|
||||
if (endTime != null && endTime < 0) {
|
||||
throw new IllegalArgumentException("End time must not be negative");
|
||||
}
|
||||
if (startTime != null && endTime != null && startTime > endTime) {
|
||||
throw new IllegalArgumentException("Start time must not be later than end time");
|
||||
}
|
||||
|
||||
int queryLimit = limit == null ? DEFAULT_LIMIT : limit;
|
||||
if (queryLimit < 1 || queryLimit > MAX_LIMIT) {
|
||||
throw new IllegalArgumentException("Limit must be between 1 and 100");
|
||||
}
|
||||
|
||||
List<String> conditions = new ArrayList<>(4);
|
||||
if (startTime != null) {
|
||||
conditions.add("timestamp >= " + toNanoseconds(startTime, "Start time"));
|
||||
}
|
||||
if (endTime != null) {
|
||||
conditions.add("timestamp <= " + toNanoseconds(endTime, "End time"));
|
||||
}
|
||||
|
||||
String normalizedSeverity = normalizeSeverity(severity);
|
||||
if (normalizedSeverity != null) {
|
||||
conditions.add("severity_text = '" + normalizedSeverity + "'");
|
||||
}
|
||||
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String normalizedKeyword = keyword.strip();
|
||||
if (normalizedKeyword.length() > MAX_KEYWORD_LENGTH) {
|
||||
throw new IllegalArgumentException("Log keyword must not exceed 256 characters");
|
||||
}
|
||||
conditions.add("matches_term(body, '" + escapeSqlLiteral(normalizedKeyword) + "')");
|
||||
}
|
||||
|
||||
StringBuilder query = new StringBuilder(BASE_QUERY);
|
||||
if (!conditions.isEmpty()) {
|
||||
query.append(" WHERE ").append(String.join(" AND ", conditions));
|
||||
}
|
||||
return query.append(" ORDER BY timestamp DESC LIMIT ").append(queryLimit).toString();
|
||||
}
|
||||
|
||||
private static String normalizeSeverity(String severity) {
|
||||
if (severity == null || severity.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedSeverity = severity.strip().toUpperCase(Locale.ROOT);
|
||||
if (!SUPPORTED_SEVERITIES.contains(normalizedSeverity)) {
|
||||
throw new IllegalArgumentException("Unsupported log severity");
|
||||
}
|
||||
return normalizedSeverity;
|
||||
}
|
||||
|
||||
private static long toNanoseconds(long epochMilliseconds, String fieldName) {
|
||||
try {
|
||||
return Math.multiplyExact(epochMilliseconds, NANOS_PER_MILLISECOND);
|
||||
} catch (ArithmeticException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is out of the supported range", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapeSqlLiteral(String value) {
|
||||
return value.replace("'", "''");
|
||||
}
|
||||
|
||||
private String executeQuery(String sql) {
|
||||
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||
formData.add("sql", sql);
|
||||
log.debug("Executing structured log query");
|
||||
|
||||
return restClient.post()
|
||||
.uri(uriBuilder -> uriBuilder.path("/v1/sql").queryParam("db", database).build())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(formData)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
}
|
||||
|
||||
private String formatQueryResults(String response) {
|
||||
ReadContext ctx = JsonPath.parse(response);
|
||||
List<Map<String, Object>> columnSchemas = ctx.read("$.output[0].records.schema.column_schemas");
|
||||
List<List<Object>> rows = ctx.read("$.output[0].records.rows");
|
||||
int totalRows = ctx.read("$.output[0].records.total_rows");
|
||||
|
||||
ColumnIndices indices = findColumnIndices(columnSchemas);
|
||||
StringBuilder result = new StringBuilder()
|
||||
.append("Query Results:\n\n")
|
||||
.append("Log Time\t\t\tLog Level\tLog Content\n")
|
||||
.append("----------------------------------------------------\n");
|
||||
|
||||
if (rows != null && !rows.isEmpty()) {
|
||||
formatRows(rows, indices, result);
|
||||
result.append("\nTotal ").append(totalRows).append(" records");
|
||||
} else {
|
||||
result.append("No data");
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private record ColumnIndices(int timestamp, int severityText, int body) {}
|
||||
|
||||
private ColumnIndices findColumnIndices(List<Map<String, Object>> columnSchemas) {
|
||||
int timestampIndex = -1;
|
||||
int severityTextIndex = -1;
|
||||
int bodyIndex = -1;
|
||||
|
||||
for (int i = 0; i < columnSchemas.size(); i++) {
|
||||
String columnName = (String) columnSchemas.get(i).get("name");
|
||||
switch (columnName) {
|
||||
case TIMESTAMP_COLUMN -> timestampIndex = i;
|
||||
case SEVERITY_TEXT_COLUMN -> severityTextIndex = i;
|
||||
case BODY_COLUMN -> bodyIndex = i;
|
||||
default -> {
|
||||
// Ignore other columns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ColumnIndices(timestampIndex, severityTextIndex, bodyIndex);
|
||||
}
|
||||
|
||||
private void formatRows(List<List<Object>> rows, ColumnIndices indices, StringBuilder result) {
|
||||
for (List<Object> row : rows) {
|
||||
appendTimestamp(row, indices.timestamp(), result);
|
||||
appendSeverity(row, indices.severityText(), result);
|
||||
appendBody(row, indices.body(), result);
|
||||
result.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendTimestamp(List<Object> row, int index, StringBuilder result) {
|
||||
if (index >= 0 && index < row.size()) {
|
||||
Object value = row.get(index);
|
||||
if (value instanceof Number) {
|
||||
long timestamp = ((Number) value).longValue();
|
||||
LocalDateTime dateTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(timestamp / 1_000_000),
|
||||
ZoneId.systemDefault());
|
||||
result.append(DATE_FORMATTER.format(dateTime)).append("\t");
|
||||
return;
|
||||
}
|
||||
}
|
||||
result.append("Unknown time\t");
|
||||
}
|
||||
|
||||
private void appendSeverity(List<Object> row, int index, StringBuilder result) {
|
||||
if (index >= 0 && index < row.size()) {
|
||||
result.append(row.get(index)).append("\t");
|
||||
} else {
|
||||
result.append("Unknown\t");
|
||||
}
|
||||
}
|
||||
|
||||
private void appendBody(List<Object> row, int index, StringBuilder result) {
|
||||
if (index >= 0 && index < row.size()) {
|
||||
result.append(row.get(index));
|
||||
} else {
|
||||
result.append("No content");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
spring:
|
||||
main:
|
||||
banner-mode: off
|
||||
ai:
|
||||
mcp:
|
||||
server:
|
||||
name: hertzbeat-log-analysis-server
|
||||
version: 0.1
|
||||
|
||||
#
|
||||
#logging:
|
||||
# file:
|
||||
# name:
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
* contributor license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright ownership.
|
||||
* The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
* (the "License"); you may not use this file except in compliance with
|
||||
* the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.hertzbeat.mcp.server.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
|
||||
/**
|
||||
* Security tests for {@link LogService} queries.
|
||||
*/
|
||||
class LogServiceTest {
|
||||
|
||||
private static final String BASE_QUERY = "SELECT timestamp, severity_text, body FROM hzb_logs";
|
||||
|
||||
@Test
|
||||
void shouldBuildDefaultReadOnlyQuery() {
|
||||
assertThat(LogService.buildQuery(null, null, null, null, null))
|
||||
.isEqualTo(BASE_QUERY + " ORDER BY timestamp DESC LIMIT 20");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeOnlyStructuredToolParameters() {
|
||||
LogService service = new LogService(
|
||||
RestClient.builder(), "http://localhost:4000", "public", "", "");
|
||||
ToolCallback[] callbacks = MethodToolCallbackProvider.builder().toolObjects(service).build().getToolCallbacks();
|
||||
|
||||
assertThat(callbacks).singleElement().satisfies(callback -> {
|
||||
assertThat(callback.getToolDefinition().name()).isEqualTo("query_logs");
|
||||
assertThat(callback.getToolDefinition().inputSchema())
|
||||
.contains("severity", "keyword", "startTime", "endTime", "limit")
|
||||
.doesNotContain("querySql");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildQueryFromValidatedFilters() {
|
||||
String query = LogService.buildQuery(
|
||||
" error ", " x'); DELETE FROM hzb_logs; -- ", 1L, 2L, 10);
|
||||
|
||||
assertThat(query).isEqualTo(BASE_QUERY
|
||||
+ " WHERE timestamp >= 1000000"
|
||||
+ " AND timestamp <= 2000000"
|
||||
+ " AND severity_text = 'ERROR'"
|
||||
+ " AND matches_term(body, 'x''); DELETE FROM hzb_logs; --')"
|
||||
+ " ORDER BY timestamp DESC LIMIT 10");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidFilters() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery("NOTICE", null, null, null, null));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, null, -1L, null, null));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, null, null, -1L, null));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, null, 2L, 1L, null));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, null, Long.MAX_VALUE, null, null));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, "x".repeat(257), null, null, null));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, null, null, null, 0));
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> LogService.buildQuery(null, null, null, null, 101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendGeneratedSelectWithBasicAuthentication() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
LogService service = new LogService(
|
||||
restClientBuilder, "http://localhost:4000", "observability", "reader", "secret");
|
||||
String query = BASE_QUERY + " ORDER BY timestamp DESC LIMIT 20";
|
||||
MultiValueMap<String, String> expectedForm = new LinkedMultiValueMap<>();
|
||||
expectedForm.setAll(Map.of("sql", query));
|
||||
String credentials = Base64.getEncoder()
|
||||
.encodeToString("reader:secret".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
server.expect(requestTo("http://localhost:4000/v1/sql?db=observability"))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, "Basic " + credentials))
|
||||
.andExpect(content().formData(expectedForm))
|
||||
.andRespond(withSuccess(emptyResult(), MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThat(service.queryLogs(null, null, null, null, null)).contains("No data");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFormatReturnedLogs() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
LogService service = new LogService(restClientBuilder, "http://localhost:4000", "public", "", "");
|
||||
server.expect(requestTo("http://localhost:4000/v1/sql?db=public"))
|
||||
.andRespond(withSuccess(resultWithOneLog(), MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThat(service.queryLogs("ERROR", "failure", null, null, 1))
|
||||
.contains("ERROR", "failure", "Total 1 records");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnSafeErrorMessages() {
|
||||
RestClient.Builder restClientBuilder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||
LogService service = new LogService(restClientBuilder, "http://localhost:4000", "public", "", "");
|
||||
|
||||
assertThat(service.queryLogs("NOTICE", null, null, null, null))
|
||||
.isEqualTo("Invalid query parameters: Unsupported log severity");
|
||||
|
||||
server.expect(requestTo("http://localhost:4000/v1/sql?db=public"))
|
||||
.andRespond(withServerError());
|
||||
assertThat(service.queryLogs(null, null, null, null, null)).isEqualTo("Failed to query logs");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireCompleteCredentials() {
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> new LogService(
|
||||
RestClient.builder(), "http://localhost:4000", "public", "reader", ""));
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> new LogService(
|
||||
RestClient.builder(), "http://localhost:4000", "public", "", "secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateWithProductionConstructor() {
|
||||
assertThat(new LogService("http://localhost:4000", "public", "", "")).isNotNull();
|
||||
}
|
||||
|
||||
private static String emptyResult() {
|
||||
return """
|
||||
{
|
||||
"output": [{
|
||||
"records": {
|
||||
"schema": {"column_schemas": [
|
||||
{"name": "timestamp"},
|
||||
{"name": "severity_text"},
|
||||
{"name": "body"}
|
||||
]},
|
||||
"rows": [],
|
||||
"total_rows": 0
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static String resultWithOneLog() {
|
||||
return """
|
||||
{
|
||||
"output": [{
|
||||
"records": {
|
||||
"schema": {"column_schemas": [
|
||||
{"name": "timestamp"},
|
||||
{"name": "severity_text"},
|
||||
{"name": "body"}
|
||||
]},
|
||||
"rows": [[0, "ERROR", "failure"]],
|
||||
"total_rows": 1
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user