From 76129d407e96ca98252d163ef9b71ed4f94d35a8 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:07:36 +0200 Subject: [PATCH] api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When apply=staged, saves to DB only — no CONFIG_UPDATE dispatched to agents. When apply=live (default, back-compat), preserves today's immediate-push behavior. Unknown apply values return 400. Audit action is stage_app_config vs update_app_config. Co-Authored-By: Claude Sonnet 4.6 --- .../ApplicationConfigController.java | 31 ++- .../ApplicationConfigControllerIT.java | 177 ++++++++++++++++++ 2 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java index cb176867..9a333fe7 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java @@ -108,13 +108,20 @@ public class ApplicationConfigController { @PutMapping("/apps/{appSlug}/config") @Operation(summary = "Update application config for this 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") + description = "Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. " + + "When apply=staged, persists without a live push — the next successful deploy applies it.") + @ApiResponse(responseCode = "200", description = "Config saved (and pushed if apply=live)") + @ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or 'live')") public ResponseEntity updateConfig(@EnvPath Environment env, @PathVariable String appSlug, + @RequestParam(name = "apply", defaultValue = "live") String apply, @RequestBody ApplicationConfig config, Authentication auth, HttpServletRequest httpRequest) { + if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + String updatedBy = auth != null ? auth.getName() : "system"; config.setApplication(appSlug); @@ -126,14 +133,24 @@ public class ApplicationConfigController { List perAppKeys = extractSensitiveKeys(saved); List mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys); - CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys); - log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded", - saved.getVersion(), appSlug, pushResult.total(), pushResult.responded()); + CommandGroupResponse pushResult; + if ("staged".equalsIgnoreCase(apply)) { + pushResult = new CommandGroupResponse(true, 0, 0, List.of(), List.of()); + log.info("Config v{} staged for '{}' (no live push)", saved.getVersion(), appSlug); + } else { + pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys); + log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded", + saved.getVersion(), appSlug, pushResult.total(), pushResult.responded()); + } - auditService.log("update_app_config", AuditCategory.CONFIG, appSlug, + auditService.log( + "staged".equalsIgnoreCase(apply) ? "stage_app_config" : "update_app_config", + AuditCategory.CONFIG, appSlug, Map.of("environment", env.slug(), "version", saved.getVersion(), + "apply", apply.toLowerCase(), "agentsPushed", pushResult.total(), - "responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()), + "responded", pushResult.responded(), + "timedOut", pushResult.timedOut().size()), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult)); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java new file mode 100644 index 00000000..c5c2d6a1 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java @@ -0,0 +1,177 @@ +package com.cameleer.server.app.controller; + +import com.cameleer.common.model.ApplicationConfig; +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.app.storage.PostgresApplicationConfigRepository; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.agent.CommandType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class ApplicationConfigControllerIT extends AbstractPostgresIT { + + /** + * Spy on the real AgentRegistryService bean so we can verify whether + * addGroupCommandWithReplies was invoked (live) or skipped (staged). + */ + @SpyBean + AgentRegistryService registryService; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private PostgresApplicationConfigRepository configRepository; + + private String operatorJwt; + /** Unique env slug per test to avoid cross-test pollution. */ + private String envSlug; + private UUID envId; + /** Unique app slug per test run to avoid cross-test row collisions. */ + private String appSlug; + + @BeforeEach + void setUp() { + operatorJwt = securityHelper.operatorToken(); + envSlug = "cfg-it-" + UUID.randomUUID().toString().substring(0, 8); + envId = UUID.randomUUID(); + appSlug = "paygw-" + UUID.randomUUID().toString().substring(0, 8); + + jdbcTemplate.update( + "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING", + envId, envSlug, envSlug); + } + + @AfterEach + void cleanUp() { + jdbcTemplate.update("DELETE FROM application_config WHERE environment = ?", envSlug); + jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private void registerLiveAgent(String agentId) { + // Use the bootstrap HTTP endpoint — same pattern as AgentCommandControllerIT. + String body = """ + { + "instanceId": "%s", + "applicationId": "%s", + "environmentId": "%s", + "version": "1.0.0", + "routeIds": ["route-1"], + "capabilities": {} + } + """.formatted(agentId, appSlug, envSlug); + restTemplate.postForEntity( + "/api/v1/agents/register", + new HttpEntity<>(body, securityHelper.bootstrapHeaders()), + String.class); + } + + private ResponseEntity putConfig(String apply) { + String url = "/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/config" + + (apply != null ? "?apply=" + apply : ""); + String body = """ + {"samplingRate": 0.1, "metricsEnabled": true} + """; + return restTemplate.exchange(url, HttpMethod.PUT, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), String.class); + } + + // ── tests ───────────────────────────────────────────────────────────────── + + @Test + void putConfig_staged_savesButDoesNotPush() { + // Given — one LIVE agent registered for (appSlug, envSlug) + String agentId = "staged-agent-" + UUID.randomUUID().toString().substring(0, 8); + registerLiveAgent(agentId); + + // When — PUT with apply=staged + ResponseEntity response = putConfig("staged"); + + // Then — HTTP 200 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // And — DB has the new config + ApplicationConfig saved = configRepository + .findByApplicationAndEnvironment(appSlug, envSlug) + .orElseThrow(() -> new AssertionError("Config not found in DB")); + assertThat(saved.getSamplingRate()).isEqualTo(0.1); + + // And — NO CONFIG_UPDATE was pushed to any agent + verify(registryService, never()) + .addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any()); + } + + @Test + void putConfig_live_savesAndPushes() { + // Given — one LIVE agent registered for (appSlug, envSlug) + String agentId = "live-agent-" + UUID.randomUUID().toString().substring(0, 8); + registerLiveAgent(agentId); + + // When — PUT without apply param (default is live) + ResponseEntity response = putConfig(null); + + // Then — HTTP 200 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // And — DB has the new config + ApplicationConfig saved = configRepository + .findByApplicationAndEnvironment(appSlug, envSlug) + .orElseThrow(() -> new AssertionError("Config not found in DB")); + assertThat(saved.getSamplingRate()).isEqualTo(0.1); + + // And — CONFIG_UPDATE was pushed (addGroupCommandWithReplies called once) + verify(registryService) + .addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any()); + } + + @Test + void putConfig_liveExplicit_savesAndPushes() { + // Same as above but with explicit apply=live + String agentId = "live-explicit-" + UUID.randomUUID().toString().substring(0, 8); + registerLiveAgent(agentId); + + ResponseEntity response = putConfig("live"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(registryService) + .addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any()); + } + + @Test + void putConfig_unknownApplyValue_returns400() { + ResponseEntity response = putConfig("BOGUS"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void putConfig_staged_auditActionIsStagedAppConfig() throws Exception { + registerLiveAgent("audit-agent-" + UUID.randomUUID().toString().substring(0, 8)); + + ResponseEntity response = putConfig("staged"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + // The response carries the saved config; no pushed agents + assertThat(body.path("pushResult").path("total").asInt()).isEqualTo(0); + } +}