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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user