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:
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 '{}';
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
Reference in New Issue
Block a user