feat(cloudwatch): support composite alarm lifecycle (#6408)

(cherry picked from commit 922e36a472341861e97d1b5639656f3c4a3978d8)
This commit is contained in:
Andre Kurait
2026-10-24 16:54:21 -05:00
committed by Zakaria
parent 83aafc0325
commit fa3327bf71
21 changed files with 1721 additions and 194 deletions
@@ -45,6 +45,7 @@ import io.github.hectorvent.floci.services.cloudformation.provisioners.AppSyncCf
import io.github.hectorvent.floci.services.cloudformation.provisioners.CdkMetadataCfnProvisioner;
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudTrailCfnProvisioner;
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchCfnProvisioner;
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchCompositeAlarmCfnProvisioner;
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchDashboardCfnProvisioner;
import io.github.hectorvent.floci.services.cloudformation.provisioners.CognitoCfnProvisioner;
import io.github.hectorvent.floci.services.cloudformation.provisioners.CognitoUserPoolGroupCfnProvisioner;
@@ -300,6 +301,7 @@ final class CfnProvisionerFixture {
}
if (cloudWatchMetricsService != null) {
discovered.add(new CloudWatchCfnProvisioner(cloudWatchMetricsService));
discovered.add(new CloudWatchCompositeAlarmCfnProvisioner(cloudWatchMetricsService));
}
if (cloudWatchDashboardsService != null) {
discovered.add(new CloudWatchDashboardCfnProvisioner(cloudWatchDashboardsService));
@@ -0,0 +1,190 @@
package io.github.hectorvent.floci.services.cloudformation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.testing.CfnStackWaits;
import io.github.hectorvent.floci.testing.PartitionCleanup;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.Response;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class CloudFormationCompositeAlarmIntegrationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@TestHTTPResource
URI baseUri;
@RegisterExtension
final PartitionCleanup cleanup = new PartitionCleanup();
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void failedUpdateRestoresRuleTagsAndIdentity(boolean rename) throws Exception {
String stack = unique("composite-rollback");
String name = stack + "-alarm";
String next = rename ? name + "-new" : name;
deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "old", "drop", "restore"), false, false));
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
String arn = alarm(name).get("AlarmArn").toString();
Response described = given().contentType("application/x-www-form-urlencoded")
.formParam("Action", "DescribeStacks").formParam("StackName", stack).post("/");
assertEquals(name, described.xmlPath().getString(
"DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Name' }.OutputValue"));
assertEquals(arn, described.xmlPath().getString(
"DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Arn' }.OutputValue"));
cw("TagResource", Map.of("ResourceARN", arn, "Tags", List.of(Map.of("Key", "external", "Value", "keep"))))
.then().statusCode(200);
deploy("UpdateStack", stack, template(next, "FALSE", Map.of("team", "new", "added", "remove"), true, false));
assertEquals("UPDATE_ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
assertEquals("TRUE", alarm(name).get("AlarmRule"));
List<Map<String, String>> tags = cw("ListTagsForResource", Map.of("ResourceARN", arn))
.then().statusCode(200).extract().jsonPath().getList("Tags");
assertEquals(Map.of("team", "old", "drop", "restore", "external", "keep"),
tags.stream().collect(Collectors.toMap(t -> t.get("Key"), t -> t.get("Value"))));
if (rename) {
assertMissing(next);
}
deleteStack(stack);
assertMissing(name);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void replacementHonorsRetentionAndDeletesItsCurrentBackingAlarm(boolean retain) throws Exception {
String stack = unique("composite-replace");
String original = stack + "-old";
String next = stack + "-new";
deploy("CreateStack", stack, template(original, "TRUE", Map.of(), false, retain));
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
deploy("UpdateStack", stack, template(next, "FALSE", Map.of(), false, retain));
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
assertEquals("FALSE", alarm(next).get("AlarmRule"));
if (retain) {
assertEquals("TRUE", alarm(original).get("AlarmRule"));
} else {
assertMissing(original);
}
deleteStack(stack);
assertMissing(next);
if (retain) {
assertEquals("TRUE", alarm(original).get("AlarmRule"));
cw("DeleteAlarms", Map.of("AlarmNames", List.of(original))).then().statusCode(200);
}
}
@Test
void committedInPlaceUpdatesAllowAnotherUpdateAndReplacement() throws Exception {
String stack = unique("composite-sequence");
String name = stack + "-alarm";
String replacement = name + "-new";
deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "initial"), false, false));
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
for (String rule : List.of("FALSE", "TRUE")) {
deploy("UpdateStack", stack, template(name, rule, Map.of("team", rule), false, false));
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
assertEquals(rule, alarm(name).get("AlarmRule"));
}
deploy("UpdateStack", stack, template(replacement, "FALSE", Map.of(), false, false));
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
assertMissing(name);
assertEquals("FALSE", alarm(replacement).get("AlarmRule"));
deleteStack(stack);
assertMissing(replacement);
}
@Test
void createCollisionCannotOverwriteOrDeleteAnUnmanagedAlarm() throws Exception {
String stack = unique("composite-foreign");
String name = stack + "-alarm";
cw("PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE")).then().statusCode(200);
deploy("CreateStack", stack, template(name, "FALSE", Map.of(), false, false));
assertEquals("ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
assertEquals("TRUE", alarm(name).get("AlarmRule"));
deleteStack(stack);
assertEquals("TRUE", alarm(name).get("AlarmRule"));
cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200);
}
private String template(String name, String rule, Map<String, String> tags, boolean fail, boolean retain)
throws JsonProcessingException {
cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200));
Map<String, Object> resources = new LinkedHashMap<>();
resources.put("Alarm", Map.of("Type", "AWS::CloudWatch::CompositeAlarm",
"UpdateReplacePolicy", retain ? "Retain" : "Delete",
"Properties", Map.of("AlarmName", name, "AlarmRule", rule,
"Tags", tags.entrySet().stream().map(e -> Map.of("Key", e.getKey(), "Value", e.getValue())).toList())));
if (fail) {
resources.put("Failure", Map.of("Type", "AWS::SecretsManager::Secret", "DependsOn", "Alarm",
"Properties", Map.of("SecretString", "explicit", "GenerateSecretString", Map.of("PasswordLength", 32))));
}
return MAPPER.writeValueAsString(Map.of("Resources", resources, "Outputs", Map.of(
"Name", Map.of("Value", Map.of("Ref", "Alarm")),
"Arn", Map.of("Value", Map.of("Fn::GetAtt", List.of("Alarm", "Arn"))))));
}
private void deploy(String action, String stack, String template) {
if ("CreateStack".equals(action)) {
cleanup.register(() -> given().port(baseUri.getPort())
.contentType("application/x-www-form-urlencoded")
.formParam("Action", "DeleteStack").formParam("StackName", stack)
.post("/").then().statusCode(200));
}
given().contentType("application/x-www-form-urlencoded").formParam("Action", action)
.formParam("StackName", stack).formParam("TemplateBody", template).post("/").then().statusCode(200);
}
private Response cw(String operation, Map<String, ?> body) {
if ("PutCompositeAlarm".equals(operation)) {
String name = body.get("AlarmName").toString();
cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200));
}
return given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0")
.header("X-Amz-Target", "GraniteServiceVersion20100801." + operation)
.body(MAPPER.valueToTree(body).toString()).post("/");
}
private Map<String, Object> alarm(String name) throws JsonProcessingException {
List<Map<String, Object>> alarms = cw("DescribeAlarms", Map.of("AlarmNames", List.of(name),
"AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200)
.extract().jsonPath().getList("CompositeAlarms");
assertEquals(1, alarms.size(), name);
return alarms.getFirst();
}
private void assertMissing(String name) throws JsonProcessingException {
assertEquals(List.of(), cw("DescribeAlarms", Map.of("AlarmNames", List.of(name),
"AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200)
.extract().jsonPath().getList("CompositeAlarms"));
}
private void deleteStack(String stack) {
given().contentType("application/x-www-form-urlencoded").formParam("Action", "DeleteStack")
.formParam("StackName", stack).post("/").then().statusCode(200);
CfnStackWaits.awaitStackDeleted(stack);
}
private static String unique(String prefix) {
return prefix + "-" + Long.toString(System.nanoTime(), 36);
}
}
@@ -0,0 +1,89 @@
package io.github.hectorvent.floci.services.cloudformation.provisioners;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.services.cloudformation.CloudFormationTemplateEngine;
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CloudWatchCompositeAlarmCfnProvisionerTest {
@Test
void rejectedBackingUpdateDoesNotArmRollbackToDeleteAnUnmanagedReplacement() throws Exception {
CloudWatchMetricsService service = mock(CloudWatchMetricsService.class);
CloudWatchCompositeAlarmCfnProvisioner provisioner = new CloudWatchCompositeAlarmCfnProvisioner(service);
CloudFormationTemplateEngine engine = mock(CloudFormationTemplateEngine.class);
when(engine.resolve(any())).thenAnswer(call -> {
JsonNode node = call.getArgument(0);
return node == null || node.isMissingNode() ? null : node.asText();
});
when(engine.resolveNode(any())).thenAnswer(call -> call.getArgument(0));
StackResource resource = new StackResource();
resource.setLogicalId("Alarm");
resource.setResourceType("AWS::CloudWatch::CompositeAlarm");
resource.setPhysicalId("kept");
resource.getAttributes().put("Arn", "prior-arn");
AwsException collision = new AwsException("ValidationError", "A metric alarm now has this name", 400);
doThrow(collision).when(service).putCompositeAlarm(any(), eq("us-east-1"), eq(true));
ProvisionContext context = new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "kept");
JsonNode props = new ObjectMapper().readTree("{\"AlarmName\":\"kept\",\"AlarmRule\":\"TRUE\"}");
assertSame(collision, assertThrows(AwsException.class, () -> provisioner.provision(resource, props, context)));
assertFalse(provisioner.retainsFailedUpdateState(resource));
assertFalse(provisioner.rollbackUpdate(resource));
assertEquals("kept", resource.getPhysicalId());
assertEquals(Map.of("Arn", "prior-arn"), resource.getAttributes());
verify(service, never()).deleteAlarms(any(), anyString());
}
@Test
void inPlaceUpdateRetriesPreviouslyDisplacedAlarmCleanup() throws Exception {
CloudWatchMetricsService service = mock(CloudWatchMetricsService.class);
CloudWatchCompositeAlarmCfnProvisioner provisioner = new CloudWatchCompositeAlarmCfnProvisioner(service);
CloudFormationTemplateEngine engine = mock(CloudFormationTemplateEngine.class);
when(engine.resolve(any())).thenAnswer(call -> {
JsonNode node = call.getArgument(0);
return node == null || node.isMissingNode() ? null : node.asText();
});
when(engine.resolveNode(any())).thenAnswer(call -> call.getArgument(0));
StackResource resource = new StackResource();
resource.setLogicalId("Alarm");
resource.setResourceType("AWS::CloudWatch::CompositeAlarm");
resource.setPhysicalId("current");
ReplacementCleanup.record(resource,
new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "displaced"), Map.of());
doThrow(new IllegalStateException("delete unavailable"))
.doThrow(new IllegalStateException("delete still unavailable")).doNothing()
.when(service).deleteAlarms(List.of("displaced"), "us-east-1");
assertFalse(provisioner.completeUpdate(resource).complete());
provisioner.clearUpdate(resource);
assertEquals(Set.of("displaced"), ReplacementCleanup.owedPhysicalIds(resource));
ProvisionContext context = new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "current");
JsonNode props = new ObjectMapper().readTree("{\"AlarmName\":\"current\",\"AlarmRule\":\"TRUE\"}");
provisioner.provision(resource, props, context);
assertTrue(provisioner.retainsFailedUpdateState(resource));
UpdateCleanupResult retry = provisioner.completeUpdate(resource);
assertFalse(retry.complete());
assertEquals(2, retry.attempts());
assertEquals("displaced", retry.previousPhysicalId());
assertEquals("delete still unavailable", retry.failureReason());
assertEquals(Set.of("displaced"), ReplacementCleanup.owedPhysicalIds(resource));
assertTrue(provisioner.completeUpdate(resource).complete());
provisioner.clearUpdate(resource);
assertFalse(provisioner.retainsFailedUpdateState(resource));
assertTrue(ReplacementCleanup.owedPhysicalIds(resource).isEmpty());
assertEquals("current", resource.getPhysicalId());
verify(service, times(3)).deleteAlarms(List.of("displaced"), "us-east-1");
verify(service, never()).deleteAlarms(List.of("current"), "us-east-1");
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.sns.SnsService;
import org.junit.jupiter.api.BeforeEach;
@@ -12,6 +13,11 @@ import org.mockito.ArgumentCaptor;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -124,4 +130,41 @@ class AlarmSnsNotifierTest {
service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION);
verify(sns).publish(eq(topic), isNull(), contains("arn:aws-cn:cloudwatch:"), anyString(), eq("cn-north-1"));
}
@Test
void blockedNotificationDoesNotHoldTheAlarmMutationLock() throws Exception {
CountDownLatch publishing = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
doAnswer(call -> {
if (call.<String>getArgument(3).startsWith("ALARM:")) {
publishing.countDown();
assertTrue(release.await(5, TimeUnit.SECONDS));
}
return "message-id";
}).when(sns).publish(eq(TOPIC), isNull(), anyString(), anyString(), eq(REGION));
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<?> transition = executor.submit(() ->
service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION));
try {
assertTrue(publishing.await(2, TimeUnit.SECONDS));
Future<?> unrelated = executor.submit(() -> {
CompositeAlarm composite = new CompositeAlarm();
composite.setAlarmName("unrelated");
composite.setAlarmRule("FALSE");
service.putCompositeAlarm(composite, REGION);
service.setAlarmState("test-alarm", "OK", "recovered", null, REGION);
});
unrelated.get(2, TimeUnit.SECONDS);
assertEquals("OK", alarm.getStateValue());
} finally {
release.countDown();
}
transition.get(2, TimeUnit.SECONDS);
}
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
verify(sns, times(2)).publish(eq(TOPIC), isNull(), messages.capture(), anyString(), eq(REGION));
assertEquals("ALARM", mapper.readTree(messages.getAllValues().getFirst()).path("NewStateValue").asText());
assertEquals("OK", mapper.readTree(messages.getAllValues().getLast()).path("NewStateValue").asText());
}
}
@@ -0,0 +1,45 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class CloudWatchAlarmQueryHandlerTest {
@ParameterizedTest
@ValueSource(strings = {"PutMetricAlarm", "PutCompositeAlarm"})
void sharedFieldsReachBothAlarmTypes(String operation) {
String region = "us-east-1";
CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(),
new InMemoryStorage<>(), new RegionResolver(region, "123456789012"));
CloudWatchMetricsQueryHandler handler = new CloudWatchMetricsQueryHandler(service, null, null);
MultivaluedMap<String, String> request = new MultivaluedHashMap<>();
request.putSingle("AlarmName", "shared");
request.putSingle("AlarmRule", "FALSE");
request.putSingle("AlarmDescription", "shared fields");
request.putSingle("ActionsEnabled", "false");
request.putSingle("AlarmActions.member.1", "alarm-action");
request.putSingle("OKActions.member.1", "ok-action");
request.putSingle("InsufficientDataActions.member.1", "missing-action");
request.putSingle("Tags.member.1.Key", "team");
request.putSingle("Tags.member.1.Value", "metrics");
assertEquals(200, handler.handle(operation, request, region).getStatus());
Alarm alarm = "PutMetricAlarm".equals(operation)
? service.describeAlarms(List.of("shared"), null, region).getFirst()
: service.describeCompositeAlarms(List.of("shared"), null, region).getFirst();
assertEquals("shared fields", alarm.getAlarmDescription());
assertFalse(alarm.isActionsEnabled());
assertEquals(List.of("alarm-action"), alarm.getAlarmActions());
assertEquals(List.of("ok-action"), alarm.getOkActions());
assertEquals(List.of("missing-action"), alarm.getInsufficientDataActions());
assertEquals(Map.of("team", "metrics"), alarm.getTags());
}
}
@@ -10,7 +10,7 @@ import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.core.storage.PersistentStorage;
import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import org.junit.jupiter.api.BeforeEach;
@@ -43,7 +43,7 @@ class CloudWatchMetricMathAlarmTest {
initialize(new InMemoryStorage<>());
}
private void initialize(StorageBackend<String, MetricAlarm> alarmStore) {
private void initialize(StorageBackend<String, Alarm> alarmStore) {
RegionResolver resolver = new RegionResolver(REGION, "000000000000");
CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(), alarmStore, resolver);
CloudWatchDashboardsService dashboards = new CloudWatchDashboardsService(new InMemoryStorage<>(), resolver);
@@ -64,12 +64,12 @@ class CloudWatchMetricMathAlarmTest {
void metricMathQueriesSurvivePersistentStorageReload(boolean queryProtocol, @TempDir Path directory)
throws Exception {
Path storageFile = directory.resolve("alarms.json");
TypeReference<Map<String, MetricAlarm>> type = new TypeReference<>() { };
TypeReference<Map<String, Alarm>> type = new TypeReference<>() { };
initialize(new PersistentStorage<>(storageFile, type));
ObjectNode request = metricMathRequest();
put(request, queryProtocol);
PersistentStorage<String, MetricAlarm> reloaded = new PersistentStorage<>(storageFile, type);
PersistentStorage<String, Alarm> reloaded = new PersistentStorage<>(storageFile, type);
reloaded.load();
assertEquals(1, reloaded.keys().size(), "the stored alarm must deserialize after a restart");
initialize(reloaded);
@@ -0,0 +1,160 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.testing.PartitionCleanup;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.Response;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class CompositeAlarmIntegrationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String SCOPE =
"AWS4-HMAC-SHA256 Credential=test/20261010/cn-north-1/monitoring/aws4_request";
@TestHTTPResource
URI baseUri;
@RegisterExtension
final PartitionCleanup cleanup = new PartitionCleanup();
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void bothProtocolsCreateDescribeEvaluateAndDeleteRealAlarms(boolean json) throws Exception {
String prefix = "wire-composite-" + Long.toString(System.nanoTime(), 36);
String source = prefix + "-source";
String aggregate = prefix + "-aggregate";
call(json, "PutCompositeAlarm", Map.of("AlarmName", source, "AlarmRule", "TRUE")).then().statusCode(200);
call(json, "PutCompositeAlarm", Map.of("AlarmName", aggregate, "AlarmRule", "ALARM(\"" + source + "\")",
"ActionsEnabled", false, "AlarmDescription", "combined")).then().statusCode(200);
Response result = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate),
"AlarmTypes", List.of("CompositeAlarm")));
result.then().statusCode(200);
if (json) {
assertEquals(List.of(), result.jsonPath().getList("LogAlarms"));
assertEquals(1, result.jsonPath().getList("CompositeAlarms").size());
assertEquals("ALARM", result.jsonPath().getString("CompositeAlarms[0].StateValue"));
assertEquals("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate,
result.jsonPath().getString("CompositeAlarms[0].AlarmArn"));
assertFalse(result.jsonPath().getBoolean("CompositeAlarms[0].ActionsEnabled"));
} else {
assertTrue(result.asString().contains("<LogAlarms>"));
assertEquals("ALARM", result.xmlPath().getString(
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue"));
assertTrue(result.asString().contains("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate));
assertEquals("false", result.xmlPath().getString(
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.ActionsEnabled"));
}
assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate)))
.then().statusCode(200).extract().asString().contains(aggregate));
call(json, "SetAlarmState", Map.of("AlarmName", source, "StateValue", "OK",
"StateReason", "recovery")).then().statusCode(200);
Response recovered = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate),
"AlarmTypes", List.of("CompositeAlarm")));
assertEquals("OK", json ? recovered.jsonPath().getString("CompositeAlarms[0].StateValue")
: recovered.xmlPath().getString(
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue"));
for (String name : List.of(aggregate, source)) {
call(json, "DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200);
}
Response empty = call(json, "DescribeAlarms", Map.of("AlarmNamePrefix", prefix,
"AlarmTypes", List.of("CompositeAlarm")));
assertFalse(empty.asString().contains(aggregate));
assertFalse(empty.asString().contains(source));
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void invalidRuleAndUnsupportedSuppressionCannotCreateAnAlarm(boolean json) throws Exception {
String name = "invalid-composite-" + Long.toString(System.nanoTime(), 36);
call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE AND"))
.then().statusCode(400);
call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE",
"ActionsSuppressor", "other")).then().statusCode(400);
assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(name),
"AlarmTypes", List.of("CompositeAlarm"))).asString().contains(name));
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void alarmTypeValidationAcceptsLogAlarmsAndUsesSmithyErrors(boolean json) throws Exception {
call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("LogAlarm"))).then().statusCode(200);
Response error = call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("Unknown")));
error.then().statusCode(400);
assertEquals("1 validation error detected: Value '[Unknown]' at 'alarmTypes' failed to satisfy constraint: "
+ "Member must satisfy constraint: [Member must satisfy enum value set: "
+ "[CompositeAlarm, MetricAlarm, LogAlarm]]", errorMessage(json, error));
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void lengthErrorsUseSmithyMessages(boolean json) throws Exception {
for (String field : List.of("AlarmName", "AlarmRule")) {
int maximum = "AlarmName".equals(field) ? 255 : 10240;
String path = "AlarmName".equals(field) ? "alarmName" : "alarmRule";
for (String value : List.of("", "x".repeat(maximum + 1))) {
Map<String, String> request = new LinkedHashMap<>(Map.of("AlarmName", "length-validation", "AlarmRule", "TRUE"));
request.put(field, value);
Response error = call(json, "PutCompositeAlarm", request);
error.then().statusCode(400);
assertEquals("1 validation error detected: Value '" + value + "' at '" + path
+ "' failed to satisfy constraint: Member must have length "
+ (value.isEmpty() ? "greater than or equal to 1" : "less than or equal to " + maximum),
errorMessage(json, error));
}
}
}
private String errorMessage(boolean json, Response response) {
return json ? response.jsonPath().getString("message") : response.xmlPath().getString("ErrorResponse.Error.Message");
}
private Response call(boolean json, String operation, Map<String, ?> body)
throws JsonProcessingException {
if ("PutCompositeAlarm".equals(operation)) {
String name = body.get("AlarmName").toString();
cleanup.register(() -> given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0")
.header("Authorization", SCOPE)
.header("X-Amz-Target", "GraniteServiceVersion20100801.DeleteAlarms")
.body(MAPPER.valueToTree(Map.of("AlarmNames", List.of(name))).toString())
.post("/").then().statusCode(200));
}
if (json) {
return given().contentType("application/x-amz-json-1.0").header("Authorization", SCOPE)
.header("X-Amz-Target", "GraniteServiceVersion20100801." + operation)
.body(MAPPER.writeValueAsString(body)).post("/");
}
Map<String, Object> form = new LinkedHashMap<>();
form.put("Action", operation);
body.forEach((key, value) -> {
if (value instanceof List<?> values) {
for (int index = 0; index < values.size(); index++) {
form.put(key + ".member." + (index + 1), values.get(index));
}
} else {
form.put(key, value);
}
});
return given().contentType("application/x-www-form-urlencoded").header("Authorization", SCOPE)
.formParams(form).post("/");
}
}
@@ -0,0 +1,274 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.testing.ThreadStates;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class CompositeAlarmServiceTest {
private static final String REGION = "cn-north-1";
private final CloudWatchMetricsService service = new CloudWatchMetricsService(
new InMemoryStorage<>(), new InMemoryStorage<>(), new RegionResolver(REGION, "123456789012"));
@Test
void metricTransitionsPropagateThroughNamesArnsAndNestedComposites() {
MetricAlarm metric = new MetricAlarm();
metric.setAlarmName("latency");
service.putMetricAlarm(metric, REGION);
put("combined", "ALARM(\"" + metric.getAlarmArn() + "\") AND NOT ALARM(\"maintenance\")");
put("outer", "ALARM(combined) OR (TRUE AND FALSE)");
assertEquals("OK", state("outer"));
service.setAlarmState("latency", "ALARM", "high latency", null, REGION);
assertEquals("ALARM", state("combined"));
assertEquals("ALARM", state("outer"));
service.setAlarmState("latency", "OK", "recovered", null, REGION);
assertEquals("OK", state("outer"));
assertEquals(List.of(metric), service.describeAlarms(null, null, REGION));
assertTrue(service.describeCompositeAlarms(null, null, "us-east-1").isEmpty());
assertTrue(service.describeCompositeAlarms(List.of("combined"), null, REGION)
.getFirst().getAlarmArn().startsWith("arn:aws-cn:cloudwatch:"));
}
@Test
void updatesPreserveStateAndTagsUntilTheNextDependencyChange() {
put("source", "FALSE");
CompositeAlarm alarm = put("aggregate", "ALARM(source)");
service.tagResource(alarm.getAlarmArn(), Map.of("owner", "retained"), REGION);
service.setAlarmState("aggregate", "ALARM", "manual", null, REGION);
CompositeAlarm replacement = put("aggregate", "OK(source)");
assertEquals("ALARM", replacement.getStateValue());
assertEquals(Map.of("owner", "retained"), replacement.getTags());
service.setAlarmState("source", "ALARM", "dependency changed", null, REGION);
assertEquals("OK", state("aggregate"));
}
@Test
void unrelatedWritesPreserveManualCompositeStatesAndParentStates() {
put("source", "FALSE");
put("aggregate", "ALARM(source)");
put("parent", "ALARM(aggregate)");
service.setAlarmState("aggregate", "ALARM", "manual", null, REGION);
assertEquals("ALARM", state("parent"));
put("unrelated", "TRUE");
assertEquals("ALARM", state("aggregate"));
assertEquals("ALARM", state("parent"));
MetricAlarm metric = new MetricAlarm();
metric.setAlarmName("unrelated-metric");
service.putMetricAlarm(metric, REGION);
assertEquals("ALARM", state("aggregate"));
service.setAlarmState("unrelated-metric", "ALARM", "unrelated", null, REGION);
assertEquals("ALARM", state("aggregate"));
service.deleteAlarms(List.of("unrelated"), REGION);
assertEquals("ALARM", state("aggregate"));
assertEquals("ALARM", state("parent"));
service.setAlarmState("source", "ALARM", "child changed", null, REGION);
service.setAlarmState("source", "OK", "child recovered", null, REGION);
assertEquals("OK", state("aggregate"));
assertEquals("OK", state("parent"));
}
@Test
void unchangedChildStateDoesNotResetManuallySetParent() {
put("source", "FALSE");
put("child", "ALARM(source) AND FALSE");
put("parent", "ALARM(child)");
service.setAlarmState("parent", "ALARM", "manual", null, REGION);
service.setAlarmState("source", "ALARM", "child condition remains false", null, REGION);
assertEquals("OK", state("child"));
assertEquals("ALARM", state("parent"));
}
@Test
void creationAndDeletionReevaluateOnlyReferencingComposites() {
MetricAlarm metric = new MetricAlarm();
metric.setAlarmName("late");
put("watcher", "INSUFFICIENT_DATA(late)");
put("outer", "ALARM(watcher)");
assertEquals("OK", state("watcher"));
service.putMetricAlarm(metric, REGION);
assertEquals("ALARM", state("watcher"));
assertEquals("ALARM", state("outer"));
service.deleteAlarms(List.of("late"), REGION);
assertEquals("OK", state("watcher"));
assertEquals("OK", state("outer"));
}
@Test
void tagReadIsAnIndependentSnapshotOfTheOwnedAlarm() {
CompositeAlarm alarm = put("tag-snapshot", "TRUE");
service.tagResource(alarm.getAlarmArn(), Map.of("team", "old"), REGION);
Map<String, String> read = service.listTagsForResource(alarm.getAlarmArn(), REGION);
service.tagResource(alarm.getAlarmArn(), Map.of("team", "new"), REGION);
assertEquals(Map.of("team", "old"), read);
read.put("injected", "unowned");
assertEquals(Map.of("team", "new"), service.listTagsForResource(alarm.getAlarmArn(), REGION));
}
@Test
void cyclesStopEvaluationEvenBehindShortCircuitAndMustBeBrokenBeforeDelete() {
put("a", "FALSE AND ALARM(b)");
put("b", "ALARM(a)");
service.setAlarmState("b", "ALARM", "manual", null, REGION);
assertThrows(AwsException.class, () -> service.deleteAlarms(List.of("a"), REGION));
assertEquals(2, service.describeCompositeAlarms(null, null, REGION).size());
put("a", "FALSE");
service.deleteAlarms(List.of("b"), REGION);
assertEquals("OK", state("a"));
service.deleteAlarms(List.of("a"), REGION);
assertTrue(service.describeCompositeAlarms(null, null, REGION).isEmpty());
}
@Test
void manuallyChangingOneMemberDoesNotResumeACyclicDependency() {
put("a", "ALARM(b)");
put("b", "ALARM(a)");
assertEquals("OK", state("a"));
service.setAlarmState("b", "ALARM", "manual", null, REGION);
assertEquals("OK", state("a"));
put("b", "TRUE");
service.setAlarmState("b", "ALARM", "cycle broken", null, REGION);
assertEquals("ALARM", state("a"));
}
@Test
void parentQuotaTreatsAChildNameAndItsArnAsTheSameAlarm() {
CompositeAlarm child = put("child", "FALSE");
for (int index = 0; index < 150; index++) {
put("parent-" + index, "ALARM(child)");
}
assertThrows(AwsException.class, () -> put("overflow", "ALARM(\"" + child.getAlarmArn() + "\")"));
service.deleteAlarms(List.of("parent-0"), REGION);
assertEquals("OK", put("replacement", "ALARM(\"" + child.getAlarmArn() + "\")").getStateValue());
}
@ParameterizedTest
@ValueSource(strings = {"", "ALARM()", "TRUE junk", "TRUE AND", "ALARM(\"unterminated)",
"(TRUE", "ALARM(x) XOR FALSE", "ALARM(\"x\\z\")"})
void invalidRulesDoNotReplaceAnExistingAlarm(String invalid) {
put("saved", "TRUE");
assertThrows(AwsException.class, () -> put("saved", invalid));
assertEquals("TRUE", service.describeCompositeAlarms(List.of("saved"), null, REGION).getFirst().getAlarmRule());
assertEquals("ALARM", state("saved"));
}
@Test
void quotasAndAtomicCreatePreventExcessReferencesAndForeignNameReplacement() {
String many = IntStream.range(0, 101).mapToObj(i -> "ALARM(a" + i + ")")
.collect(Collectors.joining(" OR "));
assertThrows(AwsException.class, () -> put("too-many", many));
put("owned", "TRUE");
CompositeAlarm foreign = new CompositeAlarm();
foreign.setAlarmName("owned");
foreign.setAlarmRule("FALSE");
assertThrows(AwsException.class, () -> service.putCompositeAlarm(foreign, REGION, false));
assertEquals("ALARM", state("owned"));
assertThrows(AwsException.class, () -> put("deep", "(".repeat(250) + "TRUE" + ")".repeat(250)));
assertEquals("ALARM", put("many-not", "NOT ".repeat(502) + "TRUE").getStateValue());
assertThrows(AwsException.class, () -> put("elements", "FALSE OR ".repeat(500) + "TRUE"));
}
@Test
void mixedPersistenceLoadsLegacyMetricsAndRoundTripsComposites() throws Exception {
ObjectMapper mapper = new ObjectMapper();
TypeReference<Map<String, Alarm>> type = new TypeReference<>() {};
Map<String, Alarm> legacy = mapper.readValue(
"{\"legacy\":{\"alarmName\":\"old\",\"metricName\":\"CPU\",\"stateValue\":\"OK\"}}", type);
assertInstanceOf(MetricAlarm.class, legacy.get("legacy"));
CompositeAlarm alarm = put("new", "FALSE");
Map<String, Alarm> restored = mapper.readValue(mapper.writeValueAsBytes(Map.of("new", alarm)), type);
assertEquals("FALSE", assertInstanceOf(CompositeAlarm.class, restored.get("new")).getAlarmRule());
assertEquals("OK", restored.get("new").getStateValue());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void concurrentTagChangesPreserveTheUpdatedDefinition(boolean remove) throws Exception {
CountDownLatch updating = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
InMemoryStorage<String, Alarm> storage = new InMemoryStorage<>() {
@Override
public void put(String key, Alarm value) {
if (value instanceof CompositeAlarm composite && "TRUE".equals(composite.getAlarmRule())) {
updating.countDown();
try {
assertTrue(release.await(5, TimeUnit.SECONDS));
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new AssertionError(error);
}
}
super.put(key, value);
}
};
CloudWatchMetricsService concurrent = new CloudWatchMetricsService(new InMemoryStorage<>(), storage,
new RegionResolver(REGION, "123456789012"));
CompositeAlarm original = new CompositeAlarm();
original.setAlarmName("concurrent");
original.setAlarmRule("FALSE");
original.setTags(new HashMap<>(Map.of("team", "old")));
concurrent.putCompositeAlarm(original, REGION);
CompositeAlarm replacement = new CompositeAlarm();
replacement.setAlarmName("concurrent");
replacement.setAlarmRule("TRUE");
AtomicReference<Thread> tagThread = new AtomicReference<>();
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<?> update = executor.submit(() -> concurrent.putCompositeAlarm(replacement, REGION));
Future<?> tags;
try {
assertTrue(updating.await(2, TimeUnit.SECONDS));
tags = executor.submit(() -> {
tagThread.set(Thread.currentThread());
if (remove) {
concurrent.untagResource(original.getAlarmArn(), List.of("team"), REGION);
} else {
concurrent.tagResource(original.getAlarmArn(), Map.of("team", "new"), REGION);
}
});
ThreadStates.awaitBlockedOrDone(tagThread, tags);
assertFalse(tags.isDone(), "tag mutation must share the definition update lock");
} finally {
release.countDown();
}
update.get(2, TimeUnit.SECONDS);
tags.get(2, TimeUnit.SECONDS);
}
CompositeAlarm result = concurrent.describeCompositeAlarms(List.of("concurrent"), null, REGION).getFirst();
assertEquals("TRUE", result.getAlarmRule());
assertEquals(remove ? Map.of() : Map.of("team", "new"), result.getTags());
}
private CompositeAlarm put(String name, String rule) {
CompositeAlarm alarm = new CompositeAlarm();
alarm.setAlarmName(name);
alarm.setAlarmRule(rule);
alarm.setActionsEnabled(true);
service.putCompositeAlarm(alarm, REGION);
return alarm;
}
private String state(String name) {
return service.describeCompositeAlarms(List.of(name), null, REGION).getFirst().getStateValue();
}
}
@@ -41,6 +41,7 @@ AWS::CloudFront::OriginRequestPolicy CloudFrontCfnProvisioner
AWS::CloudFront::ResponseHeadersPolicy CloudFrontCfnProvisioner
AWS::CloudTrail::Trail CloudTrailCfnProvisioner
AWS::CloudWatch::Alarm CloudWatchCfnProvisioner
AWS::CloudWatch::CompositeAlarm CloudWatchCompositeAlarmCfnProvisioner
AWS::CloudWatch::Dashboard CloudWatchDashboardCfnProvisioner
AWS::CodeBuild::Project CodeBuildCfnProvisioner
AWS::CodePipeline::CustomActionType CodePipelineCfnProvisioner
1 AWS::ApiGateway::Account ApiGatewayAccountCfnProvisioner
41 AWS::CloudFront::ResponseHeadersPolicy CloudFrontCfnProvisioner
42 AWS::CloudTrail::Trail CloudTrailCfnProvisioner
43 AWS::CloudWatch::Alarm CloudWatchCfnProvisioner
44 AWS::CloudWatch::CompositeAlarm CloudWatchCompositeAlarmCfnProvisioner
45 AWS::CloudWatch::Dashboard CloudWatchDashboardCfnProvisioner
46 AWS::CodeBuild::Project CodeBuildCfnProvisioner
47 AWS::CodePipeline::CustomActionType CodePipelineCfnProvisioner