feat(license): surface execution/log/metric retention days on Environment

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 15:22:40 +02:00
parent 046f08fe87
commit cc5d88d708
9 changed files with 51 additions and 10 deletions

View File

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

View File

@@ -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<Environment> 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")
);
}
}

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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));
}

View File

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

View File

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

View File

@@ -13,5 +13,23 @@ public record Environment(
Map<String, Object> 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 + ")");
}
}
}