feat: implement multitenancy with tenant isolation + environment support
Adds configurable tenant ID (CAMELEER_TENANT_ID env var, default: "default") and environment as a first-class concept. Each server instance serves one tenant with multiple environments. Changes across 36 files: - TenantProperties config bean for tenant ID injection - AgentInfo: added environmentId field - AgentRegistrationRequest: added environmentId field - All 9 ClickHouse stores: inject tenant ID, replace hardcoded "default" constant, add environment to writes/reads - ChunkAccumulator: configurable tenant ID + environment resolver - MergedExecution/ProcessorBatch/BufferedLogEntry: added environment - ClickHouse init.sql: added environment column to all tables, updated ORDER BY (tenant→time→env→app), added tenant_id to usage_events, updated all MV GROUP BY clauses - Controllers: pass environmentId through registration/auto-heal - K8s deploy: added CAMELEER_TENANT_ID env var - All tests updated for new signatures Closes #123 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import java.util.Map;
|
||||
* @param instanceId agent-provided persistent identifier
|
||||
* @param displayName human-readable agent name
|
||||
* @param applicationId application identifier (e.g., "order-service-prod")
|
||||
* @param environmentId logical environment (e.g., "dev", "staging", "prod")
|
||||
* @param version agent software version
|
||||
* @param routeIds list of Camel route IDs managed by this agent
|
||||
* @param capabilities agent-declared capabilities (free-form)
|
||||
@@ -26,6 +27,7 @@ public record AgentInfo(
|
||||
String instanceId,
|
||||
String displayName,
|
||||
String applicationId,
|
||||
String environmentId,
|
||||
String version,
|
||||
List<String> routeIds,
|
||||
Map<String, Object> capabilities,
|
||||
@@ -36,33 +38,33 @@ public record AgentInfo(
|
||||
) {
|
||||
|
||||
public AgentInfo withState(AgentState newState) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, version, routeIds, capabilities,
|
||||
return new AgentInfo(instanceId, displayName, applicationId, environmentId, version, routeIds, capabilities,
|
||||
newState, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, version, routeIds, capabilities,
|
||||
return new AgentInfo(instanceId, displayName, applicationId, environmentId, version, routeIds, capabilities,
|
||||
state, registeredAt, newLastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, version, routeIds, capabilities,
|
||||
return new AgentInfo(instanceId, displayName, applicationId, environmentId, version, routeIds, capabilities,
|
||||
state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, version, routeIds, capabilities,
|
||||
return new AgentInfo(instanceId, displayName, applicationId, environmentId, version, routeIds, capabilities,
|
||||
state, registeredAt, lastHeartbeat, newStaleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withCapabilities(Map<String, Object> newCapabilities) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, version, routeIds, newCapabilities,
|
||||
return new AgentInfo(instanceId, displayName, applicationId, environmentId, version, routeIds, newCapabilities,
|
||||
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withMetadata(String displayName, String applicationId, String version,
|
||||
List<String> routeIds, Map<String, Object> capabilities) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, version, routeIds, capabilities,
|
||||
public AgentInfo withMetadata(String displayName, String applicationId, String environmentId,
|
||||
String version, List<String> routeIds, Map<String, Object> capabilities) {
|
||||
return new AgentInfo(instanceId, displayName, applicationId, environmentId, version, routeIds, capabilities,
|
||||
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ public class AgentRegistryService {
|
||||
* Register a new agent or re-register an existing one.
|
||||
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
|
||||
*/
|
||||
public AgentInfo register(String id, String name, String application, String version,
|
||||
List<String> routeIds, Map<String, Object> capabilities) {
|
||||
public AgentInfo register(String id, String name, String application, String environmentId,
|
||||
String version, List<String> routeIds, Map<String, Object> capabilities) {
|
||||
Instant now = Instant.now();
|
||||
AgentInfo newAgent = new AgentInfo(id, name, application, version,
|
||||
AgentInfo newAgent = new AgentInfo(id, name, application, environmentId, version,
|
||||
List.copyOf(routeIds), Map.copyOf(capabilities),
|
||||
AgentState.LIVE, now, now, null);
|
||||
|
||||
@@ -58,13 +58,13 @@ public class AgentRegistryService {
|
||||
// Re-registration: update metadata, reset to LIVE
|
||||
log.info("Agent {} re-registering (was {})", id, existing.state());
|
||||
return existing
|
||||
.withMetadata(name, application, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
||||
.withMetadata(name, application, environmentId, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
||||
.withState(AgentState.LIVE)
|
||||
.withLastHeartbeat(now)
|
||||
.withRegisteredAt(now)
|
||||
.withStaleTransitionTime(null);
|
||||
}
|
||||
log.info("Agent {} registered (name={}, application={})", id, name, application);
|
||||
log.info("Agent {} registered (name={}, application={}, env={})", id, name, application, environmentId);
|
||||
return newAgent;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.cameleer3.common.model.LogEntry;
|
||||
* A log entry paired with its agent metadata, ready for buffered ClickHouse insertion.
|
||||
*/
|
||||
public record BufferedLogEntry(
|
||||
String tenantId,
|
||||
String environment,
|
||||
String instanceId,
|
||||
String applicationId,
|
||||
LogEntry entry
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Accumulates {@link ExecutionChunk} documents and produces:
|
||||
@@ -26,23 +27,28 @@ import java.util.function.Consumer;
|
||||
public class ChunkAccumulator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChunkAccumulator.class);
|
||||
private static final String DEFAULT_TENANT = "default";
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final String tenantId;
|
||||
private final Consumer<MergedExecution> executionSink;
|
||||
private final Consumer<ProcessorBatch> processorSink;
|
||||
private final DiagramStore diagramStore;
|
||||
private final Duration staleThreshold;
|
||||
private final Function<String, String> environmentResolver;
|
||||
private final ConcurrentHashMap<String, PendingExchange> pending = new ConcurrentHashMap<>();
|
||||
|
||||
public ChunkAccumulator(Consumer<MergedExecution> executionSink,
|
||||
public ChunkAccumulator(String tenantId,
|
||||
Consumer<MergedExecution> executionSink,
|
||||
Consumer<ProcessorBatch> processorSink,
|
||||
DiagramStore diagramStore,
|
||||
Duration staleThreshold) {
|
||||
Duration staleThreshold,
|
||||
Function<String, String> environmentResolver) {
|
||||
this.tenantId = tenantId;
|
||||
this.executionSink = executionSink;
|
||||
this.processorSink = processorSink;
|
||||
this.diagramStore = diagramStore;
|
||||
this.staleThreshold = staleThreshold;
|
||||
this.environmentResolver = environmentResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,13 +57,16 @@ public class ChunkAccumulator {
|
||||
*/
|
||||
public void onChunk(ExecutionChunk chunk) {
|
||||
// 1. Push processor records immediately (append-only)
|
||||
String environment = environmentResolver.apply(
|
||||
chunk.getInstanceId() != null ? chunk.getInstanceId() : "");
|
||||
boolean chunkHasTrace = false;
|
||||
if (chunk.getProcessors() != null && !chunk.getProcessors().isEmpty()) {
|
||||
processorSink.accept(new ProcessorBatch(
|
||||
DEFAULT_TENANT,
|
||||
this.tenantId,
|
||||
chunk.getExchangeId(),
|
||||
chunk.getRouteId(),
|
||||
chunk.getApplicationId(),
|
||||
environment,
|
||||
chunk.getStartTime(),
|
||||
chunk.getProcessors()));
|
||||
chunkHasTrace = chunk.getProcessors().stream()
|
||||
@@ -164,13 +173,16 @@ public class ChunkAccumulator {
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not resolve diagram hash for route={}", envelope.getRouteId());
|
||||
}
|
||||
String env = environmentResolver.apply(
|
||||
envelope.getInstanceId() != null ? envelope.getInstanceId() : "");
|
||||
return new MergedExecution(
|
||||
DEFAULT_TENANT,
|
||||
this.tenantId,
|
||||
1L,
|
||||
envelope.getExchangeId(),
|
||||
envelope.getRouteId(),
|
||||
envelope.getInstanceId(),
|
||||
envelope.getApplicationId(),
|
||||
env,
|
||||
envelope.getStatus() != null ? envelope.getStatus().name() : "RUNNING",
|
||||
envelope.getCorrelationId(),
|
||||
envelope.getExchangeId(),
|
||||
@@ -236,6 +248,7 @@ public class ChunkAccumulator {
|
||||
String executionId,
|
||||
String routeId,
|
||||
String applicationId,
|
||||
String environment,
|
||||
Instant execStartTime,
|
||||
List<FlatProcessorRecord> processors
|
||||
) {}
|
||||
|
||||
@@ -13,6 +13,7 @@ public record MergedExecution(
|
||||
String routeId,
|
||||
String instanceId,
|
||||
String applicationId,
|
||||
String environment,
|
||||
String status,
|
||||
String correlationId,
|
||||
String exchangeId,
|
||||
|
||||
@@ -26,7 +26,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void registerNewAgent_createsWithLiveState() {
|
||||
AgentInfo agent = registry.register("agent-1", "Order Agent", "order-svc",
|
||||
AgentInfo agent = registry.register("agent-1", "Order Agent", "order-svc", "default",
|
||||
"1.0.0", List.of("route1", "route2"), Map.of("feature", "tracing"));
|
||||
|
||||
assertThat(agent).isNotNull();
|
||||
@@ -44,10 +44,10 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void reRegisterSameId_updatesMetadataAndTransitionsToLive() {
|
||||
registry.register("agent-1", "Old Name", "old-group",
|
||||
registry.register("agent-1", "Old Name", "old-group", "default",
|
||||
"1.0.0", List.of("route1"), Map.of());
|
||||
|
||||
AgentInfo updated = registry.register("agent-1", "New Name", "new-group",
|
||||
AgentInfo updated = registry.register("agent-1", "New Name", "new-group", "default",
|
||||
"2.0.0", List.of("route1", "route2"), Map.of("new", "cap"));
|
||||
|
||||
assertThat(updated.instanceId()).isEqualTo("agent-1");
|
||||
@@ -62,11 +62,11 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void reRegisterSameId_updatesRegisteredAtAndLastHeartbeat() {
|
||||
AgentInfo first = registry.register("agent-1", "Name", "group",
|
||||
AgentInfo first = registry.register("agent-1", "Name", "group", "default",
|
||||
"1.0.0", List.of(), Map.of());
|
||||
Instant firstRegisteredAt = first.registeredAt();
|
||||
|
||||
AgentInfo second = registry.register("agent-1", "Name", "group",
|
||||
AgentInfo second = registry.register("agent-1", "Name", "group", "default",
|
||||
"1.0.0", List.of(), Map.of());
|
||||
|
||||
assertThat(second.registeredAt()).isAfterOrEqualTo(firstRegisteredAt);
|
||||
@@ -79,7 +79,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void heartbeatKnownAgent_returnsTrue() {
|
||||
registry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
|
||||
boolean result = registry.heartbeat("agent-1");
|
||||
|
||||
@@ -88,7 +88,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void heartbeatKnownAgent_updatesLastHeartbeat() {
|
||||
registry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
Instant before = registry.findById("agent-1").lastHeartbeat();
|
||||
|
||||
registry.heartbeat("agent-1");
|
||||
@@ -106,7 +106,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void heartbeatStaleAgent_transitionsToLive() {
|
||||
registry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
registry.transitionState("agent-1", AgentState.STALE);
|
||||
|
||||
assertThat(registry.findById("agent-1").state()).isEqualTo(AgentState.STALE);
|
||||
@@ -125,7 +125,7 @@ class AgentRegistryServiceTest {
|
||||
void liveAgentBeyondStaleThreshold_transitionsToStale() {
|
||||
// Use very short thresholds for test
|
||||
AgentRegistryService shortRegistry = new AgentRegistryService(1, 300_000, 60_000);
|
||||
shortRegistry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
shortRegistry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
|
||||
// Wait briefly to exceed 1ms threshold
|
||||
try { Thread.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
@@ -141,7 +141,7 @@ class AgentRegistryServiceTest {
|
||||
void staleAgentBeyondDeadThreshold_transitionsToDead() {
|
||||
// Use very short thresholds for test: 1ms stale, 1ms dead
|
||||
AgentRegistryService shortRegistry = new AgentRegistryService(1, 1, 60_000);
|
||||
shortRegistry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
shortRegistry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
|
||||
try { Thread.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
shortRegistry.checkLifecycle(); // LIVE -> STALE
|
||||
@@ -155,7 +155,7 @@ class AgentRegistryServiceTest {
|
||||
@Test
|
||||
void deadAgentRemainsDead() {
|
||||
AgentRegistryService shortRegistry = new AgentRegistryService(1, 1, 60_000);
|
||||
shortRegistry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
shortRegistry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
|
||||
try { Thread.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
shortRegistry.checkLifecycle();
|
||||
@@ -171,7 +171,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void transitionState_setsStaleTransitionTimeWhenGoingStale() {
|
||||
registry.register("agent-1", "Name", "group", "1.0.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "Name", "group", "default", "1.0.0", List.of(), Map.of());
|
||||
|
||||
registry.transitionState("agent-1", AgentState.STALE);
|
||||
|
||||
@@ -186,8 +186,8 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void findAll_returnsAllAgents() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-2", "A2", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-2", "A2", "g", "default", "1.0", List.of(), Map.of());
|
||||
|
||||
List<AgentInfo> all = registry.findAll();
|
||||
|
||||
@@ -197,8 +197,8 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void findByState_filtersCorrectly() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-2", "A2", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-2", "A2", "g", "default", "1.0", List.of(), Map.of());
|
||||
registry.transitionState("agent-2", AgentState.STALE);
|
||||
|
||||
List<AgentInfo> live = registry.findByState(AgentState.LIVE);
|
||||
@@ -217,7 +217,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void findById_knownReturnsAgent() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
|
||||
AgentInfo result = registry.findById("agent-1");
|
||||
|
||||
@@ -231,7 +231,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void addCommand_createsPendingCommand() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
|
||||
AgentCommand cmd = registry.addCommand("agent-1", CommandType.CONFIG_UPDATE, "{\"key\":\"val\"}");
|
||||
|
||||
@@ -246,7 +246,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void addCommand_notifiesEventListener() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
|
||||
AtomicReference<AgentCommand> received = new AtomicReference<>();
|
||||
registry.setEventListener((agentId, command) -> received.set(command));
|
||||
@@ -259,7 +259,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void acknowledgeCommand_transitionsStatus() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
AgentCommand cmd = registry.addCommand("agent-1", CommandType.REPLAY, "{}");
|
||||
|
||||
boolean acked = registry.acknowledgeCommand("agent-1", cmd.id());
|
||||
@@ -269,7 +269,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void acknowledgeCommand_unknownReturnsFalse() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
|
||||
boolean acked = registry.acknowledgeCommand("agent-1", "nonexistent-cmd");
|
||||
|
||||
@@ -278,7 +278,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void findPendingCommands_returnsOnlyPending() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
AgentCommand cmd1 = registry.addCommand("agent-1", CommandType.CONFIG_UPDATE, "{}");
|
||||
AgentCommand cmd2 = registry.addCommand("agent-1", CommandType.DEEP_TRACE, "{}");
|
||||
registry.acknowledgeCommand("agent-1", cmd1.id());
|
||||
@@ -291,7 +291,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
@Test
|
||||
void markDelivered_updatesStatus() {
|
||||
registry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
registry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
AgentCommand cmd = registry.addCommand("agent-1", CommandType.CONFIG_UPDATE, "{}");
|
||||
|
||||
registry.markDelivered("agent-1", cmd.id());
|
||||
@@ -305,7 +305,7 @@ class AgentRegistryServiceTest {
|
||||
void expireOldCommands_removesExpiredPendingCommands() {
|
||||
// Use 1ms expiry for test
|
||||
AgentRegistryService shortRegistry = new AgentRegistryService(90_000, 300_000, 1);
|
||||
shortRegistry.register("agent-1", "A1", "g", "1.0", List.of(), Map.of());
|
||||
shortRegistry.register("agent-1", "A1", "g", "default", "1.0", List.of(), Map.of());
|
||||
shortRegistry.addCommand("agent-1", CommandType.CONFIG_UPDATE, "{}");
|
||||
|
||||
try { Thread.sleep(5); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
|
||||
@@ -35,7 +35,8 @@ class ChunkAccumulatorTest {
|
||||
executionSink = new CopyOnWriteArrayList<>();
|
||||
processorSink = new CopyOnWriteArrayList<>();
|
||||
accumulator = new ChunkAccumulator(
|
||||
executionSink::add, processorSink::add, NO_OP_DIAGRAM_STORE, Duration.ofMinutes(5));
|
||||
"default", executionSink::add, processorSink::add,
|
||||
NO_OP_DIAGRAM_STORE, Duration.ofMinutes(5), id -> "default");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,7 +120,8 @@ class ChunkAccumulatorTest {
|
||||
@Test
|
||||
void staleExchange_flushedBySweep() throws Exception {
|
||||
ChunkAccumulator staleAccumulator = new ChunkAccumulator(
|
||||
executionSink::add, processorSink::add, NO_OP_DIAGRAM_STORE, Duration.ofMillis(1));
|
||||
"default", executionSink::add, processorSink::add,
|
||||
NO_OP_DIAGRAM_STORE, Duration.ofMillis(1), id -> "default");
|
||||
|
||||
ExecutionChunk c = chunk("ex-3", "RUNNING",
|
||||
Instant.parse("2026-03-31T10:00:00Z"),
|
||||
|
||||
Reference in New Issue
Block a user