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:
@@ -117,9 +117,15 @@ public class AgentRegistrationController {
|
||||
if (request.instanceId() == null || request.instanceId().isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
if (request.environmentId() == null || request.environmentId().isBlank()) {
|
||||
String remote = httpRequest.getRemoteAddr();
|
||||
log.warn("Agent registration rejected (no environmentId): instanceId={} remote={}",
|
||||
request.instanceId(), remote);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
String application = request.applicationId() != null ? request.applicationId() : "default";
|
||||
String environmentId = request.environmentId() != null ? request.environmentId() : "default";
|
||||
String environmentId = request.environmentId();
|
||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||
|
||||
@@ -206,9 +212,15 @@ public class AgentRegistrationController {
|
||||
List<String> roles = result.roles().isEmpty()
|
||||
? List.of("AGENT") : result.roles();
|
||||
String application = result.application() != null ? result.application() : "default";
|
||||
String environment = result.environment();
|
||||
|
||||
// Try to get application + environment from registry (agent may not be registered after server restart)
|
||||
String environment = result.environment() != null ? result.environment() : "default";
|
||||
// Refresh-token env claim is required — agents without env shouldn't have gotten a token in the first place.
|
||||
if (environment == null || environment.isBlank()) {
|
||||
log.warn("Refresh token has no environment claim: agentId={}", agentId);
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Prefer registry if agent is still registered (covers env edits on re-registration)
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
if (agent != null) {
|
||||
application = agent.applicationId();
|
||||
@@ -242,9 +254,14 @@ public class AgentRegistrationController {
|
||||
JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||
if (jwtResult != null) {
|
||||
String application = jwtResult.application() != null ? jwtResult.application() : "default";
|
||||
// Prefer environment from heartbeat body (most current), fall back to JWT claim
|
||||
String env = heartbeatEnv != null ? heartbeatEnv
|
||||
: jwtResult.environment() != null ? jwtResult.environment() : "default";
|
||||
// Env: prefer heartbeat body (current), else JWT claim. No silent default.
|
||||
String env = (heartbeatEnv != null && !heartbeatEnv.isBlank())
|
||||
? heartbeatEnv
|
||||
: jwtResult.environment();
|
||||
if (env == null || env.isBlank()) {
|
||||
log.warn("Heartbeat auto-heal rejected (no environment on JWT or body): agentId={}", id);
|
||||
return ResponseEntity.status(400).build();
|
||||
}
|
||||
Map<String, Object> caps = capabilities != null ? capabilities : Map.of();
|
||||
List<String> healRouteIds = routeIds != null ? routeIds : List.of();
|
||||
registryService.register(id, id, application, env, "unknown",
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -40,21 +41,24 @@ public class AppSettingsController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all application settings")
|
||||
public ResponseEntity<List<AppSettings>> getAll() {
|
||||
return ResponseEntity.ok(repository.findAll());
|
||||
@Operation(summary = "List application settings in an environment")
|
||||
public ResponseEntity<List<AppSettings>> getAll(@RequestParam String environment) {
|
||||
return ResponseEntity.ok(repository.findByEnvironment(environment));
|
||||
}
|
||||
|
||||
@GetMapping("/{appId}")
|
||||
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
|
||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
|
||||
AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(appId));
|
||||
@Operation(summary = "Get settings for an application in an environment (returns defaults if not configured)")
|
||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId,
|
||||
@RequestParam String environment) {
|
||||
AppSettings settings = repository.findByApplicationAndEnvironment(appId, environment)
|
||||
.orElse(AppSettings.defaults(appId, environment));
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PutMapping("/{appId}")
|
||||
@Operation(summary = "Create or update settings for an application")
|
||||
@Operation(summary = "Create or update settings for an application in an environment")
|
||||
public ResponseEntity<AppSettings> update(@PathVariable String appId,
|
||||
@RequestParam String environment,
|
||||
@Valid @RequestBody AppSettingsRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
List<String> errors = request.validate();
|
||||
@@ -62,18 +66,20 @@ public class AppSettingsController {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
||||
}
|
||||
|
||||
AppSettings saved = repository.save(request.toSettings(appId));
|
||||
AppSettings saved = repository.save(request.toSettings(appId, environment));
|
||||
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
|
||||
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||
Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{appId}")
|
||||
@Operation(summary = "Delete application settings (reverts to defaults)")
|
||||
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
|
||||
repository.delete(appId);
|
||||
@Operation(summary = "Delete application settings for an environment (reverts to defaults)")
|
||||
public ResponseEntity<Void> delete(@PathVariable String appId,
|
||||
@RequestParam String environment,
|
||||
HttpServletRequest httpRequest) {
|
||||
repository.delete(appId, environment);
|
||||
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
||||
Map.of(), AuditResult.SUCCESS, httpRequest);
|
||||
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import com.cameleer.server.app.dto.CommandGroupResponse;
|
||||
import com.cameleer.server.app.dto.ConfigUpdateResponse;
|
||||
import com.cameleer.server.app.dto.TestExpressionRequest;
|
||||
import com.cameleer.server.app.dto.TestExpressionResponse;
|
||||
import com.cameleer.server.app.security.JwtAuthenticationFilter;
|
||||
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
|
||||
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
|
||||
import com.cameleer.server.core.admin.AuditCategory;
|
||||
import com.cameleer.server.core.admin.AuditResult;
|
||||
import com.cameleer.server.core.admin.AuditService;
|
||||
@@ -73,23 +75,38 @@ public class ApplicationConfigController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all application configs",
|
||||
description = "Returns stored configurations for all applications")
|
||||
@Operation(summary = "List application configs in an environment",
|
||||
description = "Returns stored configurations for all applications in the given environment")
|
||||
@ApiResponse(responseCode = "200", description = "Configs returned")
|
||||
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
|
||||
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(configRepository.findAll());
|
||||
public ResponseEntity<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
|
||||
HttpServletRequest httpRequest) {
|
||||
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
|
||||
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(configRepository.findByEnvironment(environment));
|
||||
}
|
||||
|
||||
@GetMapping("/{application}")
|
||||
@Operation(summary = "Get application config",
|
||||
description = "Returns the current configuration for an application with merged sensitive keys.")
|
||||
@Operation(summary = "Get application config for an environment",
|
||||
description = "For agents: environment is taken from the JWT env claim; the query param is ignored. "
|
||||
+ "For UI/admin callers: environment must be provided via the `environment` query param. "
|
||||
+ "Returns 404 if the environment cannot be resolved. Includes merged sensitive keys.")
|
||||
@ApiResponse(responseCode = "200", description = "Config returned")
|
||||
@ApiResponse(responseCode = "404", description = "Environment could not be resolved")
|
||||
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
|
||||
ApplicationConfig config = configRepository.findByApplication(application)
|
||||
.orElse(defaultConfig(application));
|
||||
String resolved = resolveEnvironmentForRead(auth, httpRequest, environment);
|
||||
if (resolved == null || resolved.isBlank()) {
|
||||
auditService.log("view_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("reason", "missing_environment"), AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
auditService.log("view_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("environment", resolved), AuditResult.SUCCESS, httpRequest);
|
||||
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, resolved)
|
||||
.orElse(defaultConfig(application, resolved));
|
||||
|
||||
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||
.map(SensitiveKeysConfig::keys)
|
||||
@@ -99,19 +116,36 @@ public class ApplicationConfigController {
|
||||
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents identify themselves via AGENT role and a real JWT env claim — use that,
|
||||
* ignoring any query param (agents can't spoof env). Non-agent callers (admin UI)
|
||||
* must pass the env explicitly; their JWT env claim is a placeholder and not
|
||||
* authoritative.
|
||||
*/
|
||||
private String resolveEnvironmentForRead(Authentication auth,
|
||||
HttpServletRequest request,
|
||||
String queryEnvironment) {
|
||||
boolean isAgent = auth != null && auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_AGENT".equals(a.getAuthority()));
|
||||
if (isAgent) {
|
||||
return environmentFromJwt(request);
|
||||
}
|
||||
return queryEnvironment;
|
||||
}
|
||||
|
||||
@PutMapping("/{application}")
|
||||
@Operation(summary = "Update application config",
|
||||
description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application")
|
||||
@Operation(summary = "Update application config for an environment",
|
||||
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment")
|
||||
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam String environment,
|
||||
@RequestBody ApplicationConfig config,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
String updatedBy = auth != null ? auth.getName() : "system";
|
||||
|
||||
config.setApplication(application);
|
||||
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
|
||||
ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy);
|
||||
|
||||
// Merge global + per-app sensitive keys for the SSE push payload
|
||||
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||
@@ -126,7 +160,8 @@ public class ApplicationConfigController {
|
||||
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
||||
|
||||
auditService.log("update_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("version", saved.getVersion(), "agentsPushed", pushResult.total(),
|
||||
Map.of("environment", environment, "version", saved.getVersion(),
|
||||
"agentsPushed", pushResult.total(),
|
||||
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
@@ -134,30 +169,27 @@ public class ApplicationConfigController {
|
||||
}
|
||||
|
||||
@GetMapping("/{application}/processor-routes")
|
||||
@Operation(summary = "Get processor to route mapping",
|
||||
description = "Returns a map of processorId → routeId for all processors seen in this application")
|
||||
@Operation(summary = "Get processor to route mapping for an environment",
|
||||
description = "Returns a map of processorId → routeId for all processors seen in this application + environment")
|
||||
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
||||
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application) {
|
||||
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application));
|
||||
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application,
|
||||
@RequestParam String environment) {
|
||||
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
|
||||
}
|
||||
|
||||
@PostMapping("/{application}/test-expression")
|
||||
@Operation(summary = "Test a tap expression against sample data via a live agent")
|
||||
@Operation(summary = "Test a tap expression against sample data via a live agent in an environment")
|
||||
@ApiResponse(responseCode = "200", description = "Expression evaluated successfully")
|
||||
@ApiResponse(responseCode = "404", description = "No live agent available for this application")
|
||||
@ApiResponse(responseCode = "404", description = "No live agent available for this application in this environment")
|
||||
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||
public ResponseEntity<TestExpressionResponse> testExpression(
|
||||
@PathVariable String application,
|
||||
@RequestParam(required = false) String environment,
|
||||
@RequestParam String environment,
|
||||
@RequestBody TestExpressionRequest request) {
|
||||
// Find a LIVE agent for this application, optionally filtered by environment
|
||||
var candidates = registryService.findAll().stream()
|
||||
.filter(a -> application.equals(a.applicationId()))
|
||||
.filter(a -> a.state() == AgentState.LIVE);
|
||||
if (environment != null) {
|
||||
candidates = candidates.filter(a -> environment.equals(a.environmentId()));
|
||||
}
|
||||
AgentInfo agent = candidates.findFirst().orElse(null);
|
||||
AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream()
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (agent == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
@@ -201,6 +233,19 @@ public class ApplicationConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the {@code env} claim from the caller's validated JWT (populated by
|
||||
* {@link JwtAuthenticationFilter}). Returns null if no internal JWT was seen
|
||||
* on this request, or the token has no env claim.
|
||||
*/
|
||||
private static String environmentFromJwt(HttpServletRequest request) {
|
||||
Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||
if (attr instanceof JwtValidationResult result) {
|
||||
return result.environment();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time
|
||||
* dependency on getSensitiveKeys() which may not be in the published cameleer-common jar yet.
|
||||
@@ -271,9 +316,10 @@ public class ApplicationConfigController {
|
||||
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
|
||||
}
|
||||
|
||||
private static ApplicationConfig defaultConfig(String application) {
|
||||
private static ApplicationConfig defaultConfig(String application, String environment) {
|
||||
ApplicationConfig config = new ApplicationConfig();
|
||||
config.setApplication(application);
|
||||
config.setEnvironment(environment);
|
||||
config.setVersion(0);
|
||||
config.setMetricsEnabled(true);
|
||||
config.setSamplingRate(1.0);
|
||||
|
||||
@@ -50,11 +50,13 @@ public class DiagramController {
|
||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
||||
String instanceId = extractAgentId();
|
||||
String applicationId = resolveApplicationId(instanceId);
|
||||
AgentInfo agent = registryService.findById(instanceId);
|
||||
String applicationId = agent != null ? agent.applicationId() : "";
|
||||
String environment = agent != null ? agent.environmentId() : "";
|
||||
List<RouteGraph> graphs = parsePayload(body);
|
||||
|
||||
for (RouteGraph graph : graphs) {
|
||||
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph));
|
||||
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, environment, graph));
|
||||
}
|
||||
|
||||
return ResponseEntity.accepted().build();
|
||||
@@ -65,11 +67,6 @@ public class DiagramController {
|
||||
return auth != null ? auth.getName() : "";
|
||||
}
|
||||
|
||||
private String resolveApplicationId(String instanceId) {
|
||||
AgentInfo agent = registryService.findById(instanceId);
|
||||
return agent != null ? agent.applicationId() : "";
|
||||
}
|
||||
|
||||
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
|
||||
String trimmed = body.strip();
|
||||
if (trimmed.startsWith("[")) {
|
||||
|
||||
@@ -119,8 +119,10 @@ public class RouteMetricsController {
|
||||
if (!metrics.isEmpty()) {
|
||||
// Determine SLA threshold (per-app or default)
|
||||
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
||||
int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
int threshold = (effectiveAppId != null && environment != null && !environment.isBlank())
|
||||
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment)
|
||||
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||
: 300;
|
||||
|
||||
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||
effectiveAppId, threshold, environment);
|
||||
|
||||
@@ -102,10 +102,12 @@ public class SearchController {
|
||||
stats = searchService.statsForRoute(from, end, routeId, application, environment);
|
||||
}
|
||||
|
||||
// Enrich with SLA compliance
|
||||
int threshold = appSettingsRepository
|
||||
.findByApplicationId(application != null ? application : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
// Enrich with SLA compliance (per-env threshold when both app and env are specified)
|
||||
int threshold = (application != null && !application.isBlank()
|
||||
&& environment != null && !environment.isBlank())
|
||||
? appSettingsRepository.findByApplicationAndEnvironment(application, environment)
|
||||
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||
: 300;
|
||||
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
|
||||
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||
}
|
||||
|
||||
@@ -120,31 +120,37 @@ public class SensitiveKeysAdminController {
|
||||
* not yet include that field accessor.
|
||||
*/
|
||||
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
|
||||
// Collect all distinct application IDs
|
||||
Set<String> applications = new LinkedHashSet<>();
|
||||
configRepository.findAll().stream()
|
||||
.map(ApplicationConfig::getApplication)
|
||||
.filter(a -> a != null && !a.isBlank())
|
||||
.forEach(applications::add);
|
||||
// Collect every (application, environment) slice we know about: persisted config rows
|
||||
// PLUS currently-registered live agents (which may have no stored config yet).
|
||||
// Global sensitive keys are server-wide, but per-app overrides live per env, so the
|
||||
// push is scoped per (app, env) so each slice gets its own merged keys.
|
||||
Set<AppEnv> slices = new LinkedHashSet<>();
|
||||
for (ApplicationConfig cfg : configRepository.findAll()) {
|
||||
if (cfg.getApplication() != null && !cfg.getApplication().isBlank()
|
||||
&& cfg.getEnvironment() != null && !cfg.getEnvironment().isBlank()) {
|
||||
slices.add(new AppEnv(cfg.getApplication(), cfg.getEnvironment()));
|
||||
}
|
||||
}
|
||||
registryService.findAll().stream()
|
||||
.map(a -> a.applicationId())
|
||||
.filter(a -> a != null && !a.isBlank())
|
||||
.forEach(applications::add);
|
||||
.filter(a -> a.applicationId() != null && !a.applicationId().isBlank()
|
||||
&& a.environmentId() != null && !a.environmentId().isBlank())
|
||||
.forEach(a -> slices.add(new AppEnv(a.applicationId(), a.environmentId())));
|
||||
|
||||
if (applications.isEmpty()) {
|
||||
if (slices.isEmpty()) {
|
||||
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
|
||||
}
|
||||
|
||||
// Shared 10-second deadline across all applications
|
||||
// Shared 10-second deadline across all slices
|
||||
long deadline = System.currentTimeMillis() + 10_000;
|
||||
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
|
||||
List<String> allTimedOut = new ArrayList<>();
|
||||
int totalAgents = 0;
|
||||
|
||||
for (String application : applications) {
|
||||
// Load per-app sensitive keys via JsonNode to avoid dependency on
|
||||
for (AppEnv slice : slices) {
|
||||
// Load per-(app,env) sensitive keys via JsonNode to avoid dependency on
|
||||
// ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet.
|
||||
List<String> perAppKeys = configRepository.findByApplication(application)
|
||||
List<String> perAppKeys = configRepository
|
||||
.findByApplicationAndEnvironment(slice.application(), slice.environment())
|
||||
.map(cfg -> extractSensitiveKeys(cfg))
|
||||
.orElse(null);
|
||||
|
||||
@@ -153,19 +159,22 @@ public class SensitiveKeysAdminController {
|
||||
|
||||
// Build a minimal payload map — only sensitiveKeys + application fields.
|
||||
Map<String, Object> payloadMap = new LinkedHashMap<>();
|
||||
payloadMap.put("application", application);
|
||||
payloadMap.put("application", slice.application());
|
||||
payloadMap.put("sensitiveKeys", mergedKeys);
|
||||
|
||||
String payloadJson;
|
||||
try {
|
||||
payloadJson = objectMapper.writeValueAsString(payloadMap);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Failed to serialize sensitive keys push payload for application '{}'", application, e);
|
||||
log.error("Failed to serialize sensitive keys push payload for {}/{}",
|
||||
slice.application(), slice.environment(), e);
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, CompletableFuture<CommandReply>> futures =
|
||||
registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson);
|
||||
registryService.addGroupCommandWithReplies(
|
||||
slice.application(), slice.environment(),
|
||||
CommandType.CONFIG_UPDATE, payloadJson);
|
||||
|
||||
totalAgents += futures.size();
|
||||
|
||||
@@ -213,4 +222,7 @@ public class SensitiveKeysAdminController {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** (application, environment) slice used by the fan-out loop. */
|
||||
private record AppEnv(String application, String environment) {}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public record AppSettingsRequest(
|
||||
Double healthSlaCrit
|
||||
) {
|
||||
|
||||
public AppSettings toSettings(String appId) {
|
||||
public AppSettings toSettings(String appId, String environment) {
|
||||
Instant now = Instant.now();
|
||||
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||
return new AppSettings(appId, environment, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||
healthSlaWarn, healthSlaCrit, now, now);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
|
||||
private static final String INSERT_SQL = """
|
||||
INSERT INTO route_diagrams
|
||||
(tenant_id, content_hash, route_id, instance_id, application_id, definition, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
(tenant_id, content_hash, route_id, instance_id, application_id, environment, definition, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
private static final String SELECT_BY_HASH = """
|
||||
@@ -59,7 +59,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
|
||||
private static final String SELECT_DEFINITIONS_FOR_APP = """
|
||||
SELECT DISTINCT route_id, definition FROM route_diagrams
|
||||
WHERE tenant_id = ? AND application_id = ?
|
||||
WHERE tenant_id = ? AND application_id = ? AND environment = ?
|
||||
""";
|
||||
|
||||
private final String tenantId;
|
||||
@@ -104,6 +104,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
RouteGraph graph = diagram.graph();
|
||||
String agentId = diagram.instanceId() != null ? diagram.instanceId() : "";
|
||||
String applicationId = diagram.applicationId() != null ? diagram.applicationId() : "";
|
||||
String environment = (diagram.environment() != null && !diagram.environment().isBlank())
|
||||
? diagram.environment() : "default";
|
||||
String json = objectMapper.writeValueAsString(graph);
|
||||
String contentHash = sha256Hex(json);
|
||||
String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
|
||||
@@ -114,6 +116,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
routeId,
|
||||
agentId,
|
||||
applicationId,
|
||||
environment,
|
||||
json,
|
||||
Timestamp.from(Instant.now()));
|
||||
|
||||
@@ -197,10 +200,10 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> findProcessorRouteMapping(String applicationId) {
|
||||
public Map<String, String> findProcessorRouteMapping(String applicationId, String environment) {
|
||||
Map<String, String> mapping = new HashMap<>();
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId);
|
||||
SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId, environment);
|
||||
for (Map<String, Object> row : rows) {
|
||||
String routeId = (String) row.get("route_id");
|
||||
String json = (String) row.get("definition");
|
||||
@@ -211,7 +214,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
|
||||
RouteGraph graph = objectMapper.readValue(json, RouteGraph.class);
|
||||
collectNodeIds(graph.getRoot(), routeId, mapping);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to deserialize RouteGraph for route={} app={}", routeId, applicationId, e);
|
||||
log.warn("Failed to deserialize RouteGraph for route={} app={} env={}",
|
||||
routeId, applicationId, environment, e);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
|
||||
@@ -16,6 +16,7 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
|
||||
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
|
||||
rs.getString("application_id"),
|
||||
rs.getString("environment"),
|
||||
rs.getInt("sla_threshold_ms"),
|
||||
rs.getDouble("health_error_warn"),
|
||||
rs.getDouble("health_error_crit"),
|
||||
@@ -29,24 +30,27 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AppSettings> findByApplicationId(String applicationId) {
|
||||
public Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment) {
|
||||
List<AppSettings> results = jdbc.query(
|
||||
"SELECT * FROM app_settings WHERE application_id = ?", ROW_MAPPER, applicationId);
|
||||
"SELECT * FROM app_settings WHERE application_id = ? AND environment = ?",
|
||||
ROW_MAPPER, applicationId, environment);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppSettings> findAll() {
|
||||
return jdbc.query("SELECT * FROM app_settings ORDER BY application_id", ROW_MAPPER);
|
||||
public List<AppSettings> findByEnvironment(String environment) {
|
||||
return jdbc.query(
|
||||
"SELECT * FROM app_settings WHERE environment = ? ORDER BY application_id",
|
||||
ROW_MAPPER, environment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppSettings save(AppSettings settings) {
|
||||
jdbc.update("""
|
||||
INSERT INTO app_settings (application_id, sla_threshold_ms, health_error_warn,
|
||||
INSERT INTO app_settings (application_id, environment, sla_threshold_ms, health_error_warn,
|
||||
health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, now(), now())
|
||||
ON CONFLICT (application_id) DO UPDATE SET
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
ON CONFLICT (application_id, environment) DO UPDATE SET
|
||||
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
|
||||
health_error_warn = EXCLUDED.health_error_warn,
|
||||
health_error_crit = EXCLUDED.health_error_crit,
|
||||
@@ -54,14 +58,15 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
health_sla_crit = EXCLUDED.health_sla_crit,
|
||||
updated_at = now()
|
||||
""",
|
||||
settings.applicationId(), settings.slaThresholdMs(),
|
||||
settings.applicationId(), settings.environment(), settings.slaThresholdMs(),
|
||||
settings.healthErrorWarn(), settings.healthErrorCrit(),
|
||||
settings.healthSlaWarn(), settings.healthSlaCrit());
|
||||
return findByApplicationId(settings.applicationId()).orElseThrow();
|
||||
return findByApplicationAndEnvironment(settings.applicationId(), settings.environment()).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String appId) {
|
||||
jdbc.update("DELETE FROM app_settings WHERE application_id = ?", appId);
|
||||
public void delete(String appId, String environment) {
|
||||
jdbc.update("DELETE FROM app_settings WHERE application_id = ? AND environment = ?",
|
||||
appId, environment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
@@ -20,39 +21,55 @@ public class PostgresApplicationConfigRepository {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public List<ApplicationConfig> findAll() {
|
||||
return jdbc.query(
|
||||
"SELECT config_val, version, updated_at FROM application_config ORDER BY application",
|
||||
(rs, rowNum) -> {
|
||||
try {
|
||||
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
||||
cfg.setVersion(rs.getInt("version"));
|
||||
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
|
||||
return cfg;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to deserialize application config", e);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Row mapper — columns (application, environment) are authoritative and always
|
||||
* overwrite whatever was in the stored JSON body. Callers must SELECT them.
|
||||
*/
|
||||
private RowMapper<ApplicationConfig> rowMapper() {
|
||||
return (rs, rowNum) -> {
|
||||
try {
|
||||
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
||||
cfg.setApplication(rs.getString("application"));
|
||||
cfg.setEnvironment(rs.getString("environment"));
|
||||
cfg.setVersion(rs.getInt("version"));
|
||||
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
|
||||
return cfg;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to deserialize application config", e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public Optional<ApplicationConfig> findByApplication(String application) {
|
||||
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(
|
||||
"SELECT config_val, version, updated_at FROM application_config WHERE application = ?",
|
||||
(rs, rowNum) -> {
|
||||
try {
|
||||
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
|
||||
cfg.setVersion(rs.getInt("version"));
|
||||
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
|
||||
return cfg;
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to deserialize application config", e);
|
||||
}
|
||||
},
|
||||
application);
|
||||
"SELECT " + SELECT_FIELDS + " FROM application_config "
|
||||
+ "WHERE application = ? AND environment = ?",
|
||||
rowMapper(), application, environment);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public ApplicationConfig save(String application, ApplicationConfig config, String updatedBy) {
|
||||
public ApplicationConfig save(String application, String environment, ApplicationConfig config, String updatedBy) {
|
||||
// Authoritative fields stamped on the DTO before serialization — guarantees the stored
|
||||
// JSON agrees with the row's columns.
|
||||
config.setApplication(application);
|
||||
config.setEnvironment(environment);
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(config);
|
||||
@@ -60,18 +77,22 @@ public class PostgresApplicationConfigRepository {
|
||||
throw new RuntimeException("Failed to serialize application config", e);
|
||||
}
|
||||
|
||||
// Upsert: insert or update, auto-increment version
|
||||
jdbc.update("""
|
||||
INSERT INTO application_config (application, config_val, version, updated_at, updated_by)
|
||||
VALUES (?, ?::jsonb, 1, now(), ?)
|
||||
ON CONFLICT (application) DO UPDATE SET
|
||||
INSERT INTO application_config (application, environment, config_val, version, updated_at, updated_by)
|
||||
VALUES (?, ?, ?::jsonb, 1, now(), ?)
|
||||
ON CONFLICT (application, environment) DO UPDATE SET
|
||||
config_val = EXCLUDED.config_val,
|
||||
version = application_config.version + 1,
|
||||
updated_at = now(),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
""",
|
||||
application, json, updatedBy);
|
||||
application, environment, json, updatedBy);
|
||||
|
||||
return findByApplication(application).orElseThrow();
|
||||
return findByApplicationAndEnvironment(application, environment).orElseThrow();
|
||||
}
|
||||
|
||||
public void delete(String application, String environment) {
|
||||
jdbc.update("DELETE FROM application_config WHERE application = ? AND environment = ?",
|
||||
application, environment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,22 +83,26 @@ CREATE TABLE server_config (
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE application_config (
|
||||
application TEXT PRIMARY KEY,
|
||||
application TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
config_val JSONB NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT
|
||||
updated_by TEXT,
|
||||
PRIMARY KEY (application, environment)
|
||||
);
|
||||
|
||||
CREATE TABLE app_settings (
|
||||
application_id TEXT PRIMARY KEY,
|
||||
application_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
sla_threshold_ms INTEGER NOT NULL DEFAULT 300,
|
||||
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
|
||||
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (application_id, environment)
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
|
||||
@@ -59,7 +59,7 @@ class ClickHouseDiagramStoreIT {
|
||||
}
|
||||
|
||||
private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) {
|
||||
return new TaggedDiagram(instanceId, applicationId, graph);
|
||||
return new TaggedDiagram(instanceId, applicationId, "default", graph);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
@@ -180,7 +180,7 @@ class ClickHouseDiagramStoreIT {
|
||||
|
||||
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
|
||||
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping");
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping", "default");
|
||||
|
||||
assertThat(mapping).containsEntry("proc-from-1", "route-5");
|
||||
assertThat(mapping).containsEntry("proc-to-2", "route-5");
|
||||
@@ -196,7 +196,7 @@ class ClickHouseDiagramStoreIT {
|
||||
|
||||
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
|
||||
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app");
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app", "default");
|
||||
|
||||
assertThat(mapping).containsEntry("proc-a1", "route-a");
|
||||
assertThat(mapping).containsEntry("proc-a2", "route-a");
|
||||
@@ -205,7 +205,7 @@ class ClickHouseDiagramStoreIT {
|
||||
|
||||
@Test
|
||||
void findProcessorRouteMapping_unknownAppReturnsEmpty() {
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app");
|
||||
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app", "default");
|
||||
assertThat(mapping).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.admin.AppSettings;
|
||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies that the two per-app-per-env Postgres tables isolate data correctly between
|
||||
* environments — writing to (app=X, env=dev) must not affect reads for (app=X, env=prod).
|
||||
* Regression test for the pre-1.0 env-scoping gap (plans/2026-04-16-environment-scoping.md).
|
||||
*/
|
||||
class ConfigEnvIsolationIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired PostgresApplicationConfigRepository configRepo;
|
||||
@Autowired AppSettingsRepository settingsRepo;
|
||||
@Autowired ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void applicationConfig_isolatesByEnvironment() {
|
||||
ApplicationConfig dev = new ApplicationConfig();
|
||||
dev.setSamplingRate(0.5);
|
||||
dev.setApplicationLogLevel("DEBUG");
|
||||
configRepo.save("order-svc", "dev", dev, "test");
|
||||
|
||||
ApplicationConfig prod = new ApplicationConfig();
|
||||
prod.setSamplingRate(0.01);
|
||||
prod.setApplicationLogLevel("WARN");
|
||||
configRepo.save("order-svc", "prod", prod, "test");
|
||||
|
||||
ApplicationConfig readDev = configRepo.findByApplicationAndEnvironment("order-svc", "dev")
|
||||
.orElseThrow();
|
||||
assertThat(readDev.getEnvironment()).isEqualTo("dev");
|
||||
assertThat(readDev.getSamplingRate()).isEqualTo(0.5);
|
||||
assertThat(readDev.getApplicationLogLevel()).isEqualTo("DEBUG");
|
||||
|
||||
ApplicationConfig readProd = configRepo.findByApplicationAndEnvironment("order-svc", "prod")
|
||||
.orElseThrow();
|
||||
assertThat(readProd.getEnvironment()).isEqualTo("prod");
|
||||
assertThat(readProd.getSamplingRate()).isEqualTo(0.01);
|
||||
assertThat(readProd.getApplicationLogLevel()).isEqualTo("WARN");
|
||||
|
||||
assertThat(configRepo.findByApplicationAndEnvironment("order-svc", "staging")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationConfig_saveReplacesOnlySameEnv() {
|
||||
ApplicationConfig original = new ApplicationConfig();
|
||||
original.setSamplingRate(0.5);
|
||||
configRepo.save("svc", "dev", original, "alice");
|
||||
|
||||
ApplicationConfig otherEnv = new ApplicationConfig();
|
||||
otherEnv.setSamplingRate(0.1);
|
||||
configRepo.save("svc", "prod", otherEnv, "alice");
|
||||
|
||||
ApplicationConfig updated = new ApplicationConfig();
|
||||
updated.setSamplingRate(0.9);
|
||||
configRepo.save("svc", "dev", updated, "bob");
|
||||
|
||||
assertThat(configRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow()
|
||||
.getSamplingRate()).isEqualTo(0.9);
|
||||
assertThat(configRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow()
|
||||
.getSamplingRate()).isEqualTo(0.1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationConfig_findByEnvironment_excludesOtherEnvs() {
|
||||
ApplicationConfig a = new ApplicationConfig();
|
||||
a.setSamplingRate(1.0);
|
||||
configRepo.save("a", "dev", a, "test");
|
||||
configRepo.save("b", "dev", a, "test");
|
||||
configRepo.save("a", "prod", a, "test");
|
||||
|
||||
assertThat(configRepo.findByEnvironment("dev"))
|
||||
.extracting(ApplicationConfig::getApplication)
|
||||
.containsExactlyInAnyOrder("a", "b");
|
||||
assertThat(configRepo.findByEnvironment("prod"))
|
||||
.extracting(ApplicationConfig::getApplication)
|
||||
.containsExactly("a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appSettings_isolatesByEnvironment() {
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null));
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null));
|
||||
|
||||
AppSettings readDev = settingsRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow();
|
||||
assertThat(readDev.slaThresholdMs()).isEqualTo(500);
|
||||
assertThat(readDev.environment()).isEqualTo("dev");
|
||||
|
||||
AppSettings readProd = settingsRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow();
|
||||
assertThat(readProd.slaThresholdMs()).isEqualTo(100);
|
||||
assertThat(readProd.environment()).isEqualTo("prod");
|
||||
|
||||
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "staging")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void appSettings_delete_scopedToSingleEnv() {
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null));
|
||||
settingsRepo.save(new AppSettings(
|
||||
"svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null));
|
||||
|
||||
settingsRepo.delete("svc", "dev");
|
||||
|
||||
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "dev")).isEmpty();
|
||||
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "prod")).isPresent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user