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:
hsiegeln
2026-04-16 23:38:37 +02:00
parent 969cdb3bd0
commit 6d9e456b97
5 changed files with 194 additions and 125 deletions

View File

@@ -1,8 +1,10 @@
package com.cameleer.server.app.controller; 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.App;
import com.cameleer.server.core.runtime.AppService; import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion; import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.RuntimeType; import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -27,13 +29,13 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
* App CRUD and JAR upload endpoints. * App CRUD and JAR upload. All routes env-scoped: the (env, appSlug) pair
* All app-scoped endpoints accept the app slug (not UUID) as path variable. * identifies a single app — the same app slug can legitimately exist in
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}. * multiple environments with independent configuration and history.
*/ */
@RestController @RestController
@RequestMapping("/api/v1/apps") @RequestMapping("/api/v1/environments/{envSlug}/apps")
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads") @Tag(name = "App Management", description = "Application lifecycle and JAR uploads (env-scoped)")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')") @PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class AppController { public class AppController {
@@ -44,46 +46,45 @@ public class AppController {
} }
@GetMapping @GetMapping
@Operation(summary = "List apps by environment") @Operation(summary = "List apps in this environment")
@ApiResponse(responseCode = "200", description = "App list returned") @ApiResponse(responseCode = "200", description = "App list returned")
public ResponseEntity<List<App>> listApps(@RequestParam(required = false) UUID environmentId) { public ResponseEntity<List<App>> listApps(@EnvPath Environment env) {
if (environmentId != null) { return ResponseEntity.ok(appService.listByEnvironment(env.id()));
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
}
return ResponseEntity.ok(appService.listAll());
} }
@GetMapping("/{appSlug}") @GetMapping("/{appSlug}")
@Operation(summary = "Get app by slug") @Operation(summary = "Get app by env + slug")
@ApiResponse(responseCode = "200", description = "App found") @ApiResponse(responseCode = "200", description = "App found")
@ApiResponse(responseCode = "404", description = "App not found") @ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<App> getApp(@PathVariable String appSlug) { public ResponseEntity<App> getApp(@EnvPath Environment env, @PathVariable String appSlug) {
try { try {
return ResponseEntity.ok(appService.getBySlug(appSlug)); return ResponseEntity.ok(appService.getByEnvironmentAndSlug(env.id(), appSlug));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
} }
@PostMapping @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 = "201", description = "App created")
@ApiResponse(responseCode = "400", description = "Slug already exists in environment") @ApiResponse(responseCode = "400", description = "Invalid slug, or slug already exists in this environment")
public ResponseEntity<App> createApp(@RequestBody CreateAppRequest request) { public ResponseEntity<?> createApp(@EnvPath Environment env, @RequestBody CreateAppRequest request) {
try { 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)); return ResponseEntity.status(201).body(appService.getById(id));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} }
} }
@GetMapping("/{appSlug}/versions") @GetMapping("/{appSlug}/versions")
@Operation(summary = "List app versions") @Operation(summary = "List versions for this app")
@ApiResponse(responseCode = "200", description = "Version list returned") @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 { try {
App app = appService.getBySlug(appSlug); App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
return ResponseEntity.ok(appService.listVersions(app.id())); return ResponseEntity.ok(appService.listVersions(app.id()));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -91,13 +92,14 @@ public class AppController {
} }
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @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 = "201", description = "JAR uploaded and version created")
@ApiResponse(responseCode = "404", description = "App not found") @ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug, public ResponseEntity<AppVersion> uploadJar(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestParam("file") MultipartFile file) throws IOException { @RequestParam("file") MultipartFile file) throws IOException {
try { 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()); AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
return ResponseEntity.status(201).body(version); return ResponseEntity.status(201).body(version);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -106,11 +108,11 @@ public class AppController {
} }
@DeleteMapping("/{appSlug}") @DeleteMapping("/{appSlug}")
@Operation(summary = "Delete an app") @Operation(summary = "Delete this app")
@ApiResponse(responseCode = "204", description = "App deleted") @ApiResponse(responseCode = "204", description = "App deleted")
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) { public ResponseEntity<Void> deleteApp(@EnvPath Environment env, @PathVariable String appSlug) {
try { try {
App app = appService.getBySlug(appSlug); App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
appService.deleteApp(app.id()); appService.deleteApp(app.id());
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -134,24 +136,25 @@ public class AppController {
} }
@PutMapping("/{appSlug}/container-config") @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 = "200", description = "Container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration") @ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "App not found") @ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug, public ResponseEntity<?> updateContainerConfig(@EnvPath Environment env,
@RequestBody Map<String, Object> containerConfig) { @PathVariable String appSlug,
@RequestBody Map<String, Object> containerConfig) {
try { try {
validateContainerConfig(containerConfig); validateContainerConfig(containerConfig);
App app = appService.getBySlug(appSlug); App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
appService.updateContainerConfig(app.id(), containerConfig); appService.updateContainerConfig(app.id(), containerConfig);
return ResponseEntity.ok(appService.getById(app.id())); return ResponseEntity.ok(appService.getById(app.id()));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) { if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build(); 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) {}
} }

View File

@@ -1,7 +1,14 @@
package com.cameleer.server.app.controller; package com.cameleer.server.app.controller;
import com.cameleer.server.app.runtime.DeploymentExecutor; 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.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; 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 org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* Deployment management: deploy, stop, promote, and view logs. * Deployment management. Env + app come from the URL. Promote is inherently
* All app-scoped endpoints accept the app slug (not UUID) as path variable. * cross-env, so the target environment stays explicit in the request body
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}. * (as a slug).
*/ */
@RestController @RestController
@RequestMapping("/api/v1/apps/{appSlug}/deployments") @RequestMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/deployments")
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs") @Tag(name = "Deployment Management", description = "Deploy, stop, promote, and view logs")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')") @PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class DeploymentController { public class DeploymentController {
@@ -33,23 +41,26 @@ public class DeploymentController {
private final DeploymentExecutor deploymentExecutor; private final DeploymentExecutor deploymentExecutor;
private final RuntimeOrchestrator orchestrator; private final RuntimeOrchestrator orchestrator;
private final AppService appService; private final AppService appService;
private final EnvironmentService environmentService;
public DeploymentController(DeploymentService deploymentService, public DeploymentController(DeploymentService deploymentService,
DeploymentExecutor deploymentExecutor, DeploymentExecutor deploymentExecutor,
RuntimeOrchestrator orchestrator, RuntimeOrchestrator orchestrator,
AppService appService) { AppService appService,
EnvironmentService environmentService) {
this.deploymentService = deploymentService; this.deploymentService = deploymentService;
this.deploymentExecutor = deploymentExecutor; this.deploymentExecutor = deploymentExecutor;
this.orchestrator = orchestrator; this.orchestrator = orchestrator;
this.appService = appService; this.appService = appService;
this.environmentService = environmentService;
} }
@GetMapping @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") @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 { try {
App app = appService.getBySlug(appSlug); App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
return ResponseEntity.ok(deploymentService.listByApp(app.id())); return ResponseEntity.ok(deploymentService.listByApp(app.id()));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -60,7 +71,9 @@ public class DeploymentController {
@Operation(summary = "Get deployment by ID") @Operation(summary = "Get deployment by ID")
@ApiResponse(responseCode = "200", description = "Deployment found") @ApiResponse(responseCode = "200", description = "Deployment found")
@ApiResponse(responseCode = "404", description = "Deployment not 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 { try {
return ResponseEntity.ok(deploymentService.getById(deploymentId)); return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -69,12 +82,14 @@ public class DeploymentController {
} }
@PostMapping @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") @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 { try {
App app = appService.getBySlug(appSlug); App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId()); Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id());
deploymentExecutor.executeAsync(deployment); deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment); return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
@@ -86,7 +101,9 @@ public class DeploymentController {
@Operation(summary = "Stop a running deployment") @Operation(summary = "Stop a running deployment")
@ApiResponse(responseCode = "200", description = "Deployment stopped") @ApiResponse(responseCode = "200", description = "Deployment stopped")
@ApiResponse(responseCode = "404", description = "Deployment not found") @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 { try {
Deployment deployment = deploymentService.getById(deploymentId); Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment); deploymentExecutor.stopDeployment(deployment);
@@ -97,27 +114,37 @@ public class DeploymentController {
} }
@PostMapping("/{deploymentId}/promote") @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 = "202", description = "Promotion accepted and starting")
@ApiResponse(responseCode = "404", description = "Deployment not found") @ApiResponse(responseCode = "404", description = "Deployment or target environment not found")
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId, public ResponseEntity<?> promote(@EnvPath Environment env,
@RequestBody PromoteRequest request) { @PathVariable String appSlug,
@PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
try { try {
App app = appService.getBySlug(appSlug); App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment source = deploymentService.getById(deploymentId); 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); deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted); return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) { } 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") @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 = "200", description = "Logs returned")
@ApiResponse(responseCode = "404", description = "Deployment not found or no container") @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 { try {
Deployment deployment = deploymentService.getById(deploymentId); Deployment deployment = deploymentService.getById(deploymentId);
if (deployment.containerId() == null) { if (deployment.containerId() == null) {
@@ -130,6 +157,6 @@ public class DeploymentController {
} }
} }
public record DeployRequest(UUID appVersionId, UUID environmentId) {} public record DeployRequest(UUID appVersionId) {}
public record PromoteRequest(UUID targetEnvironmentId) {} public record PromoteRequest(String targetEnvironment) {}
} }

View File

@@ -12,10 +12,14 @@ import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern;
public class AppService { public class AppService {
private static final Logger log = LoggerFactory.getLogger(AppService.class); private static final Logger log = LoggerFactory.getLogger(AppService.class);
/** Slug rules mirror {@link EnvironmentService#SLUG_PATTERN}: lowercase letters, digits, hyphens; 164 chars; starts with alnum. Immutable after creation. */
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
private final AppRepository appRepo; private final AppRepository appRepo;
private final AppVersionRepository versionRepo; private final AppVersionRepository versionRepo;
private final String jarStoragePath; private final String jarStoragePath;
@@ -30,6 +34,10 @@ public class AppService {
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); } public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); } public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
public App getBySlug(String slug) { return appRepo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("App not found: " + slug)); } public App getBySlug(String slug) { return appRepo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("App not found: " + slug)); }
public App getByEnvironmentAndSlug(UUID environmentId, String slug) {
return appRepo.findByEnvironmentIdAndSlug(environmentId, slug)
.orElseThrow(() -> new IllegalArgumentException("App not found in environment: " + slug));
}
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); } public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
public AppVersion getVersion(UUID versionId) { public AppVersion getVersion(UUID versionId) {
@@ -43,6 +51,10 @@ public class AppService {
} }
public UUID createApp(UUID environmentId, String slug, String displayName) { public UUID createApp(UUID environmentId, String slug, String displayName) {
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
throw new IllegalArgumentException(
"Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
}
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
} }

View File

@@ -43,9 +43,14 @@ export interface Deployment {
createdAt: string; createdAt: string;
} }
async function appFetch<T>(path: string, options?: RequestInit): Promise<T> { /**
* Authenticated fetch. `path` is relative to apiBaseUrl, must include the
* leading slash. All app/deployment endpoints now live under
* /api/v1/environments/{envSlug}/...
*/
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken; const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/apps${path}`, { const res = await fetch(`${config.apiBaseUrl}${path}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -65,28 +70,28 @@ async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
return JSON.parse(text); return JSON.parse(text);
} }
// --- Apps --- function envBase(envSlug: string): string {
return `/environments/${encodeURIComponent(envSlug)}/apps`;
export function useAllApps() {
return useQuery({
queryKey: ['apps', 'all'],
queryFn: () => appFetch<App[]>(''),
});
} }
export function useApps(environmentId: string | undefined) { // --- Apps ---
export function useApps(envSlug: string | undefined) {
return useQuery({ return useQuery({
queryKey: ['apps', environmentId], queryKey: ['apps', envSlug],
queryFn: () => appFetch<App[]>(`?environmentId=${environmentId}`), queryFn: () => apiFetch<App[]>(envBase(envSlug!)),
enabled: !!environmentId, enabled: !!envSlug,
}); });
} }
export function useCreateApp() { export function useCreateApp() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (req: { environmentId: string; slug: string; displayName: string }) => mutationFn: ({ envSlug, slug, displayName }: { envSlug: string; slug: string; displayName: string }) =>
appFetch<App>('', { method: 'POST', body: JSON.stringify(req) }), apiFetch<App>(envBase(envSlug), {
method: 'POST',
body: JSON.stringify({ slug, displayName }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
}); });
} }
@@ -94,8 +99,8 @@ export function useCreateApp() {
export function useDeleteApp() { export function useDeleteApp() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (slug: string) => mutationFn: ({ envSlug, appSlug }: { envSlug: string; appSlug: string }) =>
appFetch<void>(`/${slug}`, { method: 'DELETE' }), apiFetch<void>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['apps'] }); qc.invalidateQueries({ queryKey: ['apps'] });
qc.invalidateQueries({ queryKey: ['catalog'] }); qc.invalidateQueries({ queryKey: ['catalog'] });
@@ -106,30 +111,34 @@ export function useDeleteApp() {
export function useUpdateContainerConfig() { export function useUpdateContainerConfig() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ appId, config }: { appId: string; config: Record<string, unknown> }) => mutationFn: ({ envSlug, appSlug, config }: { envSlug: string; appSlug: string; config: Record<string, unknown> }) =>
appFetch<App>(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }), apiFetch<App>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/container-config`, {
method: 'PUT',
body: JSON.stringify(config),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
}); });
} }
// --- Versions --- // --- Versions ---
export function useAppVersions(appId: string | undefined) { export function useAppVersions(envSlug: string | undefined, appSlug: string | undefined) {
return useQuery({ return useQuery({
queryKey: ['apps', appId, 'versions'], queryKey: ['apps', envSlug, appSlug, 'versions'],
queryFn: () => appFetch<AppVersion[]>(`/${appId}/versions`), queryFn: () => apiFetch<AppVersion[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/versions`),
enabled: !!appId, enabled: !!envSlug && !!appSlug,
}); });
} }
export function useUploadJar() { export function useUploadJar() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ appId, file }: { appId: string; file: File }) => { mutationFn: async ({ envSlug, appSlug, file }: { envSlug: string; appSlug: string; file: File }) => {
const token = useAuthStore.getState().accessToken; const token = useAuthStore.getState().accessToken;
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
const res = await fetch(`${config.apiBaseUrl}/apps/${appId}/versions`, { const res = await fetch(
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`, {
method: 'POST', method: 'POST',
headers: { headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -140,18 +149,18 @@ export function useUploadJar() {
if (!res.ok) throw new Error(`Upload failed: ${res.status}`); if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json() as Promise<AppVersion>; return res.json() as Promise<AppVersion>;
}, },
onSuccess: (_data, { appId }) => onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'versions'] }), qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
}); });
} }
// --- Deployments --- // --- Deployments ---
export function useDeployments(appId: string | undefined) { export function useDeployments(envSlug: string | undefined, appSlug: string | undefined) {
return useQuery({ return useQuery({
queryKey: ['apps', appId, 'deployments'], queryKey: ['apps', envSlug, appSlug, 'deployments'],
queryFn: () => appFetch<Deployment[]>(`/${appId}/deployments`), queryFn: () => apiFetch<Deployment[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/deployments`),
enabled: !!appId, enabled: !!envSlug && !!appSlug,
refetchInterval: 5000, refetchInterval: 5000,
}); });
} }
@@ -159,19 +168,38 @@ export function useDeployments(appId: string | undefined) {
export function useCreateDeployment() { export function useCreateDeployment() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ appId, ...req }: { appId: string; appVersionId: string; environmentId: string }) => mutationFn: ({ envSlug, appSlug, appVersionId }: { envSlug: string; appSlug: string; appVersionId: string }) =>
appFetch<Deployment>(`/${appId}/deployments`, { method: 'POST', body: JSON.stringify(req) }), apiFetch<Deployment>(
onSuccess: (_data, { appId }) => `${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments`,
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }), { method: 'POST', body: JSON.stringify({ appVersionId }) },
),
onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
}); });
} }
export function useStopDeployment() { export function useStopDeployment() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ appId, deploymentId }: { appId: string; deploymentId: string }) => mutationFn: ({ envSlug, appSlug, deploymentId }: { envSlug: string; appSlug: string; deploymentId: string }) =>
appFetch<Deployment>(`/${appId}/deployments/${deploymentId}/stop`, { method: 'POST' }), apiFetch<Deployment>(
onSuccess: (_data, { appId }) => `${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/stop`,
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }), { method: 'POST' },
),
onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
});
}
export function usePromoteDeployment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ envSlug, appSlug, deploymentId, targetEnvironment }:
{ envSlug: string; appSlug: string; deploymentId: string; targetEnvironment: string }) =>
apiFetch<Deployment>(
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/promote`,
{ method: 'POST', body: JSON.stringify({ targetEnvironment }) },
),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
}); });
} }

View File

@@ -23,7 +23,6 @@ import { EnvEditor } from '../../components/EnvEditor';
import { useEnvironmentStore } from '../../api/environment-store'; import { useEnvironmentStore } from '../../api/environment-store';
import { useEnvironments } from '../../api/queries/admin/environments'; import { useEnvironments } from '../../api/queries/admin/environments';
import { import {
useAllApps,
useApps, useApps,
useCreateApp, useCreateApp,
useDeleteApp, useDeleteApp,
@@ -92,13 +91,13 @@ export default function AppsTab() {
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) { function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: allApps = [], isLoading: allLoading } = useAllApps(); const { data: envApps = [], isLoading: envLoading } = useApps(selectedEnv);
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
const { data: catalog = [] } = useCatalog(selectedEnv); const { data: catalog = [] } = useCatalog(selectedEnv);
const apps = selectedEnv ? envApps : allApps; // Apps are env-scoped; without an env selection there is no managed-app list
const isLoading = selectedEnv ? envLoading : allLoading; // to show. The Runtime tab (catalog) is the cross-env discovery surface.
const apps = selectedEnv ? envApps : [];
const isLoading = selectedEnv ? envLoading : false;
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
@@ -259,13 +258,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
try { try {
// 1. Create app // 1. Create app
setStep('Creating app...'); setStep('Creating app...');
const app = await createApp.mutateAsync({ environmentId: envId, slug: slug.trim(), displayName: name.trim() }); const app = await createApp.mutateAsync({ envSlug: selectedEnv!, slug: slug.trim(), displayName: name.trim() });
// 2. Upload JAR (if provided) // 2. Upload JAR (if provided)
let version: AppVersion | null = null; let version: AppVersion | null = null;
if (file) { if (file) {
setStep('Uploading JAR...'); setStep('Uploading JAR...');
version = await uploadJar.mutateAsync({ appId: app.slug, file }); version = await uploadJar.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, file });
} }
// 3. Save container config // 3. Save container config
@@ -286,7 +285,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
customArgs: customArgs || null, customArgs: customArgs || null,
extraNetworks: extraNetworks, extraNetworks: extraNetworks,
}; };
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig }); await updateContainerConfig.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, config: containerConfig });
// 4. Save agent config (will be pushed to agent on first connect) // 4. Save agent config (will be pushed to agent on first connect)
setStep('Saving monitoring config...'); setStep('Saving monitoring config...');
@@ -307,13 +306,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
routeRecording: {}, routeRecording: {},
sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined, sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined,
}, },
environment: selectedEnv, environment: selectedEnv!,
}); });
// 5. Deploy (if requested and JAR was uploaded) // 5. Deploy (if requested and JAR was uploaded)
if (deploy && version) { if (deploy && version) {
setStep('Starting deployment...'); setStep('Starting deployment...');
await createDeployment.mutateAsync({ appId: app.slug, appVersionId: version.id, environmentId: envId }); await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, appVersionId: version.id });
} }
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' }); toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
@@ -661,12 +660,12 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) { function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: allApps = [] } = useAllApps(); const { data: envApps = [] } = useApps(selectedEnv);
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]); const app = useMemo(() => envApps.find((a) => a.slug === appSlug), [envApps, appSlug]);
const { data: catalogApps } = useCatalog(selectedEnv); const { data: catalogApps } = useCatalog(selectedEnv);
const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]); const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]);
const { data: versions = [] } = useAppVersions(appSlug); const { data: versions = [] } = useAppVersions(selectedEnv, appSlug);
const { data: deployments = [] } = useDeployments(appSlug); const { data: deployments = [] } = useDeployments(selectedEnv, appSlug);
const uploadJar = useUploadJar(); const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment(); const createDeployment = useCreateDeployment();
const stopDeployment = useStopDeployment(); const stopDeployment = useStopDeployment();
@@ -699,15 +698,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
try { try {
const v = await uploadJar.mutateAsync({ appId: appSlug, file }); const v = await uploadJar.mutateAsync({ envSlug: selectedEnv!, appSlug, file });
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' }); toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
} catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); } } catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); }
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
async function handleDeploy(versionId: string, environmentId: string) { async function handleDeploy(versionId: string) {
try { try {
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId }); await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, appVersionId: versionId });
toast({ title: 'Deployment started', variant: 'success' }); toast({ title: 'Deployment started', variant: 'success' });
} catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); } } catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); }
} }
@@ -719,7 +718,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
async function confirmStop() { async function confirmStop() {
if (!stopTarget) return; if (!stopTarget) return;
try { try {
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id }); await stopDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, deploymentId: stopTarget.id });
toast({ title: 'Deployment stopped', variant: 'warning' }); toast({ title: 'Deployment stopped', variant: 'warning' });
} catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); } } catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); }
setStopTarget(null); setStopTarget(null);
@@ -727,7 +726,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
async function handleDelete() { async function handleDelete() {
try { try {
await deleteApp.mutateAsync(appSlug); await deleteApp.mutateAsync({ envSlug: selectedEnv!, appSlug });
toast({ title: 'App deleted', variant: 'warning' }); toast({ title: 'App deleted', variant: 'warning' });
navigate('/apps'); navigate('/apps');
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); } } catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
@@ -994,7 +993,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const [newNetwork, setNewNetwork] = useState(''); const [newNetwork, setNewNetwork] = useState('');
// Versions query for runtime detection hints // Versions query for runtime detection hints
const { data: versions = [] } = useAppVersions(app.slug); const { data: versions = [] } = useAppVersions(environment?.slug, app.slug);
const latestVersion = versions?.[0] ?? null; const latestVersion = versions?.[0] ?? null;
// Sync from server data // Sync from server data
@@ -1080,7 +1079,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
extraNetworks: extraNetworks, extraNetworks: extraNetworks,
}; };
try { try {
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig }); await updateContainerConfig.mutateAsync({ envSlug: environment?.slug ?? '', appSlug: app.slug, config: containerConfig });
toast({ title: 'Configuration saved', description: 'Redeploy to apply changes to running deployments.', variant: 'success' }); toast({ title: 'Configuration saved', description: 'Redeploy to apply changes to running deployments.', variant: 'success' });
setEditing(false); setEditing(false);
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); } } catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }