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:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user