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