feat(license): enforce max_agents at AgentRegistryService.register

Adds a CreateGuard to AgentRegistryService that fires only on NEW
registrations: re-registers of an existing agent bypass the cap (they
don't grow the registry, and rejecting them would orphan an agent that
already counts against the cap). Live-only count for cap enforcement —
STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working
fleet, not historical residue.

Reuses the CreateGuard pattern from T18-T19. The global
LicenseExceptionAdvice maps the resulting LicenseCapExceededException to
403 with the structured envelope — no AgentRegistrationController
changes needed.

AgentCapEnforcementIT exercises the HTTP path end-to-end: two registers
succeed at cap=2, a third returns 403 with the expected envelope, and a
re-register of an already-registered agent succeeds at-cap.

Sibling agent-registering ITs (Agent*ControllerIT, Diagram*IT,
Execution*IT, Search*IT, Protocol*IT, Backpressure*IT, JwtRefresh*IT,
Registration*IT, Security*IT, SseSigning*IT, IngestionSchemaIT) lift
max_agents in @BeforeEach and clear the synthetic license in @AfterEach
— the in-memory registry is shared across @SpringBootTest reuse
boundaries, so without the lift the default-tier max_agents=5 would be
exhausted by accumulated test residue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 14:19:08 +02:00
parent 80dafe685b
commit afdaee628b
21 changed files with 367 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.core.agent;
import com.cameleer.server.core.runtime.CreateGuard;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,6 +30,7 @@ public class AgentRegistryService {
private final long staleThresholdMs;
private final long deadThresholdMs;
private final long commandExpiryMs;
private final CreateGuard registerGuard;
private final ConcurrentHashMap<String, AgentInfo> agents = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ConcurrentLinkedQueue<AgentCommand>> commands = new ConcurrentHashMap<>();
@@ -36,10 +38,18 @@ public class AgentRegistryService {
private volatile AgentEventListener eventListener;
/** Backwards-compatible 3-arg ctor (no enforcement). Used by tests. */
public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs) {
this(staleThresholdMs, deadThresholdMs, commandExpiryMs, CreateGuard.NOOP);
}
/** Production ctor with license-cap enforcement on new registrations. */
public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs,
CreateGuard registerGuard) {
this.staleThresholdMs = staleThresholdMs;
this.deadThresholdMs = deadThresholdMs;
this.commandExpiryMs = commandExpiryMs;
this.registerGuard = registerGuard;
}
/**
@@ -55,7 +65,9 @@ public class AgentRegistryService {
return agents.compute(id, (key, existing) -> {
if (existing != null) {
// Re-registration: update metadata, reset to LIVE
// Re-registration: update metadata, reset to LIVE.
// Re-registers bypass the license cap check — they don't grow the registry,
// and rejecting them would orphan an agent that already counts against the cap.
log.info("Agent {} re-registering (was {})", id, existing.state());
return existing
.withMetadata(name, application, environmentId, version, List.copyOf(routeIds), Map.copyOf(capabilities))
@@ -64,11 +76,25 @@ public class AgentRegistryService {
.withRegisteredAt(now)
.withStaleTransitionTime(null);
}
// 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());
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
* {@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();
}
/**
* Process a heartbeat from an agent.
* Updates lastHeartbeat, routeIds (if provided), capabilities (if provided),