From e4ccce1e3b930956a97c7ca180525a83afd56e0a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:20:49 +0200 Subject: [PATCH] core(deploy): add DirtyStateCalculator + DirtyStateResult Pure-logic dirty-state detection: compares desired JAR + agent config + container config against the DeploymentConfigSnapshot from the last successful deployment. Returns a structured DirtyStateResult with per-field differences. 5 unit tests. Co-Authored-By: Claude Sonnet 4.6 --- .../core/runtime/DirtyStateCalculator.java | 70 ++++++++++++++ .../server/core/runtime/DirtyStateResult.java | 7 ++ .../runtime/DirtyStateCalculatorTest.java | 91 +++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java create mode 100644 cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java 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 new file mode 100644 index 00000000..f9a32ad6 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java @@ -0,0 +1,70 @@ +package com.cameleer.server.core.runtime; + +import com.cameleer.common.model.ApplicationConfig; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +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.

+ */ +public class DirtyStateCalculator { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public DirtyStateResult compute(UUID desiredJarVersionId, + ApplicationConfig desiredAgentConfig, + Map desiredContainerConfig, + DeploymentConfigSnapshot snapshot) { + List diffs = new ArrayList<>(); + + if (snapshot == null) { + diffs.add(new DirtyStateResult.Difference("snapshot", "(none)", "(none)")); + return new DirtyStateResult(true, diffs); + } + + if (!Objects.equals(desiredJarVersionId, snapshot.jarVersionId())) { + diffs.add(new DirtyStateResult.Difference("jarVersionId", + 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); + + return new DirtyStateResult(!diffs.isEmpty(), diffs); + } + + private void compareJson(String prefix, JsonNode desired, JsonNode deployed, + List diffs) { + if (!(desired instanceof ObjectNode desiredObj) || !(deployed instanceof ObjectNode deployedObj)) { + if (!Objects.equals(desired, deployed)) { + diffs.add(new DirtyStateResult.Difference(prefix, + String.valueOf(desired), String.valueOf(deployed))); + } + return; + } + TreeSet keys = new TreeSet<>(); + desiredObj.fieldNames().forEachRemaining(keys::add); + deployedObj.fieldNames().forEachRemaining(keys::add); + for (String key : keys) { + JsonNode d = desiredObj.get(key); + JsonNode p = deployedObj.get(key); + if (!Objects.equals(d, p)) { + diffs.add(new DirtyStateResult.Difference(prefix + "." + key, + String.valueOf(d), String.valueOf(p))); + } + } + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java new file mode 100644 index 00000000..8af70fed --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java @@ -0,0 +1,7 @@ +package com.cameleer.server.core.runtime; + +import java.util.List; + +public record DirtyStateResult(boolean dirty, List differences) { + public record Difference(String field, String staged, String deployed) {} +} 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 new file mode 100644 index 00000000..ba9ec733 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java @@ -0,0 +1,91 @@ +package com.cameleer.server.core.runtime; + +import com.cameleer.common.model.ApplicationConfig; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class DirtyStateCalculatorTest { + + @Test + void noSnapshot_meansEverythingDirty() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + + ApplicationConfig desiredAgent = new ApplicationConfig(); + desiredAgent.setSamplingRate(1.0); + Map desiredContainer = Map.of("memoryLimitMb", 512); + + DirtyStateResult result = calc.compute(UUID.randomUUID(), desiredAgent, desiredContainer, null); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("snapshot"); + } + + @Test + void identicalSnapshot_isClean() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + + ApplicationConfig cfg = new ApplicationConfig(); + cfg.setSamplingRate(0.5); + Map container = Map.of("memoryLimitMb", 512); + + UUID jarId = UUID.randomUUID(); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, container); + DirtyStateResult result = calc.compute(jarId, cfg, container, snap); + + assertThat(result.dirty()).isFalse(); + assertThat(result.differences()).isEmpty(); + } + + @Test + void differentJar_marksJarField() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + ApplicationConfig cfg = new ApplicationConfig(); + Map container = Map.of(); + UUID v1 = UUID.randomUUID(); + UUID v2 = UUID.randomUUID(); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(v1, cfg, container); + + DirtyStateResult result = calc.compute(v2, cfg, container, snap); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("jarVersionId"); + } + + @Test + void differentSamplingRate_marksAgentField() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + + ApplicationConfig deployedCfg = new ApplicationConfig(); + deployedCfg.setSamplingRate(0.5); + ApplicationConfig desiredCfg = new ApplicationConfig(); + desiredCfg.setSamplingRate(1.0); + UUID jarId = UUID.randomUUID(); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployedCfg, Map.of()); + + DirtyStateResult result = calc.compute(jarId, desiredCfg, Map.of(), snap); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("agentConfig.samplingRate"); + } + + @Test + void differentContainerMemory_marksContainerField() { + DirtyStateCalculator calc = new DirtyStateCalculator(); + ApplicationConfig cfg = new ApplicationConfig(); + UUID jarId = UUID.randomUUID(); + DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512)); + + DirtyStateResult result = calc.compute(jarId, cfg, Map.of("memoryLimitMb", 1024), snap); + + assertThat(result.dirty()).isTrue(); + assertThat(result.differences()).extracting(DirtyStateResult.Difference::field) + .contains("containerConfig.memoryLimitMb"); + } +}