From 6591f2fde3fa04d93faca6083448b8ae5d7bbb4f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:35:35 +0200 Subject: [PATCH] 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 --- .claude/rules/app-classes.md | 2 +- .../server/app/config/RuntimeBeanConfig.java | 6 + .../server/app/controller/AppController.java | 67 ++++- .../server/app/dto/DirtyStateResponse.java | 12 + .../storage/PostgresDeploymentRepository.java | 10 + .../app/controller/AppDirtyStateIT.java | 235 ++++++++++++++++++ .../core/runtime/DirtyStateCalculator.java | 18 +- .../runtime/DirtyStateCalculatorTest.java | 19 +- 8 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index a2a32b8e..8a551b1f 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -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. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java index 4de4e48c..ca78caba 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java @@ -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(); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java index bd7d5b5b..336a4f74 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java @@ -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 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 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 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+\"']*$"); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java new file mode 100644 index 00000000..f79383de --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java @@ -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 differences +) { +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java index 264baf60..c653f25c 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java @@ -139,6 +139,16 @@ public class PostgresDeploymentRepository implements DeploymentRepository { } } + public Optional 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 findByContainerId(String containerId) { var results = jdbc.query( "SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " + diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java new file mode 100644 index 00000000..0b188ef7 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java @@ -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. + * + *

Uses @MockBean RuntimeOrchestrator (same pattern as DeploymentSnapshotIT). + * @DirtiesContext prevents context cache conflicts when both IT classes are loaded together.

+ */ +@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 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(); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java index 2fc24b53..5187721f 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java @@ -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. * - *

Pure logic — no IO, no Spring. Safe to unit-test as a POJO.

+ *

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.

*/ 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); } diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java index bb57be38..df0e9042 100644 --- a/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java @@ -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 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");