From cc5d88d708495ad31daf062466d9d9fbfe48de37 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:22:40 +0200 Subject: [PATCH] feat(license): surface execution/log/metric retention days on Environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three int fields to the Environment record + repository row mapper, matching the columns added in V5. Default value is 1 per the V5 NOT NULL DEFAULT 1. Read DTO surfaces the fields via Jackson record serialization; setter endpoint deferred to a follow-up that wires the corresponding license cap checks. The canonical constructor enforces >= 1 for each retention field — V5 guarantees this at the DB level, but the runtime guard catches in-memory construction errors (e.g., test sites that pass 0). Test sites updated to the 12-arg signature with retention defaults of 1. EnvironmentAdminControllerIT gains a regression test asserting the wire shape exposes all three fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/core-classes.md | 2 +- .../PostgresEnvironmentRepository.java | 8 +++++-- .../eval/AgentLifecycleEvaluatorTest.java | 2 +- .../eval/ExchangeMatchEvaluatorTest.java | 2 +- .../eval/LogPatternEvaluatorTest.java | 2 +- .../eval/RouteMetricEvaluatorTest.java | 2 +- .../NotificationContextBuilderTest.java | 2 +- .../EnvironmentAdminControllerIT.java | 19 ++++++++++++++++ .../server/core/runtime/Environment.java | 22 +++++++++++++++++-- 9 files changed, 51 insertions(+), 10 deletions(-) diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index c7b7ee64..c9607526 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -26,7 +26,7 @@ paths: - `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB) - `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass -- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration). +- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt, executionRetentionDays, logRetentionDays, metricRetentionDays. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration). The 3 retention day fields (V5) are `int`-typed (not nullable, since unlimited has no use-case), default to 1 day per the V5 `NOT NULL DEFAULT 1`, validated >= 1 in the canonical constructor. - `EnvironmentColor` — constants: `DEFAULT = "slate"`, `VALUES = {slate,red,amber,green,teal,blue,purple,pink}`, `isValid(String)`. - `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName, createdBy (String, user_id reference; nullable for pre-V4 historical rows) - `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED. `DEGRADED` is reserved for post-deploy drift (a replica died after RUNNING); `DeploymentExecutor` now marks partial-healthy deploys FAILED, not DEGRADED. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java index 8d8e550d..5067c27b 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java @@ -26,7 +26,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { } private static final String SELECT_COLS = - "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at"; + "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at, " + + "execution_retention_days, log_retention_days, metric_retention_days"; @Override public List findAll() { @@ -113,7 +114,10 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { config, jarRetentionCount, color, - rs.getTimestamp("created_at").toInstant() + rs.getTimestamp("created_at").toInstant(), + rs.getInt("execution_retention_days"), + rs.getInt("log_retention_days"), + rs.getInt("metric_retention_days") ); } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java index a46afc02..7af41c31 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AgentLifecycleEvaluatorTest.java @@ -37,7 +37,7 @@ class AgentLifecycleEvaluatorTest { events = mock(AgentEventRepository.class); envRepo = mock(EnvironmentRepository.class); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of( - new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH))); + new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH, 1, 1, 1))); eval = new AgentLifecycleEvaluator(events, envRepo); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java index e1b6b913..ddcf54c8 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluatorTest.java @@ -41,7 +41,7 @@ class ExchangeMatchEvaluatorTest { null, null, null, null, null, null, null, null, null, null, null, null, null, null); eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props); - var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null); + var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java index c113b3c3..210777cd 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java @@ -35,7 +35,7 @@ class LogPatternEvaluatorTest { envRepo = mock(EnvironmentRepository.class); eval = new LogPatternEvaluator(logStore, envRepo); - var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null); + var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java index eae39800..718e7fca 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java @@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest { envRepo = mock(EnvironmentRepository.class); eval = new RouteMetricEvaluator(statsStore, envRepo); - var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null); + var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1); when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env)); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java index c3e30e14..453f7759 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java @@ -28,7 +28,7 @@ class NotificationContextBuilderTest { // ---- helpers ---- private Environment env() { - return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH); + return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH, 1, 1, 1); } private AlertRule rule(ConditionKind kind) { 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 d3779862..98e072ee 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 @@ -105,6 +105,25 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT { assertThat(body.has("id")).isTrue(); } + @Test + void createEnvironment_surfacesRetentionDefaults() throws Exception { + // V5 columns default to 1 (matching the default-tier license cap). T26 surfaces + // them as int fields on the Environment record; the read DTO must expose them. + String json = """ + {"slug": "retention-defaults", "displayName": "Retention", "production": false} + """; + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/environments", HttpMethod.POST, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("executionRetentionDays").asInt()).isEqualTo(1); + assertThat(body.path("logRetentionDays").asInt()).isEqualTo(1); + assertThat(body.path("metricRetentionDays").asInt()).isEqualTo(1); + } + @Test void updateEnvironment_withValidColor_persists() throws Exception { restTemplate.exchange( diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java index e4b53f94..5ee4c838 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Environment.java @@ -13,5 +13,23 @@ public record Environment( Map defaultContainerConfig, Integer jarRetentionCount, String color, - Instant createdAt -) {} + Instant createdAt, + int executionRetentionDays, + int logRetentionDays, + int metricRetentionDays +) { + public Environment { + if (executionRetentionDays < 1) { + throw new IllegalArgumentException( + "executionRetentionDays must be >= 1 (got " + executionRetentionDays + ")"); + } + if (logRetentionDays < 1) { + throw new IllegalArgumentException( + "logRetentionDays must be >= 1 (got " + logRetentionDays + ")"); + } + if (metricRetentionDays < 1) { + throw new IllegalArgumentException( + "metricRetentionDays must be >= 1 (got " + metricRetentionDays + ")"); + } + } +}