diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java index 6104c9f5..ad7fb1cf 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java @@ -1,6 +1,8 @@ package com.cameleer.server.app.runtime; +import com.cameleer.common.model.ApplicationConfig; 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.core.runtime.*; import org.slf4j.Logger; @@ -25,6 +27,7 @@ public class DeploymentExecutor { private final EnvironmentService envService; private final DeploymentRepository deploymentRepository; private final PostgresDeploymentRepository pgDeployRepo; + private final PostgresApplicationConfigRepository applicationConfigRepository; @Autowired(required = false) private DockerNetworkManager networkManager; @@ -75,13 +78,15 @@ public class DeploymentExecutor { DeploymentService deploymentService, AppService appService, EnvironmentService envService, - DeploymentRepository deploymentRepository) { + DeploymentRepository deploymentRepository, + PostgresApplicationConfigRepository applicationConfigRepository) { this.orchestrator = orchestrator; this.deploymentService = deploymentService; this.appService = appService; this.envService = envService; this.deploymentRepository = deploymentRepository; this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository; + this.applicationConfigRepository = applicationConfigRepository; } @Async("deploymentTaskExecutor") @@ -252,6 +257,17 @@ public class DeploymentExecutor { // === 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); DeploymentStatus finalStatus = healthyCount == config.replicas() ? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java new file mode 100644 index 00000000..6ef09137 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java @@ -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 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 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(); + } +}