From 875062e59a2fa8df1818e23d54ed15bf40a2d216 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:18:08 +0200 Subject: [PATCH] feat: add container config to apps and default config to environments - V5 migration: container_config JSONB + updated_at on apps, default_container_config JSONB on environments - App/Environment records updated with new fields - PUT /apps/{id}/container-config endpoint for per-app config - PUT /admin/environments/{id}/default-container-config for env defaults - GET /apps now supports optional environmentId (lists all when omitted) - AppRepository.findAll() for cross-environment app listing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/config/RuntimeBeanConfig.java | 9 ++-- .../server/app/controller/AppController.java | 23 +++++++++- .../EnvironmentAdminController.java | 14 +++++++ .../app/storage/PostgresAppRepository.java | 42 ++++++++++++++++--- .../PostgresEnvironmentRepository.java | 32 ++++++++++++-- .../db/migration/V5__app_container_config.sql | 5 +++ .../cameleer3/server/core/runtime/App.java | 4 +- .../server/core/runtime/AppRepository.java | 3 ++ .../server/core/runtime/AppService.java | 7 ++++ .../server/core/runtime/Environment.java | 2 + .../core/runtime/EnvironmentRepository.java | 2 + .../core/runtime/EnvironmentService.java | 6 +++ 12 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V5__app_container_config.sql diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java index 790ca789..d24ca5b8 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java @@ -11,6 +11,7 @@ import com.cameleer3.server.core.runtime.DeploymentRepository; import com.cameleer3.server.core.runtime.DeploymentService; import com.cameleer3.server.core.runtime.EnvironmentRepository; import com.cameleer3.server.core.runtime.EnvironmentService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -28,13 +29,13 @@ import java.util.concurrent.Executor; public class RuntimeBeanConfig { @Bean - public EnvironmentRepository environmentRepository(JdbcTemplate jdbc) { - return new PostgresEnvironmentRepository(jdbc); + public EnvironmentRepository environmentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + return new PostgresEnvironmentRepository(jdbc, objectMapper); } @Bean - public AppRepository appRepository(JdbcTemplate jdbc) { - return new PostgresAppRepository(jdbc); + public AppRepository appRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + return new PostgresAppRepository(jdbc, objectMapper); } @Bean diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java index 5f8cfb17..e3d3fed6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -21,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.UUID; /** @@ -42,8 +44,11 @@ public class AppController { @GetMapping @Operation(summary = "List apps by environment") @ApiResponse(responseCode = "200", description = "App list returned") - public ResponseEntity> listApps(@RequestParam UUID environmentId) { - return ResponseEntity.ok(appService.listByEnvironment(environmentId)); + public ResponseEntity> listApps(@RequestParam(required = false) UUID environmentId) { + if (environmentId != null) { + return ResponseEntity.ok(appService.listByEnvironment(environmentId)); + } + return ResponseEntity.ok(appService.listAll()); } @GetMapping("/{appId}") @@ -100,5 +105,19 @@ public class AppController { return ResponseEntity.noContent().build(); } + @PutMapping("/{appId}/container-config") + @Operation(summary = "Update container config for an app") + @ApiResponse(responseCode = "200", description = "Container config updated") + @ApiResponse(responseCode = "404", description = "App not found") + public ResponseEntity updateContainerConfig(@PathVariable UUID appId, + @RequestBody Map containerConfig) { + try { + appService.updateContainerConfig(appId, containerConfig); + return ResponseEntity.ok(appService.getById(appId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + public record CreateAppRequest(UUID environmentId, String slug, String displayName) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java index 3844695e..fb36c4cf 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java @@ -89,6 +89,20 @@ public class EnvironmentAdminController { } } + @PutMapping("/{id}/default-container-config") + @Operation(summary = "Update default container config for an environment") + @ApiResponse(responseCode = "200", description = "Default container config updated") + @ApiResponse(responseCode = "404", description = "Environment not found") + public ResponseEntity updateDefaultContainerConfig(@PathVariable UUID id, + @RequestBody Map defaultContainerConfig) { + try { + environmentService.updateDefaultContainerConfig(id, defaultContainerConfig); + return ResponseEntity.ok(environmentService.getById(id)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {} public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java index 2d5ec3e9..1069e0f6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppRepository.java @@ -2,33 +2,39 @@ package com.cameleer3.server.app.storage; import com.cameleer3.server.core.runtime.App; import com.cameleer3.server.core.runtime.AppRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.JdbcTemplate; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; public class PostgresAppRepository implements AppRepository { + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; - public PostgresAppRepository(JdbcTemplate jdbc) { + public PostgresAppRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { this.jdbc = jdbc; + this.objectMapper = objectMapper; } @Override public List findByEnvironmentId(UUID environmentId) { return jdbc.query( - "SELECT id, environment_id, slug, display_name, created_at FROM apps WHERE environment_id = ? ORDER BY created_at", + "SELECT id, environment_id, slug, display_name, container_config, created_at, updated_at FROM apps WHERE environment_id = ? ORDER BY created_at", (rs, rowNum) -> mapRow(rs), environmentId); } @Override public Optional findById(UUID id) { var results = jdbc.query( - "SELECT id, environment_id, slug, display_name, created_at FROM apps WHERE id = ?", + "SELECT id, environment_id, slug, display_name, container_config, created_at, updated_at FROM apps WHERE id = ?", (rs, rowNum) -> mapRow(rs), id); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @@ -36,7 +42,7 @@ public class PostgresAppRepository implements AppRepository { @Override public Optional findByEnvironmentIdAndSlug(UUID environmentId, String slug) { var results = jdbc.query( - "SELECT id, environment_id, slug, display_name, created_at FROM apps WHERE environment_id = ? AND slug = ?", + "SELECT id, environment_id, slug, display_name, container_config, created_at, updated_at FROM apps WHERE environment_id = ? AND slug = ?", (rs, rowNum) -> mapRow(rs), environmentId, slug); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @@ -49,18 +55,44 @@ public class PostgresAppRepository implements AppRepository { return id; } + @Override + public List findAll() { + return jdbc.query( + "SELECT id, environment_id, slug, display_name, container_config, created_at, updated_at FROM apps ORDER BY created_at", + (rs, rowNum) -> mapRow(rs)); + } + + @Override + public void updateContainerConfig(UUID id, Map containerConfig) { + try { + String json = objectMapper.writeValueAsString(containerConfig); + jdbc.update("UPDATE apps SET container_config = ?::jsonb, updated_at = now() WHERE id = ?", json, id); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize container config", e); + } + } + @Override public void delete(UUID id) { jdbc.update("DELETE FROM apps WHERE id = ?", id); } private App mapRow(ResultSet rs) throws SQLException { + Map config = Map.of(); + try { + String json = rs.getString("container_config"); + if (json != null && !json.isBlank()) { + config = objectMapper.readValue(json, MAP_TYPE); + } + } catch (Exception e) { /* use empty default */ } return new App( UUID.fromString(rs.getString("id")), UUID.fromString(rs.getString("environment_id")), rs.getString("slug"), rs.getString("display_name"), - rs.getTimestamp("created_at").toInstant() + config, + rs.getTimestamp("created_at").toInstant(), + rs.getTimestamp("updated_at").toInstant() ); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java index f90fa45b..ca28003d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java @@ -2,33 +2,39 @@ package com.cameleer3.server.app.storage; import com.cameleer3.server.core.runtime.Environment; import com.cameleer3.server.core.runtime.EnvironmentRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.JdbcTemplate; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; public class PostgresEnvironmentRepository implements EnvironmentRepository { + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; - public PostgresEnvironmentRepository(JdbcTemplate jdbc) { + public PostgresEnvironmentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { this.jdbc = jdbc; + this.objectMapper = objectMapper; } @Override public List findAll() { return jdbc.query( - "SELECT id, slug, display_name, production, enabled, created_at FROM environments ORDER BY created_at", + "SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments ORDER BY created_at", (rs, rowNum) -> mapRow(rs)); } @Override public Optional findById(UUID id) { var results = jdbc.query( - "SELECT id, slug, display_name, production, enabled, created_at FROM environments WHERE id = ?", + "SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments WHERE id = ?", (rs, rowNum) -> mapRow(rs), id); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @@ -36,7 +42,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { @Override public Optional findBySlug(String slug) { var results = jdbc.query( - "SELECT id, slug, display_name, production, enabled, created_at FROM environments WHERE slug = ?", + "SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments WHERE slug = ?", (rs, rowNum) -> mapRow(rs), slug); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @@ -60,13 +66,31 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { jdbc.update("DELETE FROM environments WHERE id = ?", id); } + @Override + public void updateDefaultContainerConfig(UUID id, Map defaultContainerConfig) { + try { + String json = objectMapper.writeValueAsString(defaultContainerConfig); + jdbc.update("UPDATE environments SET default_container_config = ?::jsonb WHERE id = ?", json, id); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize default container config", e); + } + } + private Environment mapRow(ResultSet rs) throws SQLException { + Map config = Map.of(); + try { + String json = rs.getString("default_container_config"); + if (json != null && !json.isBlank()) { + config = objectMapper.readValue(json, MAP_TYPE); + } + } catch (Exception e) { /* use empty default */ } return new Environment( UUID.fromString(rs.getString("id")), rs.getString("slug"), rs.getString("display_name"), rs.getBoolean("production"), rs.getBoolean("enabled"), + config, rs.getTimestamp("created_at").toInstant() ); } diff --git a/cameleer3-server-app/src/main/resources/db/migration/V5__app_container_config.sql b/cameleer3-server-app/src/main/resources/db/migration/V5__app_container_config.sql new file mode 100644 index 00000000..b2849a54 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V5__app_container_config.sql @@ -0,0 +1,5 @@ +-- Add container config to apps and environment defaults +ALTER TABLE apps ADD COLUMN container_config JSONB NOT NULL DEFAULT '{}'; +ALTER TABLE apps ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +ALTER TABLE environments ADD COLUMN default_container_config JSONB NOT NULL DEFAULT '{}'; diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/App.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/App.java index 4e426e0b..6291bae3 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/App.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/App.java @@ -1,6 +1,8 @@ package com.cameleer3.server.core.runtime; import java.time.Instant; +import java.util.Map; import java.util.UUID; -public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {} +public record App(UUID id, UUID environmentId, String slug, String displayName, + Map containerConfig, Instant createdAt, Instant updatedAt) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java index 6e602dda..3e789c61 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppRepository.java @@ -1,13 +1,16 @@ package com.cameleer3.server.core.runtime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; public interface AppRepository { List findByEnvironmentId(UUID environmentId); + List findAll(); Optional findById(UUID id); Optional findByEnvironmentIdAndSlug(UUID environmentId, String slug); UUID create(UUID environmentId, String slug, String displayName); + void updateContainerConfig(UUID id, Map containerConfig); void delete(UUID id); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java index da474c0b..09d1b72b 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java @@ -10,6 +10,7 @@ import java.nio.file.Path; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.UUID; public class AppService { @@ -25,10 +26,16 @@ public class AppService { this.jarStoragePath = jarStoragePath; } + public List listAll() { return appRepo.findAll(); } public List listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); } public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); } public List listVersions(UUID appId) { return versionRepo.findByAppId(appId); } + public void updateContainerConfig(UUID id, Map containerConfig) { + getById(id); // verify exists + appRepo.updateContainerConfig(id, containerConfig); + } + public UUID createApp(UUID environmentId, String slug, String displayName) { if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java index 22fe42dc..e2563ece 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java @@ -1,6 +1,7 @@ package com.cameleer3.server.core.runtime; import java.time.Instant; +import java.util.Map; import java.util.UUID; public record Environment( @@ -9,5 +10,6 @@ public record Environment( String displayName, boolean production, boolean enabled, + Map defaultContainerConfig, Instant createdAt ) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java index f61bf948..c4bba5d9 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java @@ -1,6 +1,7 @@ package com.cameleer3.server.core.runtime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -10,5 +11,6 @@ public interface EnvironmentRepository { Optional findBySlug(String slug); UUID create(String slug, String displayName, boolean production); void update(UUID id, String displayName, boolean production, boolean enabled); + void updateDefaultContainerConfig(UUID id, Map defaultContainerConfig); void delete(UUID id); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java index 176cad67..adc329e2 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java @@ -1,6 +1,7 @@ package com.cameleer3.server.core.runtime; import java.util.List; +import java.util.Map; import java.util.UUID; public class EnvironmentService { @@ -32,6 +33,11 @@ public class EnvironmentService { repo.update(id, displayName, production, enabled); } + public void updateDefaultContainerConfig(UUID id, Map defaultContainerConfig) { + getById(id); // verify exists + repo.updateDefaultContainerConfig(id, defaultContainerConfig); + } + public void delete(UUID id) { Environment env = getById(id); if ("default".equals(env.slug())) {