Initial commit
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
/**
|
||||
* {@code toolCatalog} health indicator가 어느 probe에 연결되는지 고정하는 계약 테스트입니다.
|
||||
*
|
||||
* <p>이 indicator는 Tool Service라는 <b>외부 시스템</b>에 의존합니다. readiness에 연결하면 Tool을 읽지 못하는 Pod이 트래픽에서 빠지는, 의도한 동작이 됩니다. 그러나 같은 것을 liveness에 연결하면
|
||||
* Tool Service가 잠시 흔들릴 때 <b>모든 MCP Pod이 재시작 루프에 빠집니다.</b> readiness 실패는 트래픽만 끊지만 liveness 실패는 컨테이너를 죽이기 때문입니다.
|
||||
*
|
||||
* <p>"health 그룹을 통일하자"는 선의의 정리 한 번으로 장애가 전면화될 수 있어, 사람의 주의력 대신 테스트로 막습니다. 설정 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class HealthGroupContractTest {
|
||||
|
||||
private static final Path APPLICATION_YML =
|
||||
Path.of("src", "main", "resources", "application.yml");
|
||||
private static final String TOOL_CATALOG = "toolCatalog";
|
||||
|
||||
/**
|
||||
* readiness group이 {@code toolCatalog}를 포함하는지 확인합니다. 빠지면 usable snapshot이 없는 Pod도 트래픽을 받아, 배포 중 새 Pod이 정상 Pod을 대체하게 됩니다.
|
||||
*/
|
||||
@Test
|
||||
void readinessIncludesTheToolCatalogIndicator() throws IOException {
|
||||
assertThat(groupMembers("readiness"))
|
||||
.withFailMessage("readiness group에 %s가 없습니다. 빈 카탈로그 Pod이 트래픽을 받게 됩니다.", TOOL_CATALOG)
|
||||
.contains(TOOL_CATALOG);
|
||||
}
|
||||
|
||||
/**
|
||||
* liveness group이 {@code toolCatalog}를 포함하지 않는지 확인합니다. 포함되는 순간 Tool Service 장애가 MCP 전 Pod의 재시작 루프로 번집니다. group 선언 자체가 없으면 Spring 기본값이
|
||||
* {@code livenessState}만 쓰므로 안전합니다.
|
||||
*/
|
||||
@Test
|
||||
void livenessNeverIncludesTheToolCatalogIndicator() throws IOException {
|
||||
assertThat(groupMembers("liveness"))
|
||||
.withFailMessage(
|
||||
"liveness group에 %s가 있습니다. Tool Service 장애가 Pod 재시작 루프가 됩니다.", TOOL_CATALOG)
|
||||
.doesNotContain(TOOL_CATALOG);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code management.endpoint.health.group.<name>.include}에 선언된 항목을 읽어 옵니다. 선언이 없으면 빈 목록을 돌려줘 호출부가 null을 검사하지 않게 합니다.
|
||||
*/
|
||||
private List<String> groupMembers(String group) throws IOException {
|
||||
Map<String, Object> health =
|
||||
section(
|
||||
section(section(section(loadYaml(), "management"), "endpoint"), "health"),
|
||||
"group");
|
||||
Object include = section(health, group).get("include");
|
||||
if (include == null) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(String.valueOf(include).split("\\s*,\\s*"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 기본 설정을 YAML로 읽습니다. profile별 파일이 아니라 모든 profile이 공유하는 이 파일이 probe 구성의 정본입니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> loadYaml() throws IOException {
|
||||
Load load = new Load(LoadSettings.builder().build());
|
||||
Object loaded = load.loadFromString(Files.readString(APPLICATION_YML));
|
||||
return loaded == null ? new LinkedHashMap<>() : (Map<String, Object>) loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 절을 꺼내되 없으면 빈 map을 돌려줍니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> section(Map<String, Object> values, String name) {
|
||||
Object value = values.get(name);
|
||||
return value == null ? new LinkedHashMap<>() : (Map<String, Object>) value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolBundleStatusEndpointTest {
|
||||
|
||||
@Test
|
||||
void exposesBundleStatusThroughTheManagementEndpointContract() {
|
||||
ToolBundleDiscovery discovery = mock(ToolBundleDiscovery.class);
|
||||
BundleStatus status =
|
||||
new BundleStatus(
|
||||
"channel-tools", true, "healthy", "rev-1", 2, 0, "2026-07-30T00:00:00Z", null);
|
||||
when(discovery.statuses()).thenReturn(List.of(status));
|
||||
|
||||
ToolBundleStatusEndpoint endpoint = new ToolBundleStatusEndpoint(discovery);
|
||||
|
||||
assertThat(endpoint.bundleStatuses()).containsEntry("bundles", List.of(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.health.contributor.Status;
|
||||
|
||||
class ToolCatalogHealthIndicatorTest {
|
||||
|
||||
@Test
|
||||
void staysDownUntilTheFirstDiscoveryAttemptFinishes() {
|
||||
ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class);
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(registryService.hasUsableSnapshot()).thenReturn(true);
|
||||
|
||||
ToolCatalogHealthIndicator indicator =
|
||||
new ToolCatalogHealthIndicator(scheduler, registryService);
|
||||
|
||||
assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staysDownWhenDiscoveryFinishedWithoutAUsableSnapshot() {
|
||||
ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class);
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(scheduler.firstAttemptCompleted()).thenReturn(true);
|
||||
|
||||
ToolCatalogHealthIndicator indicator =
|
||||
new ToolCatalogHealthIndicator(scheduler, registryService);
|
||||
|
||||
assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void becomesReadyWhenDiscoveryFinishedWithAUsableSnapshot() {
|
||||
ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class);
|
||||
ToolRegistryService registryService = mock(ToolRegistryService.class);
|
||||
when(scheduler.firstAttemptCompleted()).thenReturn(true);
|
||||
when(registryService.hasUsableSnapshot()).thenReturn(true);
|
||||
|
||||
ToolCatalogHealthIndicator indicator =
|
||||
new ToolCatalogHealthIndicator(scheduler, registryService);
|
||||
|
||||
assertThat(indicator.health().getStatus()).isEqualTo(Status.UP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.shinhanlife.dap.biz.mcp.observability;
|
||||
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
|
||||
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
class TraceLoggerTest {
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
McpRequestContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesTraceAndRequestIdsFromTheRequestContext() {
|
||||
var logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(TraceLogger.class);
|
||||
var appender = new ListAppender<ILoggingEvent>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
McpRequestContextHolder.set(context());
|
||||
|
||||
new TraceLogger(properties(false, false))
|
||||
.event("mcp_http_response_completed", "httpStatus", 200);
|
||||
|
||||
assertThat(appender.list)
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
event ->
|
||||
assertThat(event.getFormattedMessage())
|
||||
.contains(
|
||||
"event=mcp_http_response_completed",
|
||||
"guid=guid-1",
|
||||
"requestId=req-1",
|
||||
"httpStatus=200"));
|
||||
logger.detachAppender(appender);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user