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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 22:20:49 +02:00
parent 76352c0d6f
commit e4ccce1e3b
3 changed files with 168 additions and 0 deletions

View File

@@ -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.
*
* <p>Pure logic — no IO, no Spring. Safe to unit-test as a POJO.</p>
*/
public class DirtyStateCalculator {
private static final ObjectMapper MAPPER = new ObjectMapper();
public DirtyStateResult compute(UUID desiredJarVersionId,
ApplicationConfig desiredAgentConfig,
Map<String, Object> desiredContainerConfig,
DeploymentConfigSnapshot snapshot) {
List<DirtyStateResult.Difference> 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<DirtyStateResult.Difference> 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<String> 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)));
}
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cameleer.server.core.runtime;
import java.util.List;
public record DirtyStateResult(boolean dirty, List<Difference> differences) {
public record Difference(String field, String staged, String deployed) {}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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");
}
}