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:
@@ -53,7 +53,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
### Env-scoped (user-facing data & config)
|
||||
|
||||
- `AppController` — `/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config`. App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex.
|
||||
- `AppController` — `/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config` / GET `{appSlug}/dirty-state` (returns `DirtyStateResponse{dirty, lastSuccessfulDeploymentId, differences}` — compares current JAR+config against last RUNNING deployment snapshot; dirty=true when no snapshot exists). App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex. Injects `DirtyStateCalculator` bean (registered in `RuntimeBeanConfig`, requires `ObjectMapper` with `JavaTimeModule`).
|
||||
- `DeploymentController` — `/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`.
|
||||
- `ApplicationConfigController` — `/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT also pushes `CONFIG_UPDATE` to LIVE agents in this env.
|
||||
- `AppSettingsController` — `/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersionRepository;
|
||||
import com.cameleer.server.core.runtime.DeploymentRepository;
|
||||
import com.cameleer.server.core.runtime.DeploymentService;
|
||||
import com.cameleer.server.core.runtime.DirtyStateCalculator;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import com.cameleer.server.core.runtime.EnvironmentService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -64,6 +65,11 @@ public class RuntimeBeanConfig {
|
||||
return new DeploymentService(deployRepo, appService, envService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DirtyStateCalculator dirtyStateCalculator(ObjectMapper objectMapper) {
|
||||
return new DirtyStateCalculator(objectMapper);
|
||||
}
|
||||
|
||||
@Bean(name = "deploymentTaskExecutor")
|
||||
public Executor deploymentTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.dto.DirtyStateResponse;
|
||||
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
|
||||
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.runtime.App;
|
||||
import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersion;
|
||||
import com.cameleer.server.core.runtime.AppVersionRepository;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
|
||||
import com.cameleer.server.core.runtime.DirtyStateCalculator;
|
||||
import com.cameleer.server.core.runtime.DirtyStateResult;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.RuntimeType;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -22,8 +32,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -40,9 +52,21 @@ import java.util.UUID;
|
||||
public class AppController {
|
||||
|
||||
private final AppService appService;
|
||||
private final AppVersionRepository appVersionRepository;
|
||||
private final PostgresApplicationConfigRepository configRepository;
|
||||
private final PostgresDeploymentRepository deploymentRepository;
|
||||
private final DirtyStateCalculator dirtyCalc;
|
||||
|
||||
public AppController(AppService appService) {
|
||||
public AppController(AppService appService,
|
||||
AppVersionRepository appVersionRepository,
|
||||
PostgresApplicationConfigRepository configRepository,
|
||||
PostgresDeploymentRepository deploymentRepository,
|
||||
DirtyStateCalculator dirtyCalc) {
|
||||
this.appService = appService;
|
||||
this.appVersionRepository = appVersionRepository;
|
||||
this.configRepository = configRepository;
|
||||
this.deploymentRepository = deploymentRepository;
|
||||
this.dirtyCalc = dirtyCalc;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -120,6 +144,47 @@ public class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{appSlug}/dirty-state")
|
||||
@Operation(summary = "Check whether the app's current config differs from the last successful deploy",
|
||||
description = "Returns dirty=true when the desired state (current JAR + agent config + container config) "
|
||||
+ "would produce a changed deployment. When no successful deploy exists yet, dirty=true.")
|
||||
@ApiResponse(responseCode = "200", description = "Dirty-state computed")
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<DirtyStateResponse> getDirtyState(@EnvPath Environment env,
|
||||
@PathVariable String appSlug) {
|
||||
App app;
|
||||
try {
|
||||
app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found");
|
||||
}
|
||||
|
||||
// Latest JAR version (newest first — findByAppId orders by version DESC)
|
||||
List<AppVersion> versions = appVersionRepository.findByAppId(app.id());
|
||||
UUID latestVersionId = versions.isEmpty() ? null
|
||||
: versions.stream().max(Comparator.comparingInt(AppVersion::version))
|
||||
.map(AppVersion::id).orElse(null);
|
||||
|
||||
// Desired agent config
|
||||
ApplicationConfig agentConfig = configRepository
|
||||
.findByApplicationAndEnvironment(appSlug, env.slug())
|
||||
.orElse(null);
|
||||
|
||||
// Container config
|
||||
Map<String, Object> containerConfig = app.containerConfig();
|
||||
|
||||
// Last successful deployment snapshot
|
||||
Deployment lastSuccessful = deploymentRepository
|
||||
.findLatestSuccessfulByAppAndEnv(app.id(), env.id())
|
||||
.orElse(null);
|
||||
DeploymentConfigSnapshot snapshot = lastSuccessful != null ? lastSuccessful.deployedConfigSnapshot() : null;
|
||||
|
||||
DirtyStateResult result = dirtyCalc.compute(latestVersionId, agentConfig, containerConfig, snapshot);
|
||||
|
||||
String lastId = lastSuccessful != null ? lastSuccessful.id().toString() : null;
|
||||
return ResponseEntity.ok(new DirtyStateResponse(result.dirty(), lastId, result.differences()));
|
||||
}
|
||||
|
||||
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
|
||||
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cameleer.server.app.dto;
|
||||
|
||||
import com.cameleer.server.core.runtime.DirtyStateResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record DirtyStateResponse(
|
||||
boolean dirty,
|
||||
String lastSuccessfulDeploymentId,
|
||||
List<DirtyStateResult.Difference> differences
|
||||
) {
|
||||
}
|
||||
@@ -139,6 +139,16 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
|
||||
var results = jdbc.query(
|
||||
"SELECT " + SELECT_COLS + " FROM deployments "
|
||||
+ "WHERE app_id = ? AND environment_id = ? "
|
||||
+ "AND status = 'RUNNING' AND deployed_config_snapshot IS NOT NULL "
|
||||
+ "ORDER BY deployed_at DESC NULLS LAST LIMIT 1",
|
||||
(rs, rowNum) -> mapRow(rs), appId, envId);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public Optional<Deployment> findByContainerId(String containerId) {
|
||||
var results = jdbc.query(
|
||||
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,17 @@ import java.util.UUID;
|
||||
* Compares the app's current desired state (JAR + agent config + container config) to the
|
||||
* config snapshot from the last successful deployment, producing a structured dirty result.
|
||||
*
|
||||
* <p>Pure logic — no IO, no Spring. Safe to unit-test as a POJO.</p>
|
||||
* <p>Pure logic — no IO, no Spring. Safe to unit-test as a POJO.
|
||||
* Caller must supply an {@link ObjectMapper} configured with {@code JavaTimeModule} so that
|
||||
* {@code ApplicationConfig.updatedAt} (an {@link java.time.Instant}) serialises correctly.</p>
|
||||
*/
|
||||
public class DirtyStateCalculator {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public DirtyStateCalculator(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public DirtyStateResult compute(UUID desiredJarVersionId,
|
||||
ApplicationConfig desiredAgentConfig,
|
||||
@@ -38,10 +44,10 @@ public class DirtyStateCalculator {
|
||||
String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId())));
|
||||
}
|
||||
|
||||
compareJson("agentConfig", MAPPER.valueToTree(desiredAgentConfig),
|
||||
MAPPER.valueToTree(snapshot.agentConfig()), diffs);
|
||||
compareJson("containerConfig", MAPPER.valueToTree(desiredContainerConfig),
|
||||
MAPPER.valueToTree(snapshot.containerConfig()), diffs);
|
||||
compareJson("agentConfig", mapper.valueToTree(desiredAgentConfig),
|
||||
mapper.valueToTree(snapshot.agentConfig()), diffs);
|
||||
compareJson("containerConfig", mapper.valueToTree(desiredContainerConfig),
|
||||
mapper.valueToTree(snapshot.containerConfig()), diffs);
|
||||
|
||||
return new DirtyStateResult(!diffs.isEmpty(), diffs);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -10,9 +11,11 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DirtyStateCalculatorTest {
|
||||
|
||||
private static final DirtyStateCalculator CALC = new DirtyStateCalculator(new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void noSnapshot_meansEverythingDirty() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig desiredAgent = new ApplicationConfig();
|
||||
desiredAgent.setSamplingRate(1.0);
|
||||
@@ -27,7 +30,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void identicalSnapshot_isClean() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig cfg = new ApplicationConfig();
|
||||
cfg.setSamplingRate(0.5);
|
||||
@@ -43,7 +46,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void differentJar_marksJarField() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
ApplicationConfig cfg = new ApplicationConfig();
|
||||
Map<String, Object> container = Map.of();
|
||||
UUID v1 = UUID.randomUUID();
|
||||
@@ -59,7 +62,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void differentSamplingRate_marksAgentField() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployedCfg = new ApplicationConfig();
|
||||
deployedCfg.setSamplingRate(0.5);
|
||||
@@ -77,7 +80,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void differentContainerMemory_marksContainerField() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
ApplicationConfig cfg = new ApplicationConfig();
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512));
|
||||
@@ -91,7 +94,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void nullAgentConfigInSnapshot_marksAgentConfigDiff() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setSamplingRate(1.0);
|
||||
UUID jarId = UUID.randomUUID();
|
||||
@@ -106,7 +109,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void nestedAgentField_reportsDeepPath() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||
@@ -124,7 +127,7 @@ class DirtyStateCalculatorTest {
|
||||
|
||||
@Test
|
||||
void stringField_differenceValueIsUnquoted() {
|
||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setApplicationLogLevel("INFO");
|
||||
|
||||
Reference in New Issue
Block a user