Initial commit
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
package io.shinhanlife.dap.biz.mcp.deploy;
|
||||
|
||||
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.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.snakeyaml.engine.v2.api.Load;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
/**
|
||||
* Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라
|
||||
* CrashLoopBackOff로 나타납니다. 같은 규칙을 여기서 먼저 적용해 잘못된 values가 머지되는 것을 막습니다.
|
||||
*
|
||||
* <p>이 테스트가 고정하는 핵심 규칙은 MCP 배포와 Tool Service의 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라
|
||||
* 배포 정의에서 잠급니다. 파일을 읽기만 하며 애플리케이션 context나 helm 바이너리를 필요로 하지 않습니다.
|
||||
*/
|
||||
class HelmDeploymentContractTest {
|
||||
|
||||
private static final Path CHART = Path.of("deploy", "helm", "mcp-server");
|
||||
private static final Path VALUES = CHART.resolve("values.yaml");
|
||||
|
||||
/**
|
||||
* 환경별 values가 파싱되고 {@code global.env}가 파일 이름과 일치하는지 확인합니다. 이 값이 어긋나면 identity 접미사가 환경과 달라져 서로 다른 환경이 같은 Redis key를 쓰게 됩니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "test", "prod"})
|
||||
void environmentValuesDeclareTheMatchingEnvironmentAndPublicHost(String env) throws IOException {
|
||||
Map<String, Object> values = loadYaml(environmentValues(env));
|
||||
Map<String, Object> global = section(values, "global");
|
||||
|
||||
assertThat(global.get("env"))
|
||||
.withFailMessage("values-%s.yaml의 global.env가 파일 이름과 다릅니다.", env)
|
||||
.isEqualTo(env);
|
||||
assertThat(String.valueOf(global.get("mcpHost")))
|
||||
.withFailMessage("values-%s.yaml에 공개 MCP host가 없습니다.", env)
|
||||
.isNotBlank()
|
||||
.doesNotContain("null", "http://", "https://", "/");
|
||||
assertThat(String.valueOf(section(values, "route").get("sourceAllowlist")))
|
||||
.withFailMessage("values-%s.yaml에 Agent Builder source CIDR allowlist가 없습니다.", env)
|
||||
.isNotBlank()
|
||||
.doesNotContain("null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경별 values가 배포 토폴로지를 소유하지 않는지 확인합니다. 환경 축과 배포 축을 한 파일에 섞으면 배포가 늘어날 때마다 환경 설정이 복제되고, 같은 사실이 여러 파일에 흩어져 결국 서로 어긋납니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "test", "prod"})
|
||||
void environmentValuesDoNotOwnTheTopology(String env) throws IOException {
|
||||
Map<String, Object> values = loadYaml(environmentValues(env));
|
||||
|
||||
assertThat(values)
|
||||
.withFailMessage(
|
||||
"values-%s.yaml이 배포 토폴로지를 갖고 있습니다. deployments는 values.yaml 한 곳에만 둡니다.", env)
|
||||
.doesNotContainKeys("deployments", "deploymentKey");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 배포가 자기가 보는 Tool Service와 가용성 등급을 선언하는지 확인합니다. 주소가 아니라 서비스 이름만 선언해야 template이 namespace를 붙여 조립할 수 있고, values에 URL을 직접 적기 시작하면 오타가 그대로 라우팅 사고가 됩니다.
|
||||
*/
|
||||
@Test
|
||||
void everyDeploymentDeclaresItsToolServiceTierAndPublicPath() throws IOException {
|
||||
Map<String, Object> values = loadYaml(VALUES);
|
||||
Map<String, Object> deployments = section(values, "deployments");
|
||||
Set<String> knownTiers = section(values, "tiers").keySet();
|
||||
|
||||
assertThat(deployments)
|
||||
.withFailMessage("values.yaml에 deployments가 없습니다. 이 목록이 배포 토폴로지의 정본입니다.")
|
||||
.isNotEmpty();
|
||||
|
||||
deployments.forEach((key, raw) -> {
|
||||
Map<String, Object> deployment = asMap(raw);
|
||||
assertThat(deployment)
|
||||
.withFailMessage(
|
||||
"deployments.%s에 name/service/namePrefix/tier/publicPath가 모두 있어야 합니다: %s",
|
||||
key, deployment)
|
||||
.containsKeys("name", "service", "namePrefix", "tier", "publicPath");
|
||||
assertThat(deployment)
|
||||
.withFailMessage("deployments.%s가 주소를 직접 적고 있습니다. template이 조립합니다.", key)
|
||||
.doesNotContainKeys("manifestUrl", "baseEndpoint", "bundles");
|
||||
assertThat(String.valueOf(deployment.get("namePrefix")))
|
||||
.withFailMessage("deployments.%s의 namePrefix가 비어 있습니다.", key)
|
||||
.isNotBlank();
|
||||
assertThat(String.valueOf(deployment.get("publicPath")))
|
||||
.withFailMessage("deployments.%s의 publicPath가 /mcp/<영문 소문자·숫자·하이픈> 형식이 아닙니다.", key)
|
||||
.matches("/mcp/[a-z0-9-]+");
|
||||
assertThat(knownTiers)
|
||||
.withFailMessage(
|
||||
"deployments.%s의 tier '%s'가 values.yaml의 tiers에 없습니다.", key, deployment.get("tier"))
|
||||
.contains(String.valueOf(deployment.get("tier")));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 path가 배포마다 유일한지 확인합니다. 같은 host와 path를 두 Route가 공유하면 어느 MCP Service로 전달될지 배포 순서에 따라 달라집니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentPublicPathsAreUnique() throws IOException {
|
||||
List<String> paths =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("publicPath")))
|
||||
.toList();
|
||||
|
||||
assertThat(paths)
|
||||
.withFailMessage("공개 MCP path가 중복됩니다. 한 path는 한 MCP Deployment만 가리켜야 합니다: %s", paths)
|
||||
.doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배포 이름이 서로 겹치지 않는지 확인합니다. 이름은 Deployment·Service·ConfigMap·NetworkPolicy의 리소스 이름이 되므로, 같은 namespace에서 겹치면 나중에 설치한 배포가 앞의 것을 덮어씁니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentResourceNamesAreUnique() throws IOException {
|
||||
List<String> names =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("name")))
|
||||
.toList();
|
||||
|
||||
assertThat(names)
|
||||
.withFailMessage("배포 이름이 중복됩니다. 같은 namespace에서 리소스가 서로를 덮어씁니다: %s", names)
|
||||
.doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 어떤 {@code namePrefix}도 다른 prefix의 <b>진부분</b> 접두사가 아닌지 확인합니다. {@code a.}와 {@code a.b.}가 함께 있으면 {@code a.b.search}가 어느 Tool Service 것인지 이름만으로는 확정되지 않습니다.
|
||||
* 서로 다른 MCP에 흩어져 있으면 MCP는 이를 감지할 수 없으므로 여기서 막습니다.
|
||||
*
|
||||
* <p>완전히 같은 prefix는 허용합니다. 같은 업무를 등급으로 나눈 두 배포가 같은 업무 prefix를
|
||||
* 공유하는 것은 의도된 구성입니다(ADR-0007). 그 안에서 Tool 이름이 겹치지 않게 하는 것은 Tool Service 책임입니다.
|
||||
*/
|
||||
@Test
|
||||
void noNamePrefixIsAStrictPrefixOfAnother() throws IOException {
|
||||
List<String> prefixes =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("namePrefix")))
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
List<String> conflicts = new ArrayList<>();
|
||||
for (String outer : prefixes) {
|
||||
for (String inner : prefixes) {
|
||||
if (!outer.equals(inner) && inner.startsWith(outer)) {
|
||||
conflicts.add(outer + " ⊂ " + inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(conflicts)
|
||||
.withFailMessage("namePrefix가 다른 prefix의 접두사입니다. Tool 이름의 소속이 확정되지 않습니다: %s", conflicts)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigMap이 Tool Service를 정확히 하나만 묶는지 확인합니다. 1:1은 ADR-0007의 결정이며 production 코드가 아니라 여기서 잠급니다. bundle 목록을 {@code range}로 돌리기 시작하면 그 순간 M:N으로 되돌아가고, 등급이 다른
|
||||
* Tool Service가 한 MCP에 묶여 카탈로그 갱신이 서로를 막게 됩니다.
|
||||
*/
|
||||
@Test
|
||||
void configMapBindsExactlyOneToolService() throws IOException {
|
||||
String configMap = Files.readString(CHART.resolve("templates/configmap.yaml"));
|
||||
|
||||
List<String> bundleEntries =
|
||||
configMap.lines().map(String::trim).filter(line -> line.startsWith("- id:")).toList();
|
||||
|
||||
assertThat(configMap).contains("bundles:");
|
||||
assertThat(bundleEntries)
|
||||
.withFailMessage("ConfigMap이 bundle을 정확히 하나만 만들어야 합니다(ADR-0007): %s", bundleEntries)
|
||||
.hasSize(1);
|
||||
assertThat(configMap)
|
||||
.withFailMessage("ConfigMap이 bundle 목록을 반복 렌더링하고 있습니다. 1:1이 깨졌습니다(ADR-0007).")
|
||||
.doesNotContain("range");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 환경이 사용 중인 등급을 빠짐없이 선언하는지 확인합니다. 환경 values가 등급 하나를 빠뜨리면 values.yaml의 기본값이 조용히 적용되어, dev인데 prod 기준 replica로 뜨거나 그 반대가 됩니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"dev", "test", "prod"})
|
||||
void everyEnvironmentDeclaresEveryTierInUse(String env) throws IOException {
|
||||
Set<String> tiersInUse =
|
||||
section(loadYaml(VALUES), "deployments").values().stream()
|
||||
.map(raw -> String.valueOf(asMap(raw).get("tier")))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<String> declared = section(loadYaml(environmentValues(env)), "tiers").keySet();
|
||||
|
||||
assertThat(declared)
|
||||
.withFailMessage("values-%s.yaml이 선언하지 않은 등급이 있습니다. 기본값이 조용히 적용됩니다.", env)
|
||||
.containsAll(tiersInUse);
|
||||
}
|
||||
|
||||
/**
|
||||
* test와 prod의 중요 등급이 단일 장애점을 갖지 않도록 설정됐는지 확인합니다. replica가 1이면 rolling update 중 반드시 공백이 생기고, PodDisruptionBudget이 없으면 노드 drain이 마지막 Pod을 내릴 수 있습니다. 노드 분산이
|
||||
* 꺼져 있으면 여러 replica가 같은 노드 장애를 공유하므로 세 설정은 함께 유지해야 합니다(ADR-0007). dev는 Pod 1개로 운영하므로 대상이 아닙니다.
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"test", "prod"})
|
||||
void criticalTierDeclaresAvailabilitySettings(String env) throws IOException {
|
||||
Map<String, Object> critical = asMap(section(loadYaml(environmentValues(env)), "tiers").get("critical"));
|
||||
|
||||
assertThat((Integer) critical.get("replicas"))
|
||||
.withFailMessage("%s의 critical 등급 replica가 2 미만입니다. 배포 중 공백이 생깁니다: %s", env, critical)
|
||||
.isGreaterThanOrEqualTo(2);
|
||||
assertThat(critical.get("podDisruptionBudget"))
|
||||
.withFailMessage("%s의 critical 등급에 PodDisruptionBudget이 없습니다.", env)
|
||||
.isEqualTo(true);
|
||||
assertThat(critical.get("spreadAcrossNodes"))
|
||||
.withFailMessage("%s의 critical 등급이 replica를 노드에 분산하지 않습니다.", env)
|
||||
.isEqualTo(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* PodDisruptionBudget template이 존재하고 등급 설정으로 켜지는지 확인합니다. 값만 {@code true}로 두고 template이 없으면 아무 일도 일어나지 않은 채 검사만 통과합니다.
|
||||
*/
|
||||
@Test
|
||||
void podDisruptionBudgetTemplateUsesTheTierSetting() throws IOException {
|
||||
String pdb = Files.readString(CHART.resolve("templates/poddisruptionbudget.yaml"));
|
||||
|
||||
assertThat(pdb).contains("kind: PodDisruptionBudget").contains("$tier.podDisruptionBudget");
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 대상 배포에 기본값이 없는지, identity를 values가 직접 정하지 않는지 확인합니다. {@code deploymentKey}에 기본값이 있으면 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치됩니다. identity를 손으로 적으면 dev·test·prod가 같은
|
||||
* 값을 갖는 실수가 나고, 그 순간 서로의 Tool snapshot을 덮어씁니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentKeyAndIdentityAreNotDefaultedInValues() throws IOException {
|
||||
Map<String, Object> values = loadYaml(VALUES);
|
||||
|
||||
Object deploymentKey = values.get("deploymentKey");
|
||||
assertThat(deploymentKey == null || String.valueOf(deploymentKey).isEmpty())
|
||||
.withFailMessage("deploymentKey에 기본값 '%s'가 있습니다. 지정을 빠뜨린 설치가 조용히 성공합니다.", deploymentKey)
|
||||
.isTrue();
|
||||
assertThat(section(values, "mcp"))
|
||||
.withFailMessage("values.yaml이 identity를 직접 정하고 있습니다. helper가 조립해야 합니다.")
|
||||
.doesNotContainKey("identity");
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증을 하지 않는 전제인 NetworkPolicy가 Chart에서 빠지지 않았는지 확인합니다. ADR-0006의 성립 조건이므로 비활성화 조건 없이 항상 렌더링되어야 합니다.
|
||||
*/
|
||||
@Test
|
||||
void networkPolicyRestrictsBothPortsAndHasNoDisableSwitch() throws IOException {
|
||||
String policy = Files.readString(CHART.resolve("templates/networkpolicy.yaml"));
|
||||
|
||||
assertThat(policy)
|
||||
.contains("kind: NetworkPolicy")
|
||||
.contains("kubernetes.io/metadata.name: {{ .Values.global.agentBuilderNamespace }}")
|
||||
.contains("policy-group.network.openshift.io/ingress: \"\"")
|
||||
.contains("kubernetes.io/metadata.name: {{ .Values.global.monitoringNamespace }}")
|
||||
.contains("port: {{ .Values.ports.http }}")
|
||||
.contains("port: {{ .Values.ports.management }}");
|
||||
// {{ if .Values...enabled }}로 감싸면 values 한 줄로 인가가 사라진다.
|
||||
assertThat(policy).doesNotContain("{{- if").doesNotContain("{{ if");
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenShift Route가 환경별 공통 host와 배포별 고유 path를 사용해 선택된 MCP Service로 전달하는지 확인합니다. path rewrite는 금지하며 TLS와 route timeout은 공개 HTTP 경계에 둡니다.
|
||||
*/
|
||||
@Test
|
||||
void routeMapsThePublicPathToTheSelectedMcpService() throws IOException {
|
||||
String route = Files.readString(CHART.resolve("templates/route.yaml"));
|
||||
|
||||
assertThat(route)
|
||||
.contains("apiVersion: route.openshift.io/v1")
|
||||
.contains("kind: Route")
|
||||
.doesNotContain("haproxy.router.openshift.io/rewrite-target")
|
||||
.contains("haproxy.router.openshift.io/timeout: {{ .Values.route.timeout }}")
|
||||
.contains("haproxy.router.openshift.io/ip_allowlist: {{ .Values.route.sourceAllowlist | quote }}")
|
||||
.contains("host: {{ .Values.global.mcpHost | quote }}")
|
||||
.contains("path: {{ $deployment.publicPath | quote }}")
|
||||
.contains("kind: Service")
|
||||
.contains("name: {{ include \"mcp-server.name\" . }}")
|
||||
.contains("targetPort: http")
|
||||
.contains("termination: edge")
|
||||
.contains("insecureEdgeTerminationPolicy: Redirect");
|
||||
|
||||
assertThat(Files.readString(CHART.resolve("templates/configmap.yaml")))
|
||||
.contains("endpoint-path: {{ $deployment.publicPath | quote }}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deployment가 운영 profile과 ConfigMap 우선 적용을 유지하는지, replica를 등급에서 가져오는지 확인합니다. ConfigMap checksum annotation이 빠지면 bundle 설정을 고쳐도 기존 Pod이 옛 설정으로 계속 돕니다.
|
||||
*/
|
||||
@Test
|
||||
void deploymentUsesOperationalProfileAndRollsOnConfigChange() throws IOException {
|
||||
String deployment = Files.readString(CHART.resolve("templates/deployment.yaml"));
|
||||
|
||||
assertThat(deployment)
|
||||
.contains("name: SPRING_PROFILES_ACTIVE")
|
||||
.contains("value: ocp")
|
||||
.contains("SPRING_CONFIG_ADDITIONAL_LOCATION")
|
||||
.contains("checksum/config:")
|
||||
.contains("replicas: {{ $tier.replicas }}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경별 values 파일 경로를 만듭니다.
|
||||
*/
|
||||
private Path environmentValues(String env) {
|
||||
return CHART.resolve("values-" + env + ".yaml");
|
||||
}
|
||||
|
||||
/**
|
||||
* values 파일을 YAML로 읽습니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> loadYaml(Path path) throws IOException {
|
||||
Load load = new Load(LoadSettings.builder().build());
|
||||
Object loaded = load.loadFromString(Files.readString(path));
|
||||
return loaded == null ? new LinkedHashMap<>() : (Map<String, Object>) loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최상위 절을 꺼내되 없으면 빈 map을 돌려줘 호출부가 null을 검사하지 않게 합니다.
|
||||
*/
|
||||
private Map<String, Object> section(Map<String, Object> values, String name) {
|
||||
return asMap(values.get(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML이 map으로 읽힌 값을 꺼내되 없으면 빈 map을 돌려줍니다.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> asMap(Object value) {
|
||||
return value == null ? new LinkedHashMap<>() : (Map<String, Object>) value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user