From fa3327bf71b922b4d03515efc7d967ee0934d0b6 Mon Sep 17 00:00:00 2001 From: Andre Kurait Date: Sat, 24 Oct 2026 16:54:21 -0500 Subject: [PATCH] feat(cloudwatch): support composite alarm lifecycle (#6408) (cherry picked from commit 922e36a472341861e97d1b5639656f3c4a3978d8) --- docs/services/cloudformation.md | 2 +- docs/services/cloudwatch.md | 6 + ...loudWatchCompositeAlarmCfnProvisioner.java | 183 +++++++++++ .../cloudwatch/metrics/AlarmSnsNotifier.java | 25 +- .../metrics/CloudWatchMetricsJsonHandler.java | 98 ++++-- .../CloudWatchMetricsQueryHandler.java | 140 +++++---- .../metrics/CloudWatchMetricsService.java | 290 +++++++++++++++--- .../metrics/CompositeAlarmRule.java | 184 +++++++++++ .../cloudwatch/metrics/model/Alarm.java | 87 ++++++ .../metrics/model/CompositeAlarm.java | 14 + .../cloudwatch/metrics/model/MetricAlarm.java | 71 +---- .../cloudformation/CfnProvisionerFixture.java | 2 + ...ormationCompositeAlarmIntegrationTest.java | 190 ++++++++++++ ...WatchCompositeAlarmCfnProvisionerTest.java | 89 ++++++ .../metrics/AlarmSnsNotifierTest.java | 43 +++ .../CloudWatchAlarmQueryHandlerTest.java | 45 +++ .../CloudWatchMetricMathAlarmTest.java | 8 +- .../CompositeAlarmIntegrationTest.java | 160 ++++++++++ .../metrics/CompositeAlarmServiceTest.java | 274 +++++++++++++++++ .../supported-resource-types.tsv | 1 + tools/docs/cfn_resource_types.yaml | 3 + 21 files changed, 1721 insertions(+), 194 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java diff --git a/docs/services/cloudformation.md b/docs/services/cloudformation.md index d09a4eb..3b73d07 100644 --- a/docs/services/cloudformation.md +++ b/docs/services/cloudformation.md @@ -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` | diff --git a/docs/services/cloudwatch.md b/docs/services/cloudwatch.md index 34b16ef..eda278a 100644 --- a/docs/services/cloudwatch.md +++ b/docs/services/cloudwatch.md @@ -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 diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java new file mode 100644 index 0000000..616334a --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java @@ -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 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 desiredTags = Map.copyOf(alarm.getTags()); + Map 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 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 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 target) { + JsonNode values = ctx.engine().resolveNode(props.path(field)); + if (values != null && values.isArray()) { + values.forEach(value -> target.add(value.asText())); + } + } + + private static Set tagKeys(String json) { + Set 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 owned, Map desired, String region) { + List 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 originalTags = Map.copyOf(original.getTags()); + service.putCompositeAlarm(original, region); + Set 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); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java index 41332ef..c1a5463 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java @@ -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 prepareTransition(MetricAlarm alarm, String oldState) { if (!alarm.isActionsEnabled() || alarm.getStateValue().equals(oldState)) { - return; + return List.of(); } List 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 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 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 { diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java index 35c0307..0920b95 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java @@ -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 alarms = metricsService.describeAlarms(alarmNames, prefix, region); + List types = new ArrayList<>(); + request.path("AlarmTypes").forEach(value -> types.add(value.asText())); + metricsService.validateAlarmTypes(types); + List 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 alarmNames = new ArrayList<>(); JsonNode namesNode = request.path("AlarmNames"); diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java index 7b55d0e..5ad8ff3 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java @@ -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 params, String region) { @@ -243,6 +252,26 @@ public class CloudWatchMetricsQueryHandler { return Response.ok(AwsQueryResponse.envelopeNoResult("PutMetricAlarm", null)).build(); } + private Response handlePutCompositeAlarm(MultivaluedMap 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 alarmMembers(MultivaluedMap params, String key) { + List 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 params, String region) { List alarmNames = new ArrayList<>(); for (int i = 1; ; i++) { @@ -252,15 +281,24 @@ public class CloudWatchMetricsQueryHandler { } String prefix = params.getFirst("AlarmNamePrefix"); - List alarms = metricsService.describeAlarms(alarmNames, prefix, region); + List types = alarmMembers(params, "AlarmTypes"); + metricsService.validateAlarmTypes(types); + List 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 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 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 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 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 parseAlarmMetricQueries(MultivaluedMap params) { List 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 metrics) { xml.start("Metrics"); for (AlarmMetricDataQuery query : metrics) { diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java index d7c35e2..74e3d18 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java @@ -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 ALARM_TYPES = List.of("CompositeAlarm", "MetricAlarm", "LogAlarm"); + private final StorageBackend metricStore; - private final StorageBackend alarmStore; + private final StorageBackend 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>() {}); this.alarmStore = storageFactory.create("cloudwatchmetrics", "cwalarms.json", - new TypeReference>() {}); + new TypeReference>() {}); this.regionResolver = regionResolver; this.alarmSnsNotifier = alarmSnsNotifier; } CloudWatchMetricsService(StorageBackend metricStore, - StorageBackend alarmStore, + StorageBackend alarmStore, RegionResolver regionResolver) { this(metricStore, alarmStore, regionResolver, null); } CloudWatchMetricsService(StorageBackend metricStore, - StorageBackend alarmStore, + StorageBackend alarmStore, RegionResolver regionResolver, AlarmSnsNotifier alarmSnsNotifier) { this.metricStore = metricStore; this.alarmStore = alarmStore; @@ -296,57 +308,253 @@ 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 references = alarmReferences(alarm, region); + if (references.size() > 100) { + throw new AwsException("LimitExceeded", "AlarmRule references more than 100 child alarms", 400); + } + List 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 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 allAlarms() { - return alarmStore.scan(k -> true); + return alarmStore.scan(k -> true).stream().filter(MetricAlarm.class::isInstance) + .map(MetricAlarm.class::cast).toList(); } - public List describeAlarms(List alarmNames, String alarmNamePrefix, String region) { - String prefix = region + "::"; - List 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 describeAlarms(List names, String prefix, String region) { + return matchingAlarms(names, prefix, region).stream().filter(MetricAlarm.class::isInstance) + .map(MetricAlarm.class::cast).toList(); } - public void deleteAlarms(List alarmNames, String region) { + public void validateAlarmTypes(List types) { + if (!ALARM_TYPES.containsAll(types)) { + throw SmithyValidationErrors.validationError( + SmithyValidationErrors.notAllInEnum(types, "alarmTypes", ALARM_TYPES)); + } + } + + public List describeCompositeAlarms(List names, String prefix, String region) { + return matchingAlarms(names, prefix, region).stream().filter(CompositeAlarm.class::isInstance) + .map(CompositeAlarm.class::cast).toList(); + } + + private List matchingAlarms(List 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 alarmIndex(String region) { + Map 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 changedAlarmNames, String newAlarm) { + if (changedAlarmNames.isEmpty() && newAlarm == null) { + return; + } + Map alarms = alarmIndex(region); + Set changed = new HashSet<>(changedAlarmNames); + for (String name : changedAlarmNames) { + changed.add(regionResolver.buildArn("cloudwatch", region, "alarm:" + name)); + } + Map 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 affected = new HashSet<>(changed); + Map pending = new HashMap<>(); + boolean discovered; + do { + discovered = false; + Iterator> iterator = candidates.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry 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 cycles = pending.keySet().stream() + .filter(name -> isCyclic(name, alarms)).collect(Collectors.toSet()); + boolean progress; + do { + progress = false; + Iterator> iterator = pending.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry 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 alarmNames, String region) { + Map 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 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 alarms) { + Set visited = new HashSet<>(); + ArrayDeque 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) { - String key = region + "::" + alarmName; - MetricAlarm alarm = alarmStore.get(key) - .orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404)); + List notifications = List.of(); + synchronized (this) { + String key = region + "::" + alarmName; + Alarm alarm = alarmStore.get(key) + .orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404)); - String oldState = alarm.getStateValue(); - alarm.setStateValue(stateValue); - alarm.setStateReason(stateReason); - alarm.setStateReasonData(stateReasonData); - alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond()); + String oldState = alarm.getStateValue(); + alarm.setStateValue(stateValue); + alarm.setStateReason(stateReason); + alarm.setStateReasonData(stateReasonData); + alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond()); - alarmStore.put(key, alarm); - LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue); + 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 listTagsForResource(String resourceArn, String region) { - return requireAlarm(resourceArn, region).getTags(); + public synchronized Map listTagsForResource(String resourceArn, String region) { + return new HashMap<>(requireAlarm(resourceArn, region).getTags()); } - public void tagResource(String resourceArn, Map tags, String region) { - MetricAlarm alarm = requireAlarm(resourceArn, region); + public synchronized void tagResource(String resourceArn, Map tags, String region) { + Alarm alarm = requireAlarm(resourceArn, region); alarm.getTags().putAll(tags); alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); } - public void untagResource(String resourceArn, List tagKeys, String region) { - MetricAlarm alarm = requireAlarm(resourceArn, region); + public synchronized void untagResource(String resourceArn, List tagKeys, String region) { + Alarm alarm = requireAlarm(resourceArn, region); tagKeys.forEach(alarm.getTags()::remove); alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java new file mode 100644 index 0000000..8d2d97e --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java @@ -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 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 references) { + boolean evaluate(Function states) { + return expression.evaluate(states); + } + } + + interface Expression { + boolean evaluate(Function states); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java new file mode 100644 index 0000000..ce81d6c --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java @@ -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 okActions = new ArrayList<>(); + private List alarmActions = new ArrayList<>(); + private List insufficientDataActions = new ArrayList<>(); + private String stateValue = "INSUFFICIENT_DATA"; + private String stateReason = "Unchecked"; + private String stateReasonData; + private long stateUpdatedTimestamp; + private Map 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 getOkActions() { return okActions; } + public void setOkActions(List okActions) { this.okActions = okActions; } + + public List getAlarmActions() { return alarmActions; } + public void setAlarmActions(List alarmActions) { this.alarmActions = alarmActions; } + + public List getInsufficientDataActions() { return insufficientDataActions; } + public void setInsufficientDataActions(List 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 getTags() { return tags; } + public void setTags(Map tags) { this.tags = tags; } + + + public String getRegion() { return region; } + public void setRegion(String region) { this.region = region; } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java new file mode 100644 index 0000000..c426e31 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java @@ -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; } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java index 7347be3..0701772 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java @@ -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 okActions = new ArrayList<>(); - private List alarmActions = new ArrayList<>(); - private List 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 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 getOkActions() { return okActions; } - public void setOkActions(List okActions) { this.okActions = okActions; } - - public List getAlarmActions() { return alarmActions; } - public void setAlarmActions(List alarmActions) { this.alarmActions = alarmActions; } - - public List getInsufficientDataActions() { return insufficientDataActions; } - public void setInsufficientDataActions(List 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 getTags() { return tags; } - public void setTags(Map 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; } } diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java index 9a01129..b131756 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java @@ -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)); diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java new file mode 100644 index 0000000..42d8805 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java @@ -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> 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 tags, boolean fail, boolean retain) + throws JsonProcessingException { + cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200)); + Map 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 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 alarm(String name) throws JsonProcessingException { + List> 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); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java new file mode 100644 index 0000000..c1a208a --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java @@ -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"); + } + +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java index 8a638b6..f6ebcad 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java @@ -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.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 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()); + } } diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java new file mode 100644 index 0000000..0612237 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java @@ -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 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()); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java index e7b16a9..c540198 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java @@ -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 alarmStore) { + private void initialize(StorageBackend 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> type = new TypeReference<>() { }; + TypeReference> type = new TypeReference<>() { }; initialize(new PersistentStorage<>(storageFile, type)); ObjectNode request = metricMathRequest(); put(request, queryProtocol); - PersistentStorage reloaded = new PersistentStorage<>(storageFile, type); + PersistentStorage reloaded = new PersistentStorage<>(storageFile, type); reloaded.load(); assertEquals(1, reloaded.keys().size(), "the stored alarm must deserialize after a restart"); initialize(reloaded); diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java new file mode 100644 index 0000000..d30ad43 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java @@ -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("")); + 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 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 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 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("/"); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java new file mode 100644 index 0000000..42797f9 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java @@ -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 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> type = new TypeReference<>() {}; + Map legacy = mapper.readValue( + "{\"legacy\":{\"alarmName\":\"old\",\"metricName\":\"CPU\",\"stateValue\":\"OK\"}}", type); + assertInstanceOf(MetricAlarm.class, legacy.get("legacy")); + CompositeAlarm alarm = put("new", "FALSE"); + Map 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 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 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(); + } +} diff --git a/src/test/resources/cloudformation/supported-resource-types.tsv b/src/test/resources/cloudformation/supported-resource-types.tsv index 48280ea..6d47e3f 100644 --- a/src/test/resources/cloudformation/supported-resource-types.tsv +++ b/src/test/resources/cloudformation/supported-resource-types.tsv @@ -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 diff --git a/tools/docs/cfn_resource_types.yaml b/tools/docs/cfn_resource_types.yaml index ba37eef..1055d74 100644 --- a/tools/docs/cfn_resource_types.yaml +++ b/tools/docs/cfn_resource_types.yaml @@ -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.