feat: add REST controllers for environment, app, and deployment management

- EnvironmentAdminController: CRUD under /api/v1/admin/environments (ADMIN)
- AppController: CRUD + JAR upload under /api/v1/apps (OPERATOR+)
- DeploymentController: deploy, stop, promote, logs under /api/v1/apps/{appId}/deployments
- Security rule for /api/v1/apps/** requiring OPERATOR or ADMIN role

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 23:47:05 +02:00
parent 248b716cb9
commit 8f2aafadc1
4 changed files with 316 additions and 0 deletions

View File

@@ -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<List<App>> 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<App> 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<App> 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<List<AppVersion>> 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<AppVersion> 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<Void> deleteApp(@PathVariable UUID appId) {
appService.deleteApp(appId);
return ResponseEntity.noContent().build();
}
public record CreateAppRequest(UUID environmentId, String slug, String displayName) {}
}

View File

@@ -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<List<Deployment>> 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<Deployment> 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<Deployment> 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<Deployment> 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<Deployment> 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<List<String>> getLogs(@PathVariable UUID appId, @PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
if (deployment.containerId() == null) {
return ResponseEntity.notFound().build();
}
List<String> 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) {}
}

View File

@@ -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<List<Environment>> 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<Environment> 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<Environment> 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<Void> 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) {}
}

View File

@@ -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")