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