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)
|
### 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`.
|
- `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.
|
- `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.
|
- `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.AppVersionRepository;
|
||||||
import com.cameleer.server.core.runtime.DeploymentRepository;
|
import com.cameleer.server.core.runtime.DeploymentRepository;
|
||||||
import com.cameleer.server.core.runtime.DeploymentService;
|
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.EnvironmentRepository;
|
||||||
import com.cameleer.server.core.runtime.EnvironmentService;
|
import com.cameleer.server.core.runtime.EnvironmentService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -64,6 +65,11 @@ public class RuntimeBeanConfig {
|
|||||||
return new DeploymentService(deployRepo, appService, envService);
|
return new DeploymentService(deployRepo, appService, envService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DirtyStateCalculator dirtyStateCalculator(ObjectMapper objectMapper) {
|
||||||
|
return new DirtyStateCalculator(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean(name = "deploymentTaskExecutor")
|
@Bean(name = "deploymentTaskExecutor")
|
||||||
public Executor deploymentTaskExecutor() {
|
public Executor deploymentTaskExecutor() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
package com.cameleer.server.app.controller;
|
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.app.web.EnvPath;
|
||||||
import com.cameleer.server.core.runtime.App;
|
import com.cameleer.server.core.runtime.App;
|
||||||
import com.cameleer.server.core.runtime.AppService;
|
import com.cameleer.server.core.runtime.AppService;
|
||||||
import com.cameleer.server.core.runtime.AppVersion;
|
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.Environment;
|
||||||
import com.cameleer.server.core.runtime.RuntimeType;
|
import com.cameleer.server.core.runtime.RuntimeType;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -40,9 +52,21 @@ import java.util.UUID;
|
|||||||
public class AppController {
|
public class AppController {
|
||||||
|
|
||||||
private final AppService appService;
|
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.appService = appService;
|
||||||
|
this.appVersionRepository = appVersionRepository;
|
||||||
|
this.configRepository = configRepository;
|
||||||
|
this.deploymentRepository = deploymentRepository;
|
||||||
|
this.dirtyCalc = dirtyCalc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@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 =
|
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
|
||||||
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
|
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) {
|
public Optional<Deployment> findByContainerId(String containerId) {
|
||||||
var results = jdbc.query(
|
var results = jdbc.query(
|
||||||
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
|
"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
|
* 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.
|
* 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 {
|
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,
|
public DirtyStateResult compute(UUID desiredJarVersionId,
|
||||||
ApplicationConfig desiredAgentConfig,
|
ApplicationConfig desiredAgentConfig,
|
||||||
@@ -38,10 +44,10 @@ public class DirtyStateCalculator {
|
|||||||
String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId())));
|
String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId())));
|
||||||
}
|
}
|
||||||
|
|
||||||
compareJson("agentConfig", MAPPER.valueToTree(desiredAgentConfig),
|
compareJson("agentConfig", mapper.valueToTree(desiredAgentConfig),
|
||||||
MAPPER.valueToTree(snapshot.agentConfig()), diffs);
|
mapper.valueToTree(snapshot.agentConfig()), diffs);
|
||||||
compareJson("containerConfig", MAPPER.valueToTree(desiredContainerConfig),
|
compareJson("containerConfig", mapper.valueToTree(desiredContainerConfig),
|
||||||
MAPPER.valueToTree(snapshot.containerConfig()), diffs);
|
mapper.valueToTree(snapshot.containerConfig()), diffs);
|
||||||
|
|
||||||
return new DirtyStateResult(!diffs.isEmpty(), diffs);
|
return new DirtyStateResult(!diffs.isEmpty(), diffs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer.server.core.runtime;
|
package com.cameleer.server.core.runtime;
|
||||||
|
|
||||||
import com.cameleer.common.model.ApplicationConfig;
|
import com.cameleer.common.model.ApplicationConfig;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -10,9 +11,11 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class DirtyStateCalculatorTest {
|
class DirtyStateCalculatorTest {
|
||||||
|
|
||||||
|
private static final DirtyStateCalculator CALC = new DirtyStateCalculator(new ObjectMapper());
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noSnapshot_meansEverythingDirty() {
|
void noSnapshot_meansEverythingDirty() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
|
|
||||||
ApplicationConfig desiredAgent = new ApplicationConfig();
|
ApplicationConfig desiredAgent = new ApplicationConfig();
|
||||||
desiredAgent.setSamplingRate(1.0);
|
desiredAgent.setSamplingRate(1.0);
|
||||||
@@ -27,7 +30,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void identicalSnapshot_isClean() {
|
void identicalSnapshot_isClean() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
|
|
||||||
ApplicationConfig cfg = new ApplicationConfig();
|
ApplicationConfig cfg = new ApplicationConfig();
|
||||||
cfg.setSamplingRate(0.5);
|
cfg.setSamplingRate(0.5);
|
||||||
@@ -43,7 +46,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void differentJar_marksJarField() {
|
void differentJar_marksJarField() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
ApplicationConfig cfg = new ApplicationConfig();
|
ApplicationConfig cfg = new ApplicationConfig();
|
||||||
Map<String, Object> container = Map.of();
|
Map<String, Object> container = Map.of();
|
||||||
UUID v1 = UUID.randomUUID();
|
UUID v1 = UUID.randomUUID();
|
||||||
@@ -59,7 +62,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void differentSamplingRate_marksAgentField() {
|
void differentSamplingRate_marksAgentField() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
|
|
||||||
ApplicationConfig deployedCfg = new ApplicationConfig();
|
ApplicationConfig deployedCfg = new ApplicationConfig();
|
||||||
deployedCfg.setSamplingRate(0.5);
|
deployedCfg.setSamplingRate(0.5);
|
||||||
@@ -77,7 +80,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void differentContainerMemory_marksContainerField() {
|
void differentContainerMemory_marksContainerField() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
ApplicationConfig cfg = new ApplicationConfig();
|
ApplicationConfig cfg = new ApplicationConfig();
|
||||||
UUID jarId = UUID.randomUUID();
|
UUID jarId = UUID.randomUUID();
|
||||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512));
|
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512));
|
||||||
@@ -91,7 +94,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nullAgentConfigInSnapshot_marksAgentConfigDiff() {
|
void nullAgentConfigInSnapshot_marksAgentConfigDiff() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
ApplicationConfig desired = new ApplicationConfig();
|
ApplicationConfig desired = new ApplicationConfig();
|
||||||
desired.setSamplingRate(1.0);
|
desired.setSamplingRate(1.0);
|
||||||
UUID jarId = UUID.randomUUID();
|
UUID jarId = UUID.randomUUID();
|
||||||
@@ -106,7 +109,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nestedAgentField_reportsDeepPath() {
|
void nestedAgentField_reportsDeepPath() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
|
|
||||||
ApplicationConfig deployed = new ApplicationConfig();
|
ApplicationConfig deployed = new ApplicationConfig();
|
||||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||||
@@ -124,7 +127,7 @@ class DirtyStateCalculatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void stringField_differenceValueIsUnquoted() {
|
void stringField_differenceValueIsUnquoted() {
|
||||||
DirtyStateCalculator calc = new DirtyStateCalculator();
|
DirtyStateCalculator calc = CALC;
|
||||||
|
|
||||||
ApplicationConfig deployed = new ApplicationConfig();
|
ApplicationConfig deployed = new ApplicationConfig();
|
||||||
deployed.setApplicationLogLevel("INFO");
|
deployed.setApplicationLogLevel("INFO");
|
||||||
|
|||||||
Reference in New Issue
Block a user