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