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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-16 22:02:56 +02:00
parent 5a4f2f35f1
commit 31d3702eaf
10 changed files with 202 additions and 17 deletions

View File

@@ -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<String>`, nullable | Server-wide defaults (informational; not applied directly) |
| `mergedSensitiveKeys` | `List<String>`, 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

View File

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

View File

@@ -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=<env> or CAMELEER_AGENT_ENVIRONMENT=<env> "
+ "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; }

View File

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

View File

@@ -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.
*
* <p>Response envelope (see PROTOCOL.md §3):
* <pre>
* { "config": { ...ApplicationConfig... },
* "globalSensitiveKeys": [...],
* "mergedSensitiveKeys": [...] }
* </pre>
* 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<String> 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;
}

View File

@@ -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<HealthCheck> 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<String, String> 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()));

View File

@@ -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<String> 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<String> 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<String> registerResp = mock(HttpResponse.class);
when(registerResp.statusCode()).thenReturn(200);
when(registerResp.body()).thenReturn(registerJson);
HttpResponse<String> 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<String> registerResp = mock(HttpResponse.class);
when(registerResp.statusCode()).thenReturn(200);
when(registerResp.body()).thenReturn(registerJson);
HttpResponse<String> 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 {

View File

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

View File

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

View File

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