From 31d3702eaf7e58cef8d2301cb15aa5e2d30c361d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:02:56 +0200 Subject: [PATCH] fix: deserialize config envelope and prepare agent for env-scoped config The server wraps GET /api/v1/config/{applicationId} in an envelope ({config, globalSensitiveKeys, mergedSensitiveKeys}). The agent was deserializing the body straight into ApplicationConfig, so version fell back to 0 and the agent logged "No server config, using defaults" even though the server had returned a populated config. - ServerConnection.fetchApplicationConfig: unwrap envelope; fall back to bare ApplicationConfig for older servers; populate sensitiveKeys from mergedSensitiveKeys when the inner config doesn't carry them. - ApplicationConfig: add @JsonIgnoreProperties(ignoreUnknown=true) so future server-added fields (environment) don't break older agents, and add the environment field itself. - CameleerAgentConfig: stop silently defaulting environmentId to "default"; log a WARN when cameleer.agent.environment is unset and expose isEnvironmentExplicit() so it's visible in diagnostics. - StartupReport: surface environment (with "(unconfigured)" suffix when defaulted) in the startup log and AGENT_STARTED event details. - deploy/perf-app.yaml: align on "development" like the other non-native sample apps. - PROTOCOL.md: document the real config endpoint path and envelope shape (was still describing the unimplemented /agents/{id}/config). Co-Authored-By: Claude Opus 4.7 (1M context) --- cameleer-common/PROTOCOL.md | 37 ++++++- .../common/model/ApplicationConfig.java | 6 ++ .../cameleer/core/CameleerAgentConfig.java | 11 +- .../com/cameleer/core/PostStartSetup.java | 3 +- .../core/connection/ServerConnection.java | 20 +++- .../cameleer/core/health/StartupReport.java | 7 +- .../core/connection/ServerConnectionTest.java | 101 ++++++++++++++++++ .../core/health/HealthEndpointTest.java | 6 +- .../core/health/StartupReportTest.java | 26 +++-- deploy/perf-app.yaml | 2 +- 10 files changed, 202 insertions(+), 17 deletions(-) diff --git a/cameleer-common/PROTOCOL.md b/cameleer-common/PROTOCOL.md index 2ca361f..f304044 100644 --- a/cameleer-common/PROTOCOL.md +++ b/cameleer-common/PROTOCOL.md @@ -55,7 +55,7 @@ Base URL configured via `cameleer.agent.export.endpoint` (e.g., `http://localhos | POST | `/api/v1/agents/{id}/deregister` | _(no body)_ | Graceful shutdown notification (agent is stopping) | | POST | `/api/v1/agents/{id}/commands/{commandId}/ack` | _(no body)_ | Acknowledge command received and applied | | POST | `/api/v1/agents/{id}/refresh` | `{"refreshToken": "..."}` | JWT renewal | -| GET | `/api/v1/agents/{id}/config` | — | Fetch latest config (on reconnect) | +| GET | `/api/v1/config/{applicationId}` | — | Fetch latest config (on startup and reconnect). Response is an envelope (see below). | | POST | `/api/v1/data/executions` | `ExecutionChunk` | Export execution chunk (envelope + flat processor records) | | POST | `/api/v1/data/diagrams` | `RouteGraph[]` | Export route diagrams | | POST | `/api/v1/data/metrics` | `MetricsSnapshot[]` | Export metrics | @@ -63,6 +63,39 @@ Base URL configured via `cameleer.agent.export.endpoint` (e.g., `http://localhos | POST | `/api/v1/data/events` | `AgentEvent[]` | Export agent lifecycle events | | GET | `/api/v1/health` | — | Server health check | +### Config Endpoint Response Envelope + +`GET /api/v1/config/{applicationId}` returns an envelope, not a bare `ApplicationConfig`: + +```json +{ + "config": { + "application": "sample-app", + "version": 5, + "updatedAt": "2026-04-16T17:25:24.238571Z", + "engineLevel": "REGULAR", + "payloadCaptureMode": "BOTH", + "metricsEnabled": true, + "samplingRate": 1.0, + "tracedProcessors": { "process5": "BOTH" }, + "applicationLogLevel": "INFO", + "agentLogLevel": "INFO", + "taps": [ ... ], + "tapVersion": 0 + }, + "globalSensitiveKeys": ["key1", "key2"], + "mergedSensitiveKeys": ["key1", "key2"] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `config` | `ApplicationConfig` | The per-application config document | +| `globalSensitiveKeys` | `List`, nullable | Server-wide defaults (informational; not applied directly) | +| `mergedSensitiveKeys` | `List`, nullable | `globalSensitiveKeys` merged with any per-app overrides — this is what the agent applies for masking | + +Agents deserialize the envelope and populate `ApplicationConfig.sensitiveKeys` from `mergedSensitiveKeys` when the inner config does not carry its own list. A response with `config.version == 0` is treated as "no config stored" — the agent falls back to defaults (or the cached config if present). + ### Data Endpoint Behavior - `/api/v1/data/executions` accepts a single `ExecutionChunk` JSON object (`{...}`). One chunk per request. @@ -88,7 +121,7 @@ Long-lived SSE connection. The server sends events as they occur. On disconnect, the agent reconnects with exponential backoff (1s initial, 2x multiplier, 60s max). On reconnect: -1. Fetch latest config via `GET /api/v1/agents/{id}/config` +1. Fetch latest config via `GET /api/v1/config/{applicationId}` 2. Resume SSE with `Last-Event-ID` header for event replay ### Auto-Recovery diff --git a/cameleer-common/src/main/java/com/cameleer/common/model/ApplicationConfig.java b/cameleer-common/src/main/java/com/cameleer/common/model/ApplicationConfig.java index 4dcdf6d..0e6c70d 100644 --- a/cameleer-common/src/main/java/com/cameleer/common/model/ApplicationConfig.java +++ b/cameleer-common/src/main/java/com/cameleer/common/model/ApplicationConfig.java @@ -1,5 +1,6 @@ package com.cameleer.common.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import java.time.Instant; import java.util.List; @@ -10,9 +11,11 @@ import java.util.Map; * Agents download this at startup and receive updates via SSE CONFIG_UPDATE events. */ @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) public class ApplicationConfig { private String application; + private String environment; private int version; private Instant updatedAt; private String engineLevel; @@ -50,6 +53,9 @@ public class ApplicationConfig { public String getApplication() { return application; } public void setApplication(String application) { this.application = application; } + public String getEnvironment() { return environment; } + public void setEnvironment(String environment) { this.environment = environment; } + public int getVersion() { return version; } public void setVersion(int version) { this.version = version; } diff --git a/cameleer-core/src/main/java/com/cameleer/core/CameleerAgentConfig.java b/cameleer-core/src/main/java/com/cameleer/core/CameleerAgentConfig.java index 10380e9..d943098 100644 --- a/cameleer-core/src/main/java/com/cameleer/core/CameleerAgentConfig.java +++ b/cameleer-core/src/main/java/com/cameleer/core/CameleerAgentConfig.java @@ -42,6 +42,7 @@ public class CameleerAgentConfig { private String instanceIdOverride; private String applicationId; private String environmentId; + private boolean environmentExplicit; private boolean replayEnabled; private boolean routeControlEnabled; private boolean prometheusEnabled; @@ -96,7 +97,14 @@ public class CameleerAgentConfig { this.exportEndpoint = getStringProp("cameleer.agent.export.endpoint", null); this.instanceIdOverride = getStringProp("cameleer.agent.instanceid", null); this.applicationId = getStringProp("cameleer.agent.application", "default"); - this.environmentId = getStringProp("cameleer.agent.environment", "default"); + String envRaw = resolve("cameleer.agent.environment"); + this.environmentExplicit = envRaw != null; + this.environmentId = envRaw != null ? envRaw : "default"; + if (!this.environmentExplicit) { + LOG.warn("Cameleer: cameleer.agent.environment not set — registering as 'default'. " + + "Set -Dcameleer.agent.environment= or CAMELEER_AGENT_ENVIRONMENT= " + + "so the server routes this agent to the correct environment config."); + } this.replayEnabled = getBoolProp("cameleer.agent.replay.enabled", false); this.routeControlEnabled = getBoolProp("cameleer.agent.routecontrol.enabled", false); this.prometheusEnabled = getBoolProp("cameleer.agent.prometheus.enabled", false); @@ -217,6 +225,7 @@ public class CameleerAgentConfig { } public String getApplicationId() { return applicationId; } public String getEnvironmentId() { return environmentId; } + public boolean isEnvironmentExplicit() { return environmentExplicit; } public boolean isReplayEnabled() { return replayEnabled; } public boolean isRouteControlEnabled() { return routeControlEnabled; } public boolean isPrometheusEnabled() { return prometheusEnabled; } diff --git a/cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java b/cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java index 80783eb..565fba5 100644 --- a/cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java +++ b/cameleer-core/src/main/java/com/cameleer/core/PostStartSetup.java @@ -209,7 +209,8 @@ public class PostStartSetup { logForwarder != null ? "active" : null, metricsBridge != null && metricsBridge.isAvailable(), prometheusEndpoint != null ? prometheusEndpoint.getEndpointUrl() : null, - 0, 0, config.getExportType() + 0, 0, config.getExportType(), + config.getEnvironmentId(), config.isEnvironmentExplicit() ); StartupReport startupReport = new StartupReport(startupCtx); startupReport.log(); diff --git a/cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java b/cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java index 924204f..19d623c 100644 --- a/cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java +++ b/cameleer-core/src/main/java/com/cameleer/core/connection/ServerConnection.java @@ -343,6 +343,15 @@ public class ServerConnection { * Fetches the application config from the server. * Returns null if no config is stored (version 0). * Throws on network/auth errors. + * + *

Response envelope (see PROTOCOL.md §3): + *

+     * { "config": { ...ApplicationConfig... },
+     *   "globalSensitiveKeys": [...],
+     *   "mergedSensitiveKeys": [...] }
+     * 
+ * The envelope's {@code mergedSensitiveKeys} populates {@code ApplicationConfig.sensitiveKeys} + * when the inner config does not carry its own list. */ public ApplicationConfig fetchApplicationConfig(String application) throws Exception { LOG.trace("Cameleer: >>> GET /api/v1/config/{}", application); @@ -367,7 +376,16 @@ public class ServerConnection { throw new RuntimeException("Config fetch failed: HTTP " + response.statusCode()); } - ApplicationConfig config = MAPPER.readValue(response.body(), ApplicationConfig.class); + JsonNode root = MAPPER.readTree(response.body()); + JsonNode configNode = root.has("config") ? root.get("config") : root; + ApplicationConfig config = MAPPER.treeToValue(configNode, ApplicationConfig.class); + + if (config.getSensitiveKeys() == null && root.has("mergedSensitiveKeys")) { + List merged = MAPPER.convertValue(root.get("mergedSensitiveKeys"), + MAPPER.getTypeFactory().constructCollectionType(List.class, String.class)); + config.setSensitiveKeys(merged); + } + // version 0 = no config stored on server (default response) return config.getVersion() > 0 ? config : null; } diff --git a/cameleer-core/src/main/java/com/cameleer/core/health/StartupReport.java b/cameleer-core/src/main/java/com/cameleer/core/health/StartupReport.java index d149ff9..6f3db3e 100644 --- a/cameleer-core/src/main/java/com/cameleer/core/health/StartupReport.java +++ b/cameleer-core/src/main/java/com/cameleer/core/health/StartupReport.java @@ -27,7 +27,8 @@ public class StartupReport { String instanceId, String engineLevel, int routeCount, int diagramCount, boolean serverConnected, String logForwardingFramework, boolean metricsAvailable, String prometheusUrl, - int tapCount, int configVersion, String exportType + int tapCount, int configVersion, String exportType, + String environment, boolean environmentExplicit ) {} private final List checks; @@ -71,6 +72,8 @@ public class StartupReport { StringBuilder sb = new StringBuilder(); sb.append("\n=== Cameleer Startup Report ===\n"); sb.append(String.format(" Instance ID: %s%n", context.instanceId())); + sb.append(String.format(" Environment: %s%s%n", context.environment(), + context.environmentExplicit() ? "" : " (unconfigured)")); sb.append(String.format(" Engine Level: %s%n", context.engineLevel())); sb.append(String.format(" Export Type: %s%n", context.exportType())); for (HealthCheck check : checks) { @@ -88,6 +91,8 @@ public class StartupReport { public AgentEvent toEvent() { Map details = new LinkedHashMap<>(); details.put("instanceId", context.instanceId()); + details.put("environment", context.environment()); + details.put("environmentExplicit", String.valueOf(context.environmentExplicit())); details.put("engineLevel", context.engineLevel()); details.put("exportType", context.exportType()); details.put("routeCount", String.valueOf(context.routeCount())); diff --git a/cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java b/cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java index 490fd4f..633d270 100644 --- a/cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java +++ b/cameleer-core/src/test/java/com/cameleer/core/connection/ServerConnectionTest.java @@ -1,4 +1,5 @@ package com.cameleer.core.connection; +import com.cameleer.common.model.ApplicationConfig; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -246,6 +247,106 @@ class ServerConnectionTest { assertDoesNotThrow(() -> connection.deregister()); } + @SuppressWarnings("unchecked") + @Test + void fetchApplicationConfig_unwrapsEnvelopeAndMergesSensitiveKeys() throws Exception { + // Server wraps ApplicationConfig in {config, globalSensitiveKeys, mergedSensitiveKeys} + String registerJson = """ + {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} + """; + HttpResponse registerResp = mock(HttpResponse.class); + when(registerResp.statusCode()).thenReturn(200); + when(registerResp.body()).thenReturn(registerJson); + + String envelope = """ + { + "config": { + "application": "sample-app", + "version": 5, + "engineLevel": "REGULAR", + "payloadCaptureMode": "BOTH", + "metricsEnabled": true, + "samplingRate": 1.0, + "applicationLogLevel": "INFO", + "agentLogLevel": "INFO" + }, + "globalSensitiveKeys": ["key1"], + "mergedSensitiveKeys": ["key1", "key2"] + } + """; + HttpResponse configResp = mock(HttpResponse.class); + when(configResp.statusCode()).thenReturn(200); + when(configResp.body()).thenReturn(envelope); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(registerResp) + .thenReturn(configResp); + + connection.register("a1", "sample-app", null, null, null, null); + ApplicationConfig config = connection.fetchApplicationConfig("sample-app"); + + assertNotNull(config, "envelope with version>0 must deserialize to a non-null config"); + assertEquals(5, config.getVersion()); + assertEquals("sample-app", config.getApplication()); + assertEquals("REGULAR", config.getEngineLevel()); + assertEquals(List.of("key1", "key2"), config.getSensitiveKeys(), + "mergedSensitiveKeys must populate config.sensitiveKeys"); + } + + @SuppressWarnings("unchecked") + @Test + void fetchApplicationConfig_returnsNullWhenVersionZero() throws Exception { + String registerJson = """ + {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} + """; + HttpResponse registerResp = mock(HttpResponse.class); + when(registerResp.statusCode()).thenReturn(200); + when(registerResp.body()).thenReturn(registerJson); + + HttpResponse configResp = mock(HttpResponse.class); + when(configResp.statusCode()).thenReturn(200); + when(configResp.body()).thenReturn(""" + {"config":{"application":"sample-app","version":0},"mergedSensitiveKeys":[]} + """); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(registerResp) + .thenReturn(configResp); + + connection.register("a1", "sample-app", null, null, null, null); + assertNull(connection.fetchApplicationConfig("sample-app"), + "version=0 is the server's 'no config stored' sentinel"); + } + + @SuppressWarnings("unchecked") + @Test + void fetchApplicationConfig_acceptsUnwrappedLegacyResponse() throws Exception { + // Tolerate a bare ApplicationConfig (no envelope) for resilience / older servers + String registerJson = """ + {"instanceId":"a1","accessToken":"jwt","refreshToken":"r","heartbeatIntervalMs":30000} + """; + HttpResponse registerResp = mock(HttpResponse.class); + when(registerResp.statusCode()).thenReturn(200); + when(registerResp.body()).thenReturn(registerJson); + + HttpResponse configResp = mock(HttpResponse.class); + when(configResp.statusCode()).thenReturn(200); + when(configResp.body()).thenReturn(""" + {"application":"sample-app","version":3,"engineLevel":"MINIMAL","sensitiveKeys":["Authorization"]} + """); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(registerResp) + .thenReturn(configResp); + + connection.register("a1", "sample-app", null, null, null, null); + ApplicationConfig config = connection.fetchApplicationConfig("sample-app"); + + assertNotNull(config); + assertEquals(3, config.getVersion()); + assertEquals(List.of("Authorization"), config.getSensitiveKeys()); + } + @SuppressWarnings("unchecked") @Test void heartbeat_sendsEnvironmentIdAndCapabilities() throws Exception { diff --git a/cameleer-core/src/test/java/com/cameleer/core/health/HealthEndpointTest.java b/cameleer-core/src/test/java/com/cameleer/core/health/HealthEndpointTest.java index d2c597a..60e979d 100644 --- a/cameleer-core/src/test/java/com/cameleer/core/health/HealthEndpointTest.java +++ b/cameleer-core/src/test/java/com/cameleer/core/health/HealthEndpointTest.java @@ -19,7 +19,8 @@ class HealthEndpointTest { return new StartupReport.StartupContext( "test-agent-health", "REGULAR", 6, 6, true, "logback", true, "http://localhost:9464/metrics", - 3, 2, "HTTP" + 3, 2, "HTTP", + "dev", true ); } @@ -181,7 +182,8 @@ class HealthEndpointTest { StartupReport.StartupContext degradedCtx = new StartupReport.StartupContext( "degraded-agent", "REGULAR", 0, 0, false, null, false, null, - 0, 0, "LOG" + 0, 0, "LOG", + "dev", true ); HealthEndpoint endpoint = new HealthEndpoint(path, port, null, diff --git a/cameleer-core/src/test/java/com/cameleer/core/health/StartupReportTest.java b/cameleer-core/src/test/java/com/cameleer/core/health/StartupReportTest.java index 9fead8c..828edae 100644 --- a/cameleer-core/src/test/java/com/cameleer/core/health/StartupReportTest.java +++ b/cameleer-core/src/test/java/com/cameleer/core/health/StartupReportTest.java @@ -13,7 +13,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-1", "REGULAR", 6, 6, true, "logback", true, "http://localhost:9464/metrics", - 3, 2, "HTTP" + 3, 2, "HTTP", + "dev", true ); StartupReport report = new StartupReport(ctx); @@ -29,7 +30,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-2", "MINIMAL", 0, 0, true, "logback", true, null, - 0, 1, "HTTP" + 0, 1, "HTTP", + "dev", true ); StartupReport report = new StartupReport(ctx); @@ -46,7 +48,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-3", "REGULAR", 4, 4, false, null, true, null, - 0, 0, "HTTP" + 0, 0, "HTTP", + "dev", true ); StartupReport report = new StartupReport(ctx); @@ -63,7 +66,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-4", "REGULAR", 4, 4, false, null, true, null, - 0, 0, "LOG" + 0, 0, "LOG", + "dev", true ); StartupReport report = new StartupReport(ctx); @@ -78,7 +82,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "my-agent-42", "COMPLETE", 3, 3, true, "log4j2", true, "http://localhost:9464/metrics", - 5, 7, "HTTP" + 5, 7, "HTTP", + "prod", true ); StartupReport report = new StartupReport(ctx); @@ -88,6 +93,8 @@ class StartupReportTest { assertNotNull(event.getTimestamp()); assertNotNull(event.getDetails()); assertEquals("my-agent-42", event.getDetails().get("instanceId")); + assertEquals("prod", event.getDetails().get("environment")); + assertEquals("true", event.getDetails().get("environmentExplicit")); assertEquals("COMPLETE", event.getDetails().get("engineLevel")); assertEquals("3", event.getDetails().get("routeCount")); assertEquals("true", event.getDetails().get("serverConnected")); @@ -101,7 +108,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-5", "REGULAR", 2, 2, false, null, false, null, - 0, 0, "LOG" + 0, 0, "LOG", + "default", false ); StartupReport report = new StartupReport(ctx); @@ -113,7 +121,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-6", "REGULAR", 3, 3, false, null, false, null, - 0, 0, "LOG" + 0, 0, "LOG", + "dev", true ); StartupReport report = new StartupReport(ctx); @@ -129,7 +138,8 @@ class StartupReportTest { StartupReport.StartupContext ctx = new StartupReport.StartupContext( "test-agent-7", "REGULAR", 4, 0, true, "logback", true, null, - 0, 0, "HTTP" + 0, 0, "HTTP", + "dev", true ); StartupReport report = new StartupReport(ctx); diff --git a/deploy/perf-app.yaml b/deploy/perf-app.yaml index c8892ba..1087e98 100644 --- a/deploy/perf-app.yaml +++ b/deploy/perf-app.yaml @@ -34,7 +34,7 @@ spec: name: cameleer-auth key: CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN - name: CAMELEER_AGENT_ENVIRONMENT - value: "perf" + value: "development" resources: requests: memory: "256Mi"