diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java new file mode 100644 index 00000000..5f8cfb17 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java @@ -0,0 +1,104 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.core.runtime.App; +import com.cameleer3.server.core.runtime.AppService; +import com.cameleer3.server.core.runtime.AppVersion; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +/** + * App CRUD and JAR upload endpoints. + * Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}. + */ +@RestController +@RequestMapping("/api/v1/apps") +@Tag(name = "App Management", description = "Application lifecycle and JAR uploads") +@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')") +public class AppController { + + private final AppService appService; + + public AppController(AppService appService) { + this.appService = appService; + } + + @GetMapping + @Operation(summary = "List apps by environment") + @ApiResponse(responseCode = "200", description = "App list returned") + public ResponseEntity> listApps(@RequestParam UUID environmentId) { + return ResponseEntity.ok(appService.listByEnvironment(environmentId)); + } + + @GetMapping("/{appId}") + @Operation(summary = "Get app by ID") + @ApiResponse(responseCode = "200", description = "App found") + @ApiResponse(responseCode = "404", description = "App not found") + public ResponseEntity getApp(@PathVariable UUID appId) { + try { + return ResponseEntity.ok(appService.getById(appId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping + @Operation(summary = "Create a new app") + @ApiResponse(responseCode = "201", description = "App created") + @ApiResponse(responseCode = "400", description = "Slug already exists in environment") + public ResponseEntity createApp(@RequestBody CreateAppRequest request) { + try { + UUID id = appService.createApp(request.environmentId(), request.slug(), request.displayName()); + return ResponseEntity.status(201).body(appService.getById(id)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @GetMapping("/{appId}/versions") + @Operation(summary = "List app versions") + @ApiResponse(responseCode = "200", description = "Version list returned") + public ResponseEntity> listVersions(@PathVariable UUID appId) { + return ResponseEntity.ok(appService.listVersions(appId)); + } + + @PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "Upload a JAR for a new app version") + @ApiResponse(responseCode = "201", description = "JAR uploaded and version created") + @ApiResponse(responseCode = "404", description = "App not found") + public ResponseEntity uploadJar(@PathVariable UUID appId, + @RequestParam("file") MultipartFile file) throws IOException { + try { + AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize()); + return ResponseEntity.status(201).body(version); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @DeleteMapping("/{appId}") + @Operation(summary = "Delete an app") + @ApiResponse(responseCode = "204", description = "App deleted") + public ResponseEntity deleteApp(@PathVariable UUID appId) { + appService.deleteApp(appId); + return ResponseEntity.noContent().build(); + } + + public record CreateAppRequest(UUID environmentId, String slug, String displayName) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java new file mode 100644 index 00000000..7ba86ad2 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java @@ -0,0 +1,122 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.runtime.DeploymentExecutor; +import com.cameleer3.server.core.runtime.Deployment; +import com.cameleer3.server.core.runtime.DeploymentService; +import com.cameleer3.server.core.runtime.RuntimeOrchestrator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Deployment management: deploy, stop, promote, and view logs. + * Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}. + */ +@RestController +@RequestMapping("/api/v1/apps/{appId}/deployments") +@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs") +@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')") +public class DeploymentController { + + private final DeploymentService deploymentService; + private final DeploymentExecutor deploymentExecutor; + private final RuntimeOrchestrator orchestrator; + + public DeploymentController(DeploymentService deploymentService, + DeploymentExecutor deploymentExecutor, + RuntimeOrchestrator orchestrator) { + this.deploymentService = deploymentService; + this.deploymentExecutor = deploymentExecutor; + this.orchestrator = orchestrator; + } + + @GetMapping + @Operation(summary = "List deployments for an app") + @ApiResponse(responseCode = "200", description = "Deployment list returned") + public ResponseEntity> listDeployments(@PathVariable UUID appId) { + return ResponseEntity.ok(deploymentService.listByApp(appId)); + } + + @GetMapping("/{deploymentId}") + @Operation(summary = "Get deployment by ID") + @ApiResponse(responseCode = "200", description = "Deployment found") + @ApiResponse(responseCode = "404", description = "Deployment not found") + public ResponseEntity getDeployment(@PathVariable UUID appId, @PathVariable UUID deploymentId) { + try { + return ResponseEntity.ok(deploymentService.getById(deploymentId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping + @Operation(summary = "Create and start a new deployment") + @ApiResponse(responseCode = "202", description = "Deployment accepted and starting") + public ResponseEntity deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) { + Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId()); + deploymentExecutor.executeAsync(deployment); + return ResponseEntity.accepted().body(deployment); + } + + @PostMapping("/{deploymentId}/stop") + @Operation(summary = "Stop a running deployment") + @ApiResponse(responseCode = "200", description = "Deployment stopped") + @ApiResponse(responseCode = "404", description = "Deployment not found") + public ResponseEntity stop(@PathVariable UUID appId, @PathVariable UUID deploymentId) { + try { + Deployment deployment = deploymentService.getById(deploymentId); + deploymentExecutor.stopDeployment(deployment); + return ResponseEntity.ok(deploymentService.getById(deploymentId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping("/{deploymentId}/promote") + @Operation(summary = "Promote deployment to a different environment") + @ApiResponse(responseCode = "202", description = "Promotion accepted and starting") + @ApiResponse(responseCode = "404", description = "Deployment not found") + public ResponseEntity promote(@PathVariable UUID appId, @PathVariable UUID deploymentId, + @RequestBody PromoteRequest request) { + try { + Deployment source = deploymentService.getById(deploymentId); + Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId()); + deploymentExecutor.executeAsync(promoted); + return ResponseEntity.accepted().body(promoted); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/{deploymentId}/logs") + @Operation(summary = "Get container logs for a deployment") + @ApiResponse(responseCode = "200", description = "Logs returned") + @ApiResponse(responseCode = "404", description = "Deployment not found or no container") + public ResponseEntity> getLogs(@PathVariable UUID appId, @PathVariable UUID deploymentId) { + try { + Deployment deployment = deploymentService.getById(deploymentId); + if (deployment.containerId() == null) { + return ResponseEntity.notFound().build(); + } + List logs = orchestrator.getLogs(deployment.containerId(), 200).collect(Collectors.toList()); + return ResponseEntity.ok(logs); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + public record DeployRequest(UUID appVersionId, UUID environmentId) {} + public record PromoteRequest(UUID targetEnvironmentId) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java new file mode 100644 index 00000000..50f84608 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java @@ -0,0 +1,87 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.core.runtime.Environment; +import com.cameleer3.server.core.runtime.EnvironmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +/** + * Admin endpoints for environment management. + * Protected by {@code ROLE_ADMIN}. + */ +@RestController +@RequestMapping("/api/v1/admin/environments") +@Tag(name = "Environment Admin", description = "Environment management (ADMIN only)") +@PreAuthorize("hasRole('ADMIN')") +public class EnvironmentAdminController { + + private final EnvironmentService environmentService; + + public EnvironmentAdminController(EnvironmentService environmentService) { + this.environmentService = environmentService; + } + + @GetMapping + @Operation(summary = "List all environments") + @ApiResponse(responseCode = "200", description = "Environment list returned") + public ResponseEntity> listEnvironments() { + return ResponseEntity.ok(environmentService.listAll()); + } + + @GetMapping("/{id}") + @Operation(summary = "Get environment by ID") + @ApiResponse(responseCode = "200", description = "Environment found") + @ApiResponse(responseCode = "404", description = "Environment not found") + public ResponseEntity getEnvironment(@PathVariable UUID id) { + try { + return ResponseEntity.ok(environmentService.getById(id)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping + @Operation(summary = "Create a new environment") + @ApiResponse(responseCode = "201", description = "Environment created") + @ApiResponse(responseCode = "400", description = "Slug already exists") + public ResponseEntity createEnvironment(@RequestBody CreateEnvironmentRequest request) { + try { + UUID id = environmentService.create(request.slug(), request.displayName()); + return ResponseEntity.status(201).body(environmentService.getById(id)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete an environment") + @ApiResponse(responseCode = "204", description = "Environment deleted") + @ApiResponse(responseCode = "400", description = "Cannot delete default environment") + @ApiResponse(responseCode = "404", description = "Environment not found") + public ResponseEntity deleteEnvironment(@PathVariable UUID id) { + try { + environmentService.delete(id); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + if (e.getMessage().contains("not found")) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.badRequest().build(); + } + } + + public record CreateEnvironmentRequest(String slug, String displayName) {} +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index fe27af21..369584ae 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -127,6 +127,9 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + // Runtime management (OPERATOR+) + .requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN") + // Admin endpoints .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")