diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java index fbc57832..06507ad3 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java @@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; -import java.util.UUID; @RestController @RequestMapping("/api/v1/admin/environments") @@ -33,13 +32,13 @@ public class EnvironmentAdminController { return ResponseEntity.ok(environmentService.listAll()); } - @GetMapping("/{id}") - @Operation(summary = "Get environment by ID") + @GetMapping("/{envSlug}") + @Operation(summary = "Get environment by slug") @ApiResponse(responseCode = "200", description = "Environment found") @ApiResponse(responseCode = "404", description = "Environment not found") - public ResponseEntity getEnvironment(@PathVariable UUID id) { + public ResponseEntity getEnvironment(@PathVariable String envSlug) { try { - return ResponseEntity.ok(environmentService.getById(id)); + return ResponseEntity.ok(environmentService.getBySlug(envSlug)); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } @@ -48,24 +47,28 @@ public class EnvironmentAdminController { @PostMapping @Operation(summary = "Create a new environment") @ApiResponse(responseCode = "201", description = "Environment created") - @ApiResponse(responseCode = "400", description = "Slug already exists") + @ApiResponse(responseCode = "400", description = "Invalid slug or slug already exists") public ResponseEntity createEnvironment(@RequestBody CreateEnvironmentRequest request) { try { - UUID id = environmentService.create(request.slug(), request.displayName(), request.production()); - return ResponseEntity.status(201).body(environmentService.getById(id)); + environmentService.create(request.slug(), request.displayName(), request.production()); + return ResponseEntity.status(201).body(environmentService.getBySlug(request.slug())); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } - @PutMapping("/{id}") - @Operation(summary = "Update an environment") + @PutMapping("/{envSlug}") + @Operation(summary = "Update an environment's mutable fields (displayName, production, enabled)", + description = "Slug is immutable after creation and cannot be changed. " + + "Any slug field in the request body is ignored.") @ApiResponse(responseCode = "200", description = "Environment updated") @ApiResponse(responseCode = "404", description = "Environment not found") - public ResponseEntity updateEnvironment(@PathVariable UUID id, @RequestBody UpdateEnvironmentRequest request) { + public ResponseEntity updateEnvironment(@PathVariable String envSlug, + @RequestBody UpdateEnvironmentRequest request) { try { - environmentService.update(id, request.displayName(), request.production(), request.enabled()); - return ResponseEntity.ok(environmentService.getById(id)); + Environment current = environmentService.getBySlug(envSlug); + environmentService.update(current.id(), request.displayName(), request.production(), request.enabled()); + return ResponseEntity.ok(environmentService.getBySlug(envSlug)); } catch (IllegalArgumentException e) { if (e.getMessage().contains("not found")) { return ResponseEntity.notFound().build(); @@ -74,14 +77,15 @@ public class EnvironmentAdminController { } } - @DeleteMapping("/{id}") + @DeleteMapping("/{envSlug}") @Operation(summary = "Delete an environment") @ApiResponse(responseCode = "204", description = "Environment deleted") @ApiResponse(responseCode = "400", description = "Cannot delete default environment") @ApiResponse(responseCode = "404", description = "Environment not found") - public ResponseEntity deleteEnvironment(@PathVariable UUID id) { + public ResponseEntity deleteEnvironment(@PathVariable String envSlug) { try { - environmentService.delete(id); + Environment current = environmentService.getBySlug(envSlug); + environmentService.delete(current.id()); return ResponseEntity.noContent().build(); } catch (IllegalArgumentException e) { if (e.getMessage().contains("not found")) { @@ -106,17 +110,18 @@ public class EnvironmentAdminController { } } - @PutMapping("/{id}/default-container-config") + @PutMapping("/{envSlug}/default-container-config") @Operation(summary = "Update default container config for an environment") @ApiResponse(responseCode = "200", description = "Default container config updated") @ApiResponse(responseCode = "400", description = "Invalid configuration") @ApiResponse(responseCode = "404", description = "Environment not found") - public ResponseEntity updateDefaultContainerConfig(@PathVariable UUID id, + public ResponseEntity updateDefaultContainerConfig(@PathVariable String envSlug, @RequestBody Map defaultContainerConfig) { try { validateContainerConfig(defaultContainerConfig); - environmentService.updateDefaultContainerConfig(id, defaultContainerConfig); - return ResponseEntity.ok(environmentService.getById(id)); + Environment current = environmentService.getBySlug(envSlug); + environmentService.updateDefaultContainerConfig(current.id(), defaultContainerConfig); + return ResponseEntity.ok(environmentService.getBySlug(envSlug)); } catch (IllegalArgumentException e) { if (e.getMessage().contains("not found")) { return ResponseEntity.notFound().build(); @@ -125,15 +130,16 @@ public class EnvironmentAdminController { } } - @PutMapping("/{id}/jar-retention") + @PutMapping("/{envSlug}/jar-retention") @Operation(summary = "Update JAR retention policy for an environment") @ApiResponse(responseCode = "200", description = "Retention policy updated") @ApiResponse(responseCode = "404", description = "Environment not found") - public ResponseEntity updateJarRetention(@PathVariable UUID id, + public ResponseEntity updateJarRetention(@PathVariable String envSlug, @RequestBody JarRetentionRequest request) { try { - environmentService.updateJarRetentionCount(id, request.jarRetentionCount()); - return ResponseEntity.ok(environmentService.getById(id)); + Environment current = environmentService.getBySlug(envSlug); + environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount()); + return ResponseEntity.ok(environmentService.getBySlug(envSlug)); } catch (IllegalArgumentException e) { if (e.getMessage().contains("not found")) { return ResponseEntity.notFound().build(); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java index 82a5e172..b4d370fe 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java @@ -97,29 +97,65 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT { String createJson = """ {"slug": "update-test", "displayName": "Before", "production": false} """; - ResponseEntity createResponse = restTemplate.exchange( + restTemplate.exchange( "/api/v1/admin/environments", HttpMethod.POST, new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)), String.class); - JsonNode created = objectMapper.readTree(createResponse.getBody()); - String envId = created.path("id").asText(); - // Update it + // Update it by slug String updateJson = """ {"displayName": "After", "production": true, "enabled": false} """; ResponseEntity response = restTemplate.exchange( - "/api/v1/admin/environments/" + envId, HttpMethod.PUT, + "/api/v1/admin/environments/update-test", HttpMethod.PUT, new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("slug").asText()).isEqualTo("update-test"); assertThat(body.path("displayName").asText()).isEqualTo("After"); assertThat(body.path("production").asBoolean()).isTrue(); assertThat(body.path("enabled").asBoolean()).isFalse(); } + @Test + void updateEnvironment_withSlugInBody_ignoresSlug() throws Exception { + String createJson = """ + {"slug": "slug-immutable-test", "displayName": "Original", "production": false} + """; + restTemplate.exchange( + "/api/v1/admin/environments", HttpMethod.POST, + new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)), + String.class); + + // Attempt to change slug via body — Jackson drops the unknown field + String updateJson = """ + {"slug": "hacked", "displayName": "Renamed", "production": false, "enabled": true} + """; + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/environments/slug-immutable-test", HttpMethod.PUT, + new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("slug").asText()).isEqualTo("slug-immutable-test"); + } + + @Test + void createEnvironment_invalidSlug_returns400() { + String json = """ + {"slug": "Invalid Slug!", "displayName": "Bad"} + """; + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/environments", HttpMethod.POST, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + @Test void createEnvironment_duplicateSlug_returns400() { String json = """ @@ -142,25 +178,9 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT { } @Test - void deleteEnvironment_defaultEnv_returns400() throws Exception { - // Find the default environment ID - ResponseEntity listResponse = restTemplate.exchange( - "/api/v1/admin/environments", HttpMethod.GET, - new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), - String.class); - - JsonNode envs = objectMapper.readTree(listResponse.getBody()); - String defaultId = null; - for (JsonNode env : envs) { - if ("default".equals(env.path("slug").asText())) { - defaultId = env.path("id").asText(); - break; - } - } - assertThat(defaultId).isNotNull(); - + void deleteEnvironment_defaultEnv_returns400() { ResponseEntity response = restTemplate.exchange( - "/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE, + "/api/v1/admin/environments/default", HttpMethod.DELETE, new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), String.class); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java index 567171a0..febd0eb1 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java @@ -3,8 +3,19 @@ package com.cameleer.server.core.runtime; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.regex.Pattern; public class EnvironmentService { + + /** + * Slug must start with a lowercase letter or digit, contain only lowercase + * letters, digits, and hyphens, and be 1–64 characters long. Slugs are + * immutable after creation — they appear in URLs, Docker network names, + * container names, and ClickHouse partition keys, so renaming is not + * supported by design. + */ + private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); + private final EnvironmentRepository repo; public EnvironmentService(EnvironmentRepository repo) { @@ -22,6 +33,10 @@ public class EnvironmentService { } public UUID create(String slug, String displayName, boolean production) { + if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) { + throw new IllegalArgumentException( + "Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)"); + } if (repo.findBySlug(slug).isPresent()) { throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists"); } diff --git a/ui/src/api/queries/admin/environments.ts b/ui/src/api/queries/admin/environments.ts index aefeb425..c32888c0 100644 --- a/ui/src/api/queries/admin/environments.ts +++ b/ui/src/api/queries/admin/environments.ts @@ -46,8 +46,8 @@ export function useCreateEnvironment() { export function useUpdateEnvironment() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, ...req }: UpdateEnvironmentRequest & { id: string }) => - adminFetch(`/environments/${id}`, { + mutationFn: ({ slug, ...req }: UpdateEnvironmentRequest & { slug: string }) => + adminFetch(`/environments/${slug}`, { method: 'PUT', body: JSON.stringify(req), }), @@ -58,8 +58,8 @@ export function useUpdateEnvironment() { export function useUpdateDefaultContainerConfig() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, config }: { id: string; config: Record }) => - adminFetch(`/environments/${id}/default-container-config`, { + mutationFn: ({ slug, config }: { slug: string; config: Record }) => + adminFetch(`/environments/${slug}/default-container-config`, { method: 'PUT', body: JSON.stringify(config), }), @@ -70,8 +70,8 @@ export function useUpdateDefaultContainerConfig() { export function useUpdateJarRetention() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, jarRetentionCount }: { id: string; jarRetentionCount: number | null }) => - adminFetch(`/environments/${id}/jar-retention`, { + mutationFn: ({ slug, jarRetentionCount }: { slug: string; jarRetentionCount: number | null }) => + adminFetch(`/environments/${slug}/jar-retention`, { method: 'PUT', body: JSON.stringify({ jarRetentionCount }), }), @@ -82,8 +82,8 @@ export function useUpdateJarRetention() { export function useDeleteEnvironment() { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: string) => - adminFetch(`/environments/${id}`, { method: 'DELETE' }), + mutationFn: (slug: string) => + adminFetch(`/environments/${slug}`, { method: 'DELETE' }), onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }), }); } diff --git a/ui/src/pages/Admin/EnvironmentsPage.tsx b/ui/src/pages/Admin/EnvironmentsPage.tsx index ab2fc8fd..ef4cee2e 100644 --- a/ui/src/pages/Admin/EnvironmentsPage.tsx +++ b/ui/src/pages/Admin/EnvironmentsPage.tsx @@ -102,7 +102,7 @@ export default function EnvironmentsPage() { async function handleDelete() { if (!deleteTarget) return; try { - await deleteEnv.mutateAsync(deleteTarget.id); + await deleteEnv.mutateAsync(deleteTarget.slug); toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' }); if (selectedId === deleteTarget.id) setSelectedId(null); setDeleteTarget(null); @@ -116,7 +116,7 @@ export default function EnvironmentsPage() { if (!selected) return; try { await updateEnv.mutateAsync({ - id: selected.id, + slug: selected.slug, displayName: newName, production: selected.production, enabled: selected.enabled, @@ -131,7 +131,7 @@ export default function EnvironmentsPage() { if (!selected) return; try { await updateEnv.mutateAsync({ - id: selected.id, + slug: selected.slug, displayName: selected.displayName, production: value, enabled: selected.enabled, @@ -146,7 +146,7 @@ export default function EnvironmentsPage() { if (!selected) return; try { await updateEnv.mutateAsync({ - id: selected.id, + slug: selected.slug, displayName: selected.displayName, production: selected.production, enabled: value, @@ -300,7 +300,7 @@ export default function EnvironmentsPage() { { try { - await updateDefaults.mutateAsync({ id: selected.id, config }); + await updateDefaults.mutateAsync({ slug: selected.slug, config }); toast({ title: 'Default resources updated', variant: 'success' }); } catch { toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 }); @@ -309,7 +309,7 @@ export default function EnvironmentsPage() { { try { - await updateRetention.mutateAsync({ id: selected.id, jarRetentionCount: count }); + await updateRetention.mutateAsync({ slug: selected.slug, jarRetentionCount: count }); toast({ title: 'Retention policy updated', variant: 'success' }); } catch { toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 });