runtime(deploy): capture config snapshot on RUNNING transition

Injects PostgresApplicationConfigRepository into DeploymentExecutor and
calls saveDeployedConfigSnapshot at the COMPLETE stage, before
markRunning. Snapshot contains jarVersionId, agentConfig (nullable),
and app.containerConfig. The FAILED catch path is left untouched so
snapshot stays null on failure. Verified by DeploymentSnapshotIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 21:51:00 +02:00
parent 9b851c4622
commit a79eafeaf4
2 changed files with 249 additions and 1 deletions

View File

@@ -1,6 +1,8 @@
package com.cameleer.server.app.runtime; package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.metrics.ServerMetrics; import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository; import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.*; import com.cameleer.server.core.runtime.*;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -25,6 +27,7 @@ public class DeploymentExecutor {
private final EnvironmentService envService; private final EnvironmentService envService;
private final DeploymentRepository deploymentRepository; private final DeploymentRepository deploymentRepository;
private final PostgresDeploymentRepository pgDeployRepo; private final PostgresDeploymentRepository pgDeployRepo;
private final PostgresApplicationConfigRepository applicationConfigRepository;
@Autowired(required = false) @Autowired(required = false)
private DockerNetworkManager networkManager; private DockerNetworkManager networkManager;
@@ -75,13 +78,15 @@ public class DeploymentExecutor {
DeploymentService deploymentService, DeploymentService deploymentService,
AppService appService, AppService appService,
EnvironmentService envService, EnvironmentService envService,
DeploymentRepository deploymentRepository) { DeploymentRepository deploymentRepository,
PostgresApplicationConfigRepository applicationConfigRepository) {
this.orchestrator = orchestrator; this.orchestrator = orchestrator;
this.deploymentService = deploymentService; this.deploymentService = deploymentService;
this.appService = appService; this.appService = appService;
this.envService = envService; this.envService = envService;
this.deploymentRepository = deploymentRepository; this.deploymentRepository = deploymentRepository;
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository; this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
this.applicationConfigRepository = applicationConfigRepository;
} }
@Async("deploymentTaskExecutor") @Async("deploymentTaskExecutor")
@@ -252,6 +257,17 @@ public class DeploymentExecutor {
// === COMPLETE === // === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE); updateStage(deployment.id(), DeployStage.COMPLETE);
// Capture config snapshot before marking RUNNING
ApplicationConfig agentConfig = applicationConfigRepository
.findByApplicationAndEnvironment(app.slug(), env.slug())
.orElse(null);
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
deployment.appVersionId(),
agentConfig,
app.containerConfig()
);
pgDeployRepo.saveDeployedConfigSnapshot(deployment.id(), snapshot);
String primaryContainerId = newContainerIds.get(0); String primaryContainerId = newContainerIds.get(0);
DeploymentStatus finalStatus = healthyCount == config.replicas() DeploymentStatus finalStatus = healthyCount == config.replicas()
? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED; ? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED;

View File

@@ -0,0 +1,232 @@
package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.ContainerStatus;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Verifies that DeploymentExecutor writes DeploymentConfigSnapshot on successful
* RUNNING transition and does NOT write it on a FAILED path.
*/
class DeploymentSnapshotIT extends AbstractPostgresIT {
@MockBean
RuntimeOrchestrator runtimeOrchestrator;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private PostgresDeploymentRepository deploymentRepository;
private String operatorJwt;
private String adminJwt;
@BeforeEach
void setUp() throws Exception {
operatorJwt = securityHelper.operatorToken();
adminJwt = securityHelper.adminToken();
// Clean up between tests
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
}
// -----------------------------------------------------------------------
// Test 1: snapshot is populated when deployment reaches RUNNING
// -----------------------------------------------------------------------
@Test
void snapshot_isPopulated_whenDeploymentReachesRunning() throws Exception {
// --- given: mock orchestrator that simulates a healthy single-replica container ---
String fakeContainerId = "fake-container-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any()))
.thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
// --- given: create app with explicit runtimeType so auto-detection is not needed ---
String appSlug = "snap-success-" + UUID.randomUUID().toString().substring(0, 8);
String containerConfigJson = """
{"runtimeType": "spring-boot", "appPort": 8081}
""";
String createAppJson = String.format("""
{"slug": "%s", "displayName": "Snapshot Success App"}
""", appSlug);
JsonNode createdApp = post("/api/v1/environments/default/apps", createAppJson, operatorJwt);
String appId = createdApp.path("id").asText();
// --- given: update containerConfig to set runtimeType ---
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
containerConfigJson, operatorJwt);
// --- given: upload a JAR (fake bytes; real file written to disk by AppService) ---
String versionId = uploadJar(appSlug, ("fake-jar-bytes-" + appSlug).getBytes());
// --- given: save agentConfig with samplingRate = 0.25 ---
String configJson = """
{"samplingRate": 0.25}
""";
put("/api/v1/environments/default/apps/" + appSlug + "/config", configJson, operatorJwt);
// --- when: trigger deploy ---
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
JsonNode deployResponse = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
deployJson, operatorJwt);
String deploymentId = deployResponse.path("id").asText();
// --- await RUNNING (async executor) ---
AtomicReference<Deployment> deploymentRef = new AtomicReference<>();
await().atMost(30, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId));
assertThat(d.status()).isIn(DeploymentStatus.RUNNING, DeploymentStatus.DEGRADED);
deploymentRef.set(d);
});
// --- then: snapshot is populated ---
Deployment deployed = deploymentRef.get();
assertThat(deployed.deployedConfigSnapshot()).isNotNull();
assertThat(deployed.deployedConfigSnapshot().jarVersionId())
.isEqualTo(UUID.fromString(versionId));
assertThat(deployed.deployedConfigSnapshot().agentConfig()).isNotNull();
assertThat(deployed.deployedConfigSnapshot().agentConfig().getSamplingRate())
.isEqualTo(0.25);
}
// -----------------------------------------------------------------------
// Test 2: snapshot is NOT populated when deployment fails
// -----------------------------------------------------------------------
@Test
void snapshot_isNotPopulated_whenDeploymentFails() throws Exception {
// --- given: mock orchestrator that throws on startContainer ---
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any()))
.thenThrow(new RuntimeException("Simulated container start failure"));
// --- given: create app with explicit runtimeType ---
String appSlug = "snap-fail-" + UUID.randomUUID().toString().substring(0, 8);
String createAppJson = String.format("""
{"slug": "%s", "displayName": "Snapshot Fail App"}
""", appSlug);
post("/api/v1/environments/default/apps", createAppJson, operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"""
{"runtimeType": "spring-boot", "appPort": 8081}
""", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-fail-" + appSlug).getBytes());
// --- when: trigger deploy ---
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
JsonNode deployResponse = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
deployJson, operatorJwt);
String deploymentId = deployResponse.path("id").asText();
// --- await FAILED (async executor catches exception and marks failed) ---
await().atMost(30, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId));
assertThat(d.status()).isEqualTo(DeploymentStatus.FAILED);
});
// --- then: snapshot is null ---
Deployment failed = deploymentRepository.findById(UUID.fromString(deploymentId)).orElseThrow();
assertThat(failed.deployedConfigSnapshot()).isNull();
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private JsonNode post(String path, String json, String jwt) throws Exception {
HttpHeaders headers = securityHelper.authHeaders(jwt);
var response = restTemplate.exchange(
path, HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
return objectMapper.readTree(response.getBody());
}
private void put(String path, String json, String jwt) {
HttpHeaders headers = securityHelper.authHeaders(jwt);
restTemplate.exchange(
path, HttpMethod.PUT,
new HttpEntity<>(json, headers),
String.class);
}
private String uploadJar(String appSlug, byte[] content) throws Exception {
ByteArrayResource resource = new ByteArrayResource(content) {
@Override
public String getFilename() { return "app.jar"; }
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions",
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
JsonNode versionNode = objectMapper.readTree(response.getBody());
return versionNode.path("id").asText();
}
}