feat: add production/enabled flags to environments, drop status enum
Environments now have: - production (bool): prod vs non-prod resource allocation - enabled (bool): disabled blocks new deployments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
@@ -7,21 +7,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
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.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin endpoints for environment management.
|
|
||||||
* Protected by {@code ROLE_ADMIN}.
|
|
||||||
*/
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/environments")
|
@RequestMapping("/api/v1/admin/environments")
|
||||||
@Tag(name = "Environment Admin", description = "Environment management (ADMIN only)")
|
@Tag(name = "Environment Admin", description = "Environment management (ADMIN only)")
|
||||||
@@ -36,7 +27,6 @@ public class EnvironmentAdminController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all environments")
|
@Operation(summary = "List all environments")
|
||||||
@ApiResponse(responseCode = "200", description = "Environment list returned")
|
|
||||||
public ResponseEntity<List<Environment>> listEnvironments() {
|
public ResponseEntity<List<Environment>> listEnvironments() {
|
||||||
return ResponseEntity.ok(environmentService.listAll());
|
return ResponseEntity.ok(environmentService.listAll());
|
||||||
}
|
}
|
||||||
@@ -57,12 +47,28 @@ public class EnvironmentAdminController {
|
|||||||
@Operation(summary = "Create a new environment")
|
@Operation(summary = "Create a new environment")
|
||||||
@ApiResponse(responseCode = "201", description = "Environment created")
|
@ApiResponse(responseCode = "201", description = "Environment created")
|
||||||
@ApiResponse(responseCode = "400", description = "Slug already exists")
|
@ApiResponse(responseCode = "400", description = "Slug already exists")
|
||||||
public ResponseEntity<Environment> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
|
public ResponseEntity<?> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
|
||||||
try {
|
try {
|
||||||
UUID id = environmentService.create(request.slug(), request.displayName());
|
UUID id = environmentService.create(request.slug(), request.displayName(), request.production());
|
||||||
return ResponseEntity.status(201).body(environmentService.getById(id));
|
return ResponseEntity.status(201).body(environmentService.getById(id));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Update an environment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Environment updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
|
public ResponseEntity<?> updateEnvironment(@PathVariable UUID id, @RequestBody UpdateEnvironmentRequest request) {
|
||||||
|
try {
|
||||||
|
environmentService.update(id, request.displayName(), request.production(), request.enabled());
|
||||||
|
return ResponseEntity.ok(environmentService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
if (e.getMessage().contains("not found")) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ public class EnvironmentAdminController {
|
|||||||
@ApiResponse(responseCode = "204", description = "Environment deleted")
|
@ApiResponse(responseCode = "204", description = "Environment deleted")
|
||||||
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
|
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
public ResponseEntity<Void> deleteEnvironment(@PathVariable UUID id) {
|
public ResponseEntity<?> deleteEnvironment(@PathVariable UUID id) {
|
||||||
try {
|
try {
|
||||||
environmentService.delete(id);
|
environmentService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
@@ -79,9 +85,10 @@ public class EnvironmentAdminController {
|
|||||||
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 CreateEnvironmentRequest(String slug, String displayName) {}
|
public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
|
||||||
|
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.cameleer3.server.app.storage;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.runtime.Environment;
|
import com.cameleer3.server.core.runtime.Environment;
|
||||||
import com.cameleer3.server.core.runtime.EnvironmentRepository;
|
import com.cameleer3.server.core.runtime.EnvironmentRepository;
|
||||||
import com.cameleer3.server.core.runtime.EnvironmentStatus;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
@@ -21,14 +20,15 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Environment> findAll() {
|
public List<Environment> findAll() {
|
||||||
return jdbc.query("SELECT id, slug, display_name, status, created_at FROM environments ORDER BY created_at",
|
return jdbc.query(
|
||||||
|
"SELECT id, slug, display_name, production, enabled, created_at FROM environments ORDER BY created_at",
|
||||||
(rs, rowNum) -> mapRow(rs));
|
(rs, rowNum) -> mapRow(rs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Environment> findById(UUID id) {
|
public Optional<Environment> findById(UUID id) {
|
||||||
var results = jdbc.query(
|
var results = jdbc.query(
|
||||||
"SELECT id, slug, display_name, status, created_at FROM environments WHERE id = ?",
|
"SELECT id, slug, display_name, production, enabled, created_at FROM environments WHERE id = ?",
|
||||||
(rs, rowNum) -> mapRow(rs), id);
|
(rs, rowNum) -> mapRow(rs), id);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@@ -36,29 +36,23 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<Environment> findBySlug(String slug) {
|
public Optional<Environment> findBySlug(String slug) {
|
||||||
var results = jdbc.query(
|
var results = jdbc.query(
|
||||||
"SELECT id, slug, display_name, status, created_at FROM environments WHERE slug = ?",
|
"SELECT id, slug, display_name, production, enabled, created_at FROM environments WHERE slug = ?",
|
||||||
(rs, rowNum) -> mapRow(rs), slug);
|
(rs, rowNum) -> mapRow(rs), slug);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public UUID create(String slug, String displayName) {
|
public UUID create(String slug, String displayName, boolean production) {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
jdbc.update("INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
jdbc.update("INSERT INTO environments (id, slug, display_name, production) VALUES (?, ?, ?, ?)",
|
||||||
id, slug, displayName);
|
id, slug, displayName, production);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateDisplayName(UUID id, String displayName) {
|
public void update(UUID id, String displayName, boolean production, boolean enabled) {
|
||||||
jdbc.update("UPDATE environments SET display_name = ?, updated_at = now() WHERE id = ?",
|
jdbc.update("UPDATE environments SET display_name = ?, production = ?, enabled = ?, updated_at = now() WHERE id = ?",
|
||||||
displayName, id);
|
displayName, production, enabled, id);
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void updateStatus(UUID id, EnvironmentStatus status) {
|
|
||||||
jdbc.update("UPDATE environments SET status = ?, updated_at = now() WHERE id = ?",
|
|
||||||
status.name(), id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -71,7 +65,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
UUID.fromString(rs.getString("id")),
|
UUID.fromString(rs.getString("id")),
|
||||||
rs.getString("slug"),
|
rs.getString("slug"),
|
||||||
rs.getString("display_name"),
|
rs.getString("display_name"),
|
||||||
EnvironmentStatus.valueOf(rs.getString("status")),
|
rs.getBoolean("production"),
|
||||||
|
rs.getBoolean("enabled"),
|
||||||
rs.getTimestamp("created_at").toInstant()
|
rs.getTimestamp("created_at").toInstant()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- V4__environment_config.sql
|
||||||
|
-- Add production flag and enabled flag to environments, drop unused status column
|
||||||
|
|
||||||
|
ALTER TABLE environments ADD COLUMN production BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE environments ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE environments DROP COLUMN status;
|
||||||
@@ -74,7 +74,7 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
|||||||
@Test
|
@Test
|
||||||
void createEnvironment_asAdmin_returns201() throws Exception {
|
void createEnvironment_asAdmin_returns201() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
{"slug": "staging", "displayName": "Staging"}
|
{"slug": "staging", "displayName": "Staging", "production": false}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
@@ -86,10 +86,40 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
|||||||
JsonNode body = objectMapper.readTree(response.getBody());
|
JsonNode body = objectMapper.readTree(response.getBody());
|
||||||
assertThat(body.path("slug").asText()).isEqualTo("staging");
|
assertThat(body.path("slug").asText()).isEqualTo("staging");
|
||||||
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
|
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
|
||||||
assertThat(body.path("status").asText()).isEqualTo("ACTIVE");
|
assertThat(body.path("production").asBoolean()).isFalse();
|
||||||
|
assertThat(body.path("enabled").asBoolean()).isTrue();
|
||||||
assertThat(body.has("id")).isTrue();
|
assertThat(body.has("id")).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateEnvironment_asAdmin_returns200() throws Exception {
|
||||||
|
// Create an environment first
|
||||||
|
String createJson = """
|
||||||
|
{"slug": "update-test", "displayName": "Before", "production": false}
|
||||||
|
""";
|
||||||
|
ResponseEntity<String> createResponse = 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
|
||||||
|
String updateJson = """
|
||||||
|
{"displayName": "After", "production": true, "enabled": false}
|
||||||
|
""";
|
||||||
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
|
"/api/v1/admin/environments/" + envId, 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("displayName").asText()).isEqualTo("After");
|
||||||
|
assertThat(body.path("production").asBoolean()).isTrue();
|
||||||
|
assertThat(body.path("enabled").asBoolean()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createEnvironment_duplicateSlug_returns400() {
|
void createEnvironment_duplicateSlug_returns400() {
|
||||||
String json = """
|
String json = """
|
||||||
|
|||||||
@@ -3,4 +3,11 @@ package com.cameleer3.server.core.runtime;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
|
public record Environment(
|
||||||
|
UUID id,
|
||||||
|
String slug,
|
||||||
|
String displayName,
|
||||||
|
boolean production,
|
||||||
|
boolean enabled,
|
||||||
|
Instant createdAt
|
||||||
|
) {}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ public interface EnvironmentRepository {
|
|||||||
List<Environment> findAll();
|
List<Environment> findAll();
|
||||||
Optional<Environment> findById(UUID id);
|
Optional<Environment> findById(UUID id);
|
||||||
Optional<Environment> findBySlug(String slug);
|
Optional<Environment> findBySlug(String slug);
|
||||||
UUID create(String slug, String displayName);
|
UUID create(String slug, String displayName, boolean production);
|
||||||
void updateDisplayName(UUID id, String displayName);
|
void update(UUID id, String displayName, boolean production, boolean enabled);
|
||||||
void updateStatus(UUID id, EnvironmentStatus status);
|
|
||||||
void delete(UUID id);
|
void delete(UUID id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ public class EnvironmentService {
|
|||||||
return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug));
|
return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID create(String slug, String displayName) {
|
public UUID create(String slug, String displayName, boolean production) {
|
||||||
if (repo.findBySlug(slug).isPresent()) {
|
if (repo.findBySlug(slug).isPresent()) {
|
||||||
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||||||
}
|
}
|
||||||
return repo.create(slug, displayName);
|
return repo.create(slug, displayName, production);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(UUID id, String displayName, boolean production, boolean enabled) {
|
||||||
|
getById(id); // verify exists
|
||||||
|
repo.update(id, displayName, production, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(UUID id) {
|
public void delete(UUID id) {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
package com.cameleer3.server.core.runtime;
|
|
||||||
|
|
||||||
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
|
|
||||||
1
ui/openapi.json
Normal file
1
ui/openapi.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user