feat: implement multitenancy with tenant isolation + environment support
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 42s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m25s

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:
hsiegeln
2026-04-04 15:00:18 +02:00
parent ee7226cf1c
commit a188308ec5
36 changed files with 310 additions and 188 deletions

View File

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

View File

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

View File

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

View File

@@ -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
) {}

View File

@@ -13,6 +13,7 @@ public record MergedExecution(
String routeId,
String instanceId,
String applicationId,
String environment,
String status,
String correlationId,
String exchangeId,

View File

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

View File

@@ -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"),