diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java new file mode 100644 index 0000000..8ec1518 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java @@ -0,0 +1,113 @@ +package net.siegeln.cameleer.saas.deployment; + +import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/apps/{appId}") +public class DeploymentController { + + private final DeploymentService deploymentService; + + public DeploymentController(DeploymentService deploymentService) { + this.deploymentService = deploymentService; + } + + @PostMapping("/deploy") + public ResponseEntity deploy( + @PathVariable UUID appId, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + var entity = deploymentService.deploy(appId, actorId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().build(); + } + } + + @GetMapping("/deployments") + public ResponseEntity> listDeployments(@PathVariable UUID appId) { + var deployments = deploymentService.listByAppId(appId) + .stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(deployments); + } + + @GetMapping("/deployments/{deploymentId}") + public ResponseEntity getDeployment( + @PathVariable UUID appId, + @PathVariable UUID deploymentId) { + return deploymentService.getById(deploymentId) + .map(entity -> ResponseEntity.ok(toResponse(entity))) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/stop") + public ResponseEntity stop( + @PathVariable UUID appId, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + var entity = deploymentService.stop(appId, actorId); + return ResponseEntity.ok(toResponse(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().build(); + } + } + + @PostMapping("/restart") + public ResponseEntity restart( + @PathVariable UUID appId, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + var entity = deploymentService.restart(appId, actorId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().build(); + } + } + + private UUID resolveActorId(Authentication authentication) { + String sub = authentication.getName(); + try { + return UUID.fromString(sub); + } catch (IllegalArgumentException e) { + return UUID.nameUUIDFromBytes(sub.getBytes()); + } + } + + private DeploymentResponse toResponse(DeploymentEntity entity) { + return new DeploymentResponse( + entity.getId(), + entity.getAppId(), + entity.getVersion(), + entity.getImageRef(), + entity.getDesiredStatus().name(), + entity.getObservedStatus().name(), + entity.getErrorMessage(), + entity.getOrchestratorMetadata(), + entity.getDeployedAt(), + entity.getStoppedAt(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/dto/DeploymentResponse.java b/src/main/java/net/siegeln/cameleer/saas/deployment/dto/DeploymentResponse.java new file mode 100644 index 0000000..9a87334 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/dto/DeploymentResponse.java @@ -0,0 +1,12 @@ +package net.siegeln.cameleer.saas.deployment.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public record DeploymentResponse( + UUID id, UUID appId, int version, String imageRef, + String desiredStatus, String observedStatus, String errorMessage, + Map orchestratorMetadata, + Instant deployedAt, Instant stoppedAt, Instant createdAt +) {} diff --git a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentControllerTest.java new file mode 100644 index 0000000..040f11c --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentControllerTest.java @@ -0,0 +1,121 @@ +package net.siegeln.cameleer.saas.deployment; + +import net.siegeln.cameleer.saas.TestSecurityConfig; +import net.siegeln.cameleer.saas.TestcontainersConfig; +import net.siegeln.cameleer.saas.app.AppEntity; +import net.siegeln.cameleer.saas.app.AppRepository; +import net.siegeln.cameleer.saas.environment.EnvironmentEntity; +import net.siegeln.cameleer.saas.environment.EnvironmentRepository; +import net.siegeln.cameleer.saas.license.LicenseDefaults; +import net.siegeln.cameleer.saas.license.LicenseEntity; +import net.siegeln.cameleer.saas.license.LicenseRepository; +import net.siegeln.cameleer.saas.tenant.TenantEntity; +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import net.siegeln.cameleer.saas.tenant.Tier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import({TestcontainersConfig.class, TestSecurityConfig.class}) +@ActiveProfiles("test") +class DeploymentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private DeploymentRepository deploymentRepository; + + @Autowired + private AppRepository appRepository; + + @Autowired + private EnvironmentRepository environmentRepository; + + @Autowired + private LicenseRepository licenseRepository; + + @Autowired + private TenantRepository tenantRepository; + + private UUID appId; + + @BeforeEach + void setUp() { + deploymentRepository.deleteAll(); + appRepository.deleteAll(); + environmentRepository.deleteAll(); + licenseRepository.deleteAll(); + tenantRepository.deleteAll(); + + var tenant = new TenantEntity(); + tenant.setName("Test Org"); + tenant.setSlug("test-org-" + System.nanoTime()); + var savedTenant = tenantRepository.save(tenant); + var tenantId = savedTenant.getId(); + + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("MID"); + license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID)); + license.setLimits(LicenseDefaults.limitsForTier(Tier.MID)); + license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS)); + license.setToken("test-token"); + licenseRepository.save(license); + + var env = new EnvironmentEntity(); + env.setTenantId(tenantId); + env.setSlug("default"); + env.setDisplayName("Default"); + env.setBootstrapToken("test-bootstrap-token"); + var savedEnv = environmentRepository.save(env); + + var app = new AppEntity(); + app.setEnvironmentId(savedEnv.getId()); + app.setSlug("test-app"); + app.setDisplayName("Test App"); + app.setJarStoragePath("tenants/test-org/envs/default/apps/test-app/app.jar"); + app.setJarChecksum("abc123def456"); + var savedApp = appRepository.save(app); + appId = savedApp.getId(); + } + + @Test + void listDeployments_shouldReturnEmpty() throws Exception { + mockMvc.perform(get("/api/apps/" + appId + "/deployments") + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void getDeployment_notFound_shouldReturn404() throws Exception { + mockMvc.perform(get("/api/apps/" + appId + "/deployments/" + UUID.randomUUID()) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isNotFound()); + } + + @Test + void deploy_noAuth_shouldReturn401() throws Exception { + mockMvc.perform(post("/api/apps/" + appId + "/deploy")) + .andExpect(status().isUnauthorized()); + } +}