feat(deploy): cascade createdBy through Deployment record + service + repo

Appends String createdBy to the Deployment record (after createdAt), updates
both with-er methods to pass it through, threads the parameter through
DeploymentRepository.create, DeploymentService.createDeployment/promote, and
PostgresDeploymentRepository (INSERT + SELECT_COLS + mapRow). DeploymentController
passes null as placeholder (Task 4 will resolve from SecurityContextHolder).
Covers with PostgresDeploymentRepositoryCreatedByIT verifying round-trip via
both createDeployment and promote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 12:04:15 +02:00
parent 15d00f039c
commit a141e99a07
8 changed files with 91 additions and 22 deletions

View File

@@ -89,7 +89,7 @@ public class DeploymentController {
@RequestBody DeployRequest request) { @RequestBody DeployRequest request) {
try { try {
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug); App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id()); Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), null);
deploymentExecutor.executeAsync(deployment); deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment); return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -129,7 +129,7 @@ public class DeploymentController {
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment()); Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
// Target must also have the app with the same slug // Target must also have the app with the same slug
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug); App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id()); Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), null);
deploymentExecutor.executeAsync(promoted); deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted); return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {

View File

@@ -22,7 +22,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
private static final String SELECT_COLS = private static final String SELECT_COLS =
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " + "id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
"replica_states, deploy_stage, container_id, container_name, error_message, " + "replica_states, deploy_stage, container_id, container_name, error_message, " +
"resolved_config, deployed_config_snapshot, deployed_at, stopped_at, created_at"; "resolved_config, deployed_config_snapshot, deployed_at, stopped_at, created_at, created_by";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -81,10 +81,10 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
} }
@Override @Override
public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName) { public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName, String createdBy) {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name) VALUES (?, ?, ?, ?, ?)", jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name, created_by) VALUES (?, ?, ?, ?, ?, ?)",
id, appId, appVersionId, environmentId, containerName); id, appId, appVersionId, environmentId, containerName, createdBy);
return id; return id;
} }
@@ -216,7 +216,8 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
deployedConfigSnapshot, deployedConfigSnapshot,
deployedAt != null ? deployedAt.toInstant() : null, deployedAt != null ? deployedAt.toInstant() : null,
stoppedAt != null ? stoppedAt.toInstant() : null, stoppedAt != null ? stoppedAt.toInstant() : null,
rs.getTimestamp("created_at").toInstant() rs.getTimestamp("created_at").toInstant(),
rs.getString("created_by")
); );
} }
} }

View File

@@ -48,7 +48,7 @@ class DeploymentStateEvaluatorTest {
private Deployment deployment(DeploymentStatus status) { private Deployment deployment(DeploymentStatus status) {
return new Deployment(DEP_ID, APP_ID, UUID.randomUUID(), ENV_ID, status, return new Deployment(DEP_ID, APP_ID, UUID.randomUUID(), ENV_ID, status,
null, null, List.of(), null, null, "orders-0", null, null, null, List.of(), null, null, "orders-0", null,
Map.of(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120)); Map.of(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120), "test-user");
} }
@Test @Test

View File

@@ -0,0 +1,67 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PostgresDeploymentRepositoryCreatedByIT extends AbstractPostgresIT {
@Autowired DeploymentService deploymentService;
@Autowired JdbcTemplate jdbc;
private UUID appId;
private UUID envId;
private UUID versionId;
@BeforeEach
void seedAppAndVersion() {
// Clean up to avoid conflicts across test runs
jdbc.update("DELETE FROM deployments");
jdbc.update("DELETE FROM app_versions");
jdbc.update("DELETE FROM apps");
envId = jdbc.queryForObject(
"SELECT id FROM environments WHERE slug = 'default'", UUID.class);
// Seed users (alice, bob) — use the bare user_id convention; provider is NOT NULL
jdbc.update("INSERT INTO users (user_id, provider) VALUES (?, 'LOCAL') " +
"ON CONFLICT (user_id) DO NOTHING", "alice");
jdbc.update("INSERT INTO users (user_id, provider) VALUES (?, 'LOCAL') " +
"ON CONFLICT (user_id) DO NOTHING", "bob");
// Seed app
appId = UUID.randomUUID();
jdbc.update("INSERT INTO apps (id, environment_id, slug, display_name) " +
"VALUES (?, ?, 'test-app', 'Test App')",
appId, envId);
// Seed version
versionId = UUID.randomUUID();
jdbc.update("INSERT INTO app_versions (id, app_id, version, jar_path, jar_checksum) " +
"VALUES (?, ?, 1, '/tmp/x.jar', 'abc')",
versionId, appId);
}
@Test
void createDeployment_persists_createdBy_and_returns_it() {
Deployment d = deploymentService.createDeployment(appId, versionId, envId, "alice");
assertThat(d.createdBy()).isEqualTo("alice");
String fromDb = jdbc.queryForObject(
"SELECT created_by FROM deployments WHERE id = ?", String.class, d.id());
assertThat(fromDb).isEqualTo("alice");
}
@Test
void promote_persists_createdBy() {
Deployment promoted = deploymentService.promote(appId, versionId, envId, "bob");
assertThat(promoted.createdBy()).isEqualTo("bob");
}
}

View File

@@ -65,7 +65,7 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
null null
); );
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container"); UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container", null);
repository.saveDeployedConfigSnapshot(deploymentId, snapshot); repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
// when — load it back // when — load it back
@@ -80,7 +80,7 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
@Test @Test
void deployedConfigSnapshot_nullByDefault() { void deployedConfigSnapshot_nullByDefault() {
// deployments created without a snapshot must return null (not throw) // deployments created without a snapshot must return null (not throw)
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-null"); UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-null", null);
Deployment loaded = repository.findById(deploymentId).orElseThrow(); Deployment loaded = repository.findById(deploymentId).orElseThrow();
@@ -90,13 +90,13 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
@Test @Test
void deleteFailedByAppAndEnvironment_keepsStoppedAndActive() { void deleteFailedByAppAndEnvironment_keepsStoppedAndActive() {
// given: one STOPPED (checkpoint), one FAILED, one RUNNING // given: one STOPPED (checkpoint), one FAILED, one RUNNING
UUID stoppedId = repository.create(appId, appVersionId, envId, "stopped"); UUID stoppedId = repository.create(appId, appVersionId, envId, "stopped", null);
repository.updateStatus(stoppedId, com.cameleer.server.core.runtime.DeploymentStatus.STOPPED, null, null); repository.updateStatus(stoppedId, com.cameleer.server.core.runtime.DeploymentStatus.STOPPED, null, null);
UUID failedId = repository.create(appId, appVersionId, envId, "failed"); UUID failedId = repository.create(appId, appVersionId, envId, "failed", null);
repository.updateStatus(failedId, com.cameleer.server.core.runtime.DeploymentStatus.FAILED, null, "boom"); repository.updateStatus(failedId, com.cameleer.server.core.runtime.DeploymentStatus.FAILED, null, "boom");
UUID runningId = repository.create(appId, appVersionId, envId, "running"); UUID runningId = repository.create(appId, appVersionId, envId, "running", null);
repository.updateStatus(runningId, com.cameleer.server.core.runtime.DeploymentStatus.RUNNING, "c1", null); repository.updateStatus(runningId, com.cameleer.server.core.runtime.DeploymentStatus.RUNNING, "c1", null);
// when // when
@@ -118,7 +118,7 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
null null
); );
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-clear"); UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-clear", null);
repository.saveDeployedConfigSnapshot(deploymentId, snapshot); repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
repository.saveDeployedConfigSnapshot(deploymentId, null); repository.saveDeployedConfigSnapshot(deploymentId, null);

View File

@@ -22,19 +22,20 @@ public record Deployment(
DeploymentConfigSnapshot deployedConfigSnapshot, DeploymentConfigSnapshot deployedConfigSnapshot,
Instant deployedAt, Instant deployedAt,
Instant stoppedAt, Instant stoppedAt,
Instant createdAt Instant createdAt,
String createdBy
) { ) {
public Deployment withStatus(DeploymentStatus newStatus) { public Deployment withStatus(DeploymentStatus newStatus) {
return new Deployment(id, appId, appVersionId, environmentId, newStatus, return new Deployment(id, appId, appVersionId, environmentId, newStatus,
targetState, deploymentStrategy, replicaStates, deployStage, targetState, deploymentStrategy, replicaStates, deployStage,
containerId, containerName, errorMessage, resolvedConfig, containerId, containerName, errorMessage, resolvedConfig,
deployedConfigSnapshot, deployedAt, stoppedAt, createdAt); deployedConfigSnapshot, deployedAt, stoppedAt, createdAt, createdBy);
} }
public Deployment withDeployedConfigSnapshot(DeploymentConfigSnapshot snapshot) { public Deployment withDeployedConfigSnapshot(DeploymentConfigSnapshot snapshot) {
return new Deployment(id, appId, appVersionId, environmentId, status, return new Deployment(id, appId, appVersionId, environmentId, status,
targetState, deploymentStrategy, replicaStates, deployStage, targetState, deploymentStrategy, replicaStates, deployStage,
containerId, containerName, errorMessage, resolvedConfig, containerId, containerName, errorMessage, resolvedConfig,
snapshot, deployedAt, stoppedAt, createdAt); snapshot, deployedAt, stoppedAt, createdAt, createdBy);
} }
} }

View File

@@ -10,7 +10,7 @@ public interface DeploymentRepository {
Optional<Deployment> findById(UUID id); Optional<Deployment> findById(UUID id);
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId); Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId); Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId);
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName); UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName, String createdBy);
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage); void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
void markDeployed(UUID id); void markDeployed(UUID id);
void markStopped(UUID id); void markStopped(UUID id);

View File

@@ -23,19 +23,19 @@ public class DeploymentService {
public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); } public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); }
/** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */ /** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) { public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId, String createdBy) {
App app = appService.getById(appId); App app = appService.getById(appId);
Environment env = envService.getById(environmentId); Environment env = envService.getById(environmentId);
String containerName = env.slug() + "-" + app.slug(); String containerName = env.slug() + "-" + app.slug();
deployRepo.deleteFailedByAppAndEnvironment(appId, environmentId); deployRepo.deleteFailedByAppAndEnvironment(appId, environmentId);
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName); UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName, createdBy);
return deployRepo.findById(deploymentId).orElseThrow(); return deployRepo.findById(deploymentId).orElseThrow();
} }
/** Promote: deploy the same app version to a different environment. */ /** Promote: deploy the same app version to a different environment. */
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) { public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId, String createdBy) {
return createDeployment(appId, appVersionId, targetEnvironmentId); return createDeployment(appId, appVersionId, targetEnvironmentId, createdBy);
} }
public void markRunning(UUID deploymentId, String containerId) { public void markRunning(UUID deploymentId, String containerId) {