feat: add deployment controller with deploy/stop/restart endpoints

Add DeploymentResponse DTO, DeploymentController at /api/apps/{appId} with POST /deploy (202), GET /deployments, GET /deployments/{id}, POST /stop, POST /restart (202), and integration tests covering empty list, 404, and 401 cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 18:00:23 +02:00
parent 59df59f406
commit fc34626a88
3 changed files with 246 additions and 0 deletions

View File

@@ -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<DeploymentResponse> 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<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
var deployments = deploymentService.listByAppId(appId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(deployments);
}
@GetMapping("/deployments/{deploymentId}")
public ResponseEntity<DeploymentResponse> 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<DeploymentResponse> 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<DeploymentResponse> 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()
);
}
}

View File

@@ -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<String, Object> orchestratorMetadata,
Instant deployedAt, Instant stoppedAt, Instant createdAt
) {}

View File

@@ -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());
}
}