Initial commit

This commit is contained in:
2026-08-05 15:54:25 +09:00
commit a4eb5a580f
168 changed files with 12057 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}