feat(cloudwatch): support composite alarm lifecycle (#6408)
(cherry picked from commit 922e36a472341861e97d1b5639656f3c4a3978d8)
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package io.github.hectorvent.floci.services.cloudformation.provisioners;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ApplicationScoped
|
||||
public class CloudWatchCompositeAlarmCfnProvisioner implements CfnResourceProvisioner {
|
||||
private static final String TYPE = "AWS::CloudWatch::CompositeAlarm";
|
||||
private static final String SNAPSHOT = "FlociCompositeAlarmSnapshot";
|
||||
private static final String TAG_KEYS = "FlociCompositeAlarmTagKeys";
|
||||
private static final String EXPLICIT_NAME = "FlociCompositeAlarmExplicitName";
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final CloudWatchMetricsService service;
|
||||
|
||||
public CloudWatchCompositeAlarmCfnProvisioner(CloudWatchMetricsService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> resourceTypes() {
|
||||
return Set.of(TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provision(StackResource resource, JsonNode props, ProvisionContext ctx) {
|
||||
for (String unsupported : List.of("ActionsSuppressor", "ActionsSuppressorWaitPeriod",
|
||||
"ActionsSuppressorExtensionPeriod")) {
|
||||
if (props.has(unsupported)) {
|
||||
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
|
||||
}
|
||||
}
|
||||
if (resource.getAttributes().containsKey(SNAPSHOT)) {
|
||||
throw new AwsException("ResourceConflictException", "Alarm update cleanup is still pending", 400);
|
||||
}
|
||||
String explicit = ctx.resolveOptional(props, "AlarmName");
|
||||
boolean removedName = explicit == null && "true".equals(resource.getAttributes().get(EXPLICIT_NAME));
|
||||
String name = removedName ? ctx.generatePhysicalName(resource.getLogicalId(), 255, false)
|
||||
: ctx.stablePhysicalName(explicit, resource.getLogicalId(), 255, false);
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
alarm.setAlarmName(name);
|
||||
alarm.setAlarmDescription(ctx.resolveOptional(props, "AlarmDescription"));
|
||||
alarm.setAlarmRule(ctx.resolveOptional(props, "AlarmRule"));
|
||||
alarm.setActionsEnabled(!"false".equals(ctx.resolveOptional(props, "ActionsEnabled")));
|
||||
alarm.setTags(ctx.resolveTags(props, "Tags"));
|
||||
actions(props, "AlarmActions", ctx, alarm.getAlarmActions());
|
||||
actions(props, "OKActions", ctx, alarm.getOkActions());
|
||||
actions(props, "InsufficientDataActions", ctx, alarm.getInsufficientDataActions());
|
||||
Map<String, String> desiredTags = Map.copyOf(alarm.getTags());
|
||||
Map<String, String> priorAttributes = Map.copyOf(resource.getAttributes());
|
||||
boolean inPlace = ctx.reusesPriorEntity(name);
|
||||
String updateSnapshot = null;
|
||||
if (inPlace) {
|
||||
ObjectNode snapshot = MAPPER.createObjectNode().put("region", ctx.region()).put("name", name);
|
||||
List<CompositeAlarm> previous = service.describeCompositeAlarms(List.of(name), null, ctx.region());
|
||||
if (!previous.isEmpty()) {
|
||||
snapshot.set("alarm", MAPPER.valueToTree(previous.getFirst()));
|
||||
}
|
||||
snapshot.set("attributes", MAPPER.valueToTree(priorAttributes));
|
||||
Set<String> changedKeys = new TreeSet<>(desiredTags.keySet());
|
||||
changedKeys.addAll(tagKeys(resource.getAttributes().get(TAG_KEYS)));
|
||||
snapshot.set("changedTagKeys", MAPPER.valueToTree(changedKeys));
|
||||
updateSnapshot = snapshot.toString();
|
||||
}
|
||||
service.putCompositeAlarm(alarm, ctx.region(), inPlace);
|
||||
if (inPlace) {
|
||||
resource.getAttributes().put(SNAPSHOT, updateSnapshot);
|
||||
reconcileTags(alarm.getAlarmArn(), tagKeys(priorAttributes.get(TAG_KEYS)), desiredTags, ctx.region());
|
||||
}
|
||||
resource.getAttributes().put(TAG_KEYS, MAPPER.valueToTree(desiredTags.keySet()).toString());
|
||||
resource.setPhysicalId(name);
|
||||
resource.getAttributes().put("Arn", alarm.getAlarmArn());
|
||||
resource.getAttributes().put(EXPLICIT_NAME, Boolean.toString(explicit != null));
|
||||
ReplacementCleanup.record(resource, ctx, priorAttributes);
|
||||
}
|
||||
|
||||
private static void actions(JsonNode props, String field, ProvisionContext ctx, List<String> target) {
|
||||
JsonNode values = ctx.engine().resolveNode(props.path(field));
|
||||
if (values != null && values.isArray()) {
|
||||
values.forEach(value -> target.add(value.asText()));
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> tagKeys(String json) {
|
||||
Set<String> keys = new TreeSet<>();
|
||||
if (json != null) {
|
||||
try {
|
||||
MAPPER.readTree(json).forEach(value -> keys.add(value.asText()));
|
||||
} catch (JsonProcessingException error) {
|
||||
throw new IllegalStateException("Cannot read composite alarm tag ownership", error);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void reconcileTags(String arn, Set<String> owned, Map<String, String> desired, String region) {
|
||||
List<String> removed = owned.stream().filter(key -> !desired.containsKey(key)).toList();
|
||||
if (!removed.isEmpty()) {
|
||||
service.untagResource(arn, removed, region);
|
||||
}
|
||||
if (!desired.isEmpty()) {
|
||||
service.tagResource(arn, desired, region);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String type, String physicalId, String region) {
|
||||
service.deleteAlarms(List.of(physicalId), region);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retainsFailedUpdateState(StackResource resource) {
|
||||
return resource.getAttributes().containsKey(SNAPSHOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean rollbackUpdate(StackResource resource) {
|
||||
if (ReplacementCleanup.rollback(resource, this::delete)) {
|
||||
return true;
|
||||
}
|
||||
String raw = resource.getAttributes().get(SNAPSHOT);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JsonNode snapshot = MAPPER.readTree(raw);
|
||||
String region = snapshot.path("region").asText();
|
||||
if (snapshot.has("alarm")) {
|
||||
CompositeAlarm original = MAPPER.treeToValue(snapshot.get("alarm"), CompositeAlarm.class);
|
||||
Map<String, String> originalTags = Map.copyOf(original.getTags());
|
||||
service.putCompositeAlarm(original, region);
|
||||
Set<String> changed = tagKeys(snapshot.path("changedTagKeys").toString());
|
||||
reconcileTags(original.getAlarmArn(), changed, originalTags.entrySet().stream()
|
||||
.filter(entry -> changed.contains(entry.getKey()))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), region);
|
||||
} else {
|
||||
delete(TYPE, snapshot.path("name").asText(), region);
|
||||
}
|
||||
resource.getAttributes().clear();
|
||||
snapshot.path("attributes").fields().forEachRemaining(entry ->
|
||||
resource.getAttributes().put(entry.getKey(), entry.getValue().asText()));
|
||||
} catch (JsonProcessingException error) {
|
||||
throw new IllegalStateException("Cannot read composite alarm rollback state", error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasReplacementUpdate(StackResource resource) {
|
||||
return ReplacementCleanup.hasReplacement(resource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String updateCleanupPhysicalId(StackResource resource) {
|
||||
return ReplacementCleanup.cleanupPhysicalId(resource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpdateCleanupResult completeUpdate(StackResource resource) {
|
||||
if (resource.getAttributes().containsKey(SNAPSHOT) && !ReplacementCleanup.hasReplacement(resource)) {
|
||||
return new UpdateCleanupResult(true, true, null, 0, null);
|
||||
}
|
||||
return ReplacementCleanup.complete(resource, this::delete);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearUpdate(StackResource resource) {
|
||||
resource.getAttributes().remove(SNAPSHOT);
|
||||
ReplacementCleanup.clear(resource);
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -11,6 +11,7 @@ import org.jboss.logging.Logger;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -63,9 +64,11 @@ public class AlarmSnsNotifier {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void notifyTransition(MetricAlarm alarm, String oldState) {
|
||||
record Notification(String topicArn, String message, String subject, String region) {}
|
||||
|
||||
List<Notification> prepareTransition(MetricAlarm alarm, String oldState) {
|
||||
if (!alarm.isActionsEnabled() || alarm.getStateValue().equals(oldState)) {
|
||||
return;
|
||||
return List.of();
|
||||
}
|
||||
List<String> actions = switch (alarm.getStateValue()) {
|
||||
case "ALARM" -> alarm.getAlarmActions();
|
||||
@@ -74,8 +77,9 @@ public class AlarmSnsNotifier {
|
||||
default -> List.of();
|
||||
};
|
||||
if (actions == null) {
|
||||
return;
|
||||
return List.of();
|
||||
}
|
||||
List<Notification> notifications = new ArrayList<>();
|
||||
for (String action : actions) {
|
||||
if (!AwsArnUtils.isArnFor(action, "sns")) {
|
||||
continue;
|
||||
@@ -86,12 +90,25 @@ public class AlarmSnsNotifier {
|
||||
if (subject.length() > 100) {
|
||||
subject = subject.substring(0, 100);
|
||||
}
|
||||
snsService.publish(action, null, message(alarm, oldState), subject, topic.region());
|
||||
notifications.add(new Notification(action, message(alarm, oldState), subject, topic.region()));
|
||||
} catch (Exception e) {
|
||||
LOG.warnv(e, "Unable to publish alarm {0} state {1} to SNS action {2}",
|
||||
alarm.getAlarmName(), alarm.getStateValue(), action);
|
||||
}
|
||||
}
|
||||
return notifications;
|
||||
}
|
||||
|
||||
void publish(List<Notification> notifications) {
|
||||
for (Notification notification : notifications) {
|
||||
try {
|
||||
snsService.publish(notification.topicArn(), null, notification.message(),
|
||||
notification.subject(), notification.region());
|
||||
} catch (Exception error) {
|
||||
LOG.warnv(error, "Unable to publish alarm transition {0} to SNS action {1}",
|
||||
notification.subject(), notification.topicArn());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String message(MetricAlarm alarm, String oldState) throws JsonProcessingException {
|
||||
|
||||
+77
-21
@@ -5,9 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.github.hectorvent.floci.core.common.AwsErrorResponse;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.PaginatedResult;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.dashboards.model.Dashboard;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter;
|
||||
@@ -57,6 +64,7 @@ public class CloudWatchMetricsJsonHandler {
|
||||
case "ListMetrics" -> handleListMetrics(request, region);
|
||||
case "GetMetricStatistics" -> handleGetMetricStatistics(request, region);
|
||||
case "PutMetricAlarm" -> handlePutMetricAlarm(request, region);
|
||||
case "PutCompositeAlarm" -> handlePutCompositeAlarm(request, region);
|
||||
case "DescribeAlarms" -> handleDescribeAlarms(request, region);
|
||||
case "DeleteAlarms" -> handleDeleteAlarms(request, region);
|
||||
case "SetAlarmState" -> handleSetAlarmState(request, region);
|
||||
@@ -156,8 +164,7 @@ public class CloudWatchMetricsJsonHandler {
|
||||
|
||||
private Response handlePutMetricAlarm(JsonNode request, String region) {
|
||||
MetricAlarm alarm = new MetricAlarm();
|
||||
alarm.setAlarmName(request.path("AlarmName").asText());
|
||||
alarm.setAlarmDescription(request.path("AlarmDescription").asText(null));
|
||||
parseAlarmFields(request, alarm);
|
||||
alarm.setMetricName(request.path("MetricName").asText(null));
|
||||
alarm.setNamespace(request.path("Namespace").asText(null));
|
||||
alarm.setStatistic(request.path("Statistic").asText(null));
|
||||
@@ -175,9 +182,33 @@ public class CloudWatchMetricsJsonHandler {
|
||||
alarm.setThreshold(request.path("Threshold").asDouble(0));
|
||||
alarm.setComparisonOperator(request.path("ComparisonOperator").asText(null));
|
||||
alarm.setTreatMissingData(request.path("TreatMissingData").asText(null));
|
||||
alarm.setActionsEnabled(request.path("ActionsEnabled").asBoolean(true));
|
||||
alarm.setDimensions(parseDimensionsJson(request.path("Dimensions")));
|
||||
|
||||
metricsService.putMetricAlarm(alarm, region);
|
||||
return Response.ok(objectMapper.createObjectNode()).build();
|
||||
}
|
||||
|
||||
private Response handlePutCompositeAlarm(JsonNode request, String region) {
|
||||
rejectSuppressor(request);
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
parseAlarmFields(request, alarm);
|
||||
alarm.setAlarmRule(request.path("AlarmRule").asText(null));
|
||||
metricsService.putCompositeAlarm(alarm, region);
|
||||
return Response.ok(objectMapper.createObjectNode()).build();
|
||||
}
|
||||
|
||||
private static void rejectSuppressor(JsonNode request) {
|
||||
if (request.has("ActionsSuppressor") || request.has("ActionsSuppressorWaitPeriod")
|
||||
|| request.has("ActionsSuppressorExtensionPeriod")) {
|
||||
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
|
||||
}
|
||||
}
|
||||
|
||||
private void parseAlarmFields(JsonNode request, Alarm alarm) {
|
||||
alarm.setAlarmName(request.path("AlarmName").asText());
|
||||
alarm.setAlarmDescription(request.path("AlarmDescription").asText(null));
|
||||
alarm.setActionsEnabled(request.path("ActionsEnabled").asBoolean(true));
|
||||
|
||||
JsonNode alarmActions = request.path("AlarmActions");
|
||||
if (alarmActions.isArray()) {
|
||||
alarmActions.forEach(a -> alarm.getAlarmActions().add(a.asText()));
|
||||
@@ -198,8 +229,6 @@ public class CloudWatchMetricsJsonHandler {
|
||||
alarm.setTags(tags);
|
||||
}
|
||||
|
||||
metricsService.putMetricAlarm(alarm, region);
|
||||
return Response.ok(objectMapper.createObjectNode()).build();
|
||||
}
|
||||
|
||||
private Response handleDescribeAlarms(JsonNode request, String region) {
|
||||
@@ -210,21 +239,17 @@ public class CloudWatchMetricsJsonHandler {
|
||||
}
|
||||
String prefix = request.has("AlarmNamePrefix") ? request.path("AlarmNamePrefix").asText() : null;
|
||||
|
||||
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
|
||||
List<String> types = new ArrayList<>();
|
||||
request.path("AlarmTypes").forEach(value -> types.add(value.asText()));
|
||||
metricsService.validateAlarmTypes(types);
|
||||
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
|
||||
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
|
||||
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
ArrayNode arr = response.putArray("MetricAlarms");
|
||||
for (MetricAlarm a : alarms) {
|
||||
ObjectNode node = arr.addObject();
|
||||
node.put("AlarmName", a.getAlarmName());
|
||||
if (a.getAlarmArn() != null) node.put("AlarmArn", a.getAlarmArn());
|
||||
if (a.getAlarmDescription() != null) node.put("AlarmDescription", a.getAlarmDescription());
|
||||
ArrayNode alarmActions = node.putArray("AlarmActions");
|
||||
a.getAlarmActions().forEach(alarmActions::add);
|
||||
ArrayNode okActions = node.putArray("OKActions");
|
||||
a.getOkActions().forEach(okActions::add);
|
||||
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
|
||||
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
|
||||
describeAlarmFields(node, a);
|
||||
if (!a.getMetrics().isEmpty()) {
|
||||
node.set("Metrics", objectMapper.valueToTree(a.getMetrics()));
|
||||
} else {
|
||||
@@ -257,17 +282,48 @@ public class CloudWatchMetricsJsonHandler {
|
||||
node.put("Threshold", a.getThreshold());
|
||||
if (a.getComparisonOperator() != null) node.put("ComparisonOperator", a.getComparisonOperator());
|
||||
if (a.getTreatMissingData() != null) node.put("TreatMissingData", a.getTreatMissingData());
|
||||
node.put("ActionsEnabled", a.isActionsEnabled());
|
||||
if (a.getStateValue() != null) node.put("StateValue", a.getStateValue());
|
||||
if (a.getStateReason() != null) node.put("StateReason", a.getStateReason());
|
||||
if (a.getStateReasonData() != null) node.put("StateReasonData", a.getStateReasonData());
|
||||
node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp());
|
||||
|
||||
}
|
||||
ArrayNode composites = response.putArray("CompositeAlarms");
|
||||
if (types.contains("CompositeAlarm")) {
|
||||
for (CompositeAlarm alarm : metricsService.describeCompositeAlarms(alarmNames, prefix, region)) {
|
||||
ObjectNode node = composites.addObject();
|
||||
describeAlarmFields(node, alarm);
|
||||
node.put("AlarmRule", alarm.getAlarmRule());
|
||||
}
|
||||
}
|
||||
response.putArray("CompositeAlarms");
|
||||
response.putArray("LogAlarms");
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
private void describeAlarmFields(ObjectNode node, Alarm a) {
|
||||
node.put("AlarmName", a.getAlarmName());
|
||||
if (a.getAlarmArn() != null) {
|
||||
node.put("AlarmArn", a.getAlarmArn());
|
||||
}
|
||||
if (a.getAlarmDescription() != null) {
|
||||
node.put("AlarmDescription", a.getAlarmDescription());
|
||||
}
|
||||
ArrayNode alarmActions = node.putArray("AlarmActions");
|
||||
a.getAlarmActions().forEach(alarmActions::add);
|
||||
ArrayNode okActions = node.putArray("OKActions");
|
||||
a.getOkActions().forEach(okActions::add);
|
||||
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
|
||||
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
|
||||
node.put("ActionsEnabled", a.isActionsEnabled());
|
||||
if (a.getStateValue() != null) {
|
||||
node.put("StateValue", a.getStateValue());
|
||||
}
|
||||
if (a.getStateReason() != null) {
|
||||
node.put("StateReason", a.getStateReason());
|
||||
}
|
||||
if (a.getStateReasonData() != null) {
|
||||
node.put("StateReasonData", a.getStateReasonData());
|
||||
}
|
||||
node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp());
|
||||
node.put("AlarmConfigurationUpdatedTimestamp", a.getAlarmConfigurationUpdatedTimestamp());
|
||||
}
|
||||
|
||||
private Response handleDeleteAlarms(JsonNode request, String region) {
|
||||
List<String> alarmNames = new ArrayList<>();
|
||||
JsonNode namesNode = request.path("AlarmNames");
|
||||
|
||||
+84
-56
@@ -1,5 +1,6 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.AwsNamespaces;
|
||||
import io.github.hectorvent.floci.core.common.AwsQueryResponse;
|
||||
import io.github.hectorvent.floci.core.common.PaginatedResult;
|
||||
@@ -7,6 +8,12 @@ import io.github.hectorvent.floci.core.common.Pagination;
|
||||
import io.github.hectorvent.floci.core.common.XmlBuilder;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.dashboards.model.Dashboard;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter;
|
||||
@@ -58,6 +65,7 @@ public class CloudWatchMetricsQueryHandler {
|
||||
case "GetMetricStatistics" -> handleGetMetricStatistics(params, region);
|
||||
case "GetMetricData" -> handleGetMetricData(params, region);
|
||||
case "PutMetricAlarm" -> handlePutMetricAlarm(params, region);
|
||||
case "PutCompositeAlarm" -> handlePutCompositeAlarm(params, region);
|
||||
case "DescribeAlarms" -> handleDescribeAlarms(params, region);
|
||||
case "DeleteAlarms" -> handleDeleteAlarms(params, region);
|
||||
case "SetAlarmState" -> handleSetAlarmState(params, region);
|
||||
@@ -159,7 +167,8 @@ public class CloudWatchMetricsQueryHandler {
|
||||
xml.elem("Unit", dp.unit()).end("member");
|
||||
}
|
||||
xml.end("Datapoints");
|
||||
return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics", AwsNamespaces.CW, xml.build())).build();
|
||||
return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics",
|
||||
AwsNamespaces.CW, xml.build())).build();
|
||||
}
|
||||
|
||||
private Response handleGetMetricData(MultivaluedMap<String, String> params, String region) {
|
||||
@@ -243,6 +252,26 @@ public class CloudWatchMetricsQueryHandler {
|
||||
return Response.ok(AwsQueryResponse.envelopeNoResult("PutMetricAlarm", null)).build();
|
||||
}
|
||||
|
||||
private Response handlePutCompositeAlarm(MultivaluedMap<String, String> params, String region) {
|
||||
if (params.containsKey("ActionsSuppressor") || params.containsKey("ActionsSuppressorWaitPeriod")
|
||||
|| params.containsKey("ActionsSuppressorExtensionPeriod")) {
|
||||
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
|
||||
}
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
parseAlarmFields(params, alarm);
|
||||
alarm.setAlarmRule(params.getFirst("AlarmRule"));
|
||||
metricsService.putCompositeAlarm(alarm, region);
|
||||
return Response.ok(AwsQueryResponse.envelopeNoResult("PutCompositeAlarm", null)).build();
|
||||
}
|
||||
|
||||
private static List<String> alarmMembers(MultivaluedMap<String, String> params, String key) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (int i = 1; params.getFirst(key + ".member." + i) != null; i++) {
|
||||
values.add(params.getFirst(key + ".member." + i));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private Response handleDescribeAlarms(MultivaluedMap<String, String> params, String region) {
|
||||
List<String> alarmNames = new ArrayList<>();
|
||||
for (int i = 1; ; i++) {
|
||||
@@ -252,15 +281,24 @@ public class CloudWatchMetricsQueryHandler {
|
||||
}
|
||||
String prefix = params.getFirst("AlarmNamePrefix");
|
||||
|
||||
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
|
||||
List<String> types = alarmMembers(params, "AlarmTypes");
|
||||
metricsService.validateAlarmTypes(types);
|
||||
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
|
||||
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
|
||||
|
||||
var xml = new XmlBuilder().start("MetricAlarms");
|
||||
for (MetricAlarm a : alarms) {
|
||||
toAlarmXml(xml, a);
|
||||
}
|
||||
xml.end("MetricAlarms");
|
||||
xml.start("CompositeAlarms").end("CompositeAlarms");
|
||||
xml.start("LogAlarms").end("LogAlarms");
|
||||
xml.end("MetricAlarms").start("CompositeAlarms");
|
||||
if (types.contains("CompositeAlarm")) {
|
||||
for (CompositeAlarm alarm : metricsService.describeCompositeAlarms(alarmNames, prefix, region)) {
|
||||
xml.start("member");
|
||||
describeAlarmFields(xml, alarm);
|
||||
xml.elem("AlarmRule", alarm.getAlarmRule()).end("member");
|
||||
}
|
||||
}
|
||||
xml.end("CompositeAlarms").start("LogAlarms").end("LogAlarms");
|
||||
return Response.ok(AwsQueryResponse.envelope("DescribeAlarms", null, xml.build())).build();
|
||||
}
|
||||
|
||||
@@ -682,10 +720,7 @@ public class CloudWatchMetricsQueryHandler {
|
||||
|
||||
private MetricAlarm parseAlarm(MultivaluedMap<String, String> params) {
|
||||
MetricAlarm a = new MetricAlarm();
|
||||
a.setAlarmName(params.getFirst("AlarmName"));
|
||||
a.setAlarmDescription(params.getFirst("AlarmDescription"));
|
||||
String actionsEnabled = params.getFirst("ActionsEnabled");
|
||||
a.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
|
||||
parseAlarmFields(params, a);
|
||||
a.setMetricName(params.getFirst("MetricName"));
|
||||
a.setNamespace(params.getFirst("Namespace"));
|
||||
a.setStatistic(params.getFirst("Statistic"));
|
||||
@@ -710,35 +745,24 @@ public class CloudWatchMetricsQueryHandler {
|
||||
}
|
||||
a.setDimensions(dims);
|
||||
|
||||
// Actions
|
||||
for (int i = 1; ; i++) {
|
||||
String act = params.getFirst("OKActions.member." + i);
|
||||
if (act == null) break;
|
||||
a.getOkActions().add(act);
|
||||
}
|
||||
for (int i = 1; ; i++) {
|
||||
String act = params.getFirst("AlarmActions.member." + i);
|
||||
if (act == null) break;
|
||||
a.getAlarmActions().add(act);
|
||||
}
|
||||
for (int i = 1; ; i++) {
|
||||
String act = params.getFirst("InsufficientDataActions.member." + i);
|
||||
if (act == null) break;
|
||||
a.getInsufficientDataActions().add(act);
|
||||
}
|
||||
|
||||
// Tags
|
||||
Map<String, String> tags = new LinkedHashMap<>();
|
||||
for (int i = 1; ; i++) {
|
||||
String key = params.getFirst("Tags.member." + i + ".Key");
|
||||
if (key == null) break;
|
||||
tags.put(key, params.getFirst("Tags.member." + i + ".Value"));
|
||||
}
|
||||
a.setTags(tags);
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
private void parseAlarmFields(MultivaluedMap<String, String> params, Alarm alarm) {
|
||||
alarm.setAlarmName(params.getFirst("AlarmName"));
|
||||
alarm.setAlarmDescription(params.getFirst("AlarmDescription"));
|
||||
String actionsEnabled = params.getFirst("ActionsEnabled");
|
||||
alarm.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
|
||||
alarm.setAlarmActions(alarmMembers(params, "AlarmActions"));
|
||||
alarm.setOkActions(alarmMembers(params, "OKActions"));
|
||||
alarm.setInsufficientDataActions(alarmMembers(params, "InsufficientDataActions"));
|
||||
Map<String, String> tags = new LinkedHashMap<>();
|
||||
for (int i = 1; params.getFirst("Tags.member." + i + ".Key") != null; i++) {
|
||||
tags.put(params.getFirst("Tags.member." + i + ".Key"), params.getFirst("Tags.member." + i + ".Value"));
|
||||
}
|
||||
alarm.setTags(tags);
|
||||
}
|
||||
|
||||
private List<AlarmMetricDataQuery> parseAlarmMetricQueries(MultivaluedMap<String, String> params) {
|
||||
List<AlarmMetricDataQuery> queries = new ArrayList<>();
|
||||
for (int index = 1; ; index++) {
|
||||
@@ -784,22 +808,8 @@ public class CloudWatchMetricsQueryHandler {
|
||||
}
|
||||
|
||||
private void toAlarmXml(XmlBuilder xml, MetricAlarm a) {
|
||||
xml.start("member")
|
||||
.elem("AlarmName", a.getAlarmName())
|
||||
.elem("AlarmArn", a.getAlarmArn())
|
||||
.elem("AlarmDescription", a.getAlarmDescription());
|
||||
xml.start("AlarmActions");
|
||||
a.getAlarmActions().forEach(act -> xml.elem("member", act));
|
||||
xml.end("AlarmActions")
|
||||
.elem("AlarmConfigurationUpdatedTimestamp", Instant.ofEpochSecond(a.getAlarmConfigurationUpdatedTimestamp()).toString())
|
||||
.elem("ActionsEnabled", String.valueOf(a.isActionsEnabled()));
|
||||
|
||||
xml.start("OKActions");
|
||||
a.getOkActions().forEach(act -> xml.elem("member", act));
|
||||
xml.end("OKActions");
|
||||
xml.start("InsufficientDataActions");
|
||||
a.getInsufficientDataActions().forEach(act -> xml.elem("member", act));
|
||||
xml.end("InsufficientDataActions");
|
||||
xml.start("member");
|
||||
describeAlarmFields(xml, a);
|
||||
if (!a.getMetrics().isEmpty()) {
|
||||
toAlarmMetricsXml(xml, a.getMetrics());
|
||||
} else {
|
||||
@@ -815,11 +825,7 @@ public class CloudWatchMetricsQueryHandler {
|
||||
.elem("Unit", a.getUnit());
|
||||
}
|
||||
|
||||
xml.elem("StateValue", a.getStateValue())
|
||||
.elem("StateReason", a.getStateReason())
|
||||
.elem("StateReasonData", a.getStateReasonData())
|
||||
.elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString())
|
||||
.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods()))
|
||||
xml.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods()))
|
||||
.elem("Threshold", String.valueOf(a.getThreshold()))
|
||||
.elem("ComparisonOperator", a.getComparisonOperator())
|
||||
.elem("TreatMissingData", a.getTreatMissingData());
|
||||
@@ -831,6 +837,28 @@ public class CloudWatchMetricsQueryHandler {
|
||||
xml.end("member");
|
||||
}
|
||||
|
||||
private void describeAlarmFields(XmlBuilder xml, Alarm a) {
|
||||
xml.elem("AlarmName", a.getAlarmName())
|
||||
.elem("AlarmArn", a.getAlarmArn())
|
||||
.elem("AlarmDescription", a.getAlarmDescription());
|
||||
xml.start("AlarmActions");
|
||||
a.getAlarmActions().forEach(act -> xml.elem("member", act));
|
||||
xml.end("AlarmActions")
|
||||
.elem("AlarmConfigurationUpdatedTimestamp", Instant.ofEpochSecond(a.getAlarmConfigurationUpdatedTimestamp()).toString())
|
||||
.elem("ActionsEnabled", String.valueOf(a.isActionsEnabled()));
|
||||
|
||||
xml.start("OKActions");
|
||||
a.getOkActions().forEach(act -> xml.elem("member", act));
|
||||
xml.end("OKActions");
|
||||
xml.start("InsufficientDataActions");
|
||||
a.getInsufficientDataActions().forEach(act -> xml.elem("member", act));
|
||||
xml.end("InsufficientDataActions");
|
||||
xml.elem("StateValue", a.getStateValue())
|
||||
.elem("StateReason", a.getStateReason())
|
||||
.elem("StateReasonData", a.getStateReasonData())
|
||||
.elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString());
|
||||
}
|
||||
|
||||
private void toAlarmMetricsXml(XmlBuilder xml, List<AlarmMetricDataQuery> metrics) {
|
||||
xml.start("Metrics");
|
||||
for (AlarmMetricDataQuery query : metrics) {
|
||||
|
||||
+240
-32
@@ -1,10 +1,15 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.github.hectorvent.floci.core.common.AwsArnUtils;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
|
||||
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum;
|
||||
@@ -14,11 +19,16 @@ import jakarta.inject.Inject;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -27,8 +37,10 @@ public class CloudWatchMetricsService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CloudWatchMetricsService.class);
|
||||
|
||||
private static final List<String> ALARM_TYPES = List.of("CompositeAlarm", "MetricAlarm", "LogAlarm");
|
||||
|
||||
private final StorageBackend<String, MetricDatum> metricStore;
|
||||
private final StorageBackend<String, MetricAlarm> alarmStore;
|
||||
private final StorageBackend<String, Alarm> alarmStore;
|
||||
private final RegionResolver regionResolver;
|
||||
private final AlarmSnsNotifier alarmSnsNotifier;
|
||||
|
||||
@@ -42,19 +54,19 @@ public class CloudWatchMetricsService {
|
||||
this.metricStore = storageFactory.create("cloudwatchmetrics", "cwmetrics.json",
|
||||
new TypeReference<Map<String, MetricDatum>>() {});
|
||||
this.alarmStore = storageFactory.create("cloudwatchmetrics", "cwalarms.json",
|
||||
new TypeReference<Map<String, MetricAlarm>>() {});
|
||||
new TypeReference<Map<String, Alarm>>() {});
|
||||
this.regionResolver = regionResolver;
|
||||
this.alarmSnsNotifier = alarmSnsNotifier;
|
||||
}
|
||||
|
||||
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
|
||||
StorageBackend<String, MetricAlarm> alarmStore,
|
||||
StorageBackend<String, Alarm> alarmStore,
|
||||
RegionResolver regionResolver) {
|
||||
this(metricStore, alarmStore, regionResolver, null);
|
||||
}
|
||||
|
||||
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
|
||||
StorageBackend<String, MetricAlarm> alarmStore,
|
||||
StorageBackend<String, Alarm> alarmStore,
|
||||
RegionResolver regionResolver, AlarmSnsNotifier alarmSnsNotifier) {
|
||||
this.metricStore = metricStore;
|
||||
this.alarmStore = alarmStore;
|
||||
@@ -296,45 +308,236 @@ public class CloudWatchMetricsService {
|
||||
};
|
||||
}
|
||||
|
||||
public void putMetricAlarm(MetricAlarm alarm, String region) {
|
||||
public synchronized void putMetricAlarm(MetricAlarm alarm, String region) {
|
||||
putAlarm(alarm, region);
|
||||
evaluateComposites(region, Set.of(alarm.getAlarmName()), null);
|
||||
}
|
||||
|
||||
public void putCompositeAlarm(CompositeAlarm alarm, String region) {
|
||||
putCompositeAlarm(alarm, region, true);
|
||||
}
|
||||
|
||||
public synchronized void putCompositeAlarm(CompositeAlarm alarm, String region, boolean replaceExisting) {
|
||||
if (!replaceExisting && alarmStore.get(region + "::" + alarm.getAlarmName()).isPresent()) {
|
||||
throw new AwsException("AlreadyExists", "Alarm already exists: " + alarm.getAlarmName(), 400);
|
||||
}
|
||||
Set<String> references = alarmReferences(alarm, region);
|
||||
if (references.size() > 100) {
|
||||
throw new AwsException("LimitExceeded", "AlarmRule references more than 100 child alarms", 400);
|
||||
}
|
||||
List<CompositeAlarm> existing = describeCompositeAlarms(null, null, region);
|
||||
for (String reference : references) {
|
||||
long parents = existing.stream().filter(parent -> !parent.getAlarmName().equals(alarm.getAlarmName()))
|
||||
.filter(parent -> alarmReferences(parent, region).contains(reference))
|
||||
.count();
|
||||
if (parents >= 150) {
|
||||
throw new AwsException("LimitExceeded", "An alarm can be referenced by at most 150 composite alarms", 400);
|
||||
}
|
||||
}
|
||||
boolean created = alarmStore.get(region + "::" + alarm.getAlarmName()).isEmpty();
|
||||
putAlarm(alarm, region);
|
||||
if (created) {
|
||||
evaluateComposites(region, Set.of(), alarm.getAlarmName());
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> alarmReferences(CompositeAlarm alarm, String region) {
|
||||
return CompositeAlarmRule.parse(alarm.getAlarmRule()).references().stream()
|
||||
.map(reference -> AwsArnUtils.isArn(reference) ? reference
|
||||
: regionResolver.buildArn("cloudwatch", region, "alarm:" + reference))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private void putAlarm(Alarm alarm, String region) {
|
||||
String name = alarm.getAlarmName();
|
||||
if (name == null || name.isBlank() || name.length() > 255) {
|
||||
throw SmithyValidationErrors.validationError(name == null ? SmithyValidationErrors.notNull("alarmName")
|
||||
: "Value '" + name + "' at 'alarmName' failed to satisfy constraint: Member must have length "
|
||||
+ (name.length() > 255 ? "less than or equal to 255" : "greater than or equal to 1"));
|
||||
}
|
||||
Alarm previous = alarmStore.get(region + "::" + name).orElse(null);
|
||||
if (previous != null) {
|
||||
if (!previous.getClass().equals(alarm.getClass())) {
|
||||
throw new AwsException("ValidationError", "An alarm of another type already has this name", 400);
|
||||
}
|
||||
if (alarm instanceof CompositeAlarm) {
|
||||
alarm.setStateValue(previous.getStateValue());
|
||||
alarm.setStateReason(previous.getStateReason());
|
||||
alarm.setStateReasonData(previous.getStateReasonData());
|
||||
alarm.setStateUpdatedTimestamp(previous.getStateUpdatedTimestamp());
|
||||
alarm.setTags(new HashMap<>(previous.getTags()));
|
||||
}
|
||||
}
|
||||
if (alarm.getAlarmArn() == null) {
|
||||
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + alarm.getAlarmName()));
|
||||
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
|
||||
}
|
||||
alarm.setRegion(region);
|
||||
alarm.setAlarmConfigurationUpdatedTimestamp(Instant.now().getEpochSecond());
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
LOG.infov("PutMetricAlarm: {0} in {1}", alarm.getAlarmName(), region);
|
||||
alarmStore.put(region + "::" + name, alarm);
|
||||
LOG.infov("Put {0}: {1} in {2}", alarm.getClass().getSimpleName(), name, region);
|
||||
}
|
||||
|
||||
/** Every stored alarm, across all regions. Used by the background {@link AlarmEvaluator}
|
||||
* tick, which has no per-request region to scope a lookup to. */
|
||||
/** Metric alarms evaluated by the existing metric evaluator. */
|
||||
public List<MetricAlarm> allAlarms() {
|
||||
return alarmStore.scan(k -> true);
|
||||
return alarmStore.scan(k -> true).stream().filter(MetricAlarm.class::isInstance)
|
||||
.map(MetricAlarm.class::cast).toList();
|
||||
}
|
||||
|
||||
public List<MetricAlarm> describeAlarms(List<String> alarmNames, String alarmNamePrefix, String region) {
|
||||
String prefix = region + "::";
|
||||
List<MetricAlarm> all = alarmStore.scan(k -> k.startsWith(prefix));
|
||||
|
||||
if (alarmNames != null && !alarmNames.isEmpty()) {
|
||||
return all.stream().filter(a -> alarmNames.contains(a.getAlarmName())).toList();
|
||||
}
|
||||
if (alarmNamePrefix != null && !alarmNamePrefix.isBlank()) {
|
||||
return all.stream().filter(a -> a.getAlarmName().startsWith(alarmNamePrefix)).toList();
|
||||
}
|
||||
return all;
|
||||
public List<MetricAlarm> describeAlarms(List<String> names, String prefix, String region) {
|
||||
return matchingAlarms(names, prefix, region).stream().filter(MetricAlarm.class::isInstance)
|
||||
.map(MetricAlarm.class::cast).toList();
|
||||
}
|
||||
|
||||
public void deleteAlarms(List<String> alarmNames, String region) {
|
||||
public void validateAlarmTypes(List<String> types) {
|
||||
if (!ALARM_TYPES.containsAll(types)) {
|
||||
throw SmithyValidationErrors.validationError(
|
||||
SmithyValidationErrors.notAllInEnum(types, "alarmTypes", ALARM_TYPES));
|
||||
}
|
||||
}
|
||||
|
||||
public List<CompositeAlarm> describeCompositeAlarms(List<String> names, String prefix, String region) {
|
||||
return matchingAlarms(names, prefix, region).stream().filter(CompositeAlarm.class::isInstance)
|
||||
.map(CompositeAlarm.class::cast).toList();
|
||||
}
|
||||
|
||||
private List<Alarm> matchingAlarms(List<String> names, String prefix, String region) {
|
||||
return alarmStore.scan(k -> k.startsWith(region + "::")).stream()
|
||||
.filter(a -> names == null || names.isEmpty() || names.contains(a.getAlarmName()))
|
||||
.filter(a -> prefix == null || a.getAlarmName().startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
private Map<String, Alarm> alarmIndex(String region) {
|
||||
Map<String, Alarm> alarms = new HashMap<>();
|
||||
for (Alarm alarm : alarmStore.scan(k -> k.startsWith(region + "::"))) {
|
||||
alarms.put(alarm.getAlarmName(), alarm);
|
||||
alarms.put(alarm.getAlarmArn(), alarm);
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
|
||||
private void evaluateComposites(String region, Set<String> changedAlarmNames, String newAlarm) {
|
||||
if (changedAlarmNames.isEmpty() && newAlarm == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Alarm> alarms = alarmIndex(region);
|
||||
Set<String> changed = new HashSet<>(changedAlarmNames);
|
||||
for (String name : changedAlarmNames) {
|
||||
changed.add(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
|
||||
}
|
||||
Map<String, CompositeAlarmRule.Rule> candidates = new HashMap<>();
|
||||
for (Alarm alarm : new HashSet<>(alarms.values())) {
|
||||
if (alarm instanceof CompositeAlarm composite && !changedAlarmNames.contains(alarm.getAlarmName())) {
|
||||
candidates.put(alarm.getAlarmName(), CompositeAlarmRule.parse(composite.getAlarmRule()));
|
||||
}
|
||||
}
|
||||
Set<String> affected = new HashSet<>(changed);
|
||||
Map<String, CompositeAlarmRule.Rule> pending = new HashMap<>();
|
||||
boolean discovered;
|
||||
do {
|
||||
discovered = false;
|
||||
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = candidates.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
|
||||
if (next.getKey().equals(newAlarm) || next.getValue().references().stream().anyMatch(affected::contains)) {
|
||||
pending.put(next.getKey(), next.getValue());
|
||||
affected.add(next.getKey());
|
||||
affected.add(alarms.get(next.getKey()).getAlarmArn());
|
||||
iterator.remove();
|
||||
discovered = true;
|
||||
}
|
||||
}
|
||||
} while (discovered);
|
||||
Set<String> cycles = pending.keySet().stream()
|
||||
.filter(name -> isCyclic(name, alarms)).collect(Collectors.toSet());
|
||||
boolean progress;
|
||||
do {
|
||||
progress = false;
|
||||
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = pending.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
|
||||
boolean blocked = next.getValue().references().stream().map(alarms::get)
|
||||
.anyMatch(child -> child != null && pending.containsKey(child.getAlarmName()));
|
||||
if (blocked || cycles.contains(next.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (!next.getKey().equals(newAlarm)
|
||||
&& next.getValue().references().stream().noneMatch(changed::contains)) {
|
||||
iterator.remove();
|
||||
progress = true;
|
||||
continue;
|
||||
}
|
||||
Alarm alarm = alarms.get(next.getKey());
|
||||
boolean triggered = next.getValue().evaluate(reference -> {
|
||||
Alarm child = alarms.get(reference);
|
||||
return child == null ? null : child.getStateValue();
|
||||
});
|
||||
String state = triggered ? "ALARM" : "OK";
|
||||
if (!state.equals(alarm.getStateValue())) {
|
||||
alarm.setStateValue(state);
|
||||
alarm.setStateReason("AlarmRule evaluated to " + state);
|
||||
alarm.setStateReasonData(null);
|
||||
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
changed.add(alarm.getAlarmName());
|
||||
changed.add(alarm.getAlarmArn());
|
||||
}
|
||||
iterator.remove();
|
||||
progress = true;
|
||||
}
|
||||
} while (progress && !pending.isEmpty());
|
||||
if (!pending.isEmpty()) {
|
||||
LOG.debugv("Composite alarm evaluation stopped at cyclic dependencies: {0}", pending.keySet());
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void deleteAlarms(List<String> alarmNames, String region) {
|
||||
Map<String, Alarm> alarms = alarmIndex(region);
|
||||
long composites = alarmNames.stream().map(alarms::get).filter(CompositeAlarm.class::isInstance).count();
|
||||
if (composites > 1) {
|
||||
throw new AwsException("ValidationError", "DeleteAlarms accepts at most one composite alarm", 400);
|
||||
}
|
||||
for (String name : alarmNames) {
|
||||
if (isCyclic(name, alarms)) {
|
||||
throw new AwsException("ValidationError", "Break the composite alarm cycle before deleting " + name, 400);
|
||||
}
|
||||
}
|
||||
Set<String> deletedNames = new HashSet<>();
|
||||
for (String name : alarmNames) {
|
||||
if (alarmStore.get(region + "::" + name).isPresent()) {
|
||||
deletedNames.add(name);
|
||||
}
|
||||
alarmStore.delete(region + "::" + name);
|
||||
}
|
||||
evaluateComposites(region, deletedNames, null);
|
||||
LOG.infov("Deleted alarms: {0} in {1}", alarmNames, region);
|
||||
}
|
||||
|
||||
private boolean isCyclic(String name, Map<String, Alarm> alarms) {
|
||||
Set<String> visited = new HashSet<>();
|
||||
ArrayDeque<String> pending = new ArrayDeque<>();
|
||||
pending.add(name);
|
||||
while (!pending.isEmpty()) {
|
||||
Alarm next = alarms.get(pending.remove());
|
||||
if (next instanceof CompositeAlarm composite && visited.add(next.getAlarmName())) {
|
||||
for (String reference : CompositeAlarmRule.parse(composite.getAlarmRule()).references()) {
|
||||
Alarm child = alarms.get(reference);
|
||||
if (child != null && child.getAlarmName().equals(name)) {
|
||||
return true;
|
||||
}
|
||||
if (child != null) {
|
||||
pending.add(child.getAlarmName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) {
|
||||
List<AlarmSnsNotifier.Notification> notifications = List.of();
|
||||
synchronized (this) {
|
||||
String key = region + "::" + alarmName;
|
||||
MetricAlarm alarm = alarmStore.get(key)
|
||||
Alarm alarm = alarmStore.get(key)
|
||||
.orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404));
|
||||
|
||||
String oldState = alarm.getStateValue();
|
||||
@@ -344,9 +547,14 @@ public class CloudWatchMetricsService {
|
||||
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
|
||||
|
||||
alarmStore.put(key, alarm);
|
||||
evaluateComposites(region, Set.of(alarmName), null);
|
||||
LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue);
|
||||
if (alarmSnsNotifier != null && alarm instanceof MetricAlarm metricAlarm) {
|
||||
notifications = alarmSnsNotifier.prepareTransition(metricAlarm, oldState);
|
||||
}
|
||||
}
|
||||
if (alarmSnsNotifier != null) {
|
||||
alarmSnsNotifier.notifyTransition(alarm, oldState);
|
||||
alarmSnsNotifier.publish(notifications);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +572,7 @@ public class CloudWatchMetricsService {
|
||||
* {@code insight-rule/} ARN being the one AWS documents; reporting that no resource
|
||||
* matches the ARN is true of those, where "alarm not found" would not be.
|
||||
*/
|
||||
private MetricAlarm requireAlarm(String resourceArn, String region) {
|
||||
private Alarm requireAlarm(String resourceArn, String region) {
|
||||
return alarmStore.scan(k -> k.startsWith(region + "::"))
|
||||
.stream()
|
||||
.filter(a -> a.getAlarmArn() != null && a.getAlarmArn().equals(resourceArn))
|
||||
@@ -373,18 +581,18 @@ public class CloudWatchMetricsService {
|
||||
"No CloudWatch resource matches the ARN " + resourceArn + ".", 404));
|
||||
}
|
||||
|
||||
public Map<String, String> listTagsForResource(String resourceArn, String region) {
|
||||
return requireAlarm(resourceArn, region).getTags();
|
||||
public synchronized Map<String, String> listTagsForResource(String resourceArn, String region) {
|
||||
return new HashMap<>(requireAlarm(resourceArn, region).getTags());
|
||||
}
|
||||
|
||||
public void tagResource(String resourceArn, Map<String, String> tags, String region) {
|
||||
MetricAlarm alarm = requireAlarm(resourceArn, region);
|
||||
public synchronized void tagResource(String resourceArn, Map<String, String> tags, String region) {
|
||||
Alarm alarm = requireAlarm(resourceArn, region);
|
||||
alarm.getTags().putAll(tags);
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
}
|
||||
|
||||
public void untagResource(String resourceArn, List<String> tagKeys, String region) {
|
||||
MetricAlarm alarm = requireAlarm(resourceArn, region);
|
||||
public synchronized void untagResource(String resourceArn, List<String> tagKeys, String region) {
|
||||
Alarm alarm = requireAlarm(resourceArn, region);
|
||||
tagKeys.forEach(alarm.getTags()::remove);
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
}
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** CloudWatch's boolean alarm language, parsed completely before any alarm is stored. */
|
||||
final class CompositeAlarmRule {
|
||||
private final String input;
|
||||
private final Set<String> references = new HashSet<>();
|
||||
private int offset;
|
||||
private int elements;
|
||||
|
||||
private CompositeAlarmRule(String input) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
static Rule parse(String input) {
|
||||
if (input == null || input.isBlank() || input.length() > 10240) {
|
||||
throw SmithyValidationErrors.validationError(input == null ? SmithyValidationErrors.notNull("alarmRule")
|
||||
: "Value '" + input + "' at 'alarmRule' failed to satisfy constraint: Member must have length "
|
||||
+ (input.length() > 10240 ? "less than or equal to 10240" : "greater than or equal to 1"));
|
||||
}
|
||||
CompositeAlarmRule parser = new CompositeAlarmRule(input);
|
||||
Expression expression = parser.or();
|
||||
parser.whitespace();
|
||||
if (parser.offset != input.length()) {
|
||||
throw invalid("Invalid AlarmRule");
|
||||
}
|
||||
return new Rule(expression, Set.copyOf(parser.references));
|
||||
}
|
||||
|
||||
private Expression or() {
|
||||
Expression left = and();
|
||||
while (word("OR")) {
|
||||
Expression prior = left;
|
||||
Expression right = and();
|
||||
left = states -> prior.evaluate(states) || right.evaluate(states);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression and() {
|
||||
Expression left = atom();
|
||||
while (word("AND")) {
|
||||
Expression prior = left;
|
||||
Expression right = atom();
|
||||
left = states -> prior.evaluate(states) && right.evaluate(states);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression atom() {
|
||||
boolean negate = false;
|
||||
while (word("NOT")) {
|
||||
negate = !negate;
|
||||
}
|
||||
Expression operand = primary();
|
||||
return negate ? states -> !operand.evaluate(states) : operand;
|
||||
}
|
||||
|
||||
private Expression primary() {
|
||||
element();
|
||||
if (character('(')) {
|
||||
Expression nested = or();
|
||||
require(')');
|
||||
element();
|
||||
return nested;
|
||||
}
|
||||
if (word("TRUE")) {
|
||||
return states -> true;
|
||||
}
|
||||
if (word("FALSE")) {
|
||||
return states -> false;
|
||||
}
|
||||
String state;
|
||||
if (word("ALARM")) {
|
||||
state = "ALARM";
|
||||
} else if (word("OK")) {
|
||||
state = "OK";
|
||||
} else if (word("INSUFFICIENT_DATA")) {
|
||||
state = "INSUFFICIENT_DATA";
|
||||
} else {
|
||||
throw invalid("Expected an alarm state, boolean, NOT, or parenthesis");
|
||||
}
|
||||
require('(');
|
||||
whitespace();
|
||||
String name;
|
||||
if (character('"')) {
|
||||
StringBuilder quoted = new StringBuilder();
|
||||
boolean closed = false;
|
||||
while (offset < input.length()) {
|
||||
char next = input.charAt(offset++);
|
||||
if (next == '"') {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if (next == '\\') {
|
||||
if (offset == input.length()) {
|
||||
throw invalid("Unterminated quoted alarm name");
|
||||
}
|
||||
next = input.charAt(offset++);
|
||||
if (next != '\\' && next != '"') {
|
||||
throw invalid("Invalid quoted alarm name escape");
|
||||
}
|
||||
}
|
||||
quoted.append(next);
|
||||
}
|
||||
if (!closed) {
|
||||
throw invalid("Unterminated quoted alarm name");
|
||||
}
|
||||
name = quoted.toString();
|
||||
} else {
|
||||
int start = offset;
|
||||
while (offset < input.length() && input.charAt(offset) != ')') {
|
||||
offset++;
|
||||
}
|
||||
name = input.substring(start, offset).trim();
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
throw invalid("An alarm name or ARN is required");
|
||||
}
|
||||
require(')');
|
||||
references.add(name);
|
||||
return states -> state.equals(states.apply(name));
|
||||
}
|
||||
|
||||
private void element() {
|
||||
if (++elements > 500) {
|
||||
throw invalid("AlarmRule exceeds 500 child alarms, booleans, and parentheses");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean word(String word) {
|
||||
whitespace();
|
||||
if (!input.startsWith(word, offset)) {
|
||||
return false;
|
||||
}
|
||||
int end = offset + word.length();
|
||||
if (end < input.length() && (Character.isLetterOrDigit(input.charAt(end)) || input.charAt(end) == '_')) {
|
||||
return false;
|
||||
}
|
||||
offset = end;
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean character(char expected) {
|
||||
whitespace();
|
||||
if (offset < input.length() && input.charAt(offset) == expected) {
|
||||
offset++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void require(char expected) {
|
||||
if (!character(expected)) {
|
||||
throw invalid("Expected '" + expected + "' in AlarmRule");
|
||||
}
|
||||
}
|
||||
|
||||
private void whitespace() {
|
||||
while (offset < input.length() && Character.isWhitespace(input.charAt(offset))) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
|
||||
private static AwsException invalid(String message) {
|
||||
return new AwsException("ValidationError", message, 400);
|
||||
}
|
||||
|
||||
record Rule(Expression expression, Set<String> references) {
|
||||
boolean evaluate(Function<String, String> states) {
|
||||
return expression.evaluate(states);
|
||||
}
|
||||
}
|
||||
|
||||
interface Expression {
|
||||
boolean evaluate(Function<String, String> states);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RegisterForReflection
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY,
|
||||
property = "alarmType", defaultImpl = MetricAlarm.class)
|
||||
@JsonSubTypes({@JsonSubTypes.Type(value = MetricAlarm.class, name = "MetricAlarm"),
|
||||
@JsonSubTypes.Type(value = CompositeAlarm.class, name = "CompositeAlarm")})
|
||||
public abstract class Alarm {
|
||||
private String alarmName;
|
||||
private String alarmArn;
|
||||
private String alarmDescription;
|
||||
private long alarmConfigurationUpdatedTimestamp;
|
||||
private boolean actionsEnabled;
|
||||
private List<String> okActions = new ArrayList<>();
|
||||
private List<String> alarmActions = new ArrayList<>();
|
||||
private List<String> insufficientDataActions = new ArrayList<>();
|
||||
private String stateValue = "INSUFFICIENT_DATA";
|
||||
private String stateReason = "Unchecked";
|
||||
private String stateReasonData;
|
||||
private long stateUpdatedTimestamp;
|
||||
private Map<String, String> tags = new HashMap<>();
|
||||
private String region;
|
||||
|
||||
|
||||
protected Alarm() {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
this.alarmConfigurationUpdatedTimestamp = now;
|
||||
this.stateUpdatedTimestamp = now;
|
||||
}
|
||||
|
||||
public abstract String getAlarmType();
|
||||
|
||||
public String getAlarmName() { return alarmName; }
|
||||
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
|
||||
|
||||
public String getAlarmArn() { return alarmArn; }
|
||||
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
|
||||
|
||||
public String getAlarmDescription() { return alarmDescription; }
|
||||
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
|
||||
|
||||
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
|
||||
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
|
||||
|
||||
public boolean isActionsEnabled() { return actionsEnabled; }
|
||||
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
|
||||
|
||||
public List<String> getOkActions() { return okActions; }
|
||||
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
|
||||
|
||||
public List<String> getAlarmActions() { return alarmActions; }
|
||||
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
|
||||
|
||||
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
|
||||
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
|
||||
|
||||
public String getStateValue() { return stateValue; }
|
||||
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
|
||||
|
||||
public String getStateReason() { return stateReason; }
|
||||
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
|
||||
|
||||
public String getStateReasonData() { return stateReasonData; }
|
||||
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
|
||||
|
||||
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
|
||||
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
|
||||
|
||||
public Map<String, String> getTags() { return tags; }
|
||||
public void setTags(Map<String, String> tags) { this.tags = tags; }
|
||||
|
||||
|
||||
public String getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
}
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+4
-67
@@ -3,27 +3,15 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RegisterForReflection
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MetricAlarm {
|
||||
private String alarmName;
|
||||
private String alarmArn;
|
||||
private String alarmDescription;
|
||||
private long alarmConfigurationUpdatedTimestamp;
|
||||
private boolean actionsEnabled;
|
||||
private List<String> okActions = new ArrayList<>();
|
||||
private List<String> alarmActions = new ArrayList<>();
|
||||
private List<String> insufficientDataActions = new ArrayList<>();
|
||||
private String stateValue = "INSUFFICIENT_DATA";
|
||||
private String stateReason = "Unchecked";
|
||||
private String stateReasonData;
|
||||
private long stateUpdatedTimestamp;
|
||||
public class MetricAlarm extends Alarm {
|
||||
@Override
|
||||
public String getAlarmType() { return "MetricAlarm"; }
|
||||
|
||||
private String metricName;
|
||||
private String namespace;
|
||||
private String statistic;
|
||||
@@ -45,50 +33,6 @@ public class MetricAlarm {
|
||||
private String comparisonOperator;
|
||||
private String treatMissingData = "missing";
|
||||
private String evaluateLowSampleCountPercentile;
|
||||
private Map<String, String> tags = new HashMap<>();
|
||||
private String region;
|
||||
|
||||
public MetricAlarm() {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
this.alarmConfigurationUpdatedTimestamp = now;
|
||||
this.stateUpdatedTimestamp = now;
|
||||
}
|
||||
|
||||
public String getAlarmName() { return alarmName; }
|
||||
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
|
||||
|
||||
public String getAlarmArn() { return alarmArn; }
|
||||
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
|
||||
|
||||
public String getAlarmDescription() { return alarmDescription; }
|
||||
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
|
||||
|
||||
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
|
||||
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
|
||||
|
||||
public boolean isActionsEnabled() { return actionsEnabled; }
|
||||
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
|
||||
|
||||
public List<String> getOkActions() { return okActions; }
|
||||
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
|
||||
|
||||
public List<String> getAlarmActions() { return alarmActions; }
|
||||
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
|
||||
|
||||
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
|
||||
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
|
||||
|
||||
public String getStateValue() { return stateValue; }
|
||||
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
|
||||
|
||||
public String getStateReason() { return stateReason; }
|
||||
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
|
||||
|
||||
public String getStateReasonData() { return stateReasonData; }
|
||||
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
|
||||
|
||||
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
|
||||
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
|
||||
|
||||
public String getMetricName() { return metricName; }
|
||||
public void setMetricName(String metricName) { this.metricName = metricName; }
|
||||
@@ -131,11 +75,4 @@ public class MetricAlarm {
|
||||
public String getEvaluateLowSampleCountPercentile() { return evaluateLowSampleCountPercentile; }
|
||||
public void setEvaluateLowSampleCountPercentile(String percentile) { this.evaluateLowSampleCountPercentile = percentile; }
|
||||
|
||||
public Map<String, String> getTags() { return tags; }
|
||||
public void setTags(Map<String, String> tags) { this.tags = tags; }
|
||||
|
||||
/** Not part of the AWS shape; set by {@code putMetricAlarm} so a background evaluator
|
||||
* that only holds the alarm object still knows which region's metric data to query. */
|
||||
public String getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
}
|
||||
|
||||
+2
@@ -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));
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package io.github.hectorvent.floci.services.cloudformation;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.testing.CfnStackWaits;
|
||||
import io.github.hectorvent.floci.testing.PartitionCleanup;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.common.http.TestHTTPResource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.Response;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@QuarkusTest
|
||||
class CloudFormationCompositeAlarmIntegrationTest {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@TestHTTPResource
|
||||
URI baseUri;
|
||||
|
||||
@RegisterExtension
|
||||
final PartitionCleanup cleanup = new PartitionCleanup();
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void failedUpdateRestoresRuleTagsAndIdentity(boolean rename) throws Exception {
|
||||
String stack = unique("composite-rollback");
|
||||
String name = stack + "-alarm";
|
||||
String next = rename ? name + "-new" : name;
|
||||
deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "old", "drop", "restore"), false, false));
|
||||
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
String arn = alarm(name).get("AlarmArn").toString();
|
||||
Response described = given().contentType("application/x-www-form-urlencoded")
|
||||
.formParam("Action", "DescribeStacks").formParam("StackName", stack).post("/");
|
||||
assertEquals(name, described.xmlPath().getString(
|
||||
"DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Name' }.OutputValue"));
|
||||
assertEquals(arn, described.xmlPath().getString(
|
||||
"DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Arn' }.OutputValue"));
|
||||
cw("TagResource", Map.of("ResourceARN", arn, "Tags", List.of(Map.of("Key", "external", "Value", "keep"))))
|
||||
.then().statusCode(200);
|
||||
deploy("UpdateStack", stack, template(next, "FALSE", Map.of("team", "new", "added", "remove"), true, false));
|
||||
assertEquals("UPDATE_ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals("TRUE", alarm(name).get("AlarmRule"));
|
||||
List<Map<String, String>> tags = cw("ListTagsForResource", Map.of("ResourceARN", arn))
|
||||
.then().statusCode(200).extract().jsonPath().getList("Tags");
|
||||
assertEquals(Map.of("team", "old", "drop", "restore", "external", "keep"),
|
||||
tags.stream().collect(Collectors.toMap(t -> t.get("Key"), t -> t.get("Value"))));
|
||||
if (rename) {
|
||||
assertMissing(next);
|
||||
}
|
||||
deleteStack(stack);
|
||||
assertMissing(name);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void replacementHonorsRetentionAndDeletesItsCurrentBackingAlarm(boolean retain) throws Exception {
|
||||
String stack = unique("composite-replace");
|
||||
String original = stack + "-old";
|
||||
String next = stack + "-new";
|
||||
deploy("CreateStack", stack, template(original, "TRUE", Map.of(), false, retain));
|
||||
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
deploy("UpdateStack", stack, template(next, "FALSE", Map.of(), false, retain));
|
||||
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals("FALSE", alarm(next).get("AlarmRule"));
|
||||
if (retain) {
|
||||
assertEquals("TRUE", alarm(original).get("AlarmRule"));
|
||||
} else {
|
||||
assertMissing(original);
|
||||
}
|
||||
deleteStack(stack);
|
||||
assertMissing(next);
|
||||
if (retain) {
|
||||
assertEquals("TRUE", alarm(original).get("AlarmRule"));
|
||||
cw("DeleteAlarms", Map.of("AlarmNames", List.of(original))).then().statusCode(200);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedInPlaceUpdatesAllowAnotherUpdateAndReplacement() throws Exception {
|
||||
String stack = unique("composite-sequence");
|
||||
String name = stack + "-alarm";
|
||||
String replacement = name + "-new";
|
||||
deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "initial"), false, false));
|
||||
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
for (String rule : List.of("FALSE", "TRUE")) {
|
||||
deploy("UpdateStack", stack, template(name, rule, Map.of("team", rule), false, false));
|
||||
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals(rule, alarm(name).get("AlarmRule"));
|
||||
}
|
||||
deploy("UpdateStack", stack, template(replacement, "FALSE", Map.of(), false, false));
|
||||
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertMissing(name);
|
||||
assertEquals("FALSE", alarm(replacement).get("AlarmRule"));
|
||||
deleteStack(stack);
|
||||
assertMissing(replacement);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createCollisionCannotOverwriteOrDeleteAnUnmanagedAlarm() throws Exception {
|
||||
String stack = unique("composite-foreign");
|
||||
String name = stack + "-alarm";
|
||||
cw("PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE")).then().statusCode(200);
|
||||
deploy("CreateStack", stack, template(name, "FALSE", Map.of(), false, false));
|
||||
assertEquals("ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals("TRUE", alarm(name).get("AlarmRule"));
|
||||
deleteStack(stack);
|
||||
assertEquals("TRUE", alarm(name).get("AlarmRule"));
|
||||
cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200);
|
||||
}
|
||||
|
||||
private String template(String name, String rule, Map<String, String> tags, boolean fail, boolean retain)
|
||||
throws JsonProcessingException {
|
||||
cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200));
|
||||
Map<String, Object> resources = new LinkedHashMap<>();
|
||||
resources.put("Alarm", Map.of("Type", "AWS::CloudWatch::CompositeAlarm",
|
||||
"UpdateReplacePolicy", retain ? "Retain" : "Delete",
|
||||
"Properties", Map.of("AlarmName", name, "AlarmRule", rule,
|
||||
"Tags", tags.entrySet().stream().map(e -> Map.of("Key", e.getKey(), "Value", e.getValue())).toList())));
|
||||
if (fail) {
|
||||
resources.put("Failure", Map.of("Type", "AWS::SecretsManager::Secret", "DependsOn", "Alarm",
|
||||
"Properties", Map.of("SecretString", "explicit", "GenerateSecretString", Map.of("PasswordLength", 32))));
|
||||
}
|
||||
return MAPPER.writeValueAsString(Map.of("Resources", resources, "Outputs", Map.of(
|
||||
"Name", Map.of("Value", Map.of("Ref", "Alarm")),
|
||||
"Arn", Map.of("Value", Map.of("Fn::GetAtt", List.of("Alarm", "Arn"))))));
|
||||
}
|
||||
|
||||
private void deploy(String action, String stack, String template) {
|
||||
if ("CreateStack".equals(action)) {
|
||||
cleanup.register(() -> given().port(baseUri.getPort())
|
||||
.contentType("application/x-www-form-urlencoded")
|
||||
.formParam("Action", "DeleteStack").formParam("StackName", stack)
|
||||
.post("/").then().statusCode(200));
|
||||
}
|
||||
given().contentType("application/x-www-form-urlencoded").formParam("Action", action)
|
||||
.formParam("StackName", stack).formParam("TemplateBody", template).post("/").then().statusCode(200);
|
||||
}
|
||||
|
||||
private Response cw(String operation, Map<String, ?> body) {
|
||||
if ("PutCompositeAlarm".equals(operation)) {
|
||||
String name = body.get("AlarmName").toString();
|
||||
cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200));
|
||||
}
|
||||
return given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0")
|
||||
.header("X-Amz-Target", "GraniteServiceVersion20100801." + operation)
|
||||
.body(MAPPER.valueToTree(body).toString()).post("/");
|
||||
}
|
||||
|
||||
private Map<String, Object> alarm(String name) throws JsonProcessingException {
|
||||
List<Map<String, Object>> alarms = cw("DescribeAlarms", Map.of("AlarmNames", List.of(name),
|
||||
"AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200)
|
||||
.extract().jsonPath().getList("CompositeAlarms");
|
||||
assertEquals(1, alarms.size(), name);
|
||||
return alarms.getFirst();
|
||||
}
|
||||
|
||||
private void assertMissing(String name) throws JsonProcessingException {
|
||||
assertEquals(List.of(), cw("DescribeAlarms", Map.of("AlarmNames", List.of(name),
|
||||
"AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200)
|
||||
.extract().jsonPath().getList("CompositeAlarms"));
|
||||
}
|
||||
|
||||
private void deleteStack(String stack) {
|
||||
given().contentType("application/x-www-form-urlencoded").formParam("Action", "DeleteStack")
|
||||
.formParam("StackName", stack).post("/").then().statusCode(200);
|
||||
CfnStackWaits.awaitStackDeleted(stack);
|
||||
}
|
||||
|
||||
private static String unique(String prefix) {
|
||||
return prefix + "-" + Long.toString(System.nanoTime(), 36);
|
||||
}
|
||||
}
|
||||
+89
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.services.sns.SnsService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -12,6 +13,11 @@ import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
@@ -124,4 +130,41 @@ class AlarmSnsNotifierTest {
|
||||
service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION);
|
||||
verify(sns).publish(eq(topic), isNull(), contains("arn:aws-cn:cloudwatch:"), anyString(), eq("cn-north-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedNotificationDoesNotHoldTheAlarmMutationLock() throws Exception {
|
||||
CountDownLatch publishing = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
doAnswer(call -> {
|
||||
if (call.<String>getArgument(3).startsWith("ALARM:")) {
|
||||
publishing.countDown();
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
return "message-id";
|
||||
}).when(sns).publish(eq(TOPIC), isNull(), anyString(), anyString(), eq(REGION));
|
||||
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
Future<?> transition = executor.submit(() ->
|
||||
service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION));
|
||||
try {
|
||||
assertTrue(publishing.await(2, TimeUnit.SECONDS));
|
||||
Future<?> unrelated = executor.submit(() -> {
|
||||
CompositeAlarm composite = new CompositeAlarm();
|
||||
composite.setAlarmName("unrelated");
|
||||
composite.setAlarmRule("FALSE");
|
||||
service.putCompositeAlarm(composite, REGION);
|
||||
service.setAlarmState("test-alarm", "OK", "recovered", null, REGION);
|
||||
});
|
||||
unrelated.get(2, TimeUnit.SECONDS);
|
||||
assertEquals("OK", alarm.getStateValue());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
transition.get(2, TimeUnit.SECONDS);
|
||||
}
|
||||
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
|
||||
verify(sns, times(2)).publish(eq(TOPIC), isNull(), messages.capture(), anyString(), eq(REGION));
|
||||
assertEquals("ALARM", mapper.readTree(messages.getAllValues().getFirst()).path("NewStateValue").asText());
|
||||
assertEquals("OK", mapper.readTree(messages.getAllValues().getLast()).path("NewStateValue").asText());
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CloudWatchAlarmQueryHandlerTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"PutMetricAlarm", "PutCompositeAlarm"})
|
||||
void sharedFieldsReachBothAlarmTypes(String operation) {
|
||||
String region = "us-east-1";
|
||||
CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(),
|
||||
new InMemoryStorage<>(), new RegionResolver(region, "123456789012"));
|
||||
CloudWatchMetricsQueryHandler handler = new CloudWatchMetricsQueryHandler(service, null, null);
|
||||
MultivaluedMap<String, String> request = new MultivaluedHashMap<>();
|
||||
request.putSingle("AlarmName", "shared");
|
||||
request.putSingle("AlarmRule", "FALSE");
|
||||
request.putSingle("AlarmDescription", "shared fields");
|
||||
request.putSingle("ActionsEnabled", "false");
|
||||
request.putSingle("AlarmActions.member.1", "alarm-action");
|
||||
request.putSingle("OKActions.member.1", "ok-action");
|
||||
request.putSingle("InsufficientDataActions.member.1", "missing-action");
|
||||
request.putSingle("Tags.member.1.Key", "team");
|
||||
request.putSingle("Tags.member.1.Value", "metrics");
|
||||
assertEquals(200, handler.handle(operation, request, region).getStatus());
|
||||
Alarm alarm = "PutMetricAlarm".equals(operation)
|
||||
? service.describeAlarms(List.of("shared"), null, region).getFirst()
|
||||
: service.describeCompositeAlarms(List.of("shared"), null, region).getFirst();
|
||||
assertEquals("shared fields", alarm.getAlarmDescription());
|
||||
assertFalse(alarm.isActionsEnabled());
|
||||
assertEquals(List.of("alarm-action"), alarm.getAlarmActions());
|
||||
assertEquals(List.of("ok-action"), alarm.getOkActions());
|
||||
assertEquals(List.of("missing-action"), alarm.getInsufficientDataActions());
|
||||
assertEquals(Map.of("team", "metrics"), alarm.getTags());
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -10,7 +10,7 @@ import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.core.storage.PersistentStorage;
|
||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -43,7 +43,7 @@ class CloudWatchMetricMathAlarmTest {
|
||||
initialize(new InMemoryStorage<>());
|
||||
}
|
||||
|
||||
private void initialize(StorageBackend<String, MetricAlarm> alarmStore) {
|
||||
private void initialize(StorageBackend<String, Alarm> alarmStore) {
|
||||
RegionResolver resolver = new RegionResolver(REGION, "000000000000");
|
||||
CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(), alarmStore, resolver);
|
||||
CloudWatchDashboardsService dashboards = new CloudWatchDashboardsService(new InMemoryStorage<>(), resolver);
|
||||
@@ -64,12 +64,12 @@ class CloudWatchMetricMathAlarmTest {
|
||||
void metricMathQueriesSurvivePersistentStorageReload(boolean queryProtocol, @TempDir Path directory)
|
||||
throws Exception {
|
||||
Path storageFile = directory.resolve("alarms.json");
|
||||
TypeReference<Map<String, MetricAlarm>> type = new TypeReference<>() { };
|
||||
TypeReference<Map<String, Alarm>> type = new TypeReference<>() { };
|
||||
initialize(new PersistentStorage<>(storageFile, type));
|
||||
ObjectNode request = metricMathRequest();
|
||||
put(request, queryProtocol);
|
||||
|
||||
PersistentStorage<String, MetricAlarm> reloaded = new PersistentStorage<>(storageFile, type);
|
||||
PersistentStorage<String, Alarm> reloaded = new PersistentStorage<>(storageFile, type);
|
||||
reloaded.load();
|
||||
assertEquals(1, reloaded.keys().size(), "the stored alarm must deserialize after a restart");
|
||||
initialize(reloaded);
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.testing.PartitionCleanup;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.common.http.TestHTTPResource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.Response;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@QuarkusTest
|
||||
class CompositeAlarmIntegrationTest {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final String SCOPE =
|
||||
"AWS4-HMAC-SHA256 Credential=test/20261010/cn-north-1/monitoring/aws4_request";
|
||||
|
||||
@TestHTTPResource
|
||||
URI baseUri;
|
||||
|
||||
@RegisterExtension
|
||||
final PartitionCleanup cleanup = new PartitionCleanup();
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void bothProtocolsCreateDescribeEvaluateAndDeleteRealAlarms(boolean json) throws Exception {
|
||||
String prefix = "wire-composite-" + Long.toString(System.nanoTime(), 36);
|
||||
String source = prefix + "-source";
|
||||
String aggregate = prefix + "-aggregate";
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", source, "AlarmRule", "TRUE")).then().statusCode(200);
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", aggregate, "AlarmRule", "ALARM(\"" + source + "\")",
|
||||
"ActionsEnabled", false, "AlarmDescription", "combined")).then().statusCode(200);
|
||||
Response result = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate),
|
||||
"AlarmTypes", List.of("CompositeAlarm")));
|
||||
result.then().statusCode(200);
|
||||
if (json) {
|
||||
assertEquals(List.of(), result.jsonPath().getList("LogAlarms"));
|
||||
assertEquals(1, result.jsonPath().getList("CompositeAlarms").size());
|
||||
assertEquals("ALARM", result.jsonPath().getString("CompositeAlarms[0].StateValue"));
|
||||
assertEquals("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate,
|
||||
result.jsonPath().getString("CompositeAlarms[0].AlarmArn"));
|
||||
assertFalse(result.jsonPath().getBoolean("CompositeAlarms[0].ActionsEnabled"));
|
||||
} else {
|
||||
assertTrue(result.asString().contains("<LogAlarms>"));
|
||||
assertEquals("ALARM", result.xmlPath().getString(
|
||||
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue"));
|
||||
assertTrue(result.asString().contains("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate));
|
||||
assertEquals("false", result.xmlPath().getString(
|
||||
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.ActionsEnabled"));
|
||||
}
|
||||
assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate)))
|
||||
.then().statusCode(200).extract().asString().contains(aggregate));
|
||||
call(json, "SetAlarmState", Map.of("AlarmName", source, "StateValue", "OK",
|
||||
"StateReason", "recovery")).then().statusCode(200);
|
||||
Response recovered = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate),
|
||||
"AlarmTypes", List.of("CompositeAlarm")));
|
||||
assertEquals("OK", json ? recovered.jsonPath().getString("CompositeAlarms[0].StateValue")
|
||||
: recovered.xmlPath().getString(
|
||||
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue"));
|
||||
for (String name : List.of(aggregate, source)) {
|
||||
call(json, "DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200);
|
||||
}
|
||||
Response empty = call(json, "DescribeAlarms", Map.of("AlarmNamePrefix", prefix,
|
||||
"AlarmTypes", List.of("CompositeAlarm")));
|
||||
assertFalse(empty.asString().contains(aggregate));
|
||||
assertFalse(empty.asString().contains(source));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void invalidRuleAndUnsupportedSuppressionCannotCreateAnAlarm(boolean json) throws Exception {
|
||||
String name = "invalid-composite-" + Long.toString(System.nanoTime(), 36);
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE AND"))
|
||||
.then().statusCode(400);
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE",
|
||||
"ActionsSuppressor", "other")).then().statusCode(400);
|
||||
assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(name),
|
||||
"AlarmTypes", List.of("CompositeAlarm"))).asString().contains(name));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void alarmTypeValidationAcceptsLogAlarmsAndUsesSmithyErrors(boolean json) throws Exception {
|
||||
call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("LogAlarm"))).then().statusCode(200);
|
||||
Response error = call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("Unknown")));
|
||||
error.then().statusCode(400);
|
||||
assertEquals("1 validation error detected: Value '[Unknown]' at 'alarmTypes' failed to satisfy constraint: "
|
||||
+ "Member must satisfy constraint: [Member must satisfy enum value set: "
|
||||
+ "[CompositeAlarm, MetricAlarm, LogAlarm]]", errorMessage(json, error));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void lengthErrorsUseSmithyMessages(boolean json) throws Exception {
|
||||
for (String field : List.of("AlarmName", "AlarmRule")) {
|
||||
int maximum = "AlarmName".equals(field) ? 255 : 10240;
|
||||
String path = "AlarmName".equals(field) ? "alarmName" : "alarmRule";
|
||||
for (String value : List.of("", "x".repeat(maximum + 1))) {
|
||||
Map<String, String> request = new LinkedHashMap<>(Map.of("AlarmName", "length-validation", "AlarmRule", "TRUE"));
|
||||
request.put(field, value);
|
||||
Response error = call(json, "PutCompositeAlarm", request);
|
||||
error.then().statusCode(400);
|
||||
assertEquals("1 validation error detected: Value '" + value + "' at '" + path
|
||||
+ "' failed to satisfy constraint: Member must have length "
|
||||
+ (value.isEmpty() ? "greater than or equal to 1" : "less than or equal to " + maximum),
|
||||
errorMessage(json, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String errorMessage(boolean json, Response response) {
|
||||
return json ? response.jsonPath().getString("message") : response.xmlPath().getString("ErrorResponse.Error.Message");
|
||||
}
|
||||
|
||||
private Response call(boolean json, String operation, Map<String, ?> body)
|
||||
throws JsonProcessingException {
|
||||
if ("PutCompositeAlarm".equals(operation)) {
|
||||
String name = body.get("AlarmName").toString();
|
||||
cleanup.register(() -> given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0")
|
||||
.header("Authorization", SCOPE)
|
||||
.header("X-Amz-Target", "GraniteServiceVersion20100801.DeleteAlarms")
|
||||
.body(MAPPER.valueToTree(Map.of("AlarmNames", List.of(name))).toString())
|
||||
.post("/").then().statusCode(200));
|
||||
}
|
||||
if (json) {
|
||||
return given().contentType("application/x-amz-json-1.0").header("Authorization", SCOPE)
|
||||
.header("X-Amz-Target", "GraniteServiceVersion20100801." + operation)
|
||||
.body(MAPPER.writeValueAsString(body)).post("/");
|
||||
}
|
||||
Map<String, Object> form = new LinkedHashMap<>();
|
||||
form.put("Action", operation);
|
||||
body.forEach((key, value) -> {
|
||||
if (value instanceof List<?> values) {
|
||||
for (int index = 0; index < values.size(); index++) {
|
||||
form.put(key + ".member." + (index + 1), values.get(index));
|
||||
}
|
||||
} else {
|
||||
form.put(key, value);
|
||||
}
|
||||
});
|
||||
return given().contentType("application/x-www-form-urlencoded").header("Authorization", SCOPE)
|
||||
.formParams(form).post("/");
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.testing.ThreadStates;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CompositeAlarmServiceTest {
|
||||
private static final String REGION = "cn-north-1";
|
||||
private final CloudWatchMetricsService service = new CloudWatchMetricsService(
|
||||
new InMemoryStorage<>(), new InMemoryStorage<>(), new RegionResolver(REGION, "123456789012"));
|
||||
|
||||
@Test
|
||||
void metricTransitionsPropagateThroughNamesArnsAndNestedComposites() {
|
||||
MetricAlarm metric = new MetricAlarm();
|
||||
metric.setAlarmName("latency");
|
||||
service.putMetricAlarm(metric, REGION);
|
||||
put("combined", "ALARM(\"" + metric.getAlarmArn() + "\") AND NOT ALARM(\"maintenance\")");
|
||||
put("outer", "ALARM(combined) OR (TRUE AND FALSE)");
|
||||
assertEquals("OK", state("outer"));
|
||||
service.setAlarmState("latency", "ALARM", "high latency", null, REGION);
|
||||
assertEquals("ALARM", state("combined"));
|
||||
assertEquals("ALARM", state("outer"));
|
||||
service.setAlarmState("latency", "OK", "recovered", null, REGION);
|
||||
assertEquals("OK", state("outer"));
|
||||
assertEquals(List.of(metric), service.describeAlarms(null, null, REGION));
|
||||
assertTrue(service.describeCompositeAlarms(null, null, "us-east-1").isEmpty());
|
||||
assertTrue(service.describeCompositeAlarms(List.of("combined"), null, REGION)
|
||||
.getFirst().getAlarmArn().startsWith("arn:aws-cn:cloudwatch:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesPreserveStateAndTagsUntilTheNextDependencyChange() {
|
||||
put("source", "FALSE");
|
||||
CompositeAlarm alarm = put("aggregate", "ALARM(source)");
|
||||
service.tagResource(alarm.getAlarmArn(), Map.of("owner", "retained"), REGION);
|
||||
service.setAlarmState("aggregate", "ALARM", "manual", null, REGION);
|
||||
CompositeAlarm replacement = put("aggregate", "OK(source)");
|
||||
assertEquals("ALARM", replacement.getStateValue());
|
||||
assertEquals(Map.of("owner", "retained"), replacement.getTags());
|
||||
service.setAlarmState("source", "ALARM", "dependency changed", null, REGION);
|
||||
assertEquals("OK", state("aggregate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedWritesPreserveManualCompositeStatesAndParentStates() {
|
||||
put("source", "FALSE");
|
||||
put("aggregate", "ALARM(source)");
|
||||
put("parent", "ALARM(aggregate)");
|
||||
service.setAlarmState("aggregate", "ALARM", "manual", null, REGION);
|
||||
assertEquals("ALARM", state("parent"));
|
||||
put("unrelated", "TRUE");
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
assertEquals("ALARM", state("parent"));
|
||||
MetricAlarm metric = new MetricAlarm();
|
||||
metric.setAlarmName("unrelated-metric");
|
||||
service.putMetricAlarm(metric, REGION);
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
service.setAlarmState("unrelated-metric", "ALARM", "unrelated", null, REGION);
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
service.deleteAlarms(List.of("unrelated"), REGION);
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
assertEquals("ALARM", state("parent"));
|
||||
service.setAlarmState("source", "ALARM", "child changed", null, REGION);
|
||||
service.setAlarmState("source", "OK", "child recovered", null, REGION);
|
||||
assertEquals("OK", state("aggregate"));
|
||||
assertEquals("OK", state("parent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedChildStateDoesNotResetManuallySetParent() {
|
||||
put("source", "FALSE");
|
||||
put("child", "ALARM(source) AND FALSE");
|
||||
put("parent", "ALARM(child)");
|
||||
service.setAlarmState("parent", "ALARM", "manual", null, REGION);
|
||||
service.setAlarmState("source", "ALARM", "child condition remains false", null, REGION);
|
||||
assertEquals("OK", state("child"));
|
||||
assertEquals("ALARM", state("parent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationAndDeletionReevaluateOnlyReferencingComposites() {
|
||||
MetricAlarm metric = new MetricAlarm();
|
||||
metric.setAlarmName("late");
|
||||
put("watcher", "INSUFFICIENT_DATA(late)");
|
||||
put("outer", "ALARM(watcher)");
|
||||
assertEquals("OK", state("watcher"));
|
||||
service.putMetricAlarm(metric, REGION);
|
||||
assertEquals("ALARM", state("watcher"));
|
||||
assertEquals("ALARM", state("outer"));
|
||||
service.deleteAlarms(List.of("late"), REGION);
|
||||
assertEquals("OK", state("watcher"));
|
||||
assertEquals("OK", state("outer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tagReadIsAnIndependentSnapshotOfTheOwnedAlarm() {
|
||||
CompositeAlarm alarm = put("tag-snapshot", "TRUE");
|
||||
service.tagResource(alarm.getAlarmArn(), Map.of("team", "old"), REGION);
|
||||
Map<String, String> read = service.listTagsForResource(alarm.getAlarmArn(), REGION);
|
||||
service.tagResource(alarm.getAlarmArn(), Map.of("team", "new"), REGION);
|
||||
assertEquals(Map.of("team", "old"), read);
|
||||
read.put("injected", "unowned");
|
||||
assertEquals(Map.of("team", "new"), service.listTagsForResource(alarm.getAlarmArn(), REGION));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclesStopEvaluationEvenBehindShortCircuitAndMustBeBrokenBeforeDelete() {
|
||||
put("a", "FALSE AND ALARM(b)");
|
||||
put("b", "ALARM(a)");
|
||||
service.setAlarmState("b", "ALARM", "manual", null, REGION);
|
||||
assertThrows(AwsException.class, () -> service.deleteAlarms(List.of("a"), REGION));
|
||||
assertEquals(2, service.describeCompositeAlarms(null, null, REGION).size());
|
||||
put("a", "FALSE");
|
||||
service.deleteAlarms(List.of("b"), REGION);
|
||||
assertEquals("OK", state("a"));
|
||||
service.deleteAlarms(List.of("a"), REGION);
|
||||
assertTrue(service.describeCompositeAlarms(null, null, REGION).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manuallyChangingOneMemberDoesNotResumeACyclicDependency() {
|
||||
put("a", "ALARM(b)");
|
||||
put("b", "ALARM(a)");
|
||||
assertEquals("OK", state("a"));
|
||||
service.setAlarmState("b", "ALARM", "manual", null, REGION);
|
||||
assertEquals("OK", state("a"));
|
||||
put("b", "TRUE");
|
||||
service.setAlarmState("b", "ALARM", "cycle broken", null, REGION);
|
||||
assertEquals("ALARM", state("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parentQuotaTreatsAChildNameAndItsArnAsTheSameAlarm() {
|
||||
CompositeAlarm child = put("child", "FALSE");
|
||||
for (int index = 0; index < 150; index++) {
|
||||
put("parent-" + index, "ALARM(child)");
|
||||
}
|
||||
assertThrows(AwsException.class, () -> put("overflow", "ALARM(\"" + child.getAlarmArn() + "\")"));
|
||||
service.deleteAlarms(List.of("parent-0"), REGION);
|
||||
assertEquals("OK", put("replacement", "ALARM(\"" + child.getAlarmArn() + "\")").getStateValue());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", "ALARM()", "TRUE junk", "TRUE AND", "ALARM(\"unterminated)",
|
||||
"(TRUE", "ALARM(x) XOR FALSE", "ALARM(\"x\\z\")"})
|
||||
void invalidRulesDoNotReplaceAnExistingAlarm(String invalid) {
|
||||
put("saved", "TRUE");
|
||||
assertThrows(AwsException.class, () -> put("saved", invalid));
|
||||
assertEquals("TRUE", service.describeCompositeAlarms(List.of("saved"), null, REGION).getFirst().getAlarmRule());
|
||||
assertEquals("ALARM", state("saved"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void quotasAndAtomicCreatePreventExcessReferencesAndForeignNameReplacement() {
|
||||
String many = IntStream.range(0, 101).mapToObj(i -> "ALARM(a" + i + ")")
|
||||
.collect(Collectors.joining(" OR "));
|
||||
assertThrows(AwsException.class, () -> put("too-many", many));
|
||||
put("owned", "TRUE");
|
||||
CompositeAlarm foreign = new CompositeAlarm();
|
||||
foreign.setAlarmName("owned");
|
||||
foreign.setAlarmRule("FALSE");
|
||||
assertThrows(AwsException.class, () -> service.putCompositeAlarm(foreign, REGION, false));
|
||||
assertEquals("ALARM", state("owned"));
|
||||
assertThrows(AwsException.class, () -> put("deep", "(".repeat(250) + "TRUE" + ")".repeat(250)));
|
||||
assertEquals("ALARM", put("many-not", "NOT ".repeat(502) + "TRUE").getStateValue());
|
||||
assertThrows(AwsException.class, () -> put("elements", "FALSE OR ".repeat(500) + "TRUE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedPersistenceLoadsLegacyMetricsAndRoundTripsComposites() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
TypeReference<Map<String, Alarm>> type = new TypeReference<>() {};
|
||||
Map<String, Alarm> legacy = mapper.readValue(
|
||||
"{\"legacy\":{\"alarmName\":\"old\",\"metricName\":\"CPU\",\"stateValue\":\"OK\"}}", type);
|
||||
assertInstanceOf(MetricAlarm.class, legacy.get("legacy"));
|
||||
CompositeAlarm alarm = put("new", "FALSE");
|
||||
Map<String, Alarm> restored = mapper.readValue(mapper.writeValueAsBytes(Map.of("new", alarm)), type);
|
||||
assertEquals("FALSE", assertInstanceOf(CompositeAlarm.class, restored.get("new")).getAlarmRule());
|
||||
assertEquals("OK", restored.get("new").getStateValue());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void concurrentTagChangesPreserveTheUpdatedDefinition(boolean remove) throws Exception {
|
||||
CountDownLatch updating = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
InMemoryStorage<String, Alarm> storage = new InMemoryStorage<>() {
|
||||
@Override
|
||||
public void put(String key, Alarm value) {
|
||||
if (value instanceof CompositeAlarm composite && "TRUE".equals(composite.getAlarmRule())) {
|
||||
updating.countDown();
|
||||
try {
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(error);
|
||||
}
|
||||
}
|
||||
super.put(key, value);
|
||||
}
|
||||
};
|
||||
CloudWatchMetricsService concurrent = new CloudWatchMetricsService(new InMemoryStorage<>(), storage,
|
||||
new RegionResolver(REGION, "123456789012"));
|
||||
CompositeAlarm original = new CompositeAlarm();
|
||||
original.setAlarmName("concurrent");
|
||||
original.setAlarmRule("FALSE");
|
||||
original.setTags(new HashMap<>(Map.of("team", "old")));
|
||||
concurrent.putCompositeAlarm(original, REGION);
|
||||
CompositeAlarm replacement = new CompositeAlarm();
|
||||
replacement.setAlarmName("concurrent");
|
||||
replacement.setAlarmRule("TRUE");
|
||||
AtomicReference<Thread> tagThread = new AtomicReference<>();
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
Future<?> update = executor.submit(() -> concurrent.putCompositeAlarm(replacement, REGION));
|
||||
Future<?> tags;
|
||||
try {
|
||||
assertTrue(updating.await(2, TimeUnit.SECONDS));
|
||||
tags = executor.submit(() -> {
|
||||
tagThread.set(Thread.currentThread());
|
||||
if (remove) {
|
||||
concurrent.untagResource(original.getAlarmArn(), List.of("team"), REGION);
|
||||
} else {
|
||||
concurrent.tagResource(original.getAlarmArn(), Map.of("team", "new"), REGION);
|
||||
}
|
||||
});
|
||||
ThreadStates.awaitBlockedOrDone(tagThread, tags);
|
||||
assertFalse(tags.isDone(), "tag mutation must share the definition update lock");
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
update.get(2, TimeUnit.SECONDS);
|
||||
tags.get(2, TimeUnit.SECONDS);
|
||||
}
|
||||
CompositeAlarm result = concurrent.describeCompositeAlarms(List.of("concurrent"), null, REGION).getFirst();
|
||||
assertEquals("TRUE", result.getAlarmRule());
|
||||
assertEquals(remove ? Map.of() : Map.of("team", "new"), result.getTags());
|
||||
}
|
||||
|
||||
private CompositeAlarm put(String name, String rule) {
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
alarm.setAlarmName(name);
|
||||
alarm.setAlarmRule(rule);
|
||||
alarm.setActionsEnabled(true);
|
||||
service.putCompositeAlarm(alarm, REGION);
|
||||
return alarm;
|
||||
}
|
||||
|
||||
private String state(String name) {
|
||||
return service.describeCompositeAlarms(List.of(name), null, REGION).getFirst().getStateValue();
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ AWS::CloudFront::OriginRequestPolicy CloudFrontCfnProvisioner
|
||||
AWS::CloudFront::ResponseHeadersPolicy CloudFrontCfnProvisioner
|
||||
AWS::CloudTrail::Trail CloudTrailCfnProvisioner
|
||||
AWS::CloudWatch::Alarm CloudWatchCfnProvisioner
|
||||
AWS::CloudWatch::CompositeAlarm CloudWatchCompositeAlarmCfnProvisioner
|
||||
AWS::CloudWatch::Dashboard CloudWatchDashboardCfnProvisioner
|
||||
AWS::CodeBuild::Project CodeBuildCfnProvisioner
|
||||
AWS::CodePipeline::CustomActionType CodePipelineCfnProvisioner
|
||||
|
||||
|
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user