feat: implement async DeploymentExecutor pipeline

- Async container deployment with health check polling
- Stops previous deployment before starting new one
- Configurable memory, CPU, health timeout via application properties
- @EnableAsync on application class for Spring async proxy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 23:45:38 +02:00
parent b05b7e5597
commit 248b716cb9
2 changed files with 163 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import com.cameleer3.server.app.config.IngestionConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
@@ -16,6 +17,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
"com.cameleer3.server.app",
"com.cameleer3.server.core"
})
@EnableAsync
@EnableScheduling
@EnableConfigurationProperties({IngestionConfig.class, AgentRegistryConfig.class})
public class Cameleer3ServerApplication {

View File

@@ -0,0 +1,161 @@
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.App;
import com.cameleer3.server.core.runtime.AppService;
import com.cameleer3.server.core.runtime.ContainerRequest;
import com.cameleer3.server.core.runtime.ContainerStatus;
import com.cameleer3.server.core.runtime.Deployment;
import com.cameleer3.server.core.runtime.DeploymentRepository;
import com.cameleer3.server.core.runtime.DeploymentService;
import com.cameleer3.server.core.runtime.DeploymentStatus;
import com.cameleer3.server.core.runtime.Environment;
import com.cameleer3.server.core.runtime.EnvironmentService;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Service
public class DeploymentExecutor {
private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class);
private final RuntimeOrchestrator orchestrator;
private final DeploymentService deploymentService;
private final DeploymentRepository deploymentRepository;
private final AppService appService;
private final EnvironmentService envService;
@Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
private String baseImage;
@Value("${cameleer.runtime.docker-network:cameleer}")
private String dockerNetwork;
@Value("${cameleer.runtime.container-memory-limit:512m}")
private String containerMemoryLimit;
@Value("${cameleer.runtime.container-cpu-shares:512}")
private int containerCpuShares;
@Value("${cameleer.runtime.health-check-timeout:60}")
private int healthCheckTimeout;
@Value("${cameleer.runtime.agent-health-port:9464}")
private int agentHealthPort;
@Value("${security.bootstrap-token:}")
private String bootstrapToken;
public DeploymentExecutor(RuntimeOrchestrator orchestrator, DeploymentService deploymentService,
DeploymentRepository deploymentRepository,
AppService appService, EnvironmentService envService) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.deploymentRepository = deploymentRepository;
this.appService = appService;
this.envService = envService;
}
@Async("deploymentExecutor")
public void executeAsync(Deployment deployment) {
try {
// Stop existing deployment in same environment for same app
Optional<Deployment> existing = deploymentRepository.findActiveByAppIdAndEnvironmentId(
deployment.appId(), deployment.environmentId());
if (existing.isPresent() && !existing.get().id().equals(deployment.id())) {
Deployment old = existing.get();
if (old.containerId() != null) {
orchestrator.stopContainer(old.containerId());
orchestrator.removeContainer(old.containerId());
}
deploymentService.markStopped(old.id());
log.info("Stopped previous deployment {} for replacement", old.id());
}
String jarPath = appService.resolveJarPath(deployment.appVersionId());
App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId());
Map<String, String> envVars = new HashMap<>();
envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
envVars.put("CAMELEER_APPLICATION_ID", app.slug());
envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
envVars.put("CAMELEER_DISPLAY_NAME", deployment.containerName());
if (bootstrapToken != null && !bootstrapToken.isBlank()) {
envVars.put("CAMELEER_AUTH_TOKEN", bootstrapToken);
}
Map<String, String> labels = buildTraefikLabels(app, env, deployment);
ContainerRequest request = new ContainerRequest(
deployment.containerName(),
baseImage,
jarPath,
dockerNetwork,
envVars,
labels,
parseMemoryLimitBytes(containerMemoryLimit),
containerCpuShares,
agentHealthPort);
String containerId = orchestrator.startContainer(request);
waitForHealthy(containerId, healthCheckTimeout);
deploymentService.markRunning(deployment.id(), containerId);
log.info("Deployment {} is RUNNING (container={})", deployment.id(), containerId);
} catch (Exception e) {
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
deploymentService.markFailed(deployment.id(), e.getMessage());
}
}
public void stopDeployment(Deployment deployment) {
if (deployment.containerId() != null) {
orchestrator.stopContainer(deployment.containerId());
orchestrator.removeContainer(deployment.containerId());
}
deploymentService.markStopped(deployment.id());
}
private void waitForHealthy(String containerId, int timeoutSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
ContainerStatus status = orchestrator.getContainerStatus(containerId);
if ("healthy".equalsIgnoreCase(status.state()) || (status.running() && "running".equalsIgnoreCase(status.state()))) {
return;
}
if (!status.running()) {
throw new RuntimeException("Container stopped unexpectedly: " + status.error());
}
Thread.sleep(2000);
}
throw new RuntimeException("Container health check timed out after " + timeoutSeconds + "s");
}
private Map<String, String> buildTraefikLabels(App app, Environment env, Deployment deployment) {
Map<String, String> labels = new HashMap<>();
labels.put("traefik.enable", "true");
labels.put("managed-by", "cameleer3-server");
labels.put("cameleer.app", app.slug());
labels.put("cameleer.environment", env.slug());
return labels;
}
private long parseMemoryLimitBytes(String limit) {
String trimmed = limit.trim().toLowerCase();
if (trimmed.endsWith("g")) {
return Long.parseLong(trimmed.substring(0, trimmed.length() - 1)) * 1024 * 1024 * 1024;
} else if (trimmed.endsWith("m")) {
return Long.parseLong(trimmed.substring(0, trimmed.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(trimmed);
}
}