diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java new file mode 100644 index 0000000..f2c34fc --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java @@ -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 listByAppId(UUID appId) { + return deploymentRepository.findByAppIdOrderByVersionDesc(appId); + } + + public Optional 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; + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java new file mode 100644 index 0000000..d0e6f15 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java @@ -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); + } +}