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:
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user