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
|
## 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)
|
- `AgentSseController` — GET /sse (Server-Sent Events connection)
|
||||||
- `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack
|
- `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack
|
||||||
- `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions
|
- `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions
|
||||||
@@ -25,17 +25,17 @@ paths:
|
|||||||
- `RoleAdminController` — CRUD /api/v1/admin/roles
|
- `RoleAdminController` — CRUD /api/v1/admin/roles
|
||||||
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
||||||
- `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test
|
- `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
|
- `AuditLogController` — GET /api/v1/admin/audit
|
||||||
- `MetricsController` — GET /api/v1/metrics, GET /timeseries
|
- `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)
|
- `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)
|
- `ClaimMappingAdminController` — CRUD /api/v1/admin/claim-mappings, POST /test (accepts inline rules + claims for preview without saving)
|
||||||
- `LicenseAdminController` — GET/POST /api/v1/admin/license
|
- `LicenseAdminController` — GET/POST /api/v1/admin/license
|
||||||
- `AgentEventsController` — GET /api/v1/agent-events (agent state change history)
|
- `AgentEventsController` — GET /api/v1/agent-events (agent state change history)
|
||||||
- `AgentMetricsController` — GET /api/v1/agent-metrics (JVM/Camel metrics per agent instance)
|
- `AgentMetricsController` — GET /api/v1/agent-metrics (JVM/Camel metrics per agent instance)
|
||||||
- `AppSettingsController` — GET/PUT /api/v1/apps/{appId}/settings
|
- `AppSettingsController` — GET/PUT /api/v1/admin/app-settings (list), /api/v1/admin/app-settings/{appId} (per-app). All endpoints require `?environment=`.
|
||||||
- `ApplicationConfigController` — GET/PUT /api/v1/apps/{appId}/config (traced processors, route recording, sensitive keys per app)
|
- `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)
|
- `ClickHouseAdminController` — GET /api/v1/admin/clickhouse (ClickHouse admin, conditional on infrastructure endpoints)
|
||||||
- `DatabaseAdminController` — GET /api/v1/admin/database (PG 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)
|
- `DetailController` — GET /api/v1/detail (execution detail with processor tree)
|
||||||
@@ -67,7 +67,7 @@ paths:
|
|||||||
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
||||||
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
||||||
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
|
- `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
|
## storage/ — ClickHouse stores
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ paths:
|
|||||||
- `SensitiveKeysConfig` — record: keys (List<String>, immutable)
|
- `SensitiveKeysConfig` — record: keys (List<String>, immutable)
|
||||||
- `SensitiveKeysRepository` — interface: find(), save()
|
- `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.
|
- `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
|
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
|
||||||
- `AuditService` — audit logging facade
|
- `AuditService` — audit logging facade
|
||||||
- `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence
|
- `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence
|
||||||
@@ -95,4 +95,4 @@ paths:
|
|||||||
- `ChunkAccumulator` — batches data for efficient flush
|
- `ChunkAccumulator` — batches data for efficient flush
|
||||||
- `WriteBuffer` — bounded ring buffer for async flush
|
- `WriteBuffer` — bounded ring buffer for async flush
|
||||||
- `BufferedLogEntry` — log entry wrapper with metadata
|
- `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
|
- 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)
|
- 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).
|
- 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))`.
|
- 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.
|
- 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`.
|
- 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
|
## Database Migrations
|
||||||
|
|
||||||
PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
|
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)
|
- V2 — Claim mappings (OIDC)
|
||||||
- V3 — Runtime management (apps, environments, deployments, app_versions)
|
- V3 — Runtime management (apps, environments, deployments, app_versions)
|
||||||
- V4 — Environment config (default_container_config JSONB)
|
- V4 — Environment config (default_container_config JSONB)
|
||||||
@@ -78,7 +78,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> 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()) {
|
if (request.instanceId() == null || request.instanceId().isBlank()) {
|
||||||
return ResponseEntity.badRequest().build();
|
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 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();
|
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
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<String> roles = result.roles().isEmpty()
|
||||||
? List.of("AGENT") : result.roles();
|
? List.of("AGENT") : result.roles();
|
||||||
String application = result.application() != null ? result.application() : "default";
|
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)
|
// Refresh-token env claim is required — agents without env shouldn't have gotten a token in the first place.
|
||||||
String environment = result.environment() != null ? result.environment() : "default";
|
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);
|
AgentInfo agent = registryService.findById(agentId);
|
||||||
if (agent != null) {
|
if (agent != null) {
|
||||||
application = agent.applicationId();
|
application = agent.applicationId();
|
||||||
@@ -242,9 +254,14 @@ public class AgentRegistrationController {
|
|||||||
JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||||
if (jwtResult != null) {
|
if (jwtResult != null) {
|
||||||
String application = jwtResult.application() != null ? jwtResult.application() : "default";
|
String application = jwtResult.application() != null ? jwtResult.application() : "default";
|
||||||
// Prefer environment from heartbeat body (most current), fall back to JWT claim
|
// Env: prefer heartbeat body (current), else JWT claim. No silent default.
|
||||||
String env = heartbeatEnv != null ? heartbeatEnv
|
String env = (heartbeatEnv != null && !heartbeatEnv.isBlank())
|
||||||
: jwtResult.environment() != null ? jwtResult.environment() : "default";
|
? 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();
|
Map<String, Object> caps = capabilities != null ? capabilities : Map.of();
|
||||||
List<String> healRouteIds = routeIds != null ? routeIds : List.of();
|
List<String> healRouteIds = routeIds != null ? routeIds : List.of();
|
||||||
registryService.register(id, id, application, env, "unknown",
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@@ -40,21 +41,24 @@ public class AppSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all application settings")
|
@Operation(summary = "List application settings in an environment")
|
||||||
public ResponseEntity<List<AppSettings>> getAll() {
|
public ResponseEntity<List<AppSettings>> getAll(@RequestParam String environment) {
|
||||||
return ResponseEntity.ok(repository.findAll());
|
return ResponseEntity.ok(repository.findByEnvironment(environment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{appId}")
|
@GetMapping("/{appId}")
|
||||||
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
|
@Operation(summary = "Get settings for an application in an environment (returns defaults if not configured)")
|
||||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
|
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId,
|
||||||
AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(appId));
|
@RequestParam String environment) {
|
||||||
|
AppSettings settings = repository.findByApplicationAndEnvironment(appId, environment)
|
||||||
|
.orElse(AppSettings.defaults(appId, environment));
|
||||||
return ResponseEntity.ok(settings);
|
return ResponseEntity.ok(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{appId}")
|
@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,
|
public ResponseEntity<AppSettings> update(@PathVariable String appId,
|
||||||
|
@RequestParam String environment,
|
||||||
@Valid @RequestBody AppSettingsRequest request,
|
@Valid @RequestBody AppSettingsRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
List<String> errors = request.validate();
|
List<String> errors = request.validate();
|
||||||
@@ -62,18 +66,20 @@ public class AppSettingsController {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
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,
|
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);
|
return ResponseEntity.ok(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{appId}")
|
@DeleteMapping("/{appId}")
|
||||||
@Operation(summary = "Delete application settings (reverts to defaults)")
|
@Operation(summary = "Delete application settings for an environment (reverts to defaults)")
|
||||||
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
|
public ResponseEntity<Void> delete(@PathVariable String appId,
|
||||||
repository.delete(appId);
|
@RequestParam String environment,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
repository.delete(appId, environment);
|
||||||
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
||||||
Map.of(), AuditResult.SUCCESS, httpRequest);
|
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.noContent().build();
|
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.ConfigUpdateResponse;
|
||||||
import com.cameleer.server.app.dto.TestExpressionRequest;
|
import com.cameleer.server.app.dto.TestExpressionRequest;
|
||||||
import com.cameleer.server.app.dto.TestExpressionResponse;
|
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.app.storage.PostgresApplicationConfigRepository;
|
||||||
|
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
|
||||||
import com.cameleer.server.core.admin.AuditCategory;
|
import com.cameleer.server.core.admin.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
import com.cameleer.server.core.admin.AuditService;
|
import com.cameleer.server.core.admin.AuditService;
|
||||||
@@ -73,23 +75,38 @@ public class ApplicationConfigController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all application configs",
|
@Operation(summary = "List application configs in an environment",
|
||||||
description = "Returns stored configurations for all applications")
|
description = "Returns stored configurations for all applications in the given environment")
|
||||||
@ApiResponse(responseCode = "200", description = "Configs returned")
|
@ApiResponse(responseCode = "200", description = "Configs returned")
|
||||||
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
|
public ResponseEntity<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
|
||||||
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
|
HttpServletRequest httpRequest) {
|
||||||
return ResponseEntity.ok(configRepository.findAll());
|
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
|
||||||
|
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(configRepository.findByEnvironment(environment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{application}")
|
@GetMapping("/{application}")
|
||||||
@Operation(summary = "Get application config",
|
@Operation(summary = "Get application config for an environment",
|
||||||
description = "Returns the current configuration for an application with merged sensitive keys.")
|
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 = "200", description = "Config returned")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment could not be resolved")
|
||||||
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
|
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
|
Authentication auth,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
|
String resolved = resolveEnvironmentForRead(auth, httpRequest, environment);
|
||||||
ApplicationConfig config = configRepository.findByApplication(application)
|
if (resolved == null || resolved.isBlank()) {
|
||||||
.orElse(defaultConfig(application));
|
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()
|
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||||
.map(SensitiveKeysConfig::keys)
|
.map(SensitiveKeysConfig::keys)
|
||||||
@@ -99,19 +116,36 @@ public class ApplicationConfigController {
|
|||||||
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
|
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}")
|
@PutMapping("/{application}")
|
||||||
@Operation(summary = "Update application config",
|
@Operation(summary = "Update application config for an environment",
|
||||||
description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application")
|
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")
|
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||||
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
|
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
|
||||||
@RequestParam(required = false) String environment,
|
@RequestParam String environment,
|
||||||
@RequestBody ApplicationConfig config,
|
@RequestBody ApplicationConfig config,
|
||||||
Authentication auth,
|
Authentication auth,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
String updatedBy = auth != null ? auth.getName() : "system";
|
String updatedBy = auth != null ? auth.getName() : "system";
|
||||||
|
|
||||||
config.setApplication(application);
|
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
|
// Merge global + per-app sensitive keys for the SSE push payload
|
||||||
List<String> globalKeys = sensitiveKeysRepository.find()
|
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||||
@@ -126,7 +160,8 @@ public class ApplicationConfigController {
|
|||||||
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
||||||
|
|
||||||
auditService.log("update_app_config", AuditCategory.CONFIG, application,
|
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()),
|
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
||||||
AuditResult.SUCCESS, httpRequest);
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
@@ -134,30 +169,27 @@ public class ApplicationConfigController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{application}/processor-routes")
|
@GetMapping("/{application}/processor-routes")
|
||||||
@Operation(summary = "Get processor to route mapping",
|
@Operation(summary = "Get processor to route mapping for an environment",
|
||||||
description = "Returns a map of processorId → routeId for all processors seen in this application")
|
description = "Returns a map of processorId → routeId for all processors seen in this application + environment")
|
||||||
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
||||||
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application) {
|
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application,
|
||||||
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application));
|
@RequestParam String environment) {
|
||||||
|
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{application}/test-expression")
|
@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 = "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")
|
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||||
public ResponseEntity<TestExpressionResponse> testExpression(
|
public ResponseEntity<TestExpressionResponse> testExpression(
|
||||||
@PathVariable String application,
|
@PathVariable String application,
|
||||||
@RequestParam(required = false) String environment,
|
@RequestParam String environment,
|
||||||
@RequestBody TestExpressionRequest request) {
|
@RequestBody TestExpressionRequest request) {
|
||||||
// Find a LIVE agent for this application, optionally filtered by environment
|
AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream()
|
||||||
var candidates = registryService.findAll().stream()
|
.filter(a -> a.state() == AgentState.LIVE)
|
||||||
.filter(a -> application.equals(a.applicationId()))
|
.findFirst()
|
||||||
.filter(a -> a.state() == AgentState.LIVE);
|
.orElse(null);
|
||||||
if (environment != null) {
|
|
||||||
candidates = candidates.filter(a -> environment.equals(a.environmentId()));
|
|
||||||
}
|
|
||||||
AgentInfo agent = candidates.findFirst().orElse(null);
|
|
||||||
|
|
||||||
if (agent == null) {
|
if (agent == null) {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
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
|
* Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time
|
||||||
* dependency on getSensitiveKeys() which may not be in the published cameleer-common jar yet.
|
* 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);
|
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();
|
ApplicationConfig config = new ApplicationConfig();
|
||||||
config.setApplication(application);
|
config.setApplication(application);
|
||||||
|
config.setEnvironment(environment);
|
||||||
config.setVersion(0);
|
config.setVersion(0);
|
||||||
config.setMetricsEnabled(true);
|
config.setMetricsEnabled(true);
|
||||||
config.setSamplingRate(1.0);
|
config.setSamplingRate(1.0);
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ public class DiagramController {
|
|||||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||||
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
||||||
String instanceId = extractAgentId();
|
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);
|
List<RouteGraph> graphs = parsePayload(body);
|
||||||
|
|
||||||
for (RouteGraph graph : graphs) {
|
for (RouteGraph graph : graphs) {
|
||||||
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph));
|
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, environment, graph));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
@@ -65,11 +67,6 @@ public class DiagramController {
|
|||||||
return auth != null ? auth.getName() : "";
|
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 {
|
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
|
||||||
String trimmed = body.strip();
|
String trimmed = body.strip();
|
||||||
if (trimmed.startsWith("[")) {
|
if (trimmed.startsWith("[")) {
|
||||||
|
|||||||
@@ -119,8 +119,10 @@ public class RouteMetricsController {
|
|||||||
if (!metrics.isEmpty()) {
|
if (!metrics.isEmpty()) {
|
||||||
// Determine SLA threshold (per-app or default)
|
// Determine SLA threshold (per-app or default)
|
||||||
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
||||||
int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "")
|
int threshold = (effectiveAppId != null && environment != null && !environment.isBlank())
|
||||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment)
|
||||||
|
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||||
|
: 300;
|
||||||
|
|
||||||
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||||
effectiveAppId, threshold, environment);
|
effectiveAppId, threshold, environment);
|
||||||
|
|||||||
@@ -102,10 +102,12 @@ public class SearchController {
|
|||||||
stats = searchService.statsForRoute(from, end, routeId, application, environment);
|
stats = searchService.statsForRoute(from, end, routeId, application, environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with SLA compliance
|
// Enrich with SLA compliance (per-env threshold when both app and env are specified)
|
||||||
int threshold = appSettingsRepository
|
int threshold = (application != null && !application.isBlank()
|
||||||
.findByApplicationId(application != null ? application : "")
|
&& environment != null && !environment.isBlank())
|
||||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
? appSettingsRepository.findByApplicationAndEnvironment(application, environment)
|
||||||
|
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||||
|
: 300;
|
||||||
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
|
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
|
||||||
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,31 +120,37 @@ public class SensitiveKeysAdminController {
|
|||||||
* not yet include that field accessor.
|
* not yet include that field accessor.
|
||||||
*/
|
*/
|
||||||
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
|
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
|
||||||
// Collect all distinct application IDs
|
// Collect every (application, environment) slice we know about: persisted config rows
|
||||||
Set<String> applications = new LinkedHashSet<>();
|
// PLUS currently-registered live agents (which may have no stored config yet).
|
||||||
configRepository.findAll().stream()
|
// Global sensitive keys are server-wide, but per-app overrides live per env, so the
|
||||||
.map(ApplicationConfig::getApplication)
|
// push is scoped per (app, env) so each slice gets its own merged keys.
|
||||||
.filter(a -> a != null && !a.isBlank())
|
Set<AppEnv> slices = new LinkedHashSet<>();
|
||||||
.forEach(applications::add);
|
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()
|
registryService.findAll().stream()
|
||||||
.map(a -> a.applicationId())
|
.filter(a -> a.applicationId() != null && !a.applicationId().isBlank()
|
||||||
.filter(a -> a != null && !a.isBlank())
|
&& a.environmentId() != null && !a.environmentId().isBlank())
|
||||||
.forEach(applications::add);
|
.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());
|
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;
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
|
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
|
||||||
List<String> allTimedOut = new ArrayList<>();
|
List<String> allTimedOut = new ArrayList<>();
|
||||||
int totalAgents = 0;
|
int totalAgents = 0;
|
||||||
|
|
||||||
for (String application : applications) {
|
for (AppEnv slice : slices) {
|
||||||
// Load per-app sensitive keys via JsonNode to avoid dependency on
|
// Load per-(app,env) sensitive keys via JsonNode to avoid dependency on
|
||||||
// ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet.
|
// 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))
|
.map(cfg -> extractSensitiveKeys(cfg))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
@@ -153,19 +159,22 @@ public class SensitiveKeysAdminController {
|
|||||||
|
|
||||||
// Build a minimal payload map — only sensitiveKeys + application fields.
|
// Build a minimal payload map — only sensitiveKeys + application fields.
|
||||||
Map<String, Object> payloadMap = new LinkedHashMap<>();
|
Map<String, Object> payloadMap = new LinkedHashMap<>();
|
||||||
payloadMap.put("application", application);
|
payloadMap.put("application", slice.application());
|
||||||
payloadMap.put("sensitiveKeys", mergedKeys);
|
payloadMap.put("sensitiveKeys", mergedKeys);
|
||||||
|
|
||||||
String payloadJson;
|
String payloadJson;
|
||||||
try {
|
try {
|
||||||
payloadJson = objectMapper.writeValueAsString(payloadMap);
|
payloadJson = objectMapper.writeValueAsString(payloadMap);
|
||||||
} catch (JsonProcessingException e) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, CompletableFuture<CommandReply>> futures =
|
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();
|
totalAgents += futures.size();
|
||||||
|
|
||||||
@@ -213,4 +222,7 @@ public class SensitiveKeysAdminController {
|
|||||||
return null;
|
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
|
Double healthSlaCrit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public AppSettings toSettings(String appId) {
|
public AppSettings toSettings(String appId, String environment) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
return new AppSettings(appId, environment, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||||
healthSlaWarn, healthSlaCrit, now, now);
|
healthSlaWarn, healthSlaCrit, now, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
|||||||
|
|
||||||
private static final String INSERT_SQL = """
|
private static final String INSERT_SQL = """
|
||||||
INSERT INTO route_diagrams
|
INSERT INTO route_diagrams
|
||||||
(tenant_id, content_hash, route_id, instance_id, application_id, definition, created_at)
|
(tenant_id, content_hash, route_id, instance_id, application_id, environment, definition, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String SELECT_BY_HASH = """
|
private static final String SELECT_BY_HASH = """
|
||||||
@@ -59,7 +59,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
|||||||
|
|
||||||
private static final String SELECT_DEFINITIONS_FOR_APP = """
|
private static final String SELECT_DEFINITIONS_FOR_APP = """
|
||||||
SELECT DISTINCT route_id, definition FROM route_diagrams
|
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;
|
private final String tenantId;
|
||||||
@@ -104,6 +104,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
|||||||
RouteGraph graph = diagram.graph();
|
RouteGraph graph = diagram.graph();
|
||||||
String agentId = diagram.instanceId() != null ? diagram.instanceId() : "";
|
String agentId = diagram.instanceId() != null ? diagram.instanceId() : "";
|
||||||
String applicationId = diagram.applicationId() != null ? diagram.applicationId() : "";
|
String applicationId = diagram.applicationId() != null ? diagram.applicationId() : "";
|
||||||
|
String environment = (diagram.environment() != null && !diagram.environment().isBlank())
|
||||||
|
? diagram.environment() : "default";
|
||||||
String json = objectMapper.writeValueAsString(graph);
|
String json = objectMapper.writeValueAsString(graph);
|
||||||
String contentHash = sha256Hex(json);
|
String contentHash = sha256Hex(json);
|
||||||
String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
|
String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
|
||||||
@@ -114,6 +116,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
|||||||
routeId,
|
routeId,
|
||||||
agentId,
|
agentId,
|
||||||
applicationId,
|
applicationId,
|
||||||
|
environment,
|
||||||
json,
|
json,
|
||||||
Timestamp.from(Instant.now()));
|
Timestamp.from(Instant.now()));
|
||||||
|
|
||||||
@@ -197,10 +200,10 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, String> findProcessorRouteMapping(String applicationId) {
|
public Map<String, String> findProcessorRouteMapping(String applicationId, String environment) {
|
||||||
Map<String, String> mapping = new HashMap<>();
|
Map<String, String> mapping = new HashMap<>();
|
||||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
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) {
|
for (Map<String, Object> row : rows) {
|
||||||
String routeId = (String) row.get("route_id");
|
String routeId = (String) row.get("route_id");
|
||||||
String json = (String) row.get("definition");
|
String json = (String) row.get("definition");
|
||||||
@@ -211,7 +214,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
|||||||
RouteGraph graph = objectMapper.readValue(json, RouteGraph.class);
|
RouteGraph graph = objectMapper.readValue(json, RouteGraph.class);
|
||||||
collectNodeIds(graph.getRoot(), routeId, mapping);
|
collectNodeIds(graph.getRoot(), routeId, mapping);
|
||||||
} catch (JsonProcessingException e) {
|
} 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;
|
return mapping;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
|||||||
|
|
||||||
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
|
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
|
||||||
rs.getString("application_id"),
|
rs.getString("application_id"),
|
||||||
|
rs.getString("environment"),
|
||||||
rs.getInt("sla_threshold_ms"),
|
rs.getInt("sla_threshold_ms"),
|
||||||
rs.getDouble("health_error_warn"),
|
rs.getDouble("health_error_warn"),
|
||||||
rs.getDouble("health_error_crit"),
|
rs.getDouble("health_error_crit"),
|
||||||
@@ -29,24 +30,27 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<AppSettings> findByApplicationId(String applicationId) {
|
public Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment) {
|
||||||
List<AppSettings> results = jdbc.query(
|
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));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AppSettings> findAll() {
|
public List<AppSettings> findByEnvironment(String environment) {
|
||||||
return jdbc.query("SELECT * FROM app_settings ORDER BY application_id", ROW_MAPPER);
|
return jdbc.query(
|
||||||
|
"SELECT * FROM app_settings WHERE environment = ? ORDER BY application_id",
|
||||||
|
ROW_MAPPER, environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AppSettings save(AppSettings settings) {
|
public AppSettings save(AppSettings settings) {
|
||||||
jdbc.update("""
|
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)
|
health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, now(), now())
|
VALUES (?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||||
ON CONFLICT (application_id) DO UPDATE SET
|
ON CONFLICT (application_id, environment) DO UPDATE SET
|
||||||
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
|
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
|
||||||
health_error_warn = EXCLUDED.health_error_warn,
|
health_error_warn = EXCLUDED.health_error_warn,
|
||||||
health_error_crit = EXCLUDED.health_error_crit,
|
health_error_crit = EXCLUDED.health_error_crit,
|
||||||
@@ -54,14 +58,15 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
|||||||
health_sla_crit = EXCLUDED.health_sla_crit,
|
health_sla_crit = EXCLUDED.health_sla_crit,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
settings.applicationId(), settings.slaThresholdMs(),
|
settings.applicationId(), settings.environment(), settings.slaThresholdMs(),
|
||||||
settings.healthErrorWarn(), settings.healthErrorCrit(),
|
settings.healthErrorWarn(), settings.healthErrorCrit(),
|
||||||
settings.healthSlaWarn(), settings.healthSlaCrit());
|
settings.healthSlaWarn(), settings.healthSlaCrit());
|
||||||
return findByApplicationId(settings.applicationId()).orElseThrow();
|
return findByApplicationAndEnvironment(settings.applicationId(), settings.environment()).orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(String appId) {
|
public void delete(String appId, String environment) {
|
||||||
jdbc.update("DELETE FROM app_settings WHERE application_id = ?", appId);
|
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.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -20,39 +21,55 @@ public class PostgresApplicationConfigRepository {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ApplicationConfig> findAll() {
|
/**
|
||||||
return jdbc.query(
|
* Row mapper — columns (application, environment) are authoritative and always
|
||||||
"SELECT config_val, version, updated_at FROM application_config ORDER BY application",
|
* overwrite whatever was in the stored JSON body. Callers must SELECT them.
|
||||||
(rs, rowNum) -> {
|
*/
|
||||||
try {
|
private RowMapper<ApplicationConfig> rowMapper() {
|
||||||
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
return (rs, rowNum) -> {
|
||||||
cfg.setVersion(rs.getInt("version"));
|
try {
|
||||||
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
|
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
||||||
return cfg;
|
cfg.setApplication(rs.getString("application"));
|
||||||
} catch (JsonProcessingException e) {
|
cfg.setEnvironment(rs.getString("environment"));
|
||||||
throw new RuntimeException("Failed to deserialize application config", e);
|
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) {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
List<ApplicationConfig> results = jdbc.query(
|
||||||
"SELECT config_val, version, updated_at FROM application_config WHERE application = ?",
|
"SELECT " + SELECT_FIELDS + " FROM application_config "
|
||||||
(rs, rowNum) -> {
|
+ "WHERE application = ? AND environment = ?",
|
||||||
try {
|
rowMapper(), application, environment);
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
application);
|
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
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;
|
String json;
|
||||||
try {
|
try {
|
||||||
json = objectMapper.writeValueAsString(config);
|
json = objectMapper.writeValueAsString(config);
|
||||||
@@ -60,18 +77,22 @@ public class PostgresApplicationConfigRepository {
|
|||||||
throw new RuntimeException("Failed to serialize application config", e);
|
throw new RuntimeException("Failed to serialize application config", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert: insert or update, auto-increment version
|
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO application_config (application, config_val, version, updated_at, updated_by)
|
INSERT INTO application_config (application, environment, config_val, version, updated_at, updated_by)
|
||||||
VALUES (?, ?::jsonb, 1, now(), ?)
|
VALUES (?, ?, ?::jsonb, 1, now(), ?)
|
||||||
ON CONFLICT (application) DO UPDATE SET
|
ON CONFLICT (application, environment) DO UPDATE SET
|
||||||
config_val = EXCLUDED.config_val,
|
config_val = EXCLUDED.config_val,
|
||||||
version = application_config.version + 1,
|
version = application_config.version + 1,
|
||||||
updated_at = now(),
|
updated_at = now(),
|
||||||
updated_by = EXCLUDED.updated_by
|
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 (
|
CREATE TABLE application_config (
|
||||||
application TEXT PRIMARY KEY,
|
application TEXT NOT NULL,
|
||||||
|
environment TEXT NOT NULL,
|
||||||
config_val JSONB NOT NULL,
|
config_val JSONB NOT NULL,
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_by TEXT
|
updated_by TEXT,
|
||||||
|
PRIMARY KEY (application, environment)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE app_settings (
|
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,
|
sla_threshold_ms INTEGER NOT NULL DEFAULT 300,
|
||||||
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||||
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||||
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
|
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
|
||||||
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
|
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
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) {
|
private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) {
|
||||||
return new TaggedDiagram(instanceId, applicationId, graph);
|
return new TaggedDiagram(instanceId, applicationId, "default", graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
@@ -180,7 +180,7 @@ class ClickHouseDiagramStoreIT {
|
|||||||
|
|
||||||
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
|
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-from-1", "route-5");
|
||||||
assertThat(mapping).containsEntry("proc-to-2", "route-5");
|
assertThat(mapping).containsEntry("proc-to-2", "route-5");
|
||||||
@@ -196,7 +196,7 @@ class ClickHouseDiagramStoreIT {
|
|||||||
|
|
||||||
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
|
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-a1", "route-a");
|
||||||
assertThat(mapping).containsEntry("proc-a2", "route-a");
|
assertThat(mapping).containsEntry("proc-a2", "route-a");
|
||||||
@@ -205,7 +205,7 @@ class ClickHouseDiagramStoreIT {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findProcessorRouteMapping_unknownAppReturnsEmpty() {
|
void findProcessorRouteMapping_unknownAppReturnsEmpty() {
|
||||||
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app");
|
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app", "default");
|
||||||
assertThat(mapping).isEmpty();
|
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(
|
public record AppSettings(
|
||||||
String applicationId,
|
String applicationId,
|
||||||
|
String environment,
|
||||||
int slaThresholdMs,
|
int slaThresholdMs,
|
||||||
double healthErrorWarn,
|
double healthErrorWarn,
|
||||||
double healthErrorCrit,
|
double healthErrorCrit,
|
||||||
@@ -12,8 +13,8 @@ public record AppSettings(
|
|||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
Instant updatedAt) {
|
Instant updatedAt) {
|
||||||
|
|
||||||
public static AppSettings defaults(String applicationId) {
|
public static AppSettings defaults(String applicationId, String environment) {
|
||||||
Instant now = Instant.now();
|
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;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AppSettingsRepository {
|
public interface AppSettingsRepository {
|
||||||
Optional<AppSettings> findByApplicationId(String applicationId);
|
Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment);
|
||||||
List<AppSettings> findAll();
|
List<AppSettings> findByEnvironment(String environment);
|
||||||
AppSettings save(AppSettings settings);
|
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;
|
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>
|
* <p>
|
||||||
* The agent ID is extracted from the SecurityContext in the controller layer
|
* 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 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);
|
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<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> findContentHashForRoute(String r, String a) { return Optional.empty(); }
|
||||||
public Optional<String> findContentHashForRouteByAgents(String r, List<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;
|
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({
|
return useQuery({
|
||||||
queryKey: ['applicationConfig', application],
|
queryKey: ['applicationConfig', application, environment],
|
||||||
queryFn: async () => {
|
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}`)
|
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
||||||
@@ -69,7 +70,7 @@ export function useApplicationConfig(application: string | undefined) {
|
|||||||
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
|
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
|
||||||
return cfg as ApplicationConfig
|
return cfg as ApplicationConfig
|
||||||
},
|
},
|
||||||
enabled: !!application,
|
enabled: !!application && !!environment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,15 +101,16 @@ export function useUpdateApplicationConfig() {
|
|||||||
|
|
||||||
// ── Processor → Route Mapping ─────────────────────────────────────────────
|
// ── Processor → Route Mapping ─────────────────────────────────────────────
|
||||||
|
|
||||||
export function useProcessorRouteMapping(application?: string) {
|
export function useProcessorRouteMapping(application?: string, environment?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['config', application, 'processor-routes'],
|
queryKey: ['config', application, environment, 'processor-routes'],
|
||||||
queryFn: async () => {
|
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')
|
if (!res.ok) throw new Error('Failed to fetch processor-route mapping')
|
||||||
return res.json() as Promise<Record<string, string>>
|
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 {
|
export interface AppSettings {
|
||||||
appId: string;
|
appId: string;
|
||||||
|
environment: string;
|
||||||
slaThresholdMs: number;
|
slaThresholdMs: number;
|
||||||
healthErrorWarn: number;
|
healthErrorWarn: number;
|
||||||
healthErrorCrit: number;
|
healthErrorCrit: number;
|
||||||
@@ -124,19 +125,22 @@ export interface AppSettings {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppSettings(appId?: string) {
|
export function useAppSettings(appId?: string, environment?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['app-settings', appId],
|
queryKey: ['app-settings', appId, environment],
|
||||||
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
|
queryFn: () => fetchJson<AppSettings>(
|
||||||
enabled: !!appId,
|
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
|
||||||
|
enabled: !!appId && !!environment,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAllAppSettings() {
|
export function useAllAppSettings(environment?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['app-settings', 'all'],
|
queryKey: ['app-settings', 'all', environment],
|
||||||
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
|
queryFn: () => fetchJson<AppSettings[]>(
|
||||||
|
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
|
||||||
|
enabled: !!environment,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,13 +148,15 @@ export function useAllAppSettings() {
|
|||||||
export function useUpdateAppSettings() {
|
export function useUpdateAppSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
mutationFn: async ({ appId, environment, settings }:
|
||||||
const token = useAuthStore.getState().accessToken;
|
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||||
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
|
const res = await fetch(
|
||||||
method: 'PUT',
|
`${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`,
|
||||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
{
|
||||||
body: JSON.stringify(settings),
|
method: 'PUT',
|
||||||
});
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error('Failed to update app settings');
|
if (!res.ok) throw new Error('Failed to update app settings');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function AppConfigDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const { data: config, isLoading } = useApplicationConfig(appId);
|
const { data: config, isLoading } = useApplicationConfig(appId, selectedEnv);
|
||||||
const updateConfig = useUpdateApplicationConfig();
|
const updateConfig = useUpdateApplicationConfig();
|
||||||
const { data: catalog } = useCatalog();
|
const { data: catalog } = useCatalog();
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function AgentHealth() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const { data: agents } = useAgents(undefined, appId, selectedEnv);
|
const { data: agents } = useAgents(undefined, appId, selectedEnv);
|
||||||
const { data: appConfig } = useApplicationConfig(appId);
|
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||||
const updateConfig = useUpdateApplicationConfig();
|
const updateConfig = useUpdateApplicationConfig();
|
||||||
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|||||||
@@ -941,11 +941,12 @@ interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
|
|||||||
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
|
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: agentConfig } = useApplicationConfig(app.slug);
|
const envSlug = environment?.slug;
|
||||||
|
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
|
||||||
const updateAgentConfig = useUpdateApplicationConfig();
|
const updateAgentConfig = useUpdateApplicationConfig();
|
||||||
const updateContainerConfig = useUpdateContainerConfig();
|
const updateContainerConfig = useUpdateContainerConfig();
|
||||||
const { data: catalog } = useCatalog();
|
const { data: catalog } = useCatalog();
|
||||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
|
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
|
||||||
const isProd = environment?.production ?? false;
|
const isProd = environment?.production ?? false;
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'traces' | 'recording'>('monitoring');
|
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: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo, selectedEnv);
|
||||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv);
|
||||||
const { data: punchcardData } = usePunchcard(undefined, selectedEnv);
|
const { data: punchcardData } = usePunchcard(undefined, selectedEnv);
|
||||||
const { data: allAppSettings } = useAllAppSettings();
|
const { data: allAppSettings } = useAllAppSettings(selectedEnv);
|
||||||
|
|
||||||
// Build settings lookup map
|
// Build settings lookup map
|
||||||
const settingsMap = useMemo(() => {
|
const settingsMap = useMemo(() => {
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export default function DashboardL2() {
|
|||||||
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId, selectedEnv);
|
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId, selectedEnv);
|
||||||
const { data: errors } = useTopErrors(timeFrom, timeTo, appId, undefined, selectedEnv);
|
const { data: errors } = useTopErrors(timeFrom, timeTo, appId, undefined, selectedEnv);
|
||||||
const { data: punchcardData } = usePunchcard(appId, selectedEnv);
|
const { data: punchcardData } = usePunchcard(appId, selectedEnv);
|
||||||
const { data: appSettings } = useAppSettings(appId);
|
const { data: appSettings } = useAppSettings(appId, selectedEnv);
|
||||||
|
|
||||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export default function DashboardL3() {
|
|||||||
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
|
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
|
||||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId, selectedEnv);
|
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId, selectedEnv);
|
||||||
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
|
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
|
||||||
const { data: appSettings } = useAppSettings(appId);
|
const { data: appSettings } = useAppSettings(appId, selectedEnv);
|
||||||
|
|
||||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
|||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
// Build nodeConfigs from app config (for TRACE/TAP badges)
|
// Build nodeConfigs from app config (for TRACE/TAP badges)
|
||||||
const { data: appConfig } = useApplicationConfig(appId);
|
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||||
const nodeConfigs = useMemo(() => {
|
const nodeConfigs = useMemo(() => {
|
||||||
const map = new Map<string, NodeConfig>();
|
const map = new Map<string, NodeConfig>();
|
||||||
if (appConfig?.tracedProcessors) {
|
if (appConfig?.tracedProcessors) {
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export default function RouteDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Application config ──────────────────────────────────────────────────────
|
// ── Application config ──────────────────────────────────────────────────────
|
||||||
const config = useApplicationConfig(appId);
|
const config = useApplicationConfig(appId, selectedEnv);
|
||||||
const updateConfig = useUpdateApplicationConfig();
|
const updateConfig = useUpdateApplicationConfig();
|
||||||
const testExpressionMutation = useTestExpression();
|
const testExpressionMutation = useTestExpression();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user