api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config

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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 22:07:36 +02:00
parent 9b1240274d
commit 76129d407e
2 changed files with 201 additions and 7 deletions

View File

@@ -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<ConfigUpdateResponse> 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<String> perAppKeys = extractSensitiveKeys(saved);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
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));

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
}
}