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:
@@ -108,13 +108,20 @@ public class ApplicationConfigController {
|
|||||||
|
|
||||||
@PutMapping("/apps/{appSlug}/config")
|
@PutMapping("/apps/{appSlug}/config")
|
||||||
@Operation(summary = "Update application config for this environment",
|
@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")
|
description = "Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. "
|
||||||
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
+ "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,
|
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
|
||||||
@PathVariable String appSlug,
|
@PathVariable String appSlug,
|
||||||
|
@RequestParam(name = "apply", defaultValue = "live") String apply,
|
||||||
@RequestBody ApplicationConfig config,
|
@RequestBody ApplicationConfig config,
|
||||||
Authentication auth,
|
Authentication auth,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
|
if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
|
||||||
|
}
|
||||||
|
|
||||||
String updatedBy = auth != null ? auth.getName() : "system";
|
String updatedBy = auth != null ? auth.getName() : "system";
|
||||||
|
|
||||||
config.setApplication(appSlug);
|
config.setApplication(appSlug);
|
||||||
@@ -126,14 +133,24 @@ public class ApplicationConfigController {
|
|||||||
List<String> perAppKeys = extractSensitiveKeys(saved);
|
List<String> perAppKeys = extractSensitiveKeys(saved);
|
||||||
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
||||||
|
|
||||||
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
|
CommandGroupResponse pushResult;
|
||||||
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
|
if ("staged".equalsIgnoreCase(apply)) {
|
||||||
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
|
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(),
|
Map.of("environment", env.slug(), "version", saved.getVersion(),
|
||||||
|
"apply", apply.toLowerCase(),
|
||||||
"agentsPushed", pushResult.total(),
|
"agentsPushed", pushResult.total(),
|
||||||
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
"responded", pushResult.responded(),
|
||||||
|
"timedOut", pushResult.timedOut().size()),
|
||||||
AuditResult.SUCCESS, httpRequest);
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
|
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user