api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff
Wires DirtyStateCalculator behind an HTTP endpoint on AppController. Adds findLatestSuccessfulByAppAndEnv to PostgresDeploymentRepository, registers DirtyStateCalculator as a Spring bean (with ObjectMapper for JavaTimeModule support), and covers all three scenarios with IT. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.app.dto.DirtyStateResponse;
|
||||
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.test.annotation.DirtiesContext;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Integration tests for GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state.
|
||||
*
|
||||
* <p>Uses @MockBean RuntimeOrchestrator (same pattern as DeploymentSnapshotIT).
|
||||
* @DirtiesContext prevents context cache conflicts when both IT classes are loaded together.</p>
|
||||
*/
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
class AppDirtyStateIT extends AbstractPostgresIT {
|
||||
|
||||
@MockBean
|
||||
RuntimeOrchestrator runtimeOrchestrator;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
@Autowired
|
||||
private PostgresDeploymentRepository deploymentRepository;
|
||||
|
||||
private String operatorJwt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
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: no deployment ever → dirty=true, lastSuccessfulDeploymentId=null
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception {
|
||||
String appSlug = "ds-nodeploy-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
post("/api/v1/environments/default/apps",
|
||||
String.format("{\"slug\": \"%s\", \"displayName\": \"DS No Deploy\"}", appSlug),
|
||||
operatorJwt);
|
||||
uploadJar(appSlug, ("fake-jar-" + appSlug).getBytes());
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.5}", operatorJwt);
|
||||
|
||||
DirtyStateResponse body = getDirtyState("default", appSlug);
|
||||
|
||||
assertThat(body.dirty()).isTrue();
|
||||
assertThat(body.lastSuccessfulDeploymentId()).isNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: after a successful deploy with matching desired state → dirty=false
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_afterSuccessfulDeploy_matchingDesiredState_returnsDirtyFalse() throws Exception {
|
||||
String fakeContainerId = "fake-cid-" + UUID.randomUUID();
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
|
||||
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
|
||||
.thenReturn(new ContainerStatus("healthy", true, 0, null));
|
||||
|
||||
String appSlug = "ds-clean-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
post("/api/v1/environments/default/apps",
|
||||
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Clean\"}", appSlug),
|
||||
operatorJwt);
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
|
||||
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
|
||||
String versionId = uploadJar(appSlug, ("fake-jar-clean-" + appSlug).getBytes());
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.25}", operatorJwt);
|
||||
|
||||
// Deploy and wait for RUNNING
|
||||
JsonNode deploy = post(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
|
||||
String.format("{\"appVersionId\": \"%s\"}", versionId),
|
||||
operatorJwt);
|
||||
String deploymentId = deploy.path("id").asText();
|
||||
|
||||
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
|
||||
.orElseThrow(() -> new AssertionError("Deployment not found"));
|
||||
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
|
||||
});
|
||||
|
||||
// Desired state matches what was deployed → dirty=false
|
||||
DirtyStateResponse body = getDirtyState("default", appSlug);
|
||||
|
||||
assertThat(body.dirty()).isFalse();
|
||||
assertThat(body.differences()).isEmpty();
|
||||
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: after successful deploy, config changed → dirty=true
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception {
|
||||
String fakeContainerId = "fake-cid2-" + UUID.randomUUID();
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
|
||||
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
|
||||
.thenReturn(new ContainerStatus("healthy", true, 0, null));
|
||||
|
||||
String appSlug = "ds-dirty-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
post("/api/v1/environments/default/apps",
|
||||
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Dirty\"}", appSlug),
|
||||
operatorJwt);
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
|
||||
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
|
||||
String versionId = uploadJar(appSlug, ("fake-jar-dirty-" + appSlug).getBytes());
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.1}", operatorJwt);
|
||||
|
||||
// Deploy and wait for RUNNING
|
||||
JsonNode deploy = post(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
|
||||
String.format("{\"appVersionId\": \"%s\"}", versionId),
|
||||
operatorJwt);
|
||||
String deploymentId = deploy.path("id").asText();
|
||||
|
||||
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
|
||||
.orElseThrow(() -> new AssertionError("Deployment not found"));
|
||||
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
|
||||
});
|
||||
|
||||
// Change samplingRate after deploy
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.9}", operatorJwt);
|
||||
|
||||
// Now desired state differs from snapshot → dirty=true
|
||||
DirtyStateResponse body = getDirtyState("default", appSlug);
|
||||
|
||||
assertThat(body.dirty()).isTrue();
|
||||
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
|
||||
assertThat(body.differences()).isNotEmpty();
|
||||
assertThat(body.differences())
|
||||
.anyMatch(d -> d.field().contains("samplingRate"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private DirtyStateResponse getDirtyState(String envSlug, String appSlug) {
|
||||
HttpHeaders headers = securityHelper.authHeaders(operatorJwt);
|
||||
var response = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/dirty-state",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
DirtyStateResponse.class);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
return response.getBody();
|
||||
}
|
||||
|
||||
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