From 945ecd78cfaf6d41d89d34772c9e5dd3f4271257 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:42:39 +0200 Subject: [PATCH] feat(license): LicenseUsageController GET /api/v1/admin/license/usage Returns state, expiresAt/daysRemaining, lastValidatedAt, message (LicenseMessageRenderer.forState), and a limits[] array where each entry carries key/current/cap/source ("license" vs "default"). Adds public AgentRegistryService.liveCount() so max_agents can be reported from the in-memory registry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/app-classes.md | 1 + .../controller/LicenseUsageController.java | 97 +++++++++++++ .../controller/LicenseUsageControllerIT.java | 130 ++++++++++++++++++ .../core/agent/AgentRegistryService.java | 12 +- 4 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index 803bdcfc..b9375bea 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -103,6 +103,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale - `SensitiveKeysAdminController` — GET/PUT `/api/v1/admin/sensitive-keys`. GET returns 200 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides. - `ClaimMappingAdminController` — CRUD `/api/v1/admin/claim-mappings`, POST `/test`. - `LicenseAdminController` — GET/POST `/api/v1/admin/license`. +- `LicenseUsageController` — GET `/api/v1/admin/license/usage`. Returns license `state`, `expiresAt`/`daysRemaining`/`gracePeriodDays`/`tenantId`/`label`/`lastValidatedAt`, the `LicenseMessageRenderer.forState(...)` message, and a `limits[]` array (`{key, current, cap, source}`) covering every effective-limits key. `source` is `"license"` when the cap came from the license override map, `"default"` otherwise. `max_agents` reads from `AgentRegistryService.liveCount()`; all other counts come from `LicenseUsageReader.snapshot()`. - `ThresholdAdminController` — CRUD `/api/v1/admin/thresholds`. - `AuditLogController` — GET `/api/v1/admin/audit`. - `RbacStatsController` — GET `/api/v1/admin/rbac/stats`. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java new file mode 100644 index 00000000..79483aee --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseUsageController.java @@ -0,0 +1,97 @@ +package com.cameleer.server.app.controller; + +import com.cameleer.server.app.license.LicenseMessageRenderer; +import com.cameleer.server.app.license.LicenseRepository; +import com.cameleer.server.app.license.LicenseService; +import com.cameleer.server.app.license.LicenseUsageReader; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.license.LicenseGate; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Read-only operator surface returning current license state, key timestamps, the + * human-readable message produced by {@link LicenseMessageRenderer}, and a per-limit + * usage/cap/source table covering every key exposed by the effective limits map. + * + *

Each limit row carries: + *

+ * + *

{@code max_agents} is sourced from the in-memory {@link AgentRegistryService} since the + * registry is not persisted; all other counts come from PostgreSQL via + * {@link LicenseUsageReader#snapshot()}.

+ */ +@RestController +@RequestMapping("/api/v1/admin/license/usage") +@PreAuthorize("hasRole('ADMIN')") +public class LicenseUsageController { + + private final LicenseGate gate; + private final LicenseUsageReader reader; + private final AgentRegistryService agents; + private final LicenseService svc; + private final LicenseRepository repo; + + public LicenseUsageController(LicenseGate gate, + LicenseUsageReader reader, + AgentRegistryService agents, + LicenseService svc, + LicenseRepository repo) { + this.gate = gate; + this.reader = reader; + this.agents = agents; + this.svc = svc; + this.repo = repo; + } + + @GetMapping + public ResponseEntity> get() { + var state = gate.getState(); + var info = gate.getCurrent(); + var effective = gate.getEffectiveLimits(); + + Map usage = new HashMap<>(reader.snapshot()); + usage.put("max_agents", (long) agents.liveCount()); + + List> limitRows = new ArrayList<>(); + for (var key : effective.values().keySet()) { + Map row = new LinkedHashMap<>(); + row.put("key", key); + row.put("current", usage.getOrDefault(key, 0L)); + row.put("cap", effective.get(key)); + row.put("source", info != null && info.limits().containsKey(key) ? "license" : "default"); + limitRows.add(row); + } + + Map body = new LinkedHashMap<>(); + body.put("state", state.name()); + body.put("expiresAt", info == null ? null : info.expiresAt().toString()); + body.put("daysRemaining", info == null ? null + : Duration.between(Instant.now(), info.expiresAt()).toDays()); + body.put("gracePeriodDays", info == null ? 0 : info.gracePeriodDays()); + body.put("tenantId", info == null ? null : info.tenantId()); + body.put("label", info == null ? null : info.label()); + repo.findByTenantId(svc.getTenantId()).ifPresent(rec -> + body.put("lastValidatedAt", rec.lastValidatedAt().toString())); + body.put("message", LicenseMessageRenderer.forState(state, info, gate.getInvalidReason())); + body.put("limits", limitRows); + return ResponseEntity.ok(body); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java new file mode 100644 index 00000000..dadb7b43 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/LicenseUsageControllerIT.java @@ -0,0 +1,130 @@ +package com.cameleer.server.app.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +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.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * IT for {@code GET /api/v1/admin/license/usage}. + * + *

Installs a synthetic license with a couple of cap overrides (so the {@code source} + * column is exercised in both branches), then verifies the response shape.

+ */ +class LicenseUsageControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + // Defensive: a sibling IT may have left a license installed. + securityHelper.clearTestLicense(); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + + @Test + void getUsage_withSyntheticLicense_returnsStateAndLimits() throws Exception { + // Override two keys; the rest stay default. + securityHelper.installSyntheticUnsignedLicense(Map.of( + "max_apps", 50, + "max_users", 100)); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/license/usage", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + + assertThat(body.path("state").asText()).isEqualTo("ACTIVE"); + assertThat(body.path("tenantId").asText()).isEqualTo("default"); + assertThat(body.path("label").asText()).isEqualTo("test-license"); + assertThat(body.path("message").asText()).isNotBlank(); + + JsonNode limits = body.path("limits"); + assertThat(limits.isArray()).isTrue(); + assertThat(limits.size()).isGreaterThan(0); + + boolean sawLicenseSource = false; + boolean sawDefaultSource = false; + boolean sawAppsRow = false; + boolean sawUsersRow = false; + for (JsonNode row : limits) { + assertThat(row.has("key")).isTrue(); + assertThat(row.has("current")).isTrue(); + assertThat(row.has("cap")).isTrue(); + assertThat(row.has("source")).isTrue(); + + String src = row.path("source").asText(); + if ("license".equals(src)) sawLicenseSource = true; + if ("default".equals(src)) sawDefaultSource = true; + + if ("max_apps".equals(row.path("key").asText())) { + sawAppsRow = true; + assertThat(row.path("source").asText()).isEqualTo("license"); + assertThat(row.path("cap").asInt()).isEqualTo(50); + } + if ("max_users".equals(row.path("key").asText())) { + sawUsersRow = true; + assertThat(row.path("source").asText()).isEqualTo("license"); + assertThat(row.path("cap").asInt()).isEqualTo(100); + } + } + assertThat(sawLicenseSource).as("at least one license-sourced row").isTrue(); + assertThat(sawDefaultSource).as("at least one default-sourced row").isTrue(); + assertThat(sawAppsRow).as("max_apps row present").isTrue(); + assertThat(sawUsersRow).as("max_users row present").isTrue(); + } + + @Test + void getUsage_absent_returnsAbsentStateAndDefaultSources() throws Exception { + // No license installed (cleared in @BeforeEach). + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/license/usage", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + + assertThat(body.path("state").asText()).isEqualTo("ABSENT"); + assertThat(body.path("tenantId").isNull()).isTrue(); + assertThat(body.path("expiresAt").isNull()).isTrue(); + assertThat(body.path("message").asText()).isNotBlank(); + + JsonNode limits = body.path("limits"); + assertThat(limits.isArray()).isTrue(); + assertThat(limits.size()).isGreaterThan(0); + for (JsonNode row : limits) { + assertThat(row.path("source").asText()).isEqualTo("default"); + } + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java index 8eb68828..4e2c6383 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java @@ -79,20 +79,20 @@ public class AgentRegistryService { // NEW registration — consult the cap. ConcurrentHashMap.compute propagates the // exception thrown here, so the controller advice (LicenseExceptionAdvice) maps // LicenseCapExceededException to 403 with the structured envelope. - registerGuard.check(liveAgentCount()); + registerGuard.check(liveCount()); log.info("Agent {} registered (name={}, application={}, env={})", id, name, application, environmentId); return newAgent; }); } /** - * Live-only agent count for license-cap enforcement. - * STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working fleet, not - * historical residue. Re-registers of an existing agent revive it via the + * Live-only agent count for license-cap enforcement and the {@code /admin/license/usage} + * surface. STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working fleet, + * not historical residue. Re-registers of an existing agent revive it via the * {@code existing != null} branch in {@link #register}, so this count never double-counts. */ - private long liveAgentCount() { - return agents.values().stream().filter(a -> a.state() == AgentState.LIVE).count(); + public int liveCount() { + return (int) agents.values().stream().filter(a -> a.state() == AgentState.LIVE).count(); } /**