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:
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user