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:
hsiegeln
2026-04-22 22:35:35 +02:00
parent 24464c0772
commit 6591f2fde3
8 changed files with 353 additions and 16 deletions

View File

@@ -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();

View File

@@ -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+\"']*$");

View File

@@ -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
) {
}

View File

@@ -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 ? " +