From 330efd551409ceeec6efd4fd46b0d0320b8f3613 Mon Sep 17 00:00:00 2001 From: SynapXnet Team <278580990+synapxnet@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:12:10 +0800 Subject: [PATCH] fix(goai): harden governed rollback runtime --- .../AgentContractAutoConfiguration.java | 7 +- .../contract/AgentRequestContextFilter.java | 60 ++++++++++++--- .../AgentRequestContextFilterTests.java | 74 +++++++++++++++++++ mlops-mep-service/pom.xml | 4 + .../MlopsMepServiceApplication.java | 8 +- .../agent/DeploymentActionService.java | 4 +- .../agent/InferenceProbeService.java | 59 ++++++++++++++- .../DockerDeploymentRuntimeProvider.java | 17 ++++- .../agent/DeploymentActionServiceTest.java | 34 +++++++++ 9 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 mlops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTests.java diff --git a/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java b/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java index f1b2e64..6761d61 100644 --- a/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java +++ b/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentContractAutoConfiguration.java @@ -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; @@ -29,13 +30,15 @@ DelegatedTokenVerifier delegatedTokenVerifier( * 注册只作用于 Agent v1 API 的上下文过滤器。 * * @param verifier 委托令牌验证器 + * @param objectMapper Spring 统一配置的 JSON 序列化器 * @return Servlet 过滤器注册对象 */ @Bean FilterRegistrationBean agentRequestContextFilter( - DelegatedTokenVerifier verifier) { + DelegatedTokenVerifier verifier, + ObjectMapper objectMapper) { FilterRegistrationBean bean = new FilterRegistrationBean<>(); - bean.setFilter(new AgentRequestContextFilter(verifier)); + bean.setFilter(new AgentRequestContextFilter(verifier, objectMapper)); bean.addUrlPatterns("/api/agent/v1/*"); bean.setOrder(-100); return bean; diff --git a/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java b/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java index 4ed036c..51db78d 100644 --- a/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java +++ b/mlops-agent-contract/src/main/java/com/synapxnet/goai/contract/AgentRequestContextFilter.java @@ -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; @@ -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; } /** @@ -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 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,避免空值和超长日志污染。 * diff --git a/mlops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTests.java b/mlops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTests.java new file mode 100644 index 0000000..8330c70 --- /dev/null +++ b/mlops-agent-contract/src/test/java/com/synapxnet/goai/contract/AgentRequestContextFilterTests.java @@ -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); + } +} diff --git a/mlops-mep-service/pom.xml b/mlops-mep-service/pom.xml index 57aea39..721c9bc 100644 --- a/mlops-mep-service/pom.xml +++ b/mlops-mep-service/pom.xml @@ -121,6 +121,10 @@ docker-java-transport-httpclient5 3.3.4 + + org.apache.httpcomponents.core5 + httpcore5-h2 + diff --git a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/MlopsMepServiceApplication.java b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/MlopsMepServiceApplication.java index de4d054..1d441f4 100644 --- a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/MlopsMepServiceApplication.java +++ b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/MlopsMepServiceApplication.java @@ -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) { diff --git a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionService.java b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionService.java index fafbf51..ad33717 100644 --- a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionService.java +++ b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionService.java @@ -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( diff --git a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/InferenceProbeService.java b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/InferenceProbeService.java index 019b390..fed909c 100644 --- a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/InferenceProbeService.java +++ b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/InferenceProbeService.java @@ -74,10 +74,59 @@ 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 samples = dataset.samples().stream().limit(limits.sampleLimit()).toList(); Instant startedAt = Instant.now(); @@ -85,12 +134,12 @@ public MepAgentDtos.InferenceProbeResult probe( ? 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(), @@ -236,6 +285,7 @@ private BigDecimal percentile(List values, double percentile) { * 计算不含原始样本的探针结果摘要。 * * @param deployment 部署 + * @param revisionNumber 本次验证采用的修订号 * @param dataset 数据集元信息 * @param aggregation 聚合结果 * @param startedAt 开始时间 @@ -244,6 +294,7 @@ private BigDecimal percentile(List values, double percentile) { */ private String digest( ModelDeployment deployment, + Long revisionNumber, ProbeDataset dataset, ProbeAggregation aggregation, Instant startedAt, @@ -251,7 +302,7 @@ private String digest( try { String value = objectMapper.writeValueAsString(Map.of( "deploymentUid", deployment.getUid(), - "revision", deployment.getActiveRevision(), + "revision", revisionNumber, "datasetRef", dataset.datasetRef(), "inputDimension", dataset.inputDimension(), "successCount", aggregation.successCount(), diff --git a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/provider/DockerDeploymentRuntimeProvider.java b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/provider/DockerDeploymentRuntimeProvider.java index 883a3fd..75a1587 100644 --- a/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/provider/DockerDeploymentRuntimeProvider.java +++ b/mlops-mep-service/src/main/java/com/synapxnet/mlopsmepservice/agent/provider/DockerDeploymentRuntimeProvider.java @@ -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; @@ -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 的部署节点。 */ @@ -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) diff --git a/mlops-mep-service/src/test/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionServiceTest.java b/mlops-mep-service/src/test/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionServiceTest.java index 671017a..ce571d4 100644 --- a/mlops-mep-service/src/test/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionServiceTest.java +++ b/mlops-mep-service/src/test/java/com/synapxnet/mlopsmepservice/agent/DeploymentActionServiceTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.same; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -129,6 +130,39 @@ void rejectsResourceVersionConflictWithoutSideEffect() { verify(executor, never()).execute(any()); } + /** 实际回滚必须使用目标修订 17 的契约执行探针,而不是仍活动的修订 18。 */ + @Test + void executionProbesAgainstTargetRevisionContract() { + AtomicReference saved = new AtomicReference<>(); + ArgumentCaptor queued = ArgumentCaptor.forClass(Runnable.class); + DeploymentRevision previous = new DeploymentRevision(); + previous.setDeploymentUid(deployment.getUid()); + previous.setRevisionNumber(18L); + MepAgentDtos.InferenceProbeResult probe = mock(MepAgentDtos.InferenceProbeResult.class); + when(mapper.findActionByIdempotency("ws_goai_demo", "ROLLBACK", "idem_rollback_001")) + .thenAnswer(invocation -> saved.get()); + org.mockito.Mockito.doAnswer(invocation -> { + saved.set(invocation.getArgument(0)); + return 1; + }).when(mapper).insertAction(any()); + when(mapper.findActionByUid(anyString())).thenAnswer(invocation -> saved.get()); + when(mapper.findRevision(deployment.getUid(), 18L)).thenReturn(previous); + when(provider.waitUntilReady(any(), any())).thenReturn( + new DeploymentRuntimeProvider.RuntimeInspection(true, true, "healthy", "v17")); + when(probeService.probeAgainstRevision(any(), any(), same(revision))).thenReturn(probe); + when(probe.contractStatus()).thenReturn(MepAgentDtos.ContractStatus.MATCHED); + when(probe.errorRate()).thenReturn(BigDecimal.ZERO); + when(probe.p95Ms()).thenReturn(new BigDecimal("100")); + when(mapper.activateRevision(anyString(), any(Long.class), same(revision))).thenReturn(1); + + service.submit(request(false), context()); + verify(executor).execute(queued.capture()); + queued.getValue().run(); + + verify(probeService).probeAgainstRevision(any(), any(), same(revision)); + verify(provider, never()).restore(any(), any(), anyString()); + } + /** 创建强类型公共回滚请求。 */ private AgentContract.ToolRequest request(boolean dryRun) { return new AgentContract.ToolRequest<>(