Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.synapxnet.goai.contract;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
Expand Down Expand Up @@ -29,13 +30,15 @@ DelegatedTokenVerifier delegatedTokenVerifier(
* 注册只作用于 Agent v1 API 的上下文过滤器。
*
* @param verifier 委托令牌验证器
* @param objectMapper Spring 统一配置的 JSON 序列化器
* @return Servlet 过滤器注册对象
*/
@Bean
FilterRegistrationBean<AgentRequestContextFilter> agentRequestContextFilter(
DelegatedTokenVerifier verifier) {
DelegatedTokenVerifier verifier,
ObjectMapper objectMapper) {
FilterRegistrationBean<AgentRequestContextFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new AgentRequestContextFilter(verifier));
bean.setFilter(new AgentRequestContextFilter(verifier, objectMapper));
bean.addUrlPatterns("/api/agent/v1/*");
bean.setOrder(-100);
return bean;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.synapxnet.goai.contract;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand All @@ -18,14 +22,17 @@ final class AgentRequestContextFilter extends OncePerRequestFilter {

private static final Pattern TOOL_PATH = Pattern.compile("^/api/agent/v1/tools/([^/]+):invoke$");
private final DelegatedTokenVerifier tokenVerifier;
private final ObjectMapper objectMapper;

/**
* 创建请求上下文过滤器。
*
* @param tokenVerifier 短期委托令牌验证器
* @param objectMapper Spring 统一配置的 JSON 序列化器
*/
AgentRequestContextFilter(DelegatedTokenVerifier tokenVerifier) {
AgentRequestContextFilter(DelegatedTokenVerifier tokenVerifier, ObjectMapper objectMapper) {
this.tokenVerifier = tokenVerifier;
this.objectMapper = objectMapper;
}

/**
Expand All @@ -45,23 +52,52 @@ protected void doFilterInternal(
return;
}
try {
String workspaceId = requiredHeader(request, "X-OpenXnet-Workspace-Id");
String incidentId = requiredHeader(request, "X-OpenXnet-Incident-Id");
String traceId = requiredHeader(request, "X-OpenXnet-Trace-Id");
String toolName = requiredHeader(request, "X-OpenXnet-Tool-Name");
String idempotencyKey = requiredHeader(request, "Idempotency-Key");
verifyPathTool(request.getRequestURI(), toolName);
String actorId = tokenVerifier.verify(request.getHeader("Authorization"), workspaceId, toolName);
AgentContract.RequestContext context = new AgentContract.RequestContext(
workspaceId, incidentId, traceId, toolName, idempotencyKey, actorId, null);
request.setAttribute(AgentContract.CONTEXT_ATTRIBUTE, context);
putMdc(context);
try {
String workspaceId = requiredHeader(request, "X-OpenXnet-Workspace-Id");
String incidentId = requiredHeader(request, "X-OpenXnet-Incident-Id");
String traceId = requiredHeader(request, "X-OpenXnet-Trace-Id");
String toolName = requiredHeader(request, "X-OpenXnet-Tool-Name");
String idempotencyKey = requiredHeader(request, "Idempotency-Key");
verifyPathTool(request.getRequestURI(), toolName);
String actorId = tokenVerifier.verify(request.getHeader("Authorization"), workspaceId, toolName);
AgentContract.RequestContext context = new AgentContract.RequestContext(
workspaceId, incidentId, traceId, toolName, idempotencyKey, actorId, null);
request.setAttribute(AgentContract.CONTEXT_ATTRIBUTE, context);
putMdc(context);
} catch (AgentContractException exception) {
writeContractError(response, exception);
return;
}
filterChain.doFilter(request, response);
} finally {
MDC.clear();
}
}

/**
* 将过滤器阶段的鉴权或契约异常写为脱敏公共 JSON 包络。
*
* @param response HTTP 响应
* @param exception 已识别的公共契约异常
* @throws IOException 响应写入失败时抛出
*/
private void writeContractError(
HttpServletResponse response,
AgentContractException exception) throws IOException {
AgentContract.ToolMeta meta = new AgentContract.ToolMeta(
null, null, null, null, null, AgentContract.CONTRACT_VERSION,
Instant.now(), 0L, "agent-contract", null, null);
AgentContract.ToolError error = new AgentContract.ToolError(
exception.getCode(), exception.getMessage(), exception.isRetryable(), exception.getDetails());
AgentContract.ToolResponse<Void> body = new AgentContract.ToolResponse<>(
false, null, error, meta, null);
response.resetBuffer();
response.setStatus(exception.getHttpStatus());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), body);
}

/**
* 读取并限制公共 Header,避免空值和超长日志污染。
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.synapxnet.goai.contract;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.assertThat;

/**
* 验证 Agent 过滤器在控制器执行前返回稳定且脱敏的公共错误包络。
*/
class AgentRequestContextFilterTests {

private static final String SECRET = "goai-filter-test-secret-1234567890";
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();

/**
* 验证缺少公共请求头时直接返回 400 JSON,而不是由容器转换为 500。
*
* @throws Exception 测试请求执行或 JSON 解析失败时抛出
*/
@Test
void returnsStructuredBadRequestWhenHeadersAreMissing() throws Exception {
AgentRequestContextFilter filter = createFilter();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/agent/v1/actions/action-1");
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilter(request, response, new MockFilterChain());

JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getContentType()).startsWith("application/json");
assertThat(body.path("success").asBoolean()).isFalse();
assertThat(body.path("error").path("code").asText()).isEqualTo("INVALID_ARGUMENT");
assertThat(body.path("meta").path("workspaceId").isNull()).isTrue();
}

/**
* 验证公共请求头有效但未携带委托令牌时返回 401 脱敏包络。
*
* @throws Exception 测试请求执行或 JSON 解析失败时抛出
*/
@Test
void returnsStructuredUnauthorizedWhenTokenIsMissing() throws Exception {
AgentRequestContextFilter filter = createFilter();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/agent/v1/actions/action-1");
request.addHeader("X-OpenXnet-Workspace-Id", "ws_goai_demo");
request.addHeader("X-OpenXnet-Incident-Id", "incident-1");
request.addHeader("X-OpenXnet-Trace-Id", "trace-1");
request.addHeader("X-OpenXnet-Tool-Name", "mlops.deployment.rollback");
request.addHeader("Idempotency-Key", "idem-1");
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilter(request, response, new MockFilterChain());

JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(401);
assertThat(body.path("error").path("code").asText()).isEqualTo("UNAUTHENTICATED");
assertThat(response.getContentAsString()).doesNotContain(SECRET);
}

/**
* 创建使用测试密钥和标准受众的过滤器实例。
*
* @return 可独立执行的 Agent 请求过滤器
*/
private AgentRequestContextFilter createFilter() {
DelegatedTokenVerifier verifier = new DelegatedTokenVerifier(SECRET, "openxnet-agent-adapter");
return new AgentRequestContextFilter(verifier, objectMapper);
}
}
4 changes: 4 additions & 0 deletions mlops-mep-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.3.4</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5-h2</artifactId>
</dependency>

<!-- SSH for remote node management -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package com.synapxnet.mlopsmepservice;

import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.synapxnet.mlopsmepservice.mapper")
@MapperScan(
basePackages = {
"com.synapxnet.mlopsmepservice.mapper",
"com.synapxnet.mlopsmepservice.agent"
},
annotationClass = Mapper.class)
public class MlopsMepServiceApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,9 @@ private void execute(String actionId) {
action.getWorkspaceId(), action.getIncidentId(), action.getTraceId(),
"mlops.inference.probe", "probe-" + action.getIdempotencyKey(), action.getCreatedBy(),
action.getRequestId() + "-verify");
MepAgentDtos.InferenceProbeResult probe = probeService.probe(
MepAgentDtos.InferenceProbeResult probe = probeService.probeAgainstRevision(
new MepAgentDtos.InferenceProbeArguments(
action.getDeploymentUid(), VERIFY_DATASET, 12, 60_000), probeContext);
action.getDeploymentUid(), VERIFY_DATASET, 12, 60_000), probeContext, target);
requireProbeThreshold(probe, policy);
transition(action, "RUNNING", "FINALIZING", null, null, false);
int updated = agentMapper.activateRevision(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,23 +74,72 @@ public MepAgentDtos.InferenceProbeResult probe(
AgentContract.RequestContext context) {
ProbeLimits limits = validate(arguments);
ModelDeployment deployment = deploymentEvidenceService.requireDeployment(arguments.deploymentUid());
Long activeRevision = deployment.getActiveRevision();
ModelContract contract = agentMapper.findContract(deployment.getModelUid(), deployment.getModelVersion());
if (contract == null || deployment.getActiveRevision() == null) {
if (contract == null || activeRevision == null) {
throw new AgentContractException(412, "PRECONDITION_FAILED", "部署缺少活动修订或模型输入契约");
}
return executeProbe(arguments, context, limits, deployment, contract, activeRevision);
}

/**
* 按尚未提交为活动版本的目标修订执行真实探针。
*
* @param arguments 探针参数
* @param context 已验证 Agent 上下文
* @param revision 回滚目标修订
* @return 使用目标修订契约和修订号生成的聚合结果
*/
public MepAgentDtos.InferenceProbeResult probeAgainstRevision(
MepAgentDtos.InferenceProbeArguments arguments,
AgentContract.RequestContext context,
DeploymentRevision revision) {
ProbeLimits limits = validate(arguments);
ModelDeployment deployment = deploymentEvidenceService.requireDeployment(arguments.deploymentUid());
if (revision == null || revision.getRevisionNumber() == null
|| revision.getDeploymentUid() == null
|| !deployment.getUid().equals(revision.getDeploymentUid())
|| revision.getModelContractUid() == null || revision.getModelContractUid().isBlank()) {
throw new AgentContractException(412, "PRECONDITION_FAILED", "目标修订缺少有效模型输入契约");
}
ModelContract contract = agentMapper.findContractByUid(revision.getModelContractUid());
if (contract == null) {
throw new AgentContractException(412, "PRECONDITION_FAILED", "目标修订模型输入契约不存在");
}
return executeProbe(arguments, context, limits, deployment, contract, revision.getRevisionNumber());
}

/**
* 使用指定契约和修订号执行探针、计算摘要并持久化结果。
*
* @param arguments 探针参数
* @param context 已验证 Agent 上下文
* @param limits 已校验探针预算
* @param deployment 当前部署和真实端点
* @param contract 本次验证采用的模型契约
* @param revisionNumber 本次验证采用的修订号
* @return 持久化后的聚合探针结果
*/
private MepAgentDtos.InferenceProbeResult executeProbe(
MepAgentDtos.InferenceProbeArguments arguments,
AgentContract.RequestContext context,
ProbeLimits limits,
ModelDeployment deployment,
ModelContract contract,
Long revisionNumber) {
ProbeDataset dataset = loadDataset(arguments.testDatasetRef(), context.workspaceId());
List<ProbeSample> samples = dataset.samples().stream().limit(limits.sampleLimit()).toList();
Instant startedAt = Instant.now();
ProbeAggregation aggregation = dataset.inputDimension() == contract.getInputDimension()
? invokeEndpoint(deployment, samples, limits.timeoutMs())
: contractMismatch(samples);
Instant completedAt = Instant.now();
String digest = digest(deployment, dataset, aggregation, startedAt, completedAt);
String digest = digest(deployment, revisionNumber, dataset, aggregation, startedAt, completedAt);
String probeUid = "probe_" + UUID.randomUUID().toString().replace("-", "").substring(0, 26);
MepAgentDtos.ContractStatus status = dataset.inputDimension() == contract.getInputDimension()
? MepAgentDtos.ContractStatus.MATCHED : MepAgentDtos.ContractStatus.MISMATCHED;
MepAgentDtos.InferenceProbeResult result = new MepAgentDtos.InferenceProbeResult(
probeUid, deployment.getUid(), deployment.getActiveRevision(), samples.size(),
probeUid, deployment.getUid(), revisionNumber, samples.size(),
aggregation.successCount(), aggregation.errorCount(), rate(aggregation.errorCount(), samples.size()),
percentile(aggregation.latenciesMs(), 0.50), percentile(aggregation.latenciesMs(), 0.95),
dataset.inputDimension(), contract.getInputDimension(), status, aggregation.failures(),
Expand Down Expand Up @@ -236,6 +285,7 @@ private BigDecimal percentile(List<Double> values, double percentile) {
* 计算不含原始样本的探针结果摘要。
*
* @param deployment 部署
* @param revisionNumber 本次验证采用的修订号
* @param dataset 数据集元信息
* @param aggregation 聚合结果
* @param startedAt 开始时间
Expand All @@ -244,14 +294,15 @@ private BigDecimal percentile(List<Double> values, double percentile) {
*/
private String digest(
ModelDeployment deployment,
Long revisionNumber,
ProbeDataset dataset,
ProbeAggregation aggregation,
Instant startedAt,
Instant completedAt) {
try {
String value = objectMapper.writeValueAsString(Map.of(
"deploymentUid", deployment.getUid(),
"revision", deployment.getActiveRevision(),
"revision", revisionNumber,
"datasetRef", dataset.datasetRef(),
"inputDimension", dataset.inputDimension(),
"successCount", aggregation.successCount(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.synapxnet.mlopsmepservice.agent.DeploymentRevision;
import com.synapxnet.mlopsmepservice.entity.ModelDeployment;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Component;

Expand All @@ -35,18 +36,25 @@ public class DockerDeploymentRuntimeProvider implements DeploymentRuntimeProvide

private final ObjectMapper objectMapper;
private final TaskScheduler readinessScheduler;
private final String runtimeNetwork;

/**
* 创建 Docker Runtime Provider。
*
* @param objectMapper 部署规格 JSON 解析器
* @param readinessScheduler Spring 管理的 readiness 轮询调度器
* @param runtimeNetwork 仅供 MEP、Docker Proxy 和模型容器通信的隔离网络
*/
public DockerDeploymentRuntimeProvider(
ObjectMapper objectMapper,
@Qualifier("deploymentReadinessScheduler") TaskScheduler readinessScheduler) {
@Qualifier("deploymentReadinessScheduler") TaskScheduler readinessScheduler,
@Value("${openxnet.docker.runtime-network:synapxnet_runtime-control}") String runtimeNetwork) {
this.objectMapper = objectMapper;
this.readinessScheduler = readinessScheduler;
if (runtimeNetwork == null || !runtimeNetwork.matches("[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")) {
throw new IllegalArgumentException("Docker Runtime 隔离网络名称无效");
}
this.runtimeNetwork = runtimeNetwork;
}

/** 仅支持明确标记为本机 Docker 的部署节点。 */
Expand Down Expand Up @@ -105,8 +113,11 @@ public void applyRevision(ModelDeployment deployment, DeploymentRevision revisio
try (DockerClient client = client()) {
removeExisting(client, spec.containerName());
ExposedPort exposedPort = ExposedPort.tcp(spec.containerPort());
PortBinding portBinding = new PortBinding(Ports.Binding.bindPort(spec.hostPort()), exposedPort);
HostConfig hostConfig = HostConfig.newHostConfig().withPortBindings(portBinding);
PortBinding portBinding = new PortBinding(
Ports.Binding.bindIpAndPort("127.0.0.1", spec.hostPort()), exposedPort);
HostConfig hostConfig = HostConfig.newHostConfig()
.withPortBindings(portBinding)
.withNetworkMode(runtimeNetwork);
String containerId = client.createContainerCmd(revision.getImageName())
.withName(spec.containerName())
.withExposedPorts(exposedPort)
Expand Down
Loading