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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user