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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 16:18:08 +02:00
parent e04dca55aa
commit 875062e59a
12 changed files with 133 additions and 16 deletions

View File

@@ -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

View File

@@ -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<List<App>> listApps(@RequestParam UUID environmentId) {
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
public ResponseEntity<List<App>> 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<App> updateContainerConfig(@PathVariable UUID appId,
@RequestBody Map<String, Object> 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) {}
}

View File

@@ -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<String, Object> 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) {}
}

View File

@@ -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<String, Object>> 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<App> 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<App> 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<App> 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<App> 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<String, Object> 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<String, Object> 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()
);
}
}

View File

@@ -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<String, Object>> 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<Environment> 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<Environment> 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<Environment> 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<String, Object> 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<String, Object> 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()
);
}

View File

@@ -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 '{}';

View File

@@ -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<String, Object> containerConfig, Instant createdAt, Instant updatedAt) {}

View File

@@ -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<App> findByEnvironmentId(UUID environmentId);
List<App> findAll();
Optional<App> findById(UUID id);
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
UUID create(UUID environmentId, String slug, String displayName);
void updateContainerConfig(UUID id, Map<String, Object> containerConfig);
void delete(UUID id);
}

View File

@@ -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<App> listAll() { return appRepo.findAll(); }
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 List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
public void updateContainerConfig(UUID id, Map<String, Object> 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");

View File

@@ -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<String, Object> defaultContainerConfig,
Instant createdAt
) {}

View File

@@ -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<Environment> 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<String, Object> defaultContainerConfig);
void delete(UUID id);
}

View File

@@ -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<String, Object> defaultContainerConfig) {
getById(id); // verify exists
repo.updateDefaultContainerConfig(id, defaultContainerConfig);
}
public void delete(UUID id) {
Environment env = getById(id);
if ("default".equals(env.slug())) {