feat: add deployment service with async pipeline

Implements DeploymentService with TDD: builds Docker images, starts containers with Cameleer env vars, polls for health, and handles stop/restart lifecycle. All 3 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 17:57:09 +02:00
parent 23a474fbf3
commit 59df59f406
2 changed files with 421 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.app.AppEntity;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.app.AppService;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
import net.siegeln.cameleer.saas.runtime.StartContainerRequest;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class DeploymentService {
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
private final DeploymentRepository deploymentRepository;
private final AppRepository appRepository;
private final AppService appService;
private final EnvironmentRepository environmentRepository;
private final TenantRepository tenantRepository;
private final RuntimeOrchestrator runtimeOrchestrator;
private final RuntimeConfig runtimeConfig;
private final AuditService auditService;
public DeploymentService(DeploymentRepository deploymentRepository,
AppRepository appRepository,
AppService appService,
EnvironmentRepository environmentRepository,
TenantRepository tenantRepository,
RuntimeOrchestrator runtimeOrchestrator,
RuntimeConfig runtimeConfig,
AuditService auditService) {
this.deploymentRepository = deploymentRepository;
this.appRepository = appRepository;
this.appService = appService;
this.environmentRepository = environmentRepository;
this.tenantRepository = tenantRepository;
this.runtimeOrchestrator = runtimeOrchestrator;
this.runtimeConfig = runtimeConfig;
this.auditService = auditService;
}
public DeploymentEntity deploy(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getJarStoragePath() == null) {
throw new IllegalStateException("App has no JAR uploaded: " + appId);
}
var env = environmentRepository.findById(app.getEnvironmentId())
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
int nextVersion = deploymentRepository.findMaxVersionByAppId(appId) + 1;
var imageRef = "cameleer-runtime-" + env.getSlug() + "-" + app.getSlug() + ":v" + nextVersion;
var deployment = new DeploymentEntity();
deployment.setAppId(appId);
deployment.setVersion(nextVersion);
deployment.setImageRef(imageRef);
deployment.setObservedStatus(ObservedStatus.BUILDING);
deployment.setDesiredStatus(DesiredStatus.RUNNING);
var saved = deploymentRepository.save(deployment);
auditService.log(actorId, null, env.getTenantId(),
AuditAction.APP_DEPLOY, app.getSlug(),
env.getSlug(), null, "SUCCESS", null);
executeDeploymentAsync(saved.getId(), app, env);
return saved;
}
@Async("deploymentExecutor")
public void executeDeploymentAsync(UUID deploymentId, AppEntity app, EnvironmentEntity env) {
var deployment = deploymentRepository.findById(deploymentId).orElse(null);
if (deployment == null) {
log.error("Deployment not found for async execution: {}", deploymentId);
return;
}
try {
var jarPath = appService.resolveJarPath(app.getJarStoragePath());
runtimeOrchestrator.buildImage(new BuildImageRequest(
runtimeConfig.getBaseImage(),
jarPath,
deployment.getImageRef()
));
deployment.setObservedStatus(ObservedStatus.STARTING);
deploymentRepository.save(deployment);
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
var tenantSlug = tenant != null ? tenant.getSlug() : env.getTenantId().toString();
var containerName = tenantSlug + "-" + env.getSlug() + "-" + app.getSlug();
if (app.getCurrentDeploymentId() != null) {
deploymentRepository.findById(app.getCurrentDeploymentId()).ifPresent(oldDeployment -> {
var oldMetadata = oldDeployment.getOrchestratorMetadata();
if (oldMetadata != null && oldMetadata.containsKey("containerId")) {
var oldContainerId = (String) oldMetadata.get("containerId");
try {
runtimeOrchestrator.stopContainer(oldContainerId);
} catch (Exception e) {
log.warn("Failed to stop old container {}: {}", oldContainerId, e.getMessage());
}
}
});
}
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
deployment.getImageRef(),
containerName,
runtimeConfig.getDockerNetwork(),
Map.of(
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
"CAMELEER_EXPORT_TYPE", "HTTP",
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(),
"CAMELEER_APPLICATION_ID", app.getSlug(),
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
"CAMELEER_DISPLAY_NAME", containerName
),
runtimeConfig.parseMemoryLimitBytes(),
runtimeConfig.getContainerCpuShares(),
runtimeConfig.getAgentHealthPort()
));
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
deploymentRepository.save(deployment);
boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout());
var previousDeploymentId = app.getCurrentDeploymentId();
if (healthy) {
deployment.setObservedStatus(ObservedStatus.RUNNING);
deployment.setDeployedAt(Instant.now());
deploymentRepository.save(deployment);
app.setPreviousDeploymentId(previousDeploymentId);
app.setCurrentDeploymentId(deployment.getId());
appRepository.save(app);
} else {
deployment.setObservedStatus(ObservedStatus.FAILED);
deployment.setErrorMessage("Container did not become healthy within timeout");
deploymentRepository.save(deployment);
app.setCurrentDeploymentId(deployment.getId());
appRepository.save(app);
}
} catch (Exception e) {
log.error("Deployment {} failed: {}", deploymentId, e.getMessage(), e);
deployment.setObservedStatus(ObservedStatus.FAILED);
deployment.setErrorMessage(e.getMessage());
deploymentRepository.save(deployment);
}
}
public DeploymentEntity stop(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getCurrentDeploymentId() == null) {
throw new IllegalStateException("App has no active deployment: " + appId);
}
var deployment = deploymentRepository.findById(app.getCurrentDeploymentId())
.orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + app.getCurrentDeploymentId()));
var metadata = deployment.getOrchestratorMetadata();
if (metadata != null && metadata.containsKey("containerId")) {
var containerId = (String) metadata.get("containerId");
runtimeOrchestrator.stopContainer(containerId);
}
deployment.setDesiredStatus(DesiredStatus.STOPPED);
deployment.setObservedStatus(ObservedStatus.STOPPED);
deployment.setStoppedAt(Instant.now());
var saved = deploymentRepository.save(deployment);
var env = environmentRepository.findById(app.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_STOP, app.getSlug(),
env != null ? env.getSlug() : null, null, "SUCCESS", null);
return saved;
}
public DeploymentEntity restart(UUID appId, UUID actorId) {
stop(appId, actorId);
return deploy(appId, actorId);
}
public List<DeploymentEntity> listByAppId(UUID appId) {
return deploymentRepository.findByAppIdOrderByVersionDesc(appId);
}
public Optional<DeploymentEntity> getById(UUID deploymentId) {
return deploymentRepository.findById(deploymentId);
}
boolean waitForHealthy(String containerId, int timeoutSeconds) {
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
while (System.currentTimeMillis() < deadline) {
var status = runtimeOrchestrator.getContainerStatus(containerId);
if (!status.running()) {
return false;
}
if ("healthy".equals(status.state())) {
return true;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
}

View File

@@ -0,0 +1,179 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.app.AppEntity;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.app.AppService;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeploymentServiceTest {
@Mock
private DeploymentRepository deploymentRepository;
@Mock
private AppRepository appRepository;
@Mock
private AppService appService;
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private TenantRepository tenantRepository;
@Mock
private RuntimeOrchestrator runtimeOrchestrator;
@Mock
private RuntimeConfig runtimeConfig;
@Mock
private AuditService auditService;
private DeploymentService deploymentService;
private UUID appId;
private UUID envId;
private UUID tenantId;
private UUID actorId;
private AppEntity app;
private EnvironmentEntity env;
private TenantEntity tenant;
@BeforeEach
void setUp() {
deploymentService = new DeploymentService(
deploymentRepository,
appRepository,
appService,
environmentRepository,
tenantRepository,
runtimeOrchestrator,
runtimeConfig,
auditService
);
appId = UUID.randomUUID();
envId = UUID.randomUUID();
tenantId = UUID.randomUUID();
actorId = UUID.randomUUID();
env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("prod");
env.setBootstrapToken("tok-abc");
tenant = new TenantEntity();
tenant.setSlug("acme");
app = new AppEntity();
app.setId(appId);
app.setEnvironmentId(envId);
app.setSlug("myapp");
app.setDisplayName("My App");
app.setJarStoragePath("tenants/acme/envs/prod/apps/myapp/app.jar");
when(runtimeConfig.getBaseImage()).thenReturn("cameleer-runtime-base:latest");
when(runtimeConfig.getDockerNetwork()).thenReturn("cameleer");
when(runtimeConfig.getAgentHealthPort()).thenReturn(9464);
when(runtimeConfig.getHealthCheckTimeout()).thenReturn(60);
when(runtimeConfig.parseMemoryLimitBytes()).thenReturn(536870912L);
when(runtimeConfig.getContainerCpuShares()).thenReturn(512);
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(tenantRepository.findById(tenantId)).thenReturn(Optional.of(tenant));
when(deploymentRepository.findMaxVersionByAppId(appId)).thenReturn(0);
when(deploymentRepository.save(any(DeploymentEntity.class))).thenAnswer(inv -> {
var d = (DeploymentEntity) inv.getArgument(0);
if (d.getId() == null) {
d.setId(UUID.randomUUID());
}
return d;
});
when(appService.resolveJarPath(any())).thenReturn(Path.of("/data/jars/tenants/acme/envs/prod/apps/myapp/app.jar"));
when(runtimeOrchestrator.buildImage(any(BuildImageRequest.class))).thenReturn("sha256:abc123");
when(runtimeOrchestrator.startContainer(any())).thenReturn("container-id-123");
}
@Test
void deploy_shouldCreateDeploymentWithBuildingStatus() {
var result = deploymentService.deploy(appId, actorId);
assertThat(result).isNotNull();
assertThat(result.getAppId()).isEqualTo(appId);
assertThat(result.getVersion()).isEqualTo(1);
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.BUILDING);
assertThat(result.getImageRef()).contains("myapp").contains("v1");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_DEPLOY);
}
@Test
void deploy_shouldRejectAppWithNoJar() {
app.setJarStoragePath(null);
assertThatThrownBy(() -> deploymentService.deploy(appId, actorId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("JAR");
}
@Test
void stop_shouldUpdateDesiredStatus() {
var deploymentId = UUID.randomUUID();
app.setCurrentDeploymentId(deploymentId);
var deployment = new DeploymentEntity();
deployment.setId(deploymentId);
deployment.setAppId(appId);
deployment.setVersion(1);
deployment.setImageRef("cameleer-runtime-prod-myapp:v1");
deployment.setObservedStatus(ObservedStatus.RUNNING);
deployment.setOrchestratorMetadata(Map.of("containerId", "container-id-123"));
when(deploymentRepository.findById(deploymentId)).thenReturn(Optional.of(deployment));
var result = deploymentService.stop(appId, actorId);
assertThat(result.getDesiredStatus()).isEqualTo(DesiredStatus.STOPPED);
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.STOPPED);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_STOP);
}
}