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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 15:42:39 +02:00
parent 3f69e546e4
commit 945ecd78cf
4 changed files with 234 additions and 6 deletions

View File

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

View File

@@ -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.
*
* <p>Each limit row carries:
* <ul>
* <li>{@code key} — the limit key (e.g. {@code max_apps})</li>
* <li>{@code current} — current usage (0 when not measured server-side)</li>
* <li>{@code cap} — effective cap (license override or default-tier value)</li>
* <li>{@code source} — {@code "license"} when the cap came from the license override map,
* {@code "default"} otherwise</li>
* </ul>
*
* <p>{@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()}.</p>
*/
@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<Map<String, Object>> get() {
var state = gate.getState();
var info = gate.getCurrent();
var effective = gate.getEffectiveLimits();
Map<String, Long> usage = new HashMap<>(reader.snapshot());
usage.put("max_agents", (long) agents.liveCount());
List<Map<String, Object>> limitRows = new ArrayList<>();
for (var key : effective.values().keySet()) {
Map<String, Object> 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<String, Object> 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);
}
}

View File

@@ -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}.
*
* <p>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.</p>
*/
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<String> 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<String> 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");
}
}
}

View File

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