diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java index bf271ffc..1232f754 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java @@ -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 { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java new file mode 100644 index 00000000..d6565f9f --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java @@ -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 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 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 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 buildTraefikLabels(App app, Environment env, Deployment deployment) { + Map 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); + } +}