feat!: move apps & deployments under /api/v1/environments/{envSlug}/apps/{appSlug}/...
P3B of the taxonomy migration. App and deployment routes are now
env-scoped in the URL itself, making the (env, app_slug) uniqueness
key explicit. Previously /api/v1/apps/{appSlug} was ambiguous: with
the same app deployed to multiple environments (dev/staging/prod),
the handler called AppService.getBySlug(slug) which returns the
first row matching slug regardless of env.
Server:
- AppController: @RequestMapping("/api/v1/environments/{envSlug}/
apps"). Every handler now calls
appService.getByEnvironmentAndSlug(env.id(), appSlug) — 404 if the
app doesn't exist in *this* env. CreateAppRequest body drops
environmentId (it's in the path).
- DeploymentController: @RequestMapping("/api/v1/environments/
{envSlug}/apps/{appSlug}/deployments"). DeployRequest body drops
environmentId. PromoteRequest body switches from
targetEnvironmentId (UUID) to targetEnvironment (slug);
promote handler resolves the target env by slug and looks up the
app with the same slug in the target env (fails with 404 if the
target app doesn't exist yet — apps must exist in both source
and target before promote).
- AppService: added getByEnvironmentAndSlug helper; createApp now
validates slug against ^[a-z0-9][a-z0-9-]{0,63}$ (400 on
invalid).
SPA:
- queries/admin/apps.ts: rewritten. Hooks take envSlug where
env-scoped. Removed useAllApps (no flat endpoint). Renamed path
param naming: appId → appSlug throughout. Added
usePromoteDeployment. Query keys include envSlug so cache is
env-scoped.
- AppsTab.tsx: call sites updated. When no environment is selected,
the managed-app list is empty — cross-env discovery lives in the
Runtime tab (catalog). handleDeploy/handleStop/etc. pass envSlug
to the new hook signatures.
BREAKING CHANGE: /api/v1/apps/** paths removed. Clients must use
/api/v1/environments/{envSlug}/apps/{appSlug}/**. Request bodies
for POST /apps and POST /apps/{slug}/deployments no longer accept
environmentId (use the URL path instead). Promote body uses slug
not UUID.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.runtime.App;
|
||||
import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersion;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.RuntimeType;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
@@ -27,13 +29,13 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* App CRUD and JAR upload endpoints.
|
||||
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
|
||||
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
|
||||
* App CRUD and JAR upload. All routes env-scoped: the (env, appSlug) pair
|
||||
* identifies a single app — the same app slug can legitimately exist in
|
||||
* multiple environments with independent configuration and history.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/apps")
|
||||
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads")
|
||||
@RequestMapping("/api/v1/environments/{envSlug}/apps")
|
||||
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads (env-scoped)")
|
||||
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
||||
public class AppController {
|
||||
|
||||
@@ -44,46 +46,45 @@ public class AppController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List apps by environment")
|
||||
@Operation(summary = "List apps in this environment")
|
||||
@ApiResponse(responseCode = "200", description = "App list returned")
|
||||
public ResponseEntity<List<App>> listApps(@RequestParam(required = false) UUID environmentId) {
|
||||
if (environmentId != null) {
|
||||
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
|
||||
}
|
||||
return ResponseEntity.ok(appService.listAll());
|
||||
public ResponseEntity<List<App>> listApps(@EnvPath Environment env) {
|
||||
return ResponseEntity.ok(appService.listByEnvironment(env.id()));
|
||||
}
|
||||
|
||||
@GetMapping("/{appSlug}")
|
||||
@Operation(summary = "Get app by slug")
|
||||
@Operation(summary = "Get app by env + slug")
|
||||
@ApiResponse(responseCode = "200", description = "App found")
|
||||
@ApiResponse(responseCode = "404", description = "App not found")
|
||||
public ResponseEntity<App> getApp(@PathVariable String appSlug) {
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<App> getApp(@EnvPath Environment env, @PathVariable String appSlug) {
|
||||
try {
|
||||
return ResponseEntity.ok(appService.getBySlug(appSlug));
|
||||
return ResponseEntity.ok(appService.getByEnvironmentAndSlug(env.id(), appSlug));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new app")
|
||||
@Operation(summary = "Create a new app in this environment",
|
||||
description = "Slug must match ^[a-z0-9][a-z0-9-]{0,63}$ and be unique within the environment. "
|
||||
+ "Slug is immutable after creation.")
|
||||
@ApiResponse(responseCode = "201", description = "App created")
|
||||
@ApiResponse(responseCode = "400", description = "Slug already exists in environment")
|
||||
public ResponseEntity<App> createApp(@RequestBody CreateAppRequest request) {
|
||||
@ApiResponse(responseCode = "400", description = "Invalid slug, or slug already exists in this environment")
|
||||
public ResponseEntity<?> createApp(@EnvPath Environment env, @RequestBody CreateAppRequest request) {
|
||||
try {
|
||||
UUID id = appService.createApp(request.environmentId(), request.slug(), request.displayName());
|
||||
UUID id = appService.createApp(env.id(), request.slug(), request.displayName());
|
||||
return ResponseEntity.status(201).body(appService.getById(id));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{appSlug}/versions")
|
||||
@Operation(summary = "List app versions")
|
||||
@Operation(summary = "List versions for this app")
|
||||
@ApiResponse(responseCode = "200", description = "Version list returned")
|
||||
public ResponseEntity<List<AppVersion>> listVersions(@PathVariable String appSlug) {
|
||||
public ResponseEntity<List<AppVersion>> listVersions(@EnvPath Environment env, @PathVariable String appSlug) {
|
||||
try {
|
||||
App app = appService.getBySlug(appSlug);
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
return ResponseEntity.ok(appService.listVersions(app.id()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -91,13 +92,14 @@ public class AppController {
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "Upload a JAR for a new app version")
|
||||
@Operation(summary = "Upload a JAR for a new version of this app")
|
||||
@ApiResponse(responseCode = "201", description = "JAR uploaded and version created")
|
||||
@ApiResponse(responseCode = "404", description = "App not found")
|
||||
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug,
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<AppVersion> uploadJar(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
try {
|
||||
App app = appService.getBySlug(appSlug);
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
|
||||
return ResponseEntity.status(201).body(version);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -106,11 +108,11 @@ public class AppController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/{appSlug}")
|
||||
@Operation(summary = "Delete an app")
|
||||
@Operation(summary = "Delete this app")
|
||||
@ApiResponse(responseCode = "204", description = "App deleted")
|
||||
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) {
|
||||
public ResponseEntity<Void> deleteApp(@EnvPath Environment env, @PathVariable String appSlug) {
|
||||
try {
|
||||
App app = appService.getBySlug(appSlug);
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
appService.deleteApp(app.id());
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -134,24 +136,25 @@ public class AppController {
|
||||
}
|
||||
|
||||
@PutMapping("/{appSlug}/container-config")
|
||||
@Operation(summary = "Update container config for an app")
|
||||
@Operation(summary = "Update container config for this app")
|
||||
@ApiResponse(responseCode = "200", description = "Container config updated")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid configuration")
|
||||
@ApiResponse(responseCode = "404", description = "App not found")
|
||||
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
|
||||
@RequestBody Map<String, Object> containerConfig) {
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<?> updateContainerConfig(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@RequestBody Map<String, Object> containerConfig) {
|
||||
try {
|
||||
validateContainerConfig(containerConfig);
|
||||
App app = appService.getBySlug(appSlug);
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
appService.updateContainerConfig(app.id(), containerConfig);
|
||||
return ResponseEntity.ok(appService.getById(app.id()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage().contains("not found")) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.badRequest().build();
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateAppRequest(UUID environmentId, String slug, String displayName) {}
|
||||
public record CreateAppRequest(String slug, String displayName) {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.app.runtime.DeploymentExecutor;
|
||||
import com.cameleer.server.core.runtime.*;
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.runtime.App;
|
||||
import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentService;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.EnvironmentService;
|
||||
import com.cameleer.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;
|
||||
@@ -15,17 +22,18 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Deployment management: deploy, stop, promote, and view logs.
|
||||
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
|
||||
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
|
||||
* Deployment management. Env + app come from the URL. Promote is inherently
|
||||
* cross-env, so the target environment stays explicit in the request body
|
||||
* (as a slug).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/apps/{appSlug}/deployments")
|
||||
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs")
|
||||
@RequestMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/deployments")
|
||||
@Tag(name = "Deployment Management", description = "Deploy, stop, promote, and view logs")
|
||||
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
||||
public class DeploymentController {
|
||||
|
||||
@@ -33,23 +41,26 @@ public class DeploymentController {
|
||||
private final DeploymentExecutor deploymentExecutor;
|
||||
private final RuntimeOrchestrator orchestrator;
|
||||
private final AppService appService;
|
||||
private final EnvironmentService environmentService;
|
||||
|
||||
public DeploymentController(DeploymentService deploymentService,
|
||||
DeploymentExecutor deploymentExecutor,
|
||||
RuntimeOrchestrator orchestrator,
|
||||
AppService appService) {
|
||||
AppService appService,
|
||||
EnvironmentService environmentService) {
|
||||
this.deploymentService = deploymentService;
|
||||
this.deploymentExecutor = deploymentExecutor;
|
||||
this.orchestrator = orchestrator;
|
||||
this.appService = appService;
|
||||
this.environmentService = environmentService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List deployments for an app")
|
||||
@Operation(summary = "List deployments for this app in this environment")
|
||||
@ApiResponse(responseCode = "200", description = "Deployment list returned")
|
||||
public ResponseEntity<List<Deployment>> listDeployments(@PathVariable String appSlug) {
|
||||
public ResponseEntity<List<Deployment>> listDeployments(@EnvPath Environment env, @PathVariable String appSlug) {
|
||||
try {
|
||||
App app = appService.getBySlug(appSlug);
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -60,7 +71,9 @@ public class DeploymentController {
|
||||
@Operation(summary = "Get deployment by ID")
|
||||
@ApiResponse(responseCode = "200", description = "Deployment found")
|
||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||
public ResponseEntity<Deployment> getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||
public ResponseEntity<Deployment> getDeployment(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable UUID deploymentId) {
|
||||
try {
|
||||
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -69,12 +82,14 @@ public class DeploymentController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create and start a new deployment")
|
||||
@Operation(summary = "Create and start a new deployment for this app in this environment")
|
||||
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
|
||||
public ResponseEntity<Deployment> deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) {
|
||||
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@RequestBody DeployRequest request) {
|
||||
try {
|
||||
App app = appService.getBySlug(appSlug);
|
||||
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId());
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id());
|
||||
deploymentExecutor.executeAsync(deployment);
|
||||
return ResponseEntity.accepted().body(deployment);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -86,7 +101,9 @@ public class DeploymentController {
|
||||
@Operation(summary = "Stop a running deployment")
|
||||
@ApiResponse(responseCode = "200", description = "Deployment stopped")
|
||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||
public ResponseEntity<Deployment> stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable UUID deploymentId) {
|
||||
try {
|
||||
Deployment deployment = deploymentService.getById(deploymentId);
|
||||
deploymentExecutor.stopDeployment(deployment);
|
||||
@@ -97,27 +114,37 @@ public class DeploymentController {
|
||||
}
|
||||
|
||||
@PostMapping("/{deploymentId}/promote")
|
||||
@Operation(summary = "Promote deployment to a different environment")
|
||||
@Operation(summary = "Promote this deployment to a different environment",
|
||||
description = "Target environment is specified by slug in the request body. "
|
||||
+ "The same app slug must exist in the target environment (or be created separately first).")
|
||||
@ApiResponse(responseCode = "202", description = "Promotion accepted and starting")
|
||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId,
|
||||
@RequestBody PromoteRequest request) {
|
||||
@ApiResponse(responseCode = "404", description = "Deployment or target environment not found")
|
||||
public ResponseEntity<?> promote(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable UUID deploymentId,
|
||||
@RequestBody PromoteRequest request) {
|
||||
try {
|
||||
App app = appService.getBySlug(appSlug);
|
||||
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
Deployment source = deploymentService.getById(deploymentId);
|
||||
Deployment promoted = deploymentService.promote(app.id(), source.appVersionId(), request.targetEnvironmentId());
|
||||
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
|
||||
// Target must also have the app with the same slug
|
||||
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
|
||||
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id());
|
||||
deploymentExecutor.executeAsync(promoted);
|
||||
return ResponseEntity.accepted().body(promoted);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{deploymentId}/logs")
|
||||
@Operation(summary = "Get container logs for a deployment")
|
||||
@Operation(summary = "Get container logs for this deployment")
|
||||
@ApiResponse(responseCode = "200", description = "Logs returned")
|
||||
@ApiResponse(responseCode = "404", description = "Deployment not found or no container")
|
||||
public ResponseEntity<List<String>> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||
public ResponseEntity<List<String>> getLogs(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable UUID deploymentId) {
|
||||
try {
|
||||
Deployment deployment = deploymentService.getById(deploymentId);
|
||||
if (deployment.containerId() == null) {
|
||||
@@ -130,6 +157,6 @@ public class DeploymentController {
|
||||
}
|
||||
}
|
||||
|
||||
public record DeployRequest(UUID appVersionId, UUID environmentId) {}
|
||||
public record PromoteRequest(UUID targetEnvironmentId) {}
|
||||
public record DeployRequest(UUID appVersionId) {}
|
||||
public record PromoteRequest(String targetEnvironment) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user