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:
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user