fix(cloudwatch): retain metric-math alarm query definitions (#4594) #6

Merged
Zakaria merged 9 commits from feat/cloudwatch-upstream-fixes into main 2026-10-25 03:08:55 +00:00
21 changed files with 1721 additions and 194 deletions
Showing only changes of commit fa3327bf71 - Show all commits
+1 -1
View File
@@ -160,7 +160,7 @@ cross-resource references.
| Kinesis Data Firehose | `DeliveryStream` |
| IoT Core | `DomainConfiguration` (`ServerCertificates` resolves to a JSON string), `Policy` (deleted after detaching it from its principals; on AWS the delete fails with `DeleteConflictException` while the policy is attached), `Thing`, `TopicRule` |
| CloudFront | `CachePolicy`, `Distribution`, `OriginAccessControl`, `OriginRequestPolicy`, `ResponseHeadersPolicy` |
| CloudWatch | `Alarm`, `Dashboard` |
| CloudWatch | `Alarm`, `CompositeAlarm`, `Dashboard`. Composite alarms have backing definitions with name Ref and Arn GetAtt, rollback, and replacement cleanup. Composite actions are stored only; suppression properties are rejected. |
| CloudWatch Logs | `LogGroup`, `LogStream`, `MetricFilter` |
| WAFv2 | `WebACL` |
| Config | `ConfigRule` |
+6
View File
@@ -311,6 +311,7 @@ aws logs put-retention-policy \
| `GetMetricStatistics` | Get metric statistics (Average, Sum, etc.) |
| `GetMetricData` | Query metrics with math expressions |
| `PutMetricAlarm` | Create a metric alarm |
| `PutCompositeAlarm` | Create or update a composite alarm rule |
| `DescribeAlarms` | List alarms |
| `DeleteAlarms` | Delete alarms |
| `SetAlarmState` | Manually set alarm state |
@@ -331,6 +332,11 @@ aws logs put-retention-policy \
Metric streams are stored as definitions with their `running` or `stopped` state. Floci never
delivers metrics to the Firehose delivery stream a metric stream names.
Composite alarms support boolean rules over alarm states. Request `AlarmTypes` containing
`CompositeAlarm` when describing them; the default describes metric alarms. Composite
action lists are stored but not executed. Action suppression is not implemented and is
rejected with `ValidationError`.
### SNS alarm actions
State transitions publish to same-account standard SNS topics in `AlarmActions`, `OKActions`, or
@@ -0,0 +1,183 @@
package io.github.hectorvent.floci.services.cloudformation.provisioners;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
@ApplicationScoped
public class CloudWatchCompositeAlarmCfnProvisioner implements CfnResourceProvisioner {
private static final String TYPE = "AWS::CloudWatch::CompositeAlarm";
private static final String SNAPSHOT = "FlociCompositeAlarmSnapshot";
private static final String TAG_KEYS = "FlociCompositeAlarmTagKeys";
private static final String EXPLICIT_NAME = "FlociCompositeAlarmExplicitName";
private static final ObjectMapper MAPPER = new ObjectMapper();
private final CloudWatchMetricsService service;
public CloudWatchCompositeAlarmCfnProvisioner(CloudWatchMetricsService service) {
this.service = service;
}
@Override
public Set<String> resourceTypes() {
return Set.of(TYPE);
}
@Override
public void provision(StackResource resource, JsonNode props, ProvisionContext ctx) {
for (String unsupported : List.of("ActionsSuppressor", "ActionsSuppressorWaitPeriod",
"ActionsSuppressorExtensionPeriod")) {
if (props.has(unsupported)) {
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
}
}
if (resource.getAttributes().containsKey(SNAPSHOT)) {
throw new AwsException("ResourceConflictException", "Alarm update cleanup is still pending", 400);
}
String explicit = ctx.resolveOptional(props, "AlarmName");
boolean removedName = explicit == null && "true".equals(resource.getAttributes().get(EXPLICIT_NAME));
String name = removedName ? ctx.generatePhysicalName(resource.getLogicalId(), 255, false)
: ctx.stablePhysicalName(explicit, resource.getLogicalId(), 255, false);
CompositeAlarm alarm = new CompositeAlarm();
alarm.setAlarmName(name);
alarm.setAlarmDescription(ctx.resolveOptional(props, "AlarmDescription"));
alarm.setAlarmRule(ctx.resolveOptional(props, "AlarmRule"));
alarm.setActionsEnabled(!"false".equals(ctx.resolveOptional(props, "ActionsEnabled")));
alarm.setTags(ctx.resolveTags(props, "Tags"));
actions(props, "AlarmActions", ctx, alarm.getAlarmActions());
actions(props, "OKActions", ctx, alarm.getOkActions());
actions(props, "InsufficientDataActions", ctx, alarm.getInsufficientDataActions());
Map<String, String> desiredTags = Map.copyOf(alarm.getTags());
Map<String, String> priorAttributes = Map.copyOf(resource.getAttributes());
boolean inPlace = ctx.reusesPriorEntity(name);
String updateSnapshot = null;
if (inPlace) {
ObjectNode snapshot = MAPPER.createObjectNode().put("region", ctx.region()).put("name", name);
List<CompositeAlarm> previous = service.describeCompositeAlarms(List.of(name), null, ctx.region());
if (!previous.isEmpty()) {
snapshot.set("alarm", MAPPER.valueToTree(previous.getFirst()));
}
snapshot.set("attributes", MAPPER.valueToTree(priorAttributes));
Set<String> changedKeys = new TreeSet<>(desiredTags.keySet());
changedKeys.addAll(tagKeys(resource.getAttributes().get(TAG_KEYS)));
snapshot.set("changedTagKeys", MAPPER.valueToTree(changedKeys));
updateSnapshot = snapshot.toString();
}
service.putCompositeAlarm(alarm, ctx.region(), inPlace);
if (inPlace) {
resource.getAttributes().put(SNAPSHOT, updateSnapshot);
reconcileTags(alarm.getAlarmArn(), tagKeys(priorAttributes.get(TAG_KEYS)), desiredTags, ctx.region());
}
resource.getAttributes().put(TAG_KEYS, MAPPER.valueToTree(desiredTags.keySet()).toString());
resource.setPhysicalId(name);
resource.getAttributes().put("Arn", alarm.getAlarmArn());
resource.getAttributes().put(EXPLICIT_NAME, Boolean.toString(explicit != null));
ReplacementCleanup.record(resource, ctx, priorAttributes);
}
private static void actions(JsonNode props, String field, ProvisionContext ctx, List<String> target) {
JsonNode values = ctx.engine().resolveNode(props.path(field));
if (values != null && values.isArray()) {
values.forEach(value -> target.add(value.asText()));
}
}
private static Set<String> tagKeys(String json) {
Set<String> keys = new TreeSet<>();
if (json != null) {
try {
MAPPER.readTree(json).forEach(value -> keys.add(value.asText()));
} catch (JsonProcessingException error) {
throw new IllegalStateException("Cannot read composite alarm tag ownership", error);
}
}
return keys;
}
private void reconcileTags(String arn, Set<String> owned, Map<String, String> desired, String region) {
List<String> removed = owned.stream().filter(key -> !desired.containsKey(key)).toList();
if (!removed.isEmpty()) {
service.untagResource(arn, removed, region);
}
if (!desired.isEmpty()) {
service.tagResource(arn, desired, region);
}
}
@Override
public void delete(String type, String physicalId, String region) {
service.deleteAlarms(List.of(physicalId), region);
}
@Override
public boolean retainsFailedUpdateState(StackResource resource) {
return resource.getAttributes().containsKey(SNAPSHOT);
}
@Override
public boolean rollbackUpdate(StackResource resource) {
if (ReplacementCleanup.rollback(resource, this::delete)) {
return true;
}
String raw = resource.getAttributes().get(SNAPSHOT);
if (raw == null) {
return false;
}
try {
JsonNode snapshot = MAPPER.readTree(raw);
String region = snapshot.path("region").asText();
if (snapshot.has("alarm")) {
CompositeAlarm original = MAPPER.treeToValue(snapshot.get("alarm"), CompositeAlarm.class);
Map<String, String> originalTags = Map.copyOf(original.getTags());
service.putCompositeAlarm(original, region);
Set<String> changed = tagKeys(snapshot.path("changedTagKeys").toString());
reconcileTags(original.getAlarmArn(), changed, originalTags.entrySet().stream()
.filter(entry -> changed.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), region);
} else {
delete(TYPE, snapshot.path("name").asText(), region);
}
resource.getAttributes().clear();
snapshot.path("attributes").fields().forEachRemaining(entry ->
resource.getAttributes().put(entry.getKey(), entry.getValue().asText()));
} catch (JsonProcessingException error) {
throw new IllegalStateException("Cannot read composite alarm rollback state", error);
}
return true;
}
@Override
public boolean hasReplacementUpdate(StackResource resource) {
return ReplacementCleanup.hasReplacement(resource);
}
@Override
public String updateCleanupPhysicalId(StackResource resource) {
return ReplacementCleanup.cleanupPhysicalId(resource);
}
@Override
public UpdateCleanupResult completeUpdate(StackResource resource) {
if (resource.getAttributes().containsKey(SNAPSHOT) && !ReplacementCleanup.hasReplacement(resource)) {
return new UpdateCleanupResult(true, true, null, 0, null);
}
return ReplacementCleanup.complete(resource, this::delete);
}
@Override
public void clearUpdate(StackResource resource) {
resource.getAttributes().remove(SNAPSHOT);
ReplacementCleanup.clear(resource);
}
}
@@ -11,6 +11,7 @@ import org.jboss.logging.Logger;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -63,9 +64,11 @@ public class AlarmSnsNotifier {
this.objectMapper = objectMapper;
}
public void notifyTransition(MetricAlarm alarm, String oldState) {
record Notification(String topicArn, String message, String subject, String region) {}
List<Notification> prepareTransition(MetricAlarm alarm, String oldState) {
if (!alarm.isActionsEnabled() || alarm.getStateValue().equals(oldState)) {
return;
return List.of();
}
List<String> actions = switch (alarm.getStateValue()) {
case "ALARM" -> alarm.getAlarmActions();
@@ -74,8 +77,9 @@ public class AlarmSnsNotifier {
default -> List.of();
};
if (actions == null) {
return;
return List.of();
}
List<Notification> notifications = new ArrayList<>();
for (String action : actions) {
if (!AwsArnUtils.isArnFor(action, "sns")) {
continue;
@@ -86,12 +90,25 @@ public class AlarmSnsNotifier {
if (subject.length() > 100) {
subject = subject.substring(0, 100);
}
snsService.publish(action, null, message(alarm, oldState), subject, topic.region());
notifications.add(new Notification(action, message(alarm, oldState), subject, topic.region()));
} catch (Exception e) {
LOG.warnv(e, "Unable to publish alarm {0} state {1} to SNS action {2}",
alarm.getAlarmName(), alarm.getStateValue(), action);
}
}
return notifications;
}
void publish(List<Notification> notifications) {
for (Notification notification : notifications) {
try {
snsService.publish(notification.topicArn(), null, notification.message(),
notification.subject(), notification.region());
} catch (Exception error) {
LOG.warnv(error, "Unable to publish alarm transition {0} to SNS action {1}",
notification.subject(), notification.topicArn());
}
}
}
private String message(MetricAlarm alarm, String oldState) throws JsonProcessingException {
@@ -5,9 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsErrorResponse;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.PaginatedResult;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.model.Dashboard;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter;
@@ -57,6 +64,7 @@ public class CloudWatchMetricsJsonHandler {
case "ListMetrics" -> handleListMetrics(request, region);
case "GetMetricStatistics" -> handleGetMetricStatistics(request, region);
case "PutMetricAlarm" -> handlePutMetricAlarm(request, region);
case "PutCompositeAlarm" -> handlePutCompositeAlarm(request, region);
case "DescribeAlarms" -> handleDescribeAlarms(request, region);
case "DeleteAlarms" -> handleDeleteAlarms(request, region);
case "SetAlarmState" -> handleSetAlarmState(request, region);
@@ -156,8 +164,7 @@ public class CloudWatchMetricsJsonHandler {
private Response handlePutMetricAlarm(JsonNode request, String region) {
MetricAlarm alarm = new MetricAlarm();
alarm.setAlarmName(request.path("AlarmName").asText());
alarm.setAlarmDescription(request.path("AlarmDescription").asText(null));
parseAlarmFields(request, alarm);
alarm.setMetricName(request.path("MetricName").asText(null));
alarm.setNamespace(request.path("Namespace").asText(null));
alarm.setStatistic(request.path("Statistic").asText(null));
@@ -175,9 +182,33 @@ public class CloudWatchMetricsJsonHandler {
alarm.setThreshold(request.path("Threshold").asDouble(0));
alarm.setComparisonOperator(request.path("ComparisonOperator").asText(null));
alarm.setTreatMissingData(request.path("TreatMissingData").asText(null));
alarm.setActionsEnabled(request.path("ActionsEnabled").asBoolean(true));
alarm.setDimensions(parseDimensionsJson(request.path("Dimensions")));
metricsService.putMetricAlarm(alarm, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
private Response handlePutCompositeAlarm(JsonNode request, String region) {
rejectSuppressor(request);
CompositeAlarm alarm = new CompositeAlarm();
parseAlarmFields(request, alarm);
alarm.setAlarmRule(request.path("AlarmRule").asText(null));
metricsService.putCompositeAlarm(alarm, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
private static void rejectSuppressor(JsonNode request) {
if (request.has("ActionsSuppressor") || request.has("ActionsSuppressorWaitPeriod")
|| request.has("ActionsSuppressorExtensionPeriod")) {
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
}
}
private void parseAlarmFields(JsonNode request, Alarm alarm) {
alarm.setAlarmName(request.path("AlarmName").asText());
alarm.setAlarmDescription(request.path("AlarmDescription").asText(null));
alarm.setActionsEnabled(request.path("ActionsEnabled").asBoolean(true));
JsonNode alarmActions = request.path("AlarmActions");
if (alarmActions.isArray()) {
alarmActions.forEach(a -> alarm.getAlarmActions().add(a.asText()));
@@ -198,8 +229,6 @@ public class CloudWatchMetricsJsonHandler {
alarm.setTags(tags);
}
metricsService.putMetricAlarm(alarm, region);
return Response.ok(objectMapper.createObjectNode()).build();
}
private Response handleDescribeAlarms(JsonNode request, String region) {
@@ -210,21 +239,17 @@ public class CloudWatchMetricsJsonHandler {
}
String prefix = request.has("AlarmNamePrefix") ? request.path("AlarmNamePrefix").asText() : null;
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
List<String> types = new ArrayList<>();
request.path("AlarmTypes").forEach(value -> types.add(value.asText()));
metricsService.validateAlarmTypes(types);
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
ObjectNode response = objectMapper.createObjectNode();
ArrayNode arr = response.putArray("MetricAlarms");
for (MetricAlarm a : alarms) {
ObjectNode node = arr.addObject();
node.put("AlarmName", a.getAlarmName());
if (a.getAlarmArn() != null) node.put("AlarmArn", a.getAlarmArn());
if (a.getAlarmDescription() != null) node.put("AlarmDescription", a.getAlarmDescription());
ArrayNode alarmActions = node.putArray("AlarmActions");
a.getAlarmActions().forEach(alarmActions::add);
ArrayNode okActions = node.putArray("OKActions");
a.getOkActions().forEach(okActions::add);
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
describeAlarmFields(node, a);
if (!a.getMetrics().isEmpty()) {
node.set("Metrics", objectMapper.valueToTree(a.getMetrics()));
} else {
@@ -257,17 +282,48 @@ public class CloudWatchMetricsJsonHandler {
node.put("Threshold", a.getThreshold());
if (a.getComparisonOperator() != null) node.put("ComparisonOperator", a.getComparisonOperator());
if (a.getTreatMissingData() != null) node.put("TreatMissingData", a.getTreatMissingData());
node.put("ActionsEnabled", a.isActionsEnabled());
if (a.getStateValue() != null) node.put("StateValue", a.getStateValue());
if (a.getStateReason() != null) node.put("StateReason", a.getStateReason());
if (a.getStateReasonData() != null) node.put("StateReasonData", a.getStateReasonData());
node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp());
}
ArrayNode composites = response.putArray("CompositeAlarms");
if (types.contains("CompositeAlarm")) {
for (CompositeAlarm alarm : metricsService.describeCompositeAlarms(alarmNames, prefix, region)) {
ObjectNode node = composites.addObject();
describeAlarmFields(node, alarm);
node.put("AlarmRule", alarm.getAlarmRule());
}
}
response.putArray("CompositeAlarms");
response.putArray("LogAlarms");
return Response.ok(response).build();
}
private void describeAlarmFields(ObjectNode node, Alarm a) {
node.put("AlarmName", a.getAlarmName());
if (a.getAlarmArn() != null) {
node.put("AlarmArn", a.getAlarmArn());
}
if (a.getAlarmDescription() != null) {
node.put("AlarmDescription", a.getAlarmDescription());
}
ArrayNode alarmActions = node.putArray("AlarmActions");
a.getAlarmActions().forEach(alarmActions::add);
ArrayNode okActions = node.putArray("OKActions");
a.getOkActions().forEach(okActions::add);
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
node.put("ActionsEnabled", a.isActionsEnabled());
if (a.getStateValue() != null) {
node.put("StateValue", a.getStateValue());
}
if (a.getStateReason() != null) {
node.put("StateReason", a.getStateReason());
}
if (a.getStateReasonData() != null) {
node.put("StateReasonData", a.getStateReasonData());
}
node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp());
node.put("AlarmConfigurationUpdatedTimestamp", a.getAlarmConfigurationUpdatedTimestamp());
}
private Response handleDeleteAlarms(JsonNode request, String region) {
List<String> alarmNames = new ArrayList<>();
JsonNode namesNode = request.path("AlarmNames");
@@ -1,5 +1,6 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.AwsNamespaces;
import io.github.hectorvent.floci.core.common.AwsQueryResponse;
import io.github.hectorvent.floci.core.common.PaginatedResult;
@@ -7,6 +8,12 @@ import io.github.hectorvent.floci.core.common.Pagination;
import io.github.hectorvent.floci.core.common.XmlBuilder;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.model.Dashboard;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream;
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter;
@@ -58,6 +65,7 @@ public class CloudWatchMetricsQueryHandler {
case "GetMetricStatistics" -> handleGetMetricStatistics(params, region);
case "GetMetricData" -> handleGetMetricData(params, region);
case "PutMetricAlarm" -> handlePutMetricAlarm(params, region);
case "PutCompositeAlarm" -> handlePutCompositeAlarm(params, region);
case "DescribeAlarms" -> handleDescribeAlarms(params, region);
case "DeleteAlarms" -> handleDeleteAlarms(params, region);
case "SetAlarmState" -> handleSetAlarmState(params, region);
@@ -159,7 +167,8 @@ public class CloudWatchMetricsQueryHandler {
xml.elem("Unit", dp.unit()).end("member");
}
xml.end("Datapoints");
return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics", AwsNamespaces.CW, xml.build())).build();
return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics",
AwsNamespaces.CW, xml.build())).build();
}
private Response handleGetMetricData(MultivaluedMap<String, String> params, String region) {
@@ -243,6 +252,26 @@ public class CloudWatchMetricsQueryHandler {
return Response.ok(AwsQueryResponse.envelopeNoResult("PutMetricAlarm", null)).build();
}
private Response handlePutCompositeAlarm(MultivaluedMap<String, String> params, String region) {
if (params.containsKey("ActionsSuppressor") || params.containsKey("ActionsSuppressorWaitPeriod")
|| params.containsKey("ActionsSuppressorExtensionPeriod")) {
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
}
CompositeAlarm alarm = new CompositeAlarm();
parseAlarmFields(params, alarm);
alarm.setAlarmRule(params.getFirst("AlarmRule"));
metricsService.putCompositeAlarm(alarm, region);
return Response.ok(AwsQueryResponse.envelopeNoResult("PutCompositeAlarm", null)).build();
}
private static List<String> alarmMembers(MultivaluedMap<String, String> params, String key) {
List<String> values = new ArrayList<>();
for (int i = 1; params.getFirst(key + ".member." + i) != null; i++) {
values.add(params.getFirst(key + ".member." + i));
}
return values;
}
private Response handleDescribeAlarms(MultivaluedMap<String, String> params, String region) {
List<String> alarmNames = new ArrayList<>();
for (int i = 1; ; i++) {
@@ -252,15 +281,24 @@ public class CloudWatchMetricsQueryHandler {
}
String prefix = params.getFirst("AlarmNamePrefix");
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
List<String> types = alarmMembers(params, "AlarmTypes");
metricsService.validateAlarmTypes(types);
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
var xml = new XmlBuilder().start("MetricAlarms");
for (MetricAlarm a : alarms) {
toAlarmXml(xml, a);
}
xml.end("MetricAlarms");
xml.start("CompositeAlarms").end("CompositeAlarms");
xml.start("LogAlarms").end("LogAlarms");
xml.end("MetricAlarms").start("CompositeAlarms");
if (types.contains("CompositeAlarm")) {
for (CompositeAlarm alarm : metricsService.describeCompositeAlarms(alarmNames, prefix, region)) {
xml.start("member");
describeAlarmFields(xml, alarm);
xml.elem("AlarmRule", alarm.getAlarmRule()).end("member");
}
}
xml.end("CompositeAlarms").start("LogAlarms").end("LogAlarms");
return Response.ok(AwsQueryResponse.envelope("DescribeAlarms", null, xml.build())).build();
}
@@ -682,10 +720,7 @@ public class CloudWatchMetricsQueryHandler {
private MetricAlarm parseAlarm(MultivaluedMap<String, String> params) {
MetricAlarm a = new MetricAlarm();
a.setAlarmName(params.getFirst("AlarmName"));
a.setAlarmDescription(params.getFirst("AlarmDescription"));
String actionsEnabled = params.getFirst("ActionsEnabled");
a.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
parseAlarmFields(params, a);
a.setMetricName(params.getFirst("MetricName"));
a.setNamespace(params.getFirst("Namespace"));
a.setStatistic(params.getFirst("Statistic"));
@@ -710,35 +745,24 @@ public class CloudWatchMetricsQueryHandler {
}
a.setDimensions(dims);
// Actions
for (int i = 1; ; i++) {
String act = params.getFirst("OKActions.member." + i);
if (act == null) break;
a.getOkActions().add(act);
}
for (int i = 1; ; i++) {
String act = params.getFirst("AlarmActions.member." + i);
if (act == null) break;
a.getAlarmActions().add(act);
}
for (int i = 1; ; i++) {
String act = params.getFirst("InsufficientDataActions.member." + i);
if (act == null) break;
a.getInsufficientDataActions().add(act);
}
// Tags
Map<String, String> tags = new LinkedHashMap<>();
for (int i = 1; ; i++) {
String key = params.getFirst("Tags.member." + i + ".Key");
if (key == null) break;
tags.put(key, params.getFirst("Tags.member." + i + ".Value"));
}
a.setTags(tags);
return a;
}
private void parseAlarmFields(MultivaluedMap<String, String> params, Alarm alarm) {
alarm.setAlarmName(params.getFirst("AlarmName"));
alarm.setAlarmDescription(params.getFirst("AlarmDescription"));
String actionsEnabled = params.getFirst("ActionsEnabled");
alarm.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
alarm.setAlarmActions(alarmMembers(params, "AlarmActions"));
alarm.setOkActions(alarmMembers(params, "OKActions"));
alarm.setInsufficientDataActions(alarmMembers(params, "InsufficientDataActions"));
Map<String, String> tags = new LinkedHashMap<>();
for (int i = 1; params.getFirst("Tags.member." + i + ".Key") != null; i++) {
tags.put(params.getFirst("Tags.member." + i + ".Key"), params.getFirst("Tags.member." + i + ".Value"));
}
alarm.setTags(tags);
}
private List<AlarmMetricDataQuery> parseAlarmMetricQueries(MultivaluedMap<String, String> params) {
List<AlarmMetricDataQuery> queries = new ArrayList<>();
for (int index = 1; ; index++) {
@@ -784,22 +808,8 @@ public class CloudWatchMetricsQueryHandler {
}
private void toAlarmXml(XmlBuilder xml, MetricAlarm a) {
xml.start("member")
.elem("AlarmName", a.getAlarmName())
.elem("AlarmArn", a.getAlarmArn())
.elem("AlarmDescription", a.getAlarmDescription());
xml.start("AlarmActions");
a.getAlarmActions().forEach(act -> xml.elem("member", act));
xml.end("AlarmActions")
.elem("AlarmConfigurationUpdatedTimestamp", Instant.ofEpochSecond(a.getAlarmConfigurationUpdatedTimestamp()).toString())
.elem("ActionsEnabled", String.valueOf(a.isActionsEnabled()));
xml.start("OKActions");
a.getOkActions().forEach(act -> xml.elem("member", act));
xml.end("OKActions");
xml.start("InsufficientDataActions");
a.getInsufficientDataActions().forEach(act -> xml.elem("member", act));
xml.end("InsufficientDataActions");
xml.start("member");
describeAlarmFields(xml, a);
if (!a.getMetrics().isEmpty()) {
toAlarmMetricsXml(xml, a.getMetrics());
} else {
@@ -815,11 +825,7 @@ public class CloudWatchMetricsQueryHandler {
.elem("Unit", a.getUnit());
}
xml.elem("StateValue", a.getStateValue())
.elem("StateReason", a.getStateReason())
.elem("StateReasonData", a.getStateReasonData())
.elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString())
.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods()))
xml.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods()))
.elem("Threshold", String.valueOf(a.getThreshold()))
.elem("ComparisonOperator", a.getComparisonOperator())
.elem("TreatMissingData", a.getTreatMissingData());
@@ -831,6 +837,28 @@ public class CloudWatchMetricsQueryHandler {
xml.end("member");
}
private void describeAlarmFields(XmlBuilder xml, Alarm a) {
xml.elem("AlarmName", a.getAlarmName())
.elem("AlarmArn", a.getAlarmArn())
.elem("AlarmDescription", a.getAlarmDescription());
xml.start("AlarmActions");
a.getAlarmActions().forEach(act -> xml.elem("member", act));
xml.end("AlarmActions")
.elem("AlarmConfigurationUpdatedTimestamp", Instant.ofEpochSecond(a.getAlarmConfigurationUpdatedTimestamp()).toString())
.elem("ActionsEnabled", String.valueOf(a.isActionsEnabled()));
xml.start("OKActions");
a.getOkActions().forEach(act -> xml.elem("member", act));
xml.end("OKActions");
xml.start("InsufficientDataActions");
a.getInsufficientDataActions().forEach(act -> xml.elem("member", act));
xml.end("InsufficientDataActions");
xml.elem("StateValue", a.getStateValue())
.elem("StateReason", a.getStateReason())
.elem("StateReasonData", a.getStateReasonData())
.elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString());
}
private void toAlarmMetricsXml(XmlBuilder xml, List<AlarmMetricDataQuery> metrics) {
xml.start("Metrics");
for (AlarmMetricDataQuery query : metrics) {
@@ -1,10 +1,15 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.core.type.TypeReference;
import io.github.hectorvent.floci.core.common.AwsArnUtils;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
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.Dimension;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
@@ -14,11 +19,16 @@ import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -27,8 +37,10 @@ public class CloudWatchMetricsService {
private static final Logger LOG = Logger.getLogger(CloudWatchMetricsService.class);
private static final List<String> ALARM_TYPES = List.of("CompositeAlarm", "MetricAlarm", "LogAlarm");
private final StorageBackend<String, MetricDatum> metricStore;
private final StorageBackend<String, MetricAlarm> alarmStore;
private final StorageBackend<String, Alarm> alarmStore;
private final RegionResolver regionResolver;
private final AlarmSnsNotifier alarmSnsNotifier;
@@ -42,19 +54,19 @@ public class CloudWatchMetricsService {
this.metricStore = storageFactory.create("cloudwatchmetrics", "cwmetrics.json",
new TypeReference<Map<String, MetricDatum>>() {});
this.alarmStore = storageFactory.create("cloudwatchmetrics", "cwalarms.json",
new TypeReference<Map<String, MetricAlarm>>() {});
new TypeReference<Map<String, Alarm>>() {});
this.regionResolver = regionResolver;
this.alarmSnsNotifier = alarmSnsNotifier;
}
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
StorageBackend<String, MetricAlarm> alarmStore,
StorageBackend<String, Alarm> alarmStore,
RegionResolver regionResolver) {
this(metricStore, alarmStore, regionResolver, null);
}
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
StorageBackend<String, MetricAlarm> alarmStore,
StorageBackend<String, Alarm> alarmStore,
RegionResolver regionResolver, AlarmSnsNotifier alarmSnsNotifier) {
this.metricStore = metricStore;
this.alarmStore = alarmStore;
@@ -296,45 +308,236 @@ public class CloudWatchMetricsService {
};
}
public void putMetricAlarm(MetricAlarm alarm, String region) {
public synchronized void putMetricAlarm(MetricAlarm alarm, String region) {
putAlarm(alarm, region);
evaluateComposites(region, Set.of(alarm.getAlarmName()), null);
}
public void putCompositeAlarm(CompositeAlarm alarm, String region) {
putCompositeAlarm(alarm, region, true);
}
public synchronized void putCompositeAlarm(CompositeAlarm alarm, String region, boolean replaceExisting) {
if (!replaceExisting && alarmStore.get(region + "::" + alarm.getAlarmName()).isPresent()) {
throw new AwsException("AlreadyExists", "Alarm already exists: " + alarm.getAlarmName(), 400);
}
Set<String> references = alarmReferences(alarm, region);
if (references.size() > 100) {
throw new AwsException("LimitExceeded", "AlarmRule references more than 100 child alarms", 400);
}
List<CompositeAlarm> existing = describeCompositeAlarms(null, null, region);
for (String reference : references) {
long parents = existing.stream().filter(parent -> !parent.getAlarmName().equals(alarm.getAlarmName()))
.filter(parent -> alarmReferences(parent, region).contains(reference))
.count();
if (parents >= 150) {
throw new AwsException("LimitExceeded", "An alarm can be referenced by at most 150 composite alarms", 400);
}
}
boolean created = alarmStore.get(region + "::" + alarm.getAlarmName()).isEmpty();
putAlarm(alarm, region);
if (created) {
evaluateComposites(region, Set.of(), alarm.getAlarmName());
}
}
private Set<String> alarmReferences(CompositeAlarm alarm, String region) {
return CompositeAlarmRule.parse(alarm.getAlarmRule()).references().stream()
.map(reference -> AwsArnUtils.isArn(reference) ? reference
: regionResolver.buildArn("cloudwatch", region, "alarm:" + reference))
.collect(Collectors.toSet());
}
private void putAlarm(Alarm alarm, String region) {
String name = alarm.getAlarmName();
if (name == null || name.isBlank() || name.length() > 255) {
throw SmithyValidationErrors.validationError(name == null ? SmithyValidationErrors.notNull("alarmName")
: "Value '" + name + "' at 'alarmName' failed to satisfy constraint: Member must have length "
+ (name.length() > 255 ? "less than or equal to 255" : "greater than or equal to 1"));
}
Alarm previous = alarmStore.get(region + "::" + name).orElse(null);
if (previous != null) {
if (!previous.getClass().equals(alarm.getClass())) {
throw new AwsException("ValidationError", "An alarm of another type already has this name", 400);
}
if (alarm instanceof CompositeAlarm) {
alarm.setStateValue(previous.getStateValue());
alarm.setStateReason(previous.getStateReason());
alarm.setStateReasonData(previous.getStateReasonData());
alarm.setStateUpdatedTimestamp(previous.getStateUpdatedTimestamp());
alarm.setTags(new HashMap<>(previous.getTags()));
}
}
if (alarm.getAlarmArn() == null) {
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + alarm.getAlarmName()));
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
}
alarm.setRegion(region);
alarm.setAlarmConfigurationUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
LOG.infov("PutMetricAlarm: {0} in {1}", alarm.getAlarmName(), region);
alarmStore.put(region + "::" + name, alarm);
LOG.infov("Put {0}: {1} in {2}", alarm.getClass().getSimpleName(), name, region);
}
/** Every stored alarm, across all regions. Used by the background {@link AlarmEvaluator}
* tick, which has no per-request region to scope a lookup to. */
/** Metric alarms evaluated by the existing metric evaluator. */
public List<MetricAlarm> allAlarms() {
return alarmStore.scan(k -> true);
return alarmStore.scan(k -> true).stream().filter(MetricAlarm.class::isInstance)
.map(MetricAlarm.class::cast).toList();
}
public List<MetricAlarm> describeAlarms(List<String> alarmNames, String alarmNamePrefix, String region) {
String prefix = region + "::";
List<MetricAlarm> all = alarmStore.scan(k -> k.startsWith(prefix));
if (alarmNames != null && !alarmNames.isEmpty()) {
return all.stream().filter(a -> alarmNames.contains(a.getAlarmName())).toList();
}
if (alarmNamePrefix != null && !alarmNamePrefix.isBlank()) {
return all.stream().filter(a -> a.getAlarmName().startsWith(alarmNamePrefix)).toList();
}
return all;
public List<MetricAlarm> describeAlarms(List<String> names, String prefix, String region) {
return matchingAlarms(names, prefix, region).stream().filter(MetricAlarm.class::isInstance)
.map(MetricAlarm.class::cast).toList();
}
public void deleteAlarms(List<String> alarmNames, String region) {
public void validateAlarmTypes(List<String> types) {
if (!ALARM_TYPES.containsAll(types)) {
throw SmithyValidationErrors.validationError(
SmithyValidationErrors.notAllInEnum(types, "alarmTypes", ALARM_TYPES));
}
}
public List<CompositeAlarm> describeCompositeAlarms(List<String> names, String prefix, String region) {
return matchingAlarms(names, prefix, region).stream().filter(CompositeAlarm.class::isInstance)
.map(CompositeAlarm.class::cast).toList();
}
private List<Alarm> matchingAlarms(List<String> names, String prefix, String region) {
return alarmStore.scan(k -> k.startsWith(region + "::")).stream()
.filter(a -> names == null || names.isEmpty() || names.contains(a.getAlarmName()))
.filter(a -> prefix == null || a.getAlarmName().startsWith(prefix)).toList();
}
private Map<String, Alarm> alarmIndex(String region) {
Map<String, Alarm> alarms = new HashMap<>();
for (Alarm alarm : alarmStore.scan(k -> k.startsWith(region + "::"))) {
alarms.put(alarm.getAlarmName(), alarm);
alarms.put(alarm.getAlarmArn(), alarm);
}
return alarms;
}
private void evaluateComposites(String region, Set<String> changedAlarmNames, String newAlarm) {
if (changedAlarmNames.isEmpty() && newAlarm == null) {
return;
}
Map<String, Alarm> alarms = alarmIndex(region);
Set<String> changed = new HashSet<>(changedAlarmNames);
for (String name : changedAlarmNames) {
changed.add(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
}
Map<String, CompositeAlarmRule.Rule> candidates = new HashMap<>();
for (Alarm alarm : new HashSet<>(alarms.values())) {
if (alarm instanceof CompositeAlarm composite && !changedAlarmNames.contains(alarm.getAlarmName())) {
candidates.put(alarm.getAlarmName(), CompositeAlarmRule.parse(composite.getAlarmRule()));
}
}
Set<String> affected = new HashSet<>(changed);
Map<String, CompositeAlarmRule.Rule> pending = new HashMap<>();
boolean discovered;
do {
discovered = false;
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = candidates.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
if (next.getKey().equals(newAlarm) || next.getValue().references().stream().anyMatch(affected::contains)) {
pending.put(next.getKey(), next.getValue());
affected.add(next.getKey());
affected.add(alarms.get(next.getKey()).getAlarmArn());
iterator.remove();
discovered = true;
}
}
} while (discovered);
Set<String> cycles = pending.keySet().stream()
.filter(name -> isCyclic(name, alarms)).collect(Collectors.toSet());
boolean progress;
do {
progress = false;
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = pending.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
boolean blocked = next.getValue().references().stream().map(alarms::get)
.anyMatch(child -> child != null && pending.containsKey(child.getAlarmName()));
if (blocked || cycles.contains(next.getKey())) {
continue;
}
if (!next.getKey().equals(newAlarm)
&& next.getValue().references().stream().noneMatch(changed::contains)) {
iterator.remove();
progress = true;
continue;
}
Alarm alarm = alarms.get(next.getKey());
boolean triggered = next.getValue().evaluate(reference -> {
Alarm child = alarms.get(reference);
return child == null ? null : child.getStateValue();
});
String state = triggered ? "ALARM" : "OK";
if (!state.equals(alarm.getStateValue())) {
alarm.setStateValue(state);
alarm.setStateReason("AlarmRule evaluated to " + state);
alarm.setStateReasonData(null);
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
changed.add(alarm.getAlarmName());
changed.add(alarm.getAlarmArn());
}
iterator.remove();
progress = true;
}
} while (progress && !pending.isEmpty());
if (!pending.isEmpty()) {
LOG.debugv("Composite alarm evaluation stopped at cyclic dependencies: {0}", pending.keySet());
}
}
public synchronized void deleteAlarms(List<String> alarmNames, String region) {
Map<String, Alarm> alarms = alarmIndex(region);
long composites = alarmNames.stream().map(alarms::get).filter(CompositeAlarm.class::isInstance).count();
if (composites > 1) {
throw new AwsException("ValidationError", "DeleteAlarms accepts at most one composite alarm", 400);
}
for (String name : alarmNames) {
if (isCyclic(name, alarms)) {
throw new AwsException("ValidationError", "Break the composite alarm cycle before deleting " + name, 400);
}
}
Set<String> deletedNames = new HashSet<>();
for (String name : alarmNames) {
if (alarmStore.get(region + "::" + name).isPresent()) {
deletedNames.add(name);
}
alarmStore.delete(region + "::" + name);
}
evaluateComposites(region, deletedNames, null);
LOG.infov("Deleted alarms: {0} in {1}", alarmNames, region);
}
private boolean isCyclic(String name, Map<String, Alarm> alarms) {
Set<String> visited = new HashSet<>();
ArrayDeque<String> pending = new ArrayDeque<>();
pending.add(name);
while (!pending.isEmpty()) {
Alarm next = alarms.get(pending.remove());
if (next instanceof CompositeAlarm composite && visited.add(next.getAlarmName())) {
for (String reference : CompositeAlarmRule.parse(composite.getAlarmRule()).references()) {
Alarm child = alarms.get(reference);
if (child != null && child.getAlarmName().equals(name)) {
return true;
}
if (child != null) {
pending.add(child.getAlarmName());
}
}
}
}
return false;
}
public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) {
List<AlarmSnsNotifier.Notification> notifications = List.of();
synchronized (this) {
String key = region + "::" + alarmName;
MetricAlarm alarm = alarmStore.get(key)
Alarm alarm = alarmStore.get(key)
.orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404));
String oldState = alarm.getStateValue();
@@ -344,9 +547,14 @@ public class CloudWatchMetricsService {
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
alarmStore.put(key, alarm);
evaluateComposites(region, Set.of(alarmName), null);
LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue);
if (alarmSnsNotifier != null && alarm instanceof MetricAlarm metricAlarm) {
notifications = alarmSnsNotifier.prepareTransition(metricAlarm, oldState);
}
}
if (alarmSnsNotifier != null) {
alarmSnsNotifier.notifyTransition(alarm, oldState);
alarmSnsNotifier.publish(notifications);
}
}
@@ -364,7 +572,7 @@ public class CloudWatchMetricsService {
* {@code insight-rule/} ARN being the one AWS documents; reporting that no resource
* matches the ARN is true of those, where "alarm not found" would not be.
*/
private MetricAlarm requireAlarm(String resourceArn, String region) {
private Alarm requireAlarm(String resourceArn, String region) {
return alarmStore.scan(k -> k.startsWith(region + "::"))
.stream()
.filter(a -> a.getAlarmArn() != null && a.getAlarmArn().equals(resourceArn))
@@ -373,18 +581,18 @@ public class CloudWatchMetricsService {
"No CloudWatch resource matches the ARN " + resourceArn + ".", 404));
}
public Map<String, String> listTagsForResource(String resourceArn, String region) {
return requireAlarm(resourceArn, region).getTags();
public synchronized Map<String, String> listTagsForResource(String resourceArn, String region) {
return new HashMap<>(requireAlarm(resourceArn, region).getTags());
}
public void tagResource(String resourceArn, Map<String, String> tags, String region) {
MetricAlarm alarm = requireAlarm(resourceArn, region);
public synchronized void tagResource(String resourceArn, Map<String, String> tags, String region) {
Alarm alarm = requireAlarm(resourceArn, region);
alarm.getTags().putAll(tags);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
}
public void untagResource(String resourceArn, List<String> tagKeys, String region) {
MetricAlarm alarm = requireAlarm(resourceArn, region);
public synchronized void untagResource(String resourceArn, List<String> tagKeys, String region) {
Alarm alarm = requireAlarm(resourceArn, region);
tagKeys.forEach(alarm.getTags()::remove);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
}
@@ -0,0 +1,184 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
/** CloudWatch's boolean alarm language, parsed completely before any alarm is stored. */
final class CompositeAlarmRule {
private final String input;
private final Set<String> references = new HashSet<>();
private int offset;
private int elements;
private CompositeAlarmRule(String input) {
this.input = input;
}
static Rule parse(String input) {
if (input == null || input.isBlank() || input.length() > 10240) {
throw SmithyValidationErrors.validationError(input == null ? SmithyValidationErrors.notNull("alarmRule")
: "Value '" + input + "' at 'alarmRule' failed to satisfy constraint: Member must have length "
+ (input.length() > 10240 ? "less than or equal to 10240" : "greater than or equal to 1"));
}
CompositeAlarmRule parser = new CompositeAlarmRule(input);
Expression expression = parser.or();
parser.whitespace();
if (parser.offset != input.length()) {
throw invalid("Invalid AlarmRule");
}
return new Rule(expression, Set.copyOf(parser.references));
}
private Expression or() {
Expression left = and();
while (word("OR")) {
Expression prior = left;
Expression right = and();
left = states -> prior.evaluate(states) || right.evaluate(states);
}
return left;
}
private Expression and() {
Expression left = atom();
while (word("AND")) {
Expression prior = left;
Expression right = atom();
left = states -> prior.evaluate(states) && right.evaluate(states);
}
return left;
}
private Expression atom() {
boolean negate = false;
while (word("NOT")) {
negate = !negate;
}
Expression operand = primary();
return negate ? states -> !operand.evaluate(states) : operand;
}
private Expression primary() {
element();
if (character('(')) {
Expression nested = or();
require(')');
element();
return nested;
}
if (word("TRUE")) {
return states -> true;
}
if (word("FALSE")) {
return states -> false;
}
String state;
if (word("ALARM")) {
state = "ALARM";
} else if (word("OK")) {
state = "OK";
} else if (word("INSUFFICIENT_DATA")) {
state = "INSUFFICIENT_DATA";
} else {
throw invalid("Expected an alarm state, boolean, NOT, or parenthesis");
}
require('(');
whitespace();
String name;
if (character('"')) {
StringBuilder quoted = new StringBuilder();
boolean closed = false;
while (offset < input.length()) {
char next = input.charAt(offset++);
if (next == '"') {
closed = true;
break;
}
if (next == '\\') {
if (offset == input.length()) {
throw invalid("Unterminated quoted alarm name");
}
next = input.charAt(offset++);
if (next != '\\' && next != '"') {
throw invalid("Invalid quoted alarm name escape");
}
}
quoted.append(next);
}
if (!closed) {
throw invalid("Unterminated quoted alarm name");
}
name = quoted.toString();
} else {
int start = offset;
while (offset < input.length() && input.charAt(offset) != ')') {
offset++;
}
name = input.substring(start, offset).trim();
}
if (name.isEmpty()) {
throw invalid("An alarm name or ARN is required");
}
require(')');
references.add(name);
return states -> state.equals(states.apply(name));
}
private void element() {
if (++elements > 500) {
throw invalid("AlarmRule exceeds 500 child alarms, booleans, and parentheses");
}
}
private boolean word(String word) {
whitespace();
if (!input.startsWith(word, offset)) {
return false;
}
int end = offset + word.length();
if (end < input.length() && (Character.isLetterOrDigit(input.charAt(end)) || input.charAt(end) == '_')) {
return false;
}
offset = end;
return true;
}
private boolean character(char expected) {
whitespace();
if (offset < input.length() && input.charAt(offset) == expected) {
offset++;
return true;
}
return false;
}
private void require(char expected) {
if (!character(expected)) {
throw invalid("Expected '" + expected + "' in AlarmRule");
}
}
private void whitespace() {
while (offset < input.length() && Character.isWhitespace(input.charAt(offset))) {
offset++;
}
}
private static AwsException invalid(String message) {
return new AwsException("ValidationError", message, 400);
}
record Rule(Expression expression, Set<String> references) {
boolean evaluate(Function<String, String> states) {
return expression.evaluate(states);
}
}
interface Expression {
boolean evaluate(Function<String, String> states);
}
}
@@ -0,0 +1,87 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "alarmType", defaultImpl = MetricAlarm.class)
@JsonSubTypes({@JsonSubTypes.Type(value = MetricAlarm.class, name = "MetricAlarm"),
@JsonSubTypes.Type(value = CompositeAlarm.class, name = "CompositeAlarm")})
public abstract class Alarm {
private String alarmName;
private String alarmArn;
private String alarmDescription;
private long alarmConfigurationUpdatedTimestamp;
private boolean actionsEnabled;
private List<String> okActions = new ArrayList<>();
private List<String> alarmActions = new ArrayList<>();
private List<String> insufficientDataActions = new ArrayList<>();
private String stateValue = "INSUFFICIENT_DATA";
private String stateReason = "Unchecked";
private String stateReasonData;
private long stateUpdatedTimestamp;
private Map<String, String> tags = new HashMap<>();
private String region;
protected Alarm() {
long now = Instant.now().getEpochSecond();
this.alarmConfigurationUpdatedTimestamp = now;
this.stateUpdatedTimestamp = now;
}
public abstract String getAlarmType();
public String getAlarmName() { return alarmName; }
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
public String getAlarmArn() { return alarmArn; }
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
public String getAlarmDescription() { return alarmDescription; }
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
public boolean isActionsEnabled() { return actionsEnabled; }
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
public List<String> getOkActions() { return okActions; }
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
public List<String> getAlarmActions() { return alarmActions; }
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
public String getStateValue() { return stateValue; }
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
public String getStateReason() { return stateReason; }
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
public String getStateReasonData() { return stateReasonData; }
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
public Map<String, String> getTags() { return tags; }
public void setTags(Map<String, String> tags) { this.tags = tags; }
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
}
@@ -0,0 +1,14 @@
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public class CompositeAlarm extends Alarm {
private String alarmRule;
@Override
public String getAlarmType() { return "CompositeAlarm"; }
public String getAlarmRule() { return alarmRule; }
public void setAlarmRule(String alarmRule) { this.alarmRule = alarmRule; }
}
@@ -3,27 +3,15 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RegisterForReflection
@JsonIgnoreProperties(ignoreUnknown = true)
public class MetricAlarm {
private String alarmName;
private String alarmArn;
private String alarmDescription;
private long alarmConfigurationUpdatedTimestamp;
private boolean actionsEnabled;
private List<String> okActions = new ArrayList<>();
private List<String> alarmActions = new ArrayList<>();
private List<String> insufficientDataActions = new ArrayList<>();
private String stateValue = "INSUFFICIENT_DATA";
private String stateReason = "Unchecked";
private String stateReasonData;
private long stateUpdatedTimestamp;
public class MetricAlarm extends Alarm {
@Override
public String getAlarmType() { return "MetricAlarm"; }
private String metricName;
private String namespace;
private String statistic;
@@ -45,50 +33,6 @@ public class MetricAlarm {
private String comparisonOperator;
private String treatMissingData = "missing";
private String evaluateLowSampleCountPercentile;
private Map<String, String> tags = new HashMap<>();
private String region;
public MetricAlarm() {
long now = Instant.now().getEpochSecond();
this.alarmConfigurationUpdatedTimestamp = now;
this.stateUpdatedTimestamp = now;
}
public String getAlarmName() { return alarmName; }
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
public String getAlarmArn() { return alarmArn; }
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
public String getAlarmDescription() { return alarmDescription; }
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
public boolean isActionsEnabled() { return actionsEnabled; }
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
public List<String> getOkActions() { return okActions; }
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
public List<String> getAlarmActions() { return alarmActions; }
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
public String getStateValue() { return stateValue; }
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
public String getStateReason() { return stateReason; }
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
public String getStateReasonData() { return stateReasonData; }
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
public String getMetricName() { return metricName; }
public void setMetricName(String metricName) { this.metricName = metricName; }
@@ -131,11 +75,4 @@ public class MetricAlarm {
public String getEvaluateLowSampleCountPercentile() { return evaluateLowSampleCountPercentile; }
public void setEvaluateLowSampleCountPercentile(String percentile) { this.evaluateLowSampleCountPercentile = percentile; }
public Map<String, String> getTags() { return tags; }
public void setTags(Map<String, String> tags) { this.tags = tags; }
/** Not part of the AWS shape; set by {@code putMetricAlarm} so a background evaluator
* that only holds the alarm object still knows which region's metric data to query. */
public String getRegion() { return region; }
public void setRegion(String region) { this.region = region; }
}
@@ -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
+3
View File
@@ -169,6 +169,9 @@ notes:
# Trailing prose appended to a row after its type list, for behaviour that belongs to the service
# rather than to one type.
row_notes:
AWS::CloudWatch: >-
Composite alarms have backing definitions with name Ref and Arn GetAtt, rollback, and
replacement cleanup. Composite actions are stored only; suppression properties are rejected.
AWS::Lambda: >-
Inline `ZipFile` packages include the `cfn-response` (Node.js) / `cfnresponse` (Python) module
AWS injects for that code path, so Solutions-style custom-resource handlers work.