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