diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index a6a6840b..803bdcfc 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -119,7 +119,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale ## runtime/ — Docker orchestration - `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle -- `DeploymentExecutor` — @Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Container names are `{tenantId}-{envSlug}-{appSlug}-{replicaIndex}-{generation}`, where `generation` is the first 8 chars of the deployment UUID — old and new replicas coexist during a blue/green swap. Per-replica `CAMELEER_AGENT_INSTANCEID` env var is `{envSlug}-{appSlug}-{replicaIndex}-{generation}`. Branches on `DeploymentStrategy.fromWire(config.deploymentStrategy())`: **blue-green** (default) starts all N → waits for all healthy → stops old (partial health = FAILED, preserves old untouched); **rolling** replaces replicas one at a time with rollback only for in-flight new containers (already-replaced old stay stopped; un-replaced old keep serving). DEGRADED is now only set by `DockerEventMonitor` post-deploy, never by the executor. +- `DeploymentExecutor` — @Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Container names are `{tenantId}-{envSlug}-{appSlug}-{replicaIndex}-{generation}`, where `generation` is the first 8 chars of the deployment UUID — old and new replicas coexist during a blue/green swap. Per-replica `CAMELEER_AGENT_INSTANCEID` env var is `{envSlug}-{appSlug}-{replicaIndex}-{generation}`. Branches on `DeploymentStrategy.fromWire(config.deploymentStrategy())`: **blue-green** (default) starts all N → waits for all healthy → stops old (partial health = FAILED, preserves old untouched); **rolling** replaces replicas one at a time with rollback only for in-flight new containers (already-replaced old stay stopped; un-replaced old keep serving). DEGRADED is now only set by `DockerEventMonitor` post-deploy, never by the executor. **License compute caps**: at PRE_FLIGHT (after `ConfigMerger.resolve`, before image pull / container creation) the executor consults `LicenseUsageReader.computeUsage()` (PG aggregate over non-stopped deployments) and runs three `LicenseEnforcer.assertWithinCap(...)` checks for `max_total_cpu_millis`, `max_total_memory_mb`, and `max_total_replicas`. A `LicenseCapExceededException` propagates to the surrounding `try/catch` which marks the deployment FAILED with the cap message in `deployments.error_message`. - `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers - `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status - `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing. Per-container identity labels: `cameleer.replica` (index), `cameleer.generation` (deployment-scoped 8-char id — for Prometheus/Grafana deploy-boundary annotations), `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`). Router/service label keys are generation-agnostic so load balancing spans old + new replicas during a blue/green overlap. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java index 049c6ec0..5132ac89 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java @@ -56,6 +56,27 @@ public class LicenseUsageReader { return out; } + /** + * Compute-cap usage tuple consumed by {@code DeploymentExecutor} pre-flight enforcement. + * Sums over all non-stopped deployments. + */ + public record ComputeUsage(long cpuMillis, long memoryMb, long replicas) {} + + /** + * Convenience accessor over {@link #snapshot()} that returns just the three compute + * aggregates as a typed tuple. Used by {@code DeploymentExecutor.executeAsync} to feed + * {@code LicenseEnforcer.assertWithinCap} for the {@code max_total_cpu_millis} / + * {@code max_total_memory_mb} / {@code max_total_replicas} caps. Each call re-reads PG + * — there is no caching, so cap checks always see the latest committed state. + */ + public ComputeUsage computeUsage() { + Map snap = snapshot(); + return new ComputeUsage( + snap.getOrDefault("max_total_cpu_millis", 0L), + snap.getOrDefault("max_total_memory_mb", 0L), + snap.getOrDefault("max_total_replicas", 0L)); + } + /** Echoes the live agent count fed in by the controller (registry is in-memory). */ public long agentCount(int liveAgents) { return liveAgents; diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java index f6a2e6ee..8e2f0a59 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java @@ -1,6 +1,8 @@ package com.cameleer.server.app.runtime; import com.cameleer.common.model.ApplicationConfig; +import com.cameleer.server.app.license.LicenseEnforcer; +import com.cameleer.server.app.license.LicenseUsageReader; import com.cameleer.server.app.metrics.ServerMetrics; import com.cameleer.server.app.storage.PostgresApplicationConfigRepository; import com.cameleer.server.app.storage.PostgresDeploymentRepository; @@ -28,6 +30,8 @@ public class DeploymentExecutor { private final DeploymentRepository deploymentRepository; private final PostgresDeploymentRepository pgDeployRepo; private final PostgresApplicationConfigRepository applicationConfigRepository; + private final LicenseEnforcer licenseEnforcer; + private final LicenseUsageReader licenseUsageReader; @Autowired(required = false) private DockerNetworkManager networkManager; @@ -82,7 +86,9 @@ public class DeploymentExecutor { AppService appService, EnvironmentService envService, DeploymentRepository deploymentRepository, - PostgresApplicationConfigRepository applicationConfigRepository) { + PostgresApplicationConfigRepository applicationConfigRepository, + LicenseEnforcer licenseEnforcer, + LicenseUsageReader licenseUsageReader) { this.orchestrator = orchestrator; this.deploymentService = deploymentService; this.appService = appService; @@ -90,6 +96,8 @@ public class DeploymentExecutor { this.deploymentRepository = deploymentRepository; this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository; this.applicationConfigRepository = applicationConfigRepository; + this.licenseEnforcer = licenseEnforcer; + this.licenseUsageReader = licenseUsageReader; } /** Deployment-scoped id suffix — distinguishes container names and @@ -147,6 +155,19 @@ public class DeploymentExecutor { updateStage(deployment.id(), DeployStage.PRE_FLIGHT); preFlightChecks(jarPath, config); + // === LICENSE COMPUTE CAPS === + // Spec §4.1: sum cpu/memory/replicas across non-stopped deployments + new request + // must fit within the effective tier caps. Throws LicenseCapExceededException, which + // the surrounding try/catch turns into a FAILED deployment with the cap message + // landing in deployments.error_message. + int reqCpu = config.cpuLimit() == null ? 0 : config.cpuLimit(); + int reqMem = config.memoryLimitMb(); + int reqReps = config.replicas(); + LicenseUsageReader.ComputeUsage usage = licenseUsageReader.computeUsage(); + licenseEnforcer.assertWithinCap("max_total_cpu_millis", usage.cpuMillis(), (long) reqCpu * reqReps); + licenseEnforcer.assertWithinCap("max_total_memory_mb", usage.memoryMb(), (long) reqMem * reqReps); + licenseEnforcer.assertWithinCap("max_total_replicas", usage.replicas(), reqReps); + // Resolve runtime type String resolvedRuntimeType = config.runtimeType(); String mainClass = null; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java index 1ad99d7c..79c6a517 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java @@ -46,10 +46,15 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT { aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR")); adminJwt = securityHelper.adminToken(); - // Lift default-tier caps so the promote-target env + apps can be created via the API. + // Lift default-tier caps so the promote-target env + apps can be created via the API, + // and lift compute caps so the async DeploymentExecutor PRE_FLIGHT cap check (T24) + // doesn't fail the deployment before audit assertions complete on long-running runs. securityHelper.installSyntheticUnsignedLicense(Map.of( "max_environments", 100, - "max_apps", 100)); + "max_apps", 100, + "max_total_cpu_millis", 100_000, + "max_total_memory_mb", 100_000, + "max_total_replicas", 100)); // Clean up deployment-related tables and test-created environments jdbcTemplate.update("DELETE FROM deployments"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java new file mode 100644 index 00000000..90b3aa49 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/ComputeCapEnforcementIT.java @@ -0,0 +1,246 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.app.storage.PostgresDeploymentRepository; +import com.cameleer.server.core.runtime.Deployment; +import com.cameleer.server.core.runtime.DeploymentStatus; +import com.cameleer.server.core.runtime.RuntimeOrchestrator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +/** + * Verifies that {@code DeploymentExecutor} consults + * {@link com.cameleer.server.app.license.LicenseEnforcer} for the three compute caps + * ({@code max_total_cpu_millis}, {@code max_total_memory_mb}, {@code max_total_replicas}) + * during {@code PRE_FLIGHT} and that a violation marks the deployment FAILED with the cap + * message in {@code deployments.error_message}. + * + *

IT design: HTTP-driven (matches the sibling {@code BlueGreenStrategyIT} / + * {@code RollingStrategyIT} pattern). We {@code @MockBean} the {@code RuntimeOrchestrator} + * so no Docker calls happen, but we never need the mock to do anything because the cap + * check fires before any orchestrator invocation — a successful rejection short- + * circuits the executor inside the {@code try} block, the catch turns the + * {@link LicenseCapExceededException} into a FAILED deployment, and the mock stays untouched.

+ * + *

Scenario: install a synthetic license that lifts {@code max_apps} / {@code max_environments} + * (so we can create the env+app), but leaves the compute caps at default + * ({@code max_total_cpu_millis = 2000}). Configure the app's containerConfig to exceed the + * default CPU cap (e.g. {@code cpuLimit = 3000}) and trigger a deployment via the HTTP API. + * Poll until the deployment lands in FAILED with an error message that contains the cap key.

+ */ +@TestPropertySource(properties = "cameleer.server.runtime.healthchecktimeout=2") +class ComputeCapEnforcementIT extends AbstractPostgresIT { + + @MockBean + RuntimeOrchestrator runtimeOrchestrator; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private PostgresDeploymentRepository deploymentRepository; + + private String operatorJwt; + + @BeforeEach + void setUp() { + operatorJwt = securityHelper.operatorToken(); + + // Defensive: prior IT may have left a license installed; we want defaults for compute caps. + securityHelper.clearTestLicense(); + + jdbcTemplate.update("DELETE FROM deployments"); + jdbcTemplate.update("DELETE FROM app_versions"); + jdbcTemplate.update("DELETE FROM apps"); + jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'"); + + // Ensure test-operator exists in users table (deployments.created_by FK). + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, display_name) VALUES " + + "('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING"); + + // Mock orchestrator stays passive — cap check rejects before any of these are called, + // but isEnabled() is consulted by some bean wiring during context startup. + when(runtimeOrchestrator.isEnabled()).thenReturn(true); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + + @Test + void cpuMillisOverCap_marksDeploymentFailedWithCapMessage() throws Exception { + // Default tier: max_total_cpu_millis = 2000. Configure cpuLimit = 3000 with replicas = 1 + // so the requested delta (3000) on its own already exceeds the cap. + String appSlug = "cpucap-" + UUID.randomUUID().toString().substring(0, 8); + post("/api/v1/environments/default/apps", String.format(""" + {"slug": "%s", "displayName": "CPU Cap App"} + """, appSlug), operatorJwt); + put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """ + {"runtimeType": "spring-boot", "appPort": 8081, + "replicas": 1, "cpuLimit": 3000, + "deploymentStrategy": "blue-green"} + """, operatorJwt); + String versionId = uploadJar(appSlug, ("cpucap-jar-" + appSlug).getBytes()); + + String deployId = triggerDeploy(appSlug, versionId); + awaitStatus(deployId, DeploymentStatus.FAILED); + + Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow(); + assertThat(d.errorMessage()) + .as("FAILED deployment should carry the cap key in its error_message") + .contains("max_total_cpu_millis"); + + // Cap rejection happens before any container start — orchestrator must never be touched. + verify(runtimeOrchestrator, never()).startContainer(any()); + } + + @Test + void replicasOverCap_marksDeploymentFailedWithCapMessage() throws Exception { + // Default tier: max_total_replicas = 5. Use cpuLimit = 0 so cpu cap doesn't trip first; + // memoryLimitMb defaults to global (~512 MB), so 6 replicas = 3072 MB — under the + // default 2048 MB cap is FALSE, but max_total_memory_mb fires AFTER cpu and BEFORE + // replicas so we'd hit memory. Set memoryLimitMb low (16 MB * 6 = 96 MB) so memory + // cap stays well under 2048, isolating the replica cap. + String appSlug = "repcap-" + UUID.randomUUID().toString().substring(0, 8); + post("/api/v1/environments/default/apps", String.format(""" + {"slug": "%s", "displayName": "Replica Cap App"} + """, appSlug), operatorJwt); + put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """ + {"runtimeType": "spring-boot", "appPort": 8081, + "replicas": 6, "memoryLimitMb": 16, + "deploymentStrategy": "blue-green"} + """, operatorJwt); + String versionId = uploadJar(appSlug, ("repcap-jar-" + appSlug).getBytes()); + + String deployId = triggerDeploy(appSlug, versionId); + awaitStatus(deployId, DeploymentStatus.FAILED); + + Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow(); + assertThat(d.errorMessage()) + .as("FAILED deployment should carry the cap key in its error_message") + .contains("max_total_replicas"); + + verify(runtimeOrchestrator, never()).startContainer(any()); + } + + @Test + void withinCap_succeedsAndDeployStarts() throws Exception { + // Default tier: max_total_cpu_millis = 2000, max_total_memory_mb = 2048, + // max_total_replicas = 5. A single replica with cpuLimit = 1000, memoryLimitMb = 512 + // is well within all three. + String appSlug = "okcap-" + UUID.randomUUID().toString().substring(0, 8); + post("/api/v1/environments/default/apps", String.format(""" + {"slug": "%s", "displayName": "OK Cap App"} + """, appSlug), operatorJwt); + put("/api/v1/environments/default/apps/" + appSlug + "/container-config", """ + {"runtimeType": "spring-boot", "appPort": 8081, + "replicas": 1, "cpuLimit": 1000, "memoryLimitMb": 512, + "deploymentStrategy": "blue-green"} + """, operatorJwt); + String versionId = uploadJar(appSlug, ("okcap-jar-" + appSlug).getBytes()); + + // Mock the orchestrator to make the deploy reach a terminal state quickly. + // We don't care which state — only that the cap check did NOT short-circuit. + when(runtimeOrchestrator.startContainer(any())).thenReturn("c-0"); + // getContainerStatus default returns null -> NPE in waitForAllHealthy; stub a starting state + // so the health check times out (healthchecktimeout=2s) and the deploy lands in FAILED for + // a different reason (health-check failure, not cap rejection). + when(runtimeOrchestrator.getContainerStatus(any())) + .thenReturn(new com.cameleer.server.core.runtime.ContainerStatus("starting", true, 0, null)); + + String deployId = triggerDeploy(appSlug, versionId); + // Either RUNNING (mock made it healthy somehow) or FAILED for a NON-cap reason. + await().atMost(20, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow(); + assertThat(d.status()).isIn(DeploymentStatus.RUNNING, DeploymentStatus.FAILED); + }); + + Deployment d = deploymentRepository.findById(UUID.fromString(deployId)).orElseThrow(); + // Whatever terminal state we hit, the cap check did not flag any compute key. + if (d.errorMessage() != null) { + assertThat(d.errorMessage()).doesNotContain("max_total_cpu_millis"); + assertThat(d.errorMessage()).doesNotContain("max_total_memory_mb"); + assertThat(d.errorMessage()).doesNotContain("max_total_replicas"); + } + // And the orchestrator was actually invoked — proving cap check did not short-circuit. + verify(runtimeOrchestrator).startContainer(any()); + } + + // ---- helpers (cribbed from BlueGreenStrategyIT) ---- + + private String triggerDeploy(String appSlug, String versionId) throws Exception { + JsonNode deployResponse = post( + "/api/v1/environments/default/apps/" + appSlug + "/deployments", + String.format("{\"appVersionId\": \"%s\"}", versionId), operatorJwt); + return deployResponse.path("id").asText(); + } + + private void awaitStatus(String deployId, DeploymentStatus expected) { + await().atMost(15, TimeUnit.SECONDS) + .pollInterval(250, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + Deployment d = deploymentRepository.findById(UUID.fromString(deployId)) + .orElseThrow(() -> new AssertionError("Deployment not found: " + deployId)); + assertThat(d.status()).isEqualTo(expected); + }); + } + + private JsonNode post(String path, String json, String jwt) throws Exception { + HttpHeaders headers = securityHelper.authHeaders(jwt); + var response = restTemplate.exchange(path, HttpMethod.POST, + new HttpEntity<>(json, headers), String.class); + return objectMapper.readTree(response.getBody()); + } + + private void put(String path, String json, String jwt) { + HttpHeaders headers = securityHelper.authHeaders(jwt); + restTemplate.exchange(path, HttpMethod.PUT, + new HttpEntity<>(json, headers), String.class); + } + + private String uploadJar(String appSlug, byte[] content) throws Exception { + ByteArrayResource resource = new ByteArrayResource(content) { + @Override public String getFilename() { return "app.jar"; } + }; + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", resource); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + operatorJwt); + headers.set("X-Cameleer-Protocol-Version", "1"); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + var response = restTemplate.exchange( + "/api/v1/environments/default/apps/" + appSlug + "/versions", + HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + JsonNode versionNode = objectMapper.readTree(response.getBody()); + return versionNode.path("id").asText(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java index 874f663d..a7cd0c09 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java @@ -66,6 +66,14 @@ class BlueGreenStrategyIT extends AbstractPostgresIT { jdbcTemplate.update( "INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING"); + // Lift compute caps so the two sequential deploys (2 reps each, 512 MB default) plus any + // residual non-stopped row from a sibling IT under testcontainer reuse don't trip the + // license enforcer at PRE_FLIGHT. + securityHelper.installSyntheticUnsignedLicense(java.util.Map.of( + "max_total_cpu_millis", 100_000, + "max_total_memory_mb", 100_000, + "max_total_replicas", 100)); + when(runtimeOrchestrator.isEnabled()).thenReturn(true); appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java index fd1df53d..fb5681b1 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java @@ -73,6 +73,14 @@ class DeploymentSnapshotIT extends AbstractPostgresIT { // Ensure test-operator exists in users table (required for deployments.created_by FK) jdbcTemplate.update( "INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING"); + + // Lift compute caps — guarantees these snapshot tests are not derailed by license + // enforcement when residual non-stopped deploys from a sibling IT inflate aggregates + // under testcontainer reuse. + securityHelper.installSyntheticUnsignedLicense(java.util.Map.of( + "max_total_cpu_millis", 100_000, + "max_total_memory_mb", 100_000, + "max_total_replicas", 100)); } // ----------------------------------------------------------------------- diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java index c7012ccf..fd6a9050 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java @@ -69,6 +69,14 @@ class RollingStrategyIT extends AbstractPostgresIT { jdbcTemplate.update( "INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING"); + // Lift compute caps so the two sequential deploys (2 reps each, 512 MB default) plus any + // residual non-stopped row from a sibling IT under testcontainer reuse don't trip the + // license enforcer at PRE_FLIGHT. + securityHelper.installSyntheticUnsignedLicense(java.util.Map.of( + "max_total_cpu_millis", 100_000, + "max_total_memory_mb", 100_000, + "max_total_replicas", 100)); + when(runtimeOrchestrator.isEnabled()).thenReturn(true); appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);