feat!: scope per-app config and settings by environment
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s
BREAKING: wipe dev PostgreSQL before deploying — V1 checksum changes. Agents must now send environmentId on registration (400 if missing). Two tables previously keyed on app name alone caused cross-environment data bleed: writing config for (app=X, env=dev) would overwrite the row used by (app=X, env=prod) agents, and agent startup fetches ignored env entirely. - V1 schema: application_config and app_settings are now PK (app, env). - Repositories: env-keyed finders/saves; env is the authoritative column, stamped on the stored JSON so the row agrees with itself. - ApplicationConfigController.getConfig is dual-mode — AGENT role uses JWT env claim (agents cannot spoof env); non-agent callers provide env via ?environment= query param. - AppSettingsController endpoints now require ?environment=. - SensitiveKeysAdminController fan-out iterates (app, env) slices so each env gets its own merged keys. - DiagramController ingestion stamps env on TaggedDiagram; ClickHouse route_diagrams INSERT + findProcessorRouteMapping are env-scoped. - AgentRegistrationController: environmentId is required on register; removed all "default" fallbacks from register/refresh/heartbeat auto-heal. - UI hooks (useApplicationConfig, useProcessorRouteMapping, useAppSettings, useAllAppSettings, useUpdateAppSettings) take env, wired to useEnvironmentStore at all call sites. - New ConfigEnvIsolationIT covers env-isolation for both repositories. Plan in docs/superpowers/plans/2026-04-16-environment-scoping.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ paths:
|
||||
|
||||
## controller/ — REST endpoints
|
||||
|
||||
- `AgentRegistrationController` — POST /register, POST /heartbeat, GET / (list), POST /refresh-token
|
||||
- `AgentRegistrationController` — POST /register (requires `environmentId` in body; 400 if missing/blank), POST /heartbeat (env from body `environmentId` → JWT `env` claim; 400 if neither present during auto-heal), GET / (list), POST /refresh-token (rejects tokens with no `env` claim)
|
||||
- `AgentSseController` — GET /sse (Server-Sent Events connection)
|
||||
- `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack
|
||||
- `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions
|
||||
@@ -25,17 +25,17 @@ paths:
|
||||
- `RoleAdminController` — CRUD /api/v1/admin/roles
|
||||
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
||||
- `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test
|
||||
- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true` to fan out merged keys to all LIVE agents. Stored in `server_config` table (key `sensitive_keys`).
|
||||
- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. The fan-out iterates over every distinct `(application, environment)` slice (from persisted `application_config` rows plus currently-registered agents) and pushes per-slice merged keys — intentional global baseline + per-env overrides. Stored in `server_config` table (key `sensitive_keys`).
|
||||
- `AuditLogController` — GET /api/v1/admin/audit
|
||||
- `MetricsController` — GET /api/v1/metrics, GET /timeseries
|
||||
- `DiagramController` — GET /api/v1/diagrams/{id}, POST /
|
||||
- `DiagramController` — GET /api/v1/diagrams/{id}, POST /api/v1/data/diagrams. Ingestion resolves applicationId + environment from the agent registry (keyed on JWT subject) and stamps both on the stored `TaggedDiagram`. `route_diagrams` CH table has an `environment` column; queries like `findProcessorRouteMapping(app, env)` filter by it.
|
||||
- `DiagramRenderController` — POST /api/v1/diagrams/render (ELK layout)
|
||||
- `ClaimMappingAdminController` — CRUD /api/v1/admin/claim-mappings, POST /test (accepts inline rules + claims for preview without saving)
|
||||
- `LicenseAdminController` — GET/POST /api/v1/admin/license
|
||||
- `AgentEventsController` — GET /api/v1/agent-events (agent state change history)
|
||||
- `AgentMetricsController` — GET /api/v1/agent-metrics (JVM/Camel metrics per agent instance)
|
||||
- `AppSettingsController` — GET/PUT /api/v1/apps/{appId}/settings
|
||||
- `ApplicationConfigController` — GET/PUT /api/v1/apps/{appId}/config (traced processors, route recording, sensitive keys per app)
|
||||
- `AppSettingsController` — GET/PUT /api/v1/admin/app-settings (list), /api/v1/admin/app-settings/{appId} (per-app). All endpoints require `?environment=`.
|
||||
- `ApplicationConfigController` — `/api/v1/config` (agent/admin observability config: traced processors, taps, route recording, per-app sensitive keys). GET list requires `?environment=`. GET/PUT/DELETE for a single app are env-scoped: for AGENT role the env comes from the JWT `env` claim (query param ignored, agents cannot spoof env); for non-agent callers env must be supplied via `?environment=` (user JWTs carry a placeholder env="default" that is NOT authoritative). `defaultConfig(application, environment)` is returned when no row exists.
|
||||
- `ClickHouseAdminController` — GET /api/v1/admin/clickhouse (ClickHouse admin, conditional on infrastructure endpoints)
|
||||
- `DatabaseAdminController` — GET /api/v1/admin/database (PG admin, conditional on infrastructure endpoints)
|
||||
- `DetailController` — GET /api/v1/detail (execution detail with processor tree)
|
||||
@@ -67,7 +67,7 @@ paths:
|
||||
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
||||
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
||||
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
|
||||
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`
|
||||
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`. Both `app_settings` and `application_config` are env-scoped (PK `(app_id, environment)` / `(application, environment)`); finders take `(app, env)` — no env-agnostic variants.
|
||||
|
||||
## storage/ — ClickHouse stores
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ paths:
|
||||
- `SensitiveKeysConfig` — record: keys (List<String>, immutable)
|
||||
- `SensitiveKeysRepository` — interface: find(), save()
|
||||
- `SensitiveKeysMerger` — pure function: merge(global, perApp) -> union with case-insensitive dedup, preserves first-seen casing. Returns null when both inputs null.
|
||||
- `AppSettings`, `AppSettingsRepository` — per-app settings config and persistence
|
||||
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
|
||||
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
|
||||
- `AuditService` — audit logging facade
|
||||
- `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence
|
||||
@@ -95,4 +95,4 @@ paths:
|
||||
- `ChunkAccumulator` — batches data for efficient flush
|
||||
- `WriteBuffer` — bounded ring buffer for async flush
|
||||
- `BufferedLogEntry` — log entry wrapper with metadata
|
||||
- `MergedExecution`, `TaggedExecution`, `TaggedDiagram` — tagged ingestion records
|
||||
- `MergedExecution`, `TaggedExecution`, `TaggedDiagram` — tagged ingestion records. `TaggedDiagram` carries `(instanceId, applicationId, environment, graph)` — env is resolved from the agent registry in the controller and stamped on the ClickHouse `route_diagrams` row.
|
||||
|
||||
@@ -38,7 +38,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
|
||||
- Jackson `JavaTimeModule` for `Instant` deserialization
|
||||
- Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands (config-update, deep-trace, replay, route-control)
|
||||
- Environment filtering: all data queries filter by the selected environment. All commands target only agents in the selected environment. Backend endpoints accept optional `environment` query parameter; null = all environments (backward compatible).
|
||||
- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim > `"default"`). Capabilities and route states updated on every heartbeat (protocol v2). Route catalog merges three sources: in-memory agent registry, persistent `route_catalog` table (ClickHouse), and `stats_1m_route` execution stats. The persistent catalog tracks `first_seen`/`last_seen` per route per environment, updated on every registration and heartbeat. Routes appear in the sidebar when their lifecycle overlaps the selected time window (`first_seen <= to AND last_seen >= from`), so historical routes remain visible even after being dropped from newer app versions.
|
||||
- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim; no silent default — missing env on heartbeat auto-heal returns 400). Registration (`POST /api/v1/agents/register`) requires `environmentId` in the request body; missing or blank returns 400. Capabilities and route states updated on every heartbeat (protocol v2). Route catalog merges three sources: in-memory agent registry, persistent `route_catalog` table (ClickHouse), and `stats_1m_route` execution stats. The persistent catalog tracks `first_seen`/`last_seen` per route per environment, updated on every registration and heartbeat. Routes appear in the sidebar when their lifecycle overlaps the selected time window (`first_seen <= to AND last_seen >= from`), so historical routes remain visible even after being dropped from newer app versions.
|
||||
- Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_SERVER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`) and `ApplicationName=tenant_{id}` on the JDBC URL. ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`.
|
||||
- Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION.
|
||||
- Log exchange correlation: `ClickHouseLogStore` extracts `exchange_id` from log entry MDC, preferring `cameleer.exchangeId` over `camel.exchangeId` (fallback for older agents). For `ON_COMPLETION` exchange copies, the agent sets `cameleer.exchangeId` to the parent's exchange ID via `CORRELATION_ID`.
|
||||
@@ -54,7 +54,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
|
||||
## Database Migrations
|
||||
|
||||
PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
|
||||
- V1 — RBAC (users, roles, groups, audit_log)
|
||||
- V1 — RBAC (users, roles, groups, audit_log). `application_config` PK is `(application, environment)`; `app_settings` PK is `(application_id, environment)` — both tables are env-scoped.
|
||||
- V2 — Claim mappings (OIDC)
|
||||
- V3 — Runtime management (apps, environments, deployments, app_versions)
|
||||
- V4 — Environment config (default_container_config JSONB)
|
||||
@@ -78,7 +78,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (6281 symbols, 15871 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (6364 symbols, 16045 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -117,9 +117,15 @@ public class AgentRegistrationController {
|
||||
if (request.instanceId() == null || request.instanceId().isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
if (request.environmentId() == null || request.environmentId().isBlank()) {
|
||||
String remote = httpRequest.getRemoteAddr();
|
||||
log.warn("Agent registration rejected (no environmentId): instanceId={} remote={}",
|
||||
request.instanceId(), remote);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
String application = request.applicationId() != null ? request.applicationId() : "default";
|
||||
String environmentId = request.environmentId() != null ? request.environmentId() : "default";
|
||||
String environmentId = request.environmentId();
|
||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||
|
||||
@@ -206,9 +212,15 @@ public class AgentRegistrationController {
|
||||
List<String> roles = result.roles().isEmpty()
|
||||
? List.of("AGENT") : result.roles();
|
||||
String application = result.application() != null ? result.application() : "default";
|
||||
String environment = result.environment();
|
||||
|
||||
// Try to get application + environment from registry (agent may not be registered after server restart)
|
||||
String environment = result.environment() != null ? result.environment() : "default";
|
||||
// Refresh-token env claim is required — agents without env shouldn't have gotten a token in the first place.
|
||||
if (environment == null || environment.isBlank()) {
|
||||
log.warn("Refresh token has no environment claim: agentId={}", agentId);
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Prefer registry if agent is still registered (covers env edits on re-registration)
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
if (agent != null) {
|
||||
application = agent.applicationId();
|
||||
@@ -242,9 +254,14 @@ public class AgentRegistrationController {
|
||||
JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||
if (jwtResult != null) {
|
||||
String application = jwtResult.application() != null ? jwtResult.application() : "default";
|
||||
// Prefer environment from heartbeat body (most current), fall back to JWT claim
|
||||
String env = heartbeatEnv != null ? heartbeatEnv
|
||||
: jwtResult.environment() != null ? jwtResult.environment() : "default";
|
||||
// Env: prefer heartbeat body (current), else JWT claim. No silent default.
|
||||
String env = (heartbeatEnv != null && !heartbeatEnv.isBlank())
|
||||
? heartbeatEnv
|
||||
: jwtResult.environment();
|
||||
if (env == null || env.isBlank()) {
|
||||
log.warn("Heartbeat auto-heal rejected (no environment on JWT or body): agentId={}", id);
|
||||
return ResponseEntity.status(400).build();
|
||||
}
|
||||
Map<String, Object> caps = capabilities != null ? capabilities : Map.of();
|
||||
List<String> healRouteIds = routeIds != null ? routeIds : List.of();
|
||||
registryService.register(id, id, application, env, "unknown",
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -40,21 +41,24 @@ public class AppSettingsController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all application settings")
|
||||
public ResponseEntity<List<AppSettings>> getAll() {
|
||||
return ResponseEntity.ok(repository.findAll());
|
||||
@Operation(summary = "List application settings in an environment")
|
||||
public ResponseEntity<List<AppSettings>> getAll(@RequestParam String environment) {
|
||||
return ResponseEntity.ok(repository.findByEnvironment(environment));
|
||||
}
|
||||
|
||||
@GetMapping("/{appId}")
|
||||
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
|
||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
|
||||
AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(appId));
|
||||
@Operation(summary = "Get settings for an application in an environment (returns defaults if not configured)")
|
||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId,
|
||||
@RequestParam String environment) {
|
||||
AppSettings settings = repository.findByApplicationAndEnvironment(appId, environment)
|
||||
.orElse(AppSettings.defaults(appId, environment));
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PutMapping("/{appId}")
|
||||
@Operation(summary = "Create or update settings for an application")
|
||||
@Operation(summary = "Create or update settings for an application in an environment")
|
||||
public ResponseEntity<AppSettings> update(@PathVariable String appId,
|
||||
@RequestParam String environment,
|
||||
@Valid @RequestBody AppSettingsRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
List<String> errors = request.validate();
|
||||
@@ -62,18 +66,20 @@ public class AppSettingsController {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
||||
}
|
||||
|
||||
AppSettings saved = repository.save(request.toSettings(appId));
|
||||
AppSettings saved = repository.save(request.toSettings(appId, environment));
|
||||
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
|
||||
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||
Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{appId}")
|
||||
@Operation(summary = "Delete application settings (reverts to defaults)")
|
||||
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
|
||||
repository.delete(appId);
|
||||
@Operation(summary = "Delete application settings for an environment (reverts to defaults)")
|
||||
public ResponseEntity<Void> delete(@PathVariable String appId,
|
||||
@RequestParam String environment,
|
||||
HttpServletRequest httpRequest) {
|
||||
repository.delete(appId, environment);
|
||||
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
||||
Map.of(), AuditResult.SUCCESS, httpRequest);
|
||||
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import com.cameleer.server.app.dto.CommandGroupResponse;
|
||||
import com.cameleer.server.app.dto.ConfigUpdateResponse;
|
||||
import com.cameleer.server.app.dto.TestExpressionRequest;
|
||||
import com.cameleer.server.app.dto.TestExpressionResponse;
|
||||
import com.cameleer.server.app.security.JwtAuthenticationFilter;
|
||||
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
|
||||
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
|
||||
import com.cameleer.server.core.admin.AuditCategory;
|
||||
import com.cameleer.server.core.admin.AuditResult;
|
||||
import com.cameleer.server.core.admin.AuditService;
|
||||
@@ -73,23 +75,38 @@ public class ApplicationConfigController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all application configs",
|
||||
description = "Returns stored configurations for all applications")
|
||||
@Operation(summary = "List application configs in an environment",
|
||||
description = "Returns stored configurations for all applications in the given environment")
|
||||
@ApiResponse(responseCode = "200", description = "Configs returned")
|
||||
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
|
||||
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(configRepository.findAll());
|
||||
public ResponseEntity<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
|
||||
HttpServletRequest httpRequest) {
|
||||
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
|
||||
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(configRepository.findByEnvironment(environment));
|
||||
}
|
||||
|
||||
@GetMapping("/{application}")
|
||||
@Operation(summary = "Get application config",
|
||||
description = "Returns the current configuration for an application with merged sensitive keys.")
|
||||
@Operation(summary = "Get application config for an environment",
|
||||
description = "For agents: environment is taken from the JWT env claim; the query param is ignored. "
|
||||
+ "For UI/admin callers: environment must be provided via the `environment` query param. "
|
||||
+ "Returns 404 if the environment cannot be resolved. Includes merged sensitive keys.")
|
||||
@ApiResponse(responseCode = "200", description = "Config returned")
|
||||
@ApiResponse(responseCode = "404", description = "Environment could not be resolved")
|
||||
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
|
||||
ApplicationConfig config = configRepository.findByApplication(application)
|
||||
.orElse(defaultConfig(application));
|
||||
String resolved = resolveEnvironmentForRead(auth, httpRequest, environment);
|
||||
if (resolved == null || resolved.isBlank()) {
|
||||
auditService.log("view_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("reason", "missing_environment"), AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
auditService.log("view_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("environment", resolved), AuditResult.SUCCESS, httpRequest);
|
||||
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, resolved)
|
||||
.orElse(defaultConfig(application, resolved));
|
||||
|
||||
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||
.map(SensitiveKeysConfig::keys)
|
||||
@@ -99,19 +116,36 @@ public class ApplicationConfigController {
|
||||
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents identify themselves via AGENT role and a real JWT env claim — use that,
|
||||
* ignoring any query param (agents can't spoof env). Non-agent callers (admin UI)
|
||||
* must pass the env explicitly; their JWT env claim is a placeholder and not
|
||||
* authoritative.
|
||||
*/
|
||||
private String resolveEnvironmentForRead(Authentication auth,
|
||||
HttpServletRequest request,
|
||||
String queryEnvironment) {
|
||||
boolean isAgent = auth != null && auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_AGENT".equals(a.getAuthority()));
|
||||
if (isAgent) {
|
||||
return environmentFromJwt(request);
|
||||
}
|
||||
return queryEnvironment;
|
||||
}
|
||||
|
||||
@PutMapping("/{application}")
|
||||
@Operation(summary = "Update application config",
|
||||
description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application")
|
||||
@Operation(summary = "Update application config for an environment",
|
||||
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment")
|
||||
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam String environment,
|
||||
@RequestBody ApplicationConfig config,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
String updatedBy = auth != null ? auth.getName() : "system";
|
||||
|
||||
config.setApplication(application);
|
||||
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
|
||||
ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy);
|
||||
|
||||
// Merge global + per-app sensitive keys for the SSE push payload
|
||||
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||
@@ -126,7 +160,8 @@ public class ApplicationConfigController {
|
||||
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
||||
|
||||
auditService.log("update_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("version", saved.getVersion(), "agentsPushed", pushResult.total(),
|
||||
Map.of("environment", environment, "version", saved.getVersion(),
|
||||
"agentsPushed", pushResult.total(),
|
||||
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
@@ -134,30 +169,27 @@ public class ApplicationConfigController {
|
||||
}
|
||||
|
||||
@GetMapping("/{application}/processor-routes")
|
||||
@Operation(summary = "Get processor to route mapping",
|
||||
description = "Returns a map of processorId → routeId for all processors seen in this application")
|
||||
@Operation(summary = "Get processor to route mapping for an environment",
|
||||
description = "Returns a map of processorId → routeId for all processors seen in this application + environment")
|
||||
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
||||
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application) {
|
||||
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application));
|
||||
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application,
|
||||
@RequestParam String environment) {
|
||||
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
|
||||
}
|
||||
|
||||
@PostMapping("/{application}/test-expression")
|
||||
@Operation(summary = "Test a tap expression against sample data via a live agent")
|
||||
@Operation(summary = "Test a tap expression against sample data via a live agent in an environment")
|
||||
@ApiResponse(responseCode = "200", description = "Expression evaluated successfully")
|
||||
@ApiResponse(responseCode = "404", description = "No live agent available for this application")
|
||||
@ApiResponse(responseCode = "404", description = "No live agent available for this application in this environment")
|
||||
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||
public ResponseEntity<TestExpressionResponse> testExpression(
|
||||
@PathVariable String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam String environment,
|
||||
@RequestBody TestExpressionRequest request) {
|
||||
// Find a LIVE agent for this application, optionally filtered by environment
|
||||
var candidates = registryService.findAll().stream()
|
||||
.filter(a -> application.equals(a.applicationId()))
|
||||
.filter(a -> a.state() == AgentState.LIVE);
|
||||
if (environment != null) {
|
||||
candidates = candidates.filter(a -> environment.equals(a.environmentId()));
|
||||
}
|
||||
AgentInfo agent = candidates.findFirst().orElse(null);
|
||||
AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream()
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (agent == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
@@ -201,6 +233,19 @@ public class ApplicationConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the {@code env} claim from the caller's validated JWT (populated by
|
||||
* {@link JwtAuthenticationFilter}). Returns null if no internal JWT was seen
|
||||
* on this request, or the token has no env claim.
|
||||
*/
|
||||
private static String environmentFromJwt(HttpServletRequest request) {
|
||||
Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||
if (attr instanceof JwtValidationResult result) {
|
||||
return result.environment();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time
|
||||
* dependency on getSensitiveKeys() which may not be in the published cameleer-common jar yet.
|
||||
@@ -271,9 +316,10 @@ public class ApplicationConfigController {
|
||||
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
|
||||
}
|
||||
|
||||
private static ApplicationConfig defaultConfig(String application) {
|
||||
private static ApplicationConfig defaultConfig(String application, String environment) {
|
||||
ApplicationConfig config = new ApplicationConfig();
|
||||
config.setApplication(application);
|
||||
config.setEnvironment(environment);
|
||||
config.setVersion(0);
|
||||
config.setMetricsEnabled(true);
|
||||
config.setSamplingRate(1.0);
|
||||
|
||||
@@ -50,11 +50,13 @@ public class DiagramController {
|
||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
||||
String instanceId = extractAgentId();
|
||||
String applicationId = resolveApplicationId(instanceId);
|
||||
AgentInfo agent = registryService.findById(instanceId);
|
||||
String applicationId = agent != null ? agent.applicationId() : "";
|
||||
String environment = agent != null ? agent.environmentId() : "";
|
||||
List<RouteGraph> graphs = parsePayload(body);
|
||||
|
||||
for (RouteGraph graph : graphs) {
|
||||
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph));
|
||||
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, environment, graph));
|
||||
}
|
||||
|
||||
return ResponseEntity.accepted().build();
|
||||
@@ -65,11 +67,6 @@ public class DiagramController {
|
||||
return auth != null ? auth.getName() : "";
|
||||
}
|
||||
|
||||
private String resolveApplicationId(String instanceId) {
|
||||
AgentInfo agent = registryService.findById(instanceId);
|
||||
return agent != null ? agent.applicationId() : "";
|
||||
}
|
||||
|
||||
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
|
||||
String trimmed = body.strip();
|
||||
if (trimmed.startsWith("[")) {
|
||||
|
||||
@@ -119,8 +119,10 @@ public class RouteMetricsController {
|
||||
if (!metrics.isEmpty()) {
|
||||
// Determine SLA threshold (per-app or default)
|
||||
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
||||
int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
int threshold = (effectiveAppId != null && environment != null && !environment.isBlank())
|
||||
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment)
|
||||
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||
: 300;
|
||||
|
||||
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||
effectiveAppId, threshold, environment);
|
||||
|
||||
@@ -102,10 +102,12 @@ public class SearchController {
|
||||
stats = searchService.statsForRoute(from, end, routeId, application, environment);
|
||||
}
|
||||
|
||||
// Enrich with SLA compliance
|
||||
int threshold = appSettingsRepository
|
||||
.findByApplicationId(application != null ? application : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
// Enrich with SLA compliance (per-env threshold when both app and env are specified)
|
||||
int threshold = (application != null && !application.isBlank()
|
||||
&& environment != null && !environment.isBlank())
|
||||
? appSettingsRepository.findByApplicationAndEnvironment(application, environment)
|
||||
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||
: 300;
|
||||
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
|
||||
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||
}
|
||||
|
||||
@@ -120,31 +120,37 @@ public class SensitiveKeysAdminController {
|
||||
* not yet include that field accessor.
|
||||
*/
|
||||
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
|
||||
// Collect all distinct application IDs
|
||||
Set<String> applications = new LinkedHashSet<>();
|
||||
configRepository.findAll().stream()
|
||||
.map(ApplicationConfig::getApplication)
|
||||
.filter(a -> a != null && !a.isBlank())
|
||||
.forEach(applications::add);
|
||||
// Collect every (application, environment) slice we know about: persisted config rows
|
||||
// PLUS currently-registered live agents (which may have no stored config yet).
|
||||
// Global sensitive keys are server-wide, but per-app overrides live per env, so the
|
||||
// push is scoped per (app, env) so each slice gets its own merged keys.
|
||||
Set<AppEnv> slices = new LinkedHashSet<>();
|
||||
for (ApplicationConfig cfg : configRepository.findAll()) {
|
||||
if (cfg.getApplication() != null && !cfg.getApplication().isBlank()
|
||||
&& cfg.getEnvironment() != null && !cfg.getEnvironment().isBlank()) {
|
||||
slices.add(new AppEnv(cfg.getApplication(), cfg.getEnvironment()));
|
||||
}
|
||||
}
|
||||
registryService.findAll().stream()
|
||||
.map(a -> a.applicationId())
|
||||
.filter(a -> a != null && !a.isBlank())
|
||||
.forEach(applications::add);
|
||||
.filter(a -> a.applicationId() != null && !a.applicationId().isBlank()
|
||||
&& a.environmentId() != null && !a.environmentId().isBlank())
|
||||
.forEach(a -> slices.add(new AppEnv(a.applicationId(), a.environmentId())));
|
||||
|
||||
if (applications.isEmpty()) {
|
||||
if (slices.isEmpty()) {
|
||||
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
|
||||
}
|
||||
|
||||
// Shared 10-second deadline across all applications
|
||||
// Shared 10-second deadline across all slices
|
||||
long deadline = System.currentTimeMillis() + 10_000;
|
||||
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
|
||||
List<String> allTimedOut = new ArrayList<>();
|
||||
int totalAgents = 0;
|
||||
|
||||
for (String application : applications) {
|
||||
// Load per-app sensitive keys via JsonNode to avoid dependency on
|
||||
for (AppEnv slice : slices) {
|
||||
// Load per-(app,env) sensitive keys via JsonNode to avoid dependency on
|
||||
// ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet.
|
||||
List<String> perAppKeys = configRepository.findByApplication(application)
|
||||
List<String> perAppKeys = configRepository
|
||||
.findByApplicationAndEnvironment(slice.application(), slice.environment())
|
||||
.map(cfg -> extractSensitiveKeys(cfg))
|
||||
.orElse(null);
|
||||
|
||||
@@ -153,19 +159,22 @@ public class SensitiveKeysAdminController {
|
||||
|
||||
// Build a minimal payload map — only sensitiveKeys + application fields.
|
||||
Map<String, Object> payloadMap = new LinkedHashMap<>();
|
||||
payloadMap.put("application", application);
|
||||
payloadMap.put("application", slice.application());
|
||||
payloadMap.put("sensitiveKeys", mergedKeys);
|
||||
|
||||
String payloadJson;
|
||||
try {
|
||||
payloadJson = objectMapper.writeValueAsString(payloadMap);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Failed to serialize sensitive keys push payload for application '{}'", application, e);
|
||||
log.error("Failed to serialize sensitive keys push payload for {}/{}",
|
||||
slice.application(), slice.environment(), e);
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, CompletableFuture<CommandReply>> futures =
|
||||
registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson);
|
||||
registryService.addGroupCommandWithReplies(
|
||||
slice.application(), slice.environment(),
|
||||
CommandType.CONFIG_UPDATE, payloadJson);
|
||||
|
||||
totalAgents += futures.size();
|
||||
|
||||
@@ -213,4 +222,7 @@ public class SensitiveKeysAdminController {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** (application, environment) slice used by the fan-out loop. */
|
||||
private record AppEnv(String application, String environment) {}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public record AppSettingsRequest(
|
||||
Double healthSlaCrit
|
||||
) {
|
||||
|
||||
public AppSettings toSettings(String appId) {
|
||||
public AppSettings toSettings(String appId, String environment) {
|
||||
Instant now = Instant.now();
|
||||
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||
return new AppSettings(appId, environment, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||
healthSlaWarn, healthSlaCrit, now, now);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
|
||||
private static final String INSERT_SQL = """
|
||||
INSERT INTO route_diagrams
|
||||
(tenant_id, content_hash, route_id, instance_id, application_id, definition, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
(tenant_id, content_hash, route_id, instance_id, application_id, environment, definition, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
private static final String SELECT_BY_HASH = """
|
||||
@@ -59,7 +59,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
|
||||
private static final String SELECT_DEFINITIONS_FOR_APP = """
|
||||
SELECT DISTINCT route_id, definition FROM route_diagrams
|
||||
WHERE tenant_id = ? AND application_id = ?
|
||||
WHERE tenant_id = ? AND application_id = ? AND environment = ?
|
||||
""";
|
||||
|
||||
private final String tenantId;
|
||||
@@ -104,6 +104,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
RouteGraph graph = diagram.graph();
|
||||
String agentId = diagram.instanceId() != null ? diagram.instanceId() : "";
|
||||
String applicationId = diagram.applicationId() != null ? diagram.applicationId() : "";
|
||||
String environment = (diagram.environment() != null && !diagram.environment().isBlank())
|
||||
? diagram.environment() : "default";
|
||||
String json = objectMapper.writeValueAsString(graph);
|
||||
String contentHash = sha256Hex(json);
|
||||
String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
|
||||
@@ -114,6 +116,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
routeId,
|
||||
agentId,
|
||||
applicationId,
|
||||
environment,
|
||||
json,
|
||||
Timestamp.from(Instant.now()));
|
||||
|
||||
@@ -197,10 +200,10 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> findProcessorRouteMapping(String applicationId) {
|
||||
public Map<String, String> findProcessorRouteMapping(String applicationId, String environment) {
|
||||
Map<String, String> mapping = new HashMap<>();
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId);
|
||||
SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId, environment);
|
||||
for (Map<String, Object> row : rows) {
|
||||
String routeId = (String) row.get("route_id");
|
||||
String json = (String) row.get("definition");
|
||||
@@ -211,7 +214,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
RouteGraph graph = objectMapper.readValue(json, RouteGraph.class);
|
||||
collectNodeIds(graph.getRoot(), routeId, mapping);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to deserialize RouteGraph for route={} app={}", routeId, applicationId, e);
|
||||
log.warn("Failed to deserialize RouteGraph for route={} app={} env={}",
|
||||
routeId, applicationId, environment, e);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
|
||||
@@ -16,6 +16,7 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
|
||||
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
|
||||
rs.getString("application_id"),
|
||||
rs.getString("environment"),
|
||||
rs.getInt("sla_threshold_ms"),
|
||||
rs.getDouble("health_error_warn"),
|
||||
rs.getDouble("health_error_crit"),
|
||||
@@ -29,24 +30,27 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AppSettings> findByApplicationId(String applicationId) {
|
||||
public Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment) {
|
||||
List<AppSettings> results = jdbc.query(
|
||||
"SELECT * FROM app_settings WHERE application_id = ?", ROW_MAPPER, applicationId);
|
||||
"SELECT * FROM app_settings WHERE application_id = ? AND environment = ?",
|
||||
ROW_MAPPER, applicationId, environment);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppSettings> findAll() {
|
||||
return jdbc.query("SELECT * FROM app_settings ORDER BY application_id", ROW_MAPPER);
|
||||
public List<AppSettings> findByEnvironment(String environment) {
|
||||
return jdbc.query(
|
||||
"SELECT * FROM app_settings WHERE environment = ? ORDER BY application_id",
|
||||
ROW_MAPPER, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppSettings save(AppSettings settings) {
|
||||
jdbc.update("""
|
||||
INSERT INTO app_settings (application_id, sla_threshold_ms, health_error_warn,
|
||||
INSERT INTO app_settings (application_id, environment, sla_threshold_ms, health_error_warn,
|
||||
health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, now(), now())
|
||||
ON CONFLICT (application_id) DO UPDATE SET
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
ON CONFLICT (application_id, environment) DO UPDATE SET
|
||||
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
|
||||
health_error_warn = EXCLUDED.health_error_warn,
|
||||
health_error_crit = EXCLUDED.health_error_crit,
|
||||
@@ -54,14 +58,15 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
health_sla_crit = EXCLUDED.health_sla_crit,
|
||||
updated_at = now()
|
||||
""",
|
||||
settings.applicationId(), settings.slaThresholdMs(),
|
||||
settings.applicationId(), settings.environment(), settings.slaThresholdMs(),
|
||||
settings.healthErrorWarn(), settings.healthErrorCrit(),
|
||||
settings.healthSlaWarn(), settings.healthSlaCrit());
|
||||
return findByApplicationId(settings.applicationId()).orElseThrow();
|
||||
return findByApplicationAndEnvironment(settings.applicationId(), settings.environment()).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String appId) {
|
||||
jdbc.update("DELETE FROM app_settings WHERE application_id = ?", appId);
|
||||
public void delete(String appId, String environment) {
|
||||
jdbc.update("DELETE FROM app_settings WHERE application_id = ? AND environment = ?",
|
||||
appId, environment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
@@ -20,39 +21,55 @@ public class PostgresApplicationConfigRepository {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public List<ApplicationConfig> findAll() {
|
||||
return jdbc.query(
|
||||
"SELECT config_val, version, updated_at FROM application_config ORDER BY application",
|
||||
(rs, rowNum) -> {
|
||||
/**
|
||||
* Row mapper — columns (application, environment) are authoritative and always
|
||||
* overwrite whatever was in the stored JSON body. Callers must SELECT them.
|
||||
*/
|
||||
private RowMapper<ApplicationConfig> rowMapper() {
|
||||
return (rs, rowNum) -> {
|
||||
try {
|
||||
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
||||
cfg.setApplication(rs.getString("application"));
|
||||
cfg.setEnvironment(rs.getString("environment"));
|
||||
cfg.setVersion(rs.getInt("version"));
|
||||
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
|
||||
return cfg;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to deserialize application config", e);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
public Optional<ApplicationConfig> findByApplication(String application) {
|
||||
List<ApplicationConfig> results = jdbc.query(
|
||||
"SELECT config_val, version, updated_at FROM application_config WHERE application = ?",
|
||||
(rs, rowNum) -> {
|
||||
try {
|
||||
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
||||
cfg.setVersion(rs.getInt("version"));
|
||||
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
|
||||
return cfg;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to deserialize application config", e);
|
||||
private static final String SELECT_FIELDS =
|
||||
"application, environment, config_val, version, updated_at";
|
||||
|
||||
public List<ApplicationConfig> findByEnvironment(String environment) {
|
||||
return jdbc.query(
|
||||
"SELECT " + SELECT_FIELDS + " FROM application_config "
|
||||
+ "WHERE environment = ? ORDER BY application",
|
||||
rowMapper(), environment);
|
||||
}
|
||||
},
|
||||
application);
|
||||
|
||||
public List<ApplicationConfig> findAll() {
|
||||
return jdbc.query(
|
||||
"SELECT " + SELECT_FIELDS + " FROM application_config "
|
||||
+ "ORDER BY application, environment",
|
||||
rowMapper());
|
||||
}
|
||||
|
||||
public Optional<ApplicationConfig> findByApplicationAndEnvironment(String application, String environment) {
|
||||
List<ApplicationConfig> results = jdbc.query(
|
||||
"SELECT " + SELECT_FIELDS + " FROM application_config "
|
||||
+ "WHERE application = ? AND environment = ?",
|
||||
rowMapper(), application, environment);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public ApplicationConfig save(String application, ApplicationConfig config, String updatedBy) {
|
||||
public ApplicationConfig save(String application, String environment, ApplicationConfig config, String updatedBy) {
|
||||
// Authoritative fields stamped on the DTO before serialization — guarantees the stored
|
||||
// JSON agrees with the row's columns.
|
||||
config.setApplication(application);
|
||||
config.setEnvironment(environment);
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(config);
|
||||
@@ -60,18 +77,22 @@ public class PostgresApplicationConfigRepository {
|
||||
throw new RuntimeException("Failed to serialize application config", e);
|
||||
}
|
||||
|
||||
// Upsert: insert or update, auto-increment version
|
||||
jdbc.update("""
|
||||
INSERT INTO application_config (application, config_val, version, updated_at, updated_by)
|
||||
VALUES (?, ?::jsonb, 1, now(), ?)
|
||||
ON CONFLICT (application) DO UPDATE SET
|
||||
INSERT INTO application_config (application, environment, config_val, version, updated_at, updated_by)
|
||||
VALUES (?, ?, ?::jsonb, 1, now(), ?)
|
||||
ON CONFLICT (application, environment) DO UPDATE SET
|
||||
config_val = EXCLUDED.config_val,
|
||||
version = application_config.version + 1,
|
||||
updated_at = now(),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
""",
|
||||
application, json, updatedBy);
|
||||
application, environment, json, updatedBy);
|
||||
|
||||
return findByApplication(application).orElseThrow();
|
||||
return findByApplicationAndEnvironment(application, environment).orElseThrow();
|
||||
}
|
||||
|
||||
public void delete(String application, String environment) {
|
||||
jdbc.update("DELETE FROM application_config WHERE application = ? AND environment = ?",
|
||||
application, environment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,22 +83,26 @@ CREATE TABLE server_config (
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE application_config (
|
||||
application TEXT PRIMARY KEY,
|
||||
application TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
config_val JSONB NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT
|
||||
updated_by TEXT,
|
||||
PRIMARY KEY (application, environment)
|
||||
);
|
||||
|
||||
CREATE TABLE app_settings (
|
||||
application_id TEXT PRIMARY KEY,
|
||||
application_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
sla_threshold_ms INTEGER NOT NULL DEFAULT 300,
|
||||
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
|
||||
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (application_id, environment)
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
|
||||
@@ -59,7 +59,7 @@ class ClickHouseDiagramStoreIT {
|
||||
}
|
||||
|
||||
private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) {
|
||||
return new TaggedDiagram(instanceId, applicationId, graph);
|
||||
return new TaggedDiagram(instanceId, applicationId, "default", graph);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
@@ -180,7 +180,7 @@ class ClickHouseDiagramStoreIT {
|
||||
|
||||
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
|
||||
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping");
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping", "default");
|
||||
|
||||
assertThat(mapping).containsEntry("proc-from-1", "route-5");
|
||||
assertThat(mapping).containsEntry("proc-to-2", "route-5");
|
||||
@@ -196,7 +196,7 @@ class ClickHouseDiagramStoreIT {
|
||||
|
||||
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
|
||||
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app");
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app", "default");
|
||||
|
||||
assertThat(mapping).containsEntry("proc-a1", "route-a");
|
||||
assertThat(mapping).containsEntry("proc-a2", "route-a");
|
||||
@@ -205,7 +205,7 @@ class ClickHouseDiagramStoreIT {
|
||||
|
||||
@Test
|
||||
void findProcessorRouteMapping_unknownAppReturnsEmpty() {
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app");
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app", "default");
|
||||
assertThat(mapping).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.admin.AppSettings;
|
||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies that the two per-app-per-env Postgres tables isolate data correctly between
|
||||
* environments — writing to (app=X, env=dev) must not affect reads for (app=X, env=prod).
|
||||
* Regression test for the pre-1.0 env-scoping gap (plans/2026-04-16-environment-scoping.md).
|
||||
*/
|
||||
class ConfigEnvIsolationIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired PostgresApplicationConfigRepository configRepo;
|
||||
@Autowired AppSettingsRepository settingsRepo;
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void applicationConfig_isolatesByEnvironment() {
|
||||
ApplicationConfig dev = new ApplicationConfig();
|
||||
dev.setSamplingRate(0.5);
|
||||
dev.setApplicationLogLevel("DEBUG");
|
||||
configRepo.save("order-svc", "dev", dev, "test");
|
||||
|
||||
ApplicationConfig prod = new ApplicationConfig();
|
||||
prod.setSamplingRate(0.01);
|
||||
prod.setApplicationLogLevel("WARN");
|
||||
configRepo.save("order-svc", "prod", prod, "test");
|
||||
|
||||
ApplicationConfig readDev = configRepo.findByApplicationAndEnvironment("order-svc", "dev")
|
||||
.orElseThrow();
|
||||
assertThat(readDev.getEnvironment()).isEqualTo("dev");
|
||||
assertThat(readDev.getSamplingRate()).isEqualTo(0.5);
|
||||
assertThat(readDev.getApplicationLogLevel()).isEqualTo("DEBUG");
|
||||
|
||||
ApplicationConfig readProd = configRepo.findByApplicationAndEnvironment("order-svc", "prod")
|
||||
.orElseThrow();
|
||||
assertThat(readProd.getEnvironment()).isEqualTo("prod");
|
||||
assertThat(readProd.getSamplingRate()).isEqualTo(0.01);
|
||||
assertThat(readProd.getApplicationLogLevel()).isEqualTo("WARN");
|
||||
|
||||
assertThat(configRepo.findByApplicationAndEnvironment("order-svc", "staging")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationConfig_saveReplacesOnlySameEnv() {
|
||||
ApplicationConfig original = new ApplicationConfig();
|
||||
original.setSamplingRate(0.5);
|
||||
configRepo.save("svc", "dev", original, "alice");
|
||||
|
||||
ApplicationConfig otherEnv = new ApplicationConfig();
|
||||
otherEnv.setSamplingRate(0.1);
|
||||
configRepo.save("svc", "prod", otherEnv, "alice");
|
||||
|
||||
ApplicationConfig updated = new ApplicationConfig();
|
||||
updated.setSamplingRate(0.9);
|
||||
configRepo.save("svc", "dev", updated, "bob");
|
||||
|
||||
assertThat(configRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow()
|
||||
.getSamplingRate()).isEqualTo(0.9);
|
||||
assertThat(configRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow()
|
||||
.getSamplingRate()).isEqualTo(0.1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationConfig_findByEnvironment_excludesOtherEnvs() {
|
||||
ApplicationConfig a = new ApplicationConfig();
|
||||
a.setSamplingRate(1.0);
|
||||
configRepo.save("a", "dev", a, "test");
|
||||
configRepo.save("b", "dev", a, "test");
|
||||
configRepo.save("a", "prod", a, "test");
|
||||
|
||||
assertThat(configRepo.findByEnvironment("dev"))
|
||||
.extracting(ApplicationConfig::getApplication)
|
||||
.containsExactlyInAnyOrder("a", "b");
|
||||
assertThat(configRepo.findByEnvironment("prod"))
|
||||
.extracting(ApplicationConfig::getApplication)
|
||||
.containsExactly("a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appSettings_isolatesByEnvironment() {
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null));
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null));
|
||||
|
||||
AppSettings readDev = settingsRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow();
|
||||
assertThat(readDev.slaThresholdMs()).isEqualTo(500);
|
||||
assertThat(readDev.environment()).isEqualTo("dev");
|
||||
|
||||
AppSettings readProd = settingsRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow();
|
||||
assertThat(readProd.slaThresholdMs()).isEqualTo(100);
|
||||
assertThat(readProd.environment()).isEqualTo("prod");
|
||||
|
||||
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "staging")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appSettings_delete_scopedToSingleEnv() {
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null));
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null));
|
||||
|
||||
settingsRepo.delete("svc", "dev");
|
||||
|
||||
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "dev")).isEmpty();
|
||||
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "prod")).isPresent();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.time.Instant;
|
||||
|
||||
public record AppSettings(
|
||||
String applicationId,
|
||||
String environment,
|
||||
int slaThresholdMs,
|
||||
double healthErrorWarn,
|
||||
double healthErrorCrit,
|
||||
@@ -12,8 +13,8 @@ public record AppSettings(
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
|
||||
public static AppSettings defaults(String applicationId) {
|
||||
public static AppSettings defaults(String applicationId, String environment) {
|
||||
Instant now = Instant.now();
|
||||
return new AppSettings(applicationId, 300, 1.0, 5.0, 99.0, 95.0, now, now);
|
||||
return new AppSettings(applicationId, environment, 300, 1.0, 5.0, 99.0, 95.0, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AppSettingsRepository {
|
||||
Optional<AppSettings> findByApplicationId(String applicationId);
|
||||
List<AppSettings> findAll();
|
||||
Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment);
|
||||
List<AppSettings> findByEnvironment(String environment);
|
||||
AppSettings save(AppSettings settings);
|
||||
void delete(String applicationId);
|
||||
void delete(String applicationId, String environment);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.cameleer.server.core.ingestion;
|
||||
import com.cameleer.common.graph.RouteGraph;
|
||||
|
||||
/**
|
||||
* Pairs a {@link RouteGraph} with the authenticated agent identity.
|
||||
* Pairs a {@link RouteGraph} with the authenticated agent identity and environment.
|
||||
* <p>
|
||||
* The agent ID is extracted from the SecurityContext in the controller layer
|
||||
* and carried through the write buffer so the flush scheduler can persist it.
|
||||
* The agent ID is extracted from the SecurityContext in the controller layer,
|
||||
* the environment from the agent registry (which in turn came from the agent's JWT
|
||||
* at registration), and all are carried through the write buffer so the flush
|
||||
* scheduler can persist them.
|
||||
*/
|
||||
public record TaggedDiagram(String instanceId, String applicationId, RouteGraph graph) {}
|
||||
public record TaggedDiagram(String instanceId, String applicationId, String environment, RouteGraph graph) {}
|
||||
|
||||
@@ -17,5 +17,5 @@ public interface DiagramStore {
|
||||
|
||||
Optional<String> findContentHashForRouteByAgents(String routeId, List<String> instanceIds);
|
||||
|
||||
Map<String, String> findProcessorRouteMapping(String applicationId);
|
||||
Map<String, String> findProcessorRouteMapping(String applicationId, String environment);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class ChunkAccumulatorTest {
|
||||
public Optional<com.cameleer.common.graph.RouteGraph> findByContentHash(String h) { return Optional.empty(); }
|
||||
public Optional<String> findContentHashForRoute(String r, String a) { return Optional.empty(); }
|
||||
public Optional<String> findContentHashForRouteByAgents(String r, List<String> a) { return Optional.empty(); }
|
||||
public Map<String, String> findProcessorRouteMapping(String app) { return Map.of(); }
|
||||
public Map<String, String> findProcessorRouteMapping(String app, String env) { return Map.of(); }
|
||||
};
|
||||
|
||||
private CopyOnWriteArrayList<MergedExecution> executionSink;
|
||||
|
||||
135
docs/superpowers/plans/2026-04-16-environment-scoping.md
Normal file
135
docs/superpowers/plans/2026-04-16-environment-scoping.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Environment-scoped config — fixing cross-env data bleed
|
||||
|
||||
**Date:** 2026-04-16
|
||||
**Status:** Not started
|
||||
**Backwards compatibility:** None (pre-1.0; user will wipe dev DB)
|
||||
|
||||
## Problem
|
||||
|
||||
Two PostgreSQL tables key per-app state on the application name alone, despite environments (`dev`/`staging`/`prod`) being first-class in the rest of the system:
|
||||
|
||||
- `application_config` PK `(application)` — traced processors, taps, route recording, per-app sensitive keys. All env-sensitive.
|
||||
- `app_settings` PK `(application_id)` — SLA threshold, health warn/crit thresholds. All env-sensitive.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Config corruption**: `PUT /api/v1/config/{app}?environment=dev` correctly scopes the SSE fan-out but overwrites the single DB row, so when `prod` agents restart and fetch config they get the `dev` config.
|
||||
- **Agent startup is env-blind**: `GET /api/v1/config/{app}` reads neither JWT `env` claim nor any request parameter; returns whichever row exists.
|
||||
- **Dashboard settings ambiguous**: `AppSettings` endpoints have no env parameter; SLA/health displayed without env context.
|
||||
- Ancillary: `ClickHouseDiagramStore.findProcessorRouteMapping(appId)` doesn't filter by env even though the table has an `environment` column.
|
||||
- Ancillary: `AgentRegistrationController` accepts registrations without `environmentId` and silently defaults to `"default"` — masks misconfigured agents.
|
||||
|
||||
## Non-goals / working correctly (do not touch)
|
||||
|
||||
- All ClickHouse observability tables (executions, logs, metrics, stats_1m_*) — already env-scoped.
|
||||
- `AgentCommandController` / SSE command fan-out — already env-filtered via `AgentRegistryService.findByApplicationAndEnvironment`.
|
||||
- `SearchController` search path — fixed in commit `e2d9428`.
|
||||
- RBAC (users/roles/groups/claim mappings) — tenant-wide by design.
|
||||
- Global sensitive-keys push to all envs (`SensitiveKeysAdminController.fanOutToAllAgents`) — by design; global baseline.
|
||||
- Admin UI per-page env indicator — not needed, already shown in top-right of the shell.
|
||||
|
||||
## Design decisions (fixed)
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Schema migration strategy | Edit `V1__init.sql` in place. User wipes dev DB. |
|
||||
| Agent config fetch with no/unknown env | Return `404 Not Found`. No `"default"` fallback. |
|
||||
| `cameleer-common` `ApplicationConfig` model | Add `environment` field in-place; agent team coordinates the bump (SNAPSHOT). |
|
||||
| Agent registration without `environmentId` | Return `400 Bad Request`. Registration MUST include env. |
|
||||
| UI per-screen env display | Already covered by top-right global env indicator — no extra UI work. |
|
||||
|
||||
## Plan
|
||||
|
||||
### Phase 1 — PostgreSQL schema
|
||||
|
||||
1. Edit `cameleer-server-app/src/main/resources/db/migration/V1__init.sql`:
|
||||
- `application_config`: add `environment TEXT NOT NULL` column; change PK to `(application, environment)`.
|
||||
- `app_settings`: add `environment TEXT NOT NULL` column; change PK to `(application_id, environment)`.
|
||||
2. Commit message MUST call out: "Wipe dev DB before deploying — Flyway V1 checksum changes."
|
||||
|
||||
### Phase 2 — Shared model (`cameleer-common`)
|
||||
|
||||
3. **`ApplicationConfig` stays untouched on the server side.** The agent team is adding `environment` to the common class separately; the server doesn't depend on it. On the server, `environment` flows as a sidecar parameter through repositories/controllers and as a dedicated `environment` column on `application_config`. The stored JSON body contains only the config content. If/when the field appears in the common class, we'll hydrate it from the DB column into the returned DTO — no code change needed today.
|
||||
4. Add `environment` field to `AppSettings` record in `cameleer-server-core` (`admin/AppSettings.java`). **Done.**
|
||||
|
||||
### Phase 3 — Repositories
|
||||
|
||||
5. `PostgresApplicationConfigRepository`:
|
||||
- `findByApplicationAndEnvironment(String app, String env)` replaces `findByApplication(app)`.
|
||||
- `findAll(String env)` (env required) replaces `findAll()`.
|
||||
- `save(String app, String env, ApplicationConfig, String updatedBy)` replaces `save(app, config, updatedBy)`.
|
||||
- Keep behaviour identical except for the PK.
|
||||
6. `AppSettingsRepository` interface (core) and `PostgresAppSettingsRepository` (app) — same treatment with `(applicationId, environment)`.
|
||||
|
||||
### Phase 4 — REST controllers
|
||||
|
||||
7. `ApplicationConfigController`:
|
||||
- `getConfig(@PathVariable app)`: dual-mode by caller role. For AGENT role → env taken from JWT `env` claim, query param ignored (agents cannot spoof env). For non-agent callers (admin UI, with user JWTs whose `env="default"` is a placeholder) → env must be passed via `?environment=` query param. If neither produces a value → **404**.
|
||||
- `updateConfig(@PathVariable app, @RequestParam String environment, ...)`: make `environment` required. Forward to repo save. SSE push already env-scoped — keep.
|
||||
- `listConfigs(@RequestParam String environment)`: require env; filter.
|
||||
- `getProcessorRouteMapping(@PathVariable app, @RequestParam String environment)`: require env; forward to ClickHouse.
|
||||
- `testExpression(@PathVariable app, @RequestParam String environment, ...)`: make env required (already accepted as optional — tighten).
|
||||
8. `AppSettingsController`:
|
||||
- `GET /api/v1/admin/app-settings?environment=`: list filtered.
|
||||
- `GET /api/v1/admin/app-settings/{appId}?environment=`: require env.
|
||||
- `PUT /api/v1/admin/app-settings/{appId}?environment=`: require env.
|
||||
9. `SensitiveKeysAdminController`: review — global sensitive keys are server-wide (one row in `server_config`), no change needed. Add code comment clarifying env-wide push is intentional.
|
||||
10. `SearchController.stats`: the SLA threshold lookup `appSettingsRepository.findByApplicationId(app)` becomes env-aware via the existing `environment` query param.
|
||||
|
||||
### Phase 5 — Storage
|
||||
|
||||
11. `ClickHouseDiagramStore.findProcessorRouteMapping(app)` → `findProcessorRouteMapping(app, env)`. Include `environment = ?` in `WHERE`.
|
||||
|
||||
### Phase 6 — JWT surface
|
||||
|
||||
12. Expose `env` claim via Spring `Authentication` principal — simplest path is a small custom `AuthenticationPrincipal` or `@RequestAttribute("env")` populated by `JwtAuthenticationFilter`. Keep scope minimal; only `ApplicationConfigController.getConfig` needs it directly for the 404 rule.
|
||||
|
||||
### Phase 7 — Agent registration hardening
|
||||
|
||||
13. `AgentRegistrationController.register`:
|
||||
- If `request.environmentId()` is null or blank → `400 Bad Request` with an explicit error message. Drop the `"default"` fallback on line 122.
|
||||
- Log the rejection (agent identity + remote IP) at INFO for diagnostics.
|
||||
14. `AgentRegistrationController.refreshToken`: remove the `"default"` fallback at line 211 (dead after Phase 7.13, but harmless to clean up).
|
||||
15. `AgentRegistrationController.heartbeat`: already falls back to JWT claim; after Phase 7.13 every JWT has a real env, so the `"default"` fallback at line 247 is dead code — remove.
|
||||
|
||||
### Phase 8 — UI queries
|
||||
|
||||
16. `ui/src/api/queries/dashboard.ts`: `useAppSettings(appId)` → `useAppSettings(appId, environment)`; same for `useAllAppSettings()`. Pull env from `useEnvironmentStore`.
|
||||
17. `ui/src/api/queries/commands.ts`: verify `useApplicationConfig(appId)` / `useUpdateApplicationConfig` already pass env. Add if missing. (Audit pass only, may be no-op.)
|
||||
18. Verify no other UI hook fetches per-app state without env.
|
||||
|
||||
### Phase 9 — Tests
|
||||
|
||||
19. Integration: write config for `(app=X, env=dev)`; read for `(app=X, env=prod)` returns empty/default.
|
||||
20. Integration: agent JWT with `env=dev` calling `GET /api/v1/config/X` returns the dev config row. JWT with no env claim → 404.
|
||||
21. Integration: `POST /api/v1/agents/register` with no `environmentId` → 400.
|
||||
22. Unit: `AppSettingsRepository` env-isolation test.
|
||||
|
||||
### Phase 10 — Documentation
|
||||
|
||||
23. `CLAUDE.md`:
|
||||
- "Storage" section: update `application_config` and `app_settings` PK description.
|
||||
- Agent lifecycle section: note that registration requires `environmentId` (was optional, defaulted to `"default"`).
|
||||
- Remove the "priority: heartbeat `environmentId` > JWT `env` claim > `"default"`" note — after fix, every agent has a real env on every path.
|
||||
24. `.claude/rules/app-classes.md`:
|
||||
- `ApplicationConfigController` — reflect env-required endpoints.
|
||||
- `AppSettingsController` — reflect env-required endpoints.
|
||||
- `AgentRegistrationController` — note env required.
|
||||
25. `.claude/rules/core-classes.md`:
|
||||
- `PostgresApplicationConfigRepository`, `PostgresAppSettingsRepository` — updated signatures.
|
||||
|
||||
## Execution order
|
||||
|
||||
Phases are mostly sequential by dependency: 1 → 2 → 3 → (4, 5 in parallel) → 6 → 7 → 8 → 9 → 10. Phase 6 (JWT surfacing) is a small dependency for Phase 4 controller changes; do them together.
|
||||
|
||||
## Verification
|
||||
|
||||
- `mvn clean verify` passes.
|
||||
- `detect_changes` scope matches the files touched per phase.
|
||||
- Manual: spin up two envs (`dev` + `prod`) locally; configure tap in `dev`; confirm `prod` agent doesn't receive it and its DB row is untouched.
|
||||
- Manual: stop an agent without env in the registration payload; confirm server returns 400.
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- `audit_log` has no `environment` column; filtering audit by env would be nice-to-have but not a correctness issue. Defer.
|
||||
- Agent bootstrap-token scoping to env (so a dev token can't register as prod) — security hardening for after 1.0.
|
||||
@@ -56,11 +56,12 @@ export function useAllApplicationConfigs() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useApplicationConfig(application: string | undefined) {
|
||||
export function useApplicationConfig(application: string | undefined, environment: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['applicationConfig', application],
|
||||
queryKey: ['applicationConfig', application, environment],
|
||||
queryFn: async () => {
|
||||
const res = await authFetch(`/config/${application}`)
|
||||
const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : ''
|
||||
const res = await authFetch(`/config/${application}${envParam}`)
|
||||
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
||||
const data = await res.json()
|
||||
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
||||
@@ -69,7 +70,7 @@ export function useApplicationConfig(application: string | undefined) {
|
||||
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
|
||||
return cfg as ApplicationConfig
|
||||
},
|
||||
enabled: !!application,
|
||||
enabled: !!application && !!environment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,15 +101,16 @@ export function useUpdateApplicationConfig() {
|
||||
|
||||
// ── Processor → Route Mapping ─────────────────────────────────────────────
|
||||
|
||||
export function useProcessorRouteMapping(application?: string) {
|
||||
export function useProcessorRouteMapping(application?: string, environment?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['config', application, 'processor-routes'],
|
||||
queryKey: ['config', application, environment, 'processor-routes'],
|
||||
queryFn: async () => {
|
||||
const res = await authFetch(`/config/${application}/processor-routes`)
|
||||
const res = await authFetch(
|
||||
`/config/${application}/processor-routes?environment=${encodeURIComponent(environment!)}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch processor-route mapping')
|
||||
return res.json() as Promise<Record<string, string>>
|
||||
},
|
||||
enabled: !!application,
|
||||
enabled: !!application && !!environment,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export function usePunchcard(application?: string, environment?: string) {
|
||||
|
||||
export interface AppSettings {
|
||||
appId: string;
|
||||
environment: string;
|
||||
slaThresholdMs: number;
|
||||
healthErrorWarn: number;
|
||||
healthErrorCrit: number;
|
||||
@@ -124,19 +125,22 @@ export interface AppSettings {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useAppSettings(appId?: string) {
|
||||
export function useAppSettings(appId?: string, environment?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['app-settings', appId],
|
||||
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
|
||||
enabled: !!appId,
|
||||
queryKey: ['app-settings', appId, environment],
|
||||
queryFn: () => fetchJson<AppSettings>(
|
||||
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
|
||||
enabled: !!appId && !!environment,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllAppSettings() {
|
||||
export function useAllAppSettings(environment?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['app-settings', 'all'],
|
||||
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
|
||||
queryKey: ['app-settings', 'all', environment],
|
||||
queryFn: () => fetchJson<AppSettings[]>(
|
||||
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
|
||||
enabled: !!environment,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -144,9 +148,11 @@ export function useAllAppSettings() {
|
||||
export function useUpdateAppSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
|
||||
mutationFn: async ({ appId, environment, settings }:
|
||||
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||
const res = await fetch(
|
||||
`${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function AppConfigDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||
const { data: config, isLoading } = useApplicationConfig(appId);
|
||||
const { data: config, isLoading } = useApplicationConfig(appId, selectedEnv);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const { data: catalog } = useCatalog();
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function AgentHealth() {
|
||||
const { toast } = useToast();
|
||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||
const { data: agents } = useAgents(undefined, appId, selectedEnv);
|
||||
const { data: appConfig } = useApplicationConfig(appId);
|
||||
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
@@ -941,11 +941,12 @@ interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
|
||||
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug);
|
||||
const envSlug = environment?.slug;
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
const updateContainerConfig = useUpdateContainerConfig();
|
||||
const { data: catalog } = useCatalog();
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
|
||||
const isProd = environment?.production ?? false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'traces' | 'recording'>('monitoring');
|
||||
|
||||
@@ -305,7 +305,7 @@ export default function DashboardL1() {
|
||||
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo, selectedEnv);
|
||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
||||
const { data: punchcardData } = usePunchcard(undefined, selectedEnv);
|
||||
const { data: allAppSettings } = useAllAppSettings();
|
||||
const { data: allAppSettings } = useAllAppSettings(selectedEnv);
|
||||
|
||||
// Build settings lookup map
|
||||
const settingsMap = useMemo(() => {
|
||||
|
||||
@@ -287,7 +287,7 @@ export default function DashboardL2() {
|
||||
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId, selectedEnv);
|
||||
const { data: errors } = useTopErrors(timeFrom, timeTo, appId, undefined, selectedEnv);
|
||||
const { data: punchcardData } = usePunchcard(appId, selectedEnv);
|
||||
const { data: appSettings } = useAppSettings(appId);
|
||||
const { data: appSettings } = useAppSettings(appId, selectedEnv);
|
||||
|
||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ export default function DashboardL3() {
|
||||
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
|
||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId, selectedEnv);
|
||||
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
|
||||
const { data: appSettings } = useAppSettings(appId);
|
||||
const { data: appSettings } = useAppSettings(appId, selectedEnv);
|
||||
|
||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
}, [catalog]);
|
||||
|
||||
// Build nodeConfigs from app config (for TRACE/TAP badges)
|
||||
const { data: appConfig } = useApplicationConfig(appId);
|
||||
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||
const nodeConfigs = useMemo(() => {
|
||||
const map = new Map<string, NodeConfig>();
|
||||
if (appConfig?.tracedProcessors) {
|
||||
|
||||
@@ -340,7 +340,7 @@ export default function RouteDetail() {
|
||||
});
|
||||
|
||||
// ── Application config ──────────────────────────────────────────────────────
|
||||
const config = useApplicationConfig(appId);
|
||||
const config = useApplicationConfig(appId, selectedEnv);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const testExpressionMutation = useTestExpression();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user