diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java new file mode 100644 index 0000000..d32f9d5 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java @@ -0,0 +1,117 @@ +package com.floci.test; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.cloudwatch.CloudWatchClient; +import software.amazon.awssdk.services.cloudwatch.model.ComparisonOperator; +import software.amazon.awssdk.services.cloudwatch.model.Dimension; +import software.amazon.awssdk.services.cloudwatch.model.Metric; +import software.amazon.awssdk.services.cloudwatch.model.MetricAlarm; +import software.amazon.awssdk.services.cloudwatch.model.MetricDataQuery; +import software.amazon.awssdk.services.cloudwatch.model.MetricStat; +import software.amazon.awssdk.services.cloudwatch.model.StandardUnit; +import software.amazon.awssdk.services.cloudwatch.model.Statistic; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("CloudWatch - Metric-math alarm definitions") +class CloudWatchMetricMathAlarmTest { + + private CloudWatchClient cloudWatch; + private String alarmName; + + @BeforeEach + void setup() { + cloudWatch = TestFixtures.cloudWatchClient(); + alarmName = TestFixtures.uniqueName("sdk-metric-math-alarm"); + } + + @AfterEach + void cleanup() { + try { + cloudWatch.deleteAlarms(request -> request.alarmNames(alarmName)); + } finally { + cloudWatch.close(); + } + } + + @Test + void metricsRoundTripWithoutInventingSingleMetricFields() { + List metrics = metrics(); + putMathAlarm(metrics); + assertMathAlarm(metrics); + } + + @Test + void updatesReplaceQueriesAndSwitchBackToASingleMetric() { + putSingleMetricAlarm(300); + List original = metrics(); + putMathAlarm(original); + assertMathAlarm(original); + + List replacement = List.of( + original.get(0).toBuilder().expression("errors / requests").label("Ratio").build(), + original.get(1), original.get(2)); + putMathAlarm(replacement); + assertMathAlarm(replacement); + + putSingleMetricAlarm(120); + MetricAlarm alarm = describe(); + assertThat(alarm.hasMetrics()).isFalse(); + assertThat(alarm.period()).isEqualTo(120); + assertThat(alarm.metricName()).isEqualTo("5xx"); + assertThat(alarm.namespace()).isEqualTo("AWS/ApiGateway"); + } + + private void putMathAlarm(List metrics) { + cloudWatch.putMetricAlarm(request -> request.alarmName(alarmName) + .comparisonOperator(ComparisonOperator.GREATER_THAN_THRESHOLD) + .evaluationPeriods(2).threshold(5.0).metrics(metrics)); + } + + private void putSingleMetricAlarm(int period) { + cloudWatch.putMetricAlarm(request -> request.alarmName(alarmName) + .comparisonOperator(ComparisonOperator.GREATER_THAN_THRESHOLD) + .evaluationPeriods(2).threshold(5.0).namespace("AWS/ApiGateway") + .metricName("5xx").statistic(Statistic.SUM).period(period)); + } + + private MetricAlarm describe() { + List alarms = cloudWatch.describeAlarms(request -> request.alarmNames(alarmName)).metricAlarms(); + assertThat(alarms).hasSize(1); + return alarms.get(0); + } + + private void assertMathAlarm(List metrics) { + MetricAlarm alarm = describe(); + assertThat(alarm.metrics()).containsExactlyElementsOf(metrics); + assertThat(alarm.period()).isNull(); + assertThat(alarm.metricName()).isNull(); + assertThat(alarm.namespace()).isNull(); + assertThat(alarm.statistic()).isNull(); + assertThat(alarm.extendedStatistic()).isNull(); + assertThat(alarm.unit()).isNull(); + assertThat(alarm.hasDimensions()).isFalse(); + assertThat(alarm.evaluationPeriods()).isEqualTo(2); + assertThat(alarm.threshold()).isEqualTo(5.0); + } + + private static List metrics() { + return List.of( + MetricDataQuery.builder().id("rate").expression("100 * errors / requests") + .returnData(true).period(300).label("Error & requests").build(), + MetricDataQuery.builder().id("errors").returnData(false).accountId("123456789012") + .metricStat(MetricStat.builder().period(300).stat("Sum").unit(StandardUnit.COUNT) + .metric(Metric.builder().namespace("AWS/ApiGateway").metricName("5xx") + .dimensions(Dimension.builder().name("ApiName").value("api<&>").build()) + .build()).build()).build(), + MetricDataQuery.builder().id("requests").returnData(false) + .metricStat(MetricStat.builder().period(300).stat("Sum") + .metric(Metric.builder().namespace("AWS/ApiGateway").metricName("Count").build()) + .build()).build()); + } +} diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java index 8f4ecca..66dc0e9 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchTest.java @@ -6,6 +6,7 @@ import software.amazon.awssdk.services.cloudwatch.model.*; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Map; import static org.assertj.core.api.Assertions.*; @@ -185,4 +186,67 @@ class CloudWatchTest { ); assertThat(noMatch.datapoints()).isEmpty(); } + + @Test + @DisplayName("PutMetricData stores datums nested under EntityMetricData") + void testPutMetricDataWithEntityMetricData() { + String namespace = "JavaTestEntityMetricData"; + Instant now = Instant.now(); + + cloudWatchClient.putMetricData(request -> request + .namespace(namespace) + .metricData(datum -> datum + .metricName("TopLevel") + .value(1.0) + .timestamp(now)) + .entityMetricData(entityMetricData -> entityMetricData + .entity(entity -> entity + .keyAttributes(Map.of( + "Type", "AWS::Resource", + "ResourceType", "AWS::EC2::Instance", + "Identifier", "i-0123456789abcdef0"))) + .metricData(MetricDatum.builder() + .metricName("mem_used_percent") + .value(6.5) + .unit(StandardUnit.PERCENT) + .storageResolution(60) + .timestamp(now) + .dimensions(Dimension.builder().name("host").value("h1").build()) + .build())) + .strictEntityValidation(false) + ); + + ListMetricsResponse listed = cloudWatchClient.listMetrics(request -> request.namespace(namespace)); + assertThat(listed.metrics()) + .extracting(Metric::metricName) + .containsExactlyInAnyOrder("TopLevel", "mem_used_percent"); + + GetMetricStatisticsResponse stats = cloudWatchClient.getMetricStatistics(request -> request + .namespace(namespace) + .metricName("mem_used_percent") + .dimensions(Dimension.builder().name("host").value("h1").build()) + .startTime(now.minus(1, ChronoUnit.HOURS)) + .endTime(now.plus(1, ChronoUnit.MINUTES)) + .period(3600) + .statistics(Statistic.SUM) + ); + assertThat(stats.datapoints()).hasSize(1); + assertThat(stats.datapoints().get(0).sum()).isEqualTo(6.5); + } + + @Test + @DisplayName("PutMetricData without MetricName throws MissingRequiredParameterException") + void testPutMetricDataWithoutMetricNameIsRejected() { + String namespace = "JavaTestMissingMetricName"; + + assertThatThrownBy(() -> cloudWatchClient.putMetricData(request -> request + .namespace(namespace) + .entityMetricData(entityMetricData -> entityMetricData + .metricData(MetricDatum.builder().value(1.0).build())))) + .isInstanceOf(MissingRequiredParameterException.class) + .satisfies(e -> assertThat(((MissingRequiredParameterException) e).awsErrorDetails().errorCode()) + .isEqualTo("MissingParameter")); + + assertThat(cloudWatchClient.listMetrics(request -> request.namespace(namespace)).metrics()).isEmpty(); + } } diff --git a/compatibility-tests/sdk-test-python/tests/test_cloudwatch.py b/compatibility-tests/sdk-test-python/tests/test_cloudwatch.py index 9a674cf..6baa173 100644 --- a/compatibility-tests/sdk-test-python/tests/test_cloudwatch.py +++ b/compatibility-tests/sdk-test-python/tests/test_cloudwatch.py @@ -203,6 +203,8 @@ class TestCloudWatchAlarms: alarm = next(a for a in alarms if a["AlarmName"] == alarm_name) assert alarm["StateValue"] == "INSUFFICIENT_DATA" + assert response["CompositeAlarms"] == [] + assert response["LogAlarms"] == [] finally: cloudwatch_client.delete_alarms(AlarmNames=[alarm_name]) diff --git a/docs/services/cloudformation.md b/docs/services/cloudformation.md index d09a4eb..3b73d07 100644 --- a/docs/services/cloudformation.md +++ b/docs/services/cloudformation.md @@ -160,7 +160,7 @@ cross-resource references. | Kinesis Data Firehose | `DeliveryStream` | | IoT Core | `DomainConfiguration` (`ServerCertificates` resolves to a JSON string), `Policy` (deleted after detaching it from its principals; on AWS the delete fails with `DeleteConflictException` while the policy is attached), `Thing`, `TopicRule` | | CloudFront | `CachePolicy`, `Distribution`, `OriginAccessControl`, `OriginRequestPolicy`, `ResponseHeadersPolicy` | -| CloudWatch | `Alarm`, `Dashboard` | +| CloudWatch | `Alarm`, `CompositeAlarm`, `Dashboard`. Composite alarms have backing definitions with name Ref and Arn GetAtt, rollback, and replacement cleanup. Composite actions are stored only; suppression properties are rejected. | | CloudWatch Logs | `LogGroup`, `LogStream`, `MetricFilter` | | WAFv2 | `WebACL` | | Config | `ConfigRule` | diff --git a/docs/services/cloudwatch.md b/docs/services/cloudwatch.md index e9e1b61..eda278a 100644 --- a/docs/services/cloudwatch.md +++ b/docs/services/cloudwatch.md @@ -311,6 +311,7 @@ aws logs put-retention-policy \ | `GetMetricStatistics` | Get metric statistics (Average, Sum, etc.) | | `GetMetricData` | Query metrics with math expressions | | `PutMetricAlarm` | Create a metric alarm | +| `PutCompositeAlarm` | Create or update a composite alarm rule | | `DescribeAlarms` | List alarms | | `DeleteAlarms` | Delete alarms | | `SetAlarmState` | Manually set alarm state | @@ -331,6 +332,31 @@ 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 +`InsufficientDataActions`, including transitions from `SetAlarmState`. Unchanged states and +`ActionsEnabled=false` suppress notifications. Scaling action retries remain independent. +Failures are logged per target without blocking other targets. Notifications include a +single-metric `Trigger`; durable retries, FIFO topics, and metric-math payloads are not covered. + +### Metric-math alarm definitions + +`PutMetricAlarm` stores the `Metrics` query definitions and `DescribeAlarms` returns them over +both JSON and Query protocols. Expressions, labels, `ReturnData`, account IDs, query periods, +and nested metric statistics (including dimensions and units) survive storage reloads. +Metric-math alarm responses omit the single-metric fields, including top-level `Period`. +Updating an alarm replaces its metric definition, so changing between `Metrics` and a single +metric does not retain the previous definition. + +This is management-plane support: the alarm evaluator currently evaluates only single-metric +alarms, not metric-math expressions. `SetAlarmState` remains available for manual transitions. + ### Examples ```bash diff --git a/src/main/java/io/github/hectorvent/floci/core/common/SmithyValidationErrors.java b/src/main/java/io/github/hectorvent/floci/core/common/SmithyValidationErrors.java new file mode 100644 index 0000000..bb33c6c --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/core/common/SmithyValidationErrors.java @@ -0,0 +1,97 @@ +package io.github.hectorvent.floci.core.common; + +import java.util.List; + +/** + * The message AWS's Smithy validation layer answers when a request breaks its model's constraints: + * "1 validation error detected: a" or "N validation errors detected: a; b", each violation reading + * "Value at 'path' failed to satisfy constraint: ...". The wording is Smithy's, shared by every + * service built on it; only the error code differs by protocol. + */ +public final class SmithyValidationErrors { + + private SmithyValidationErrors() {} + + /** + * The message for one or more violations. An empty list is the caller's bug, so it fails rather + * than answering "0 validation errors"; check {@code isEmpty()} before throwing. + */ + public static String message(List violations) { + if (violations.isEmpty()) { + throw new IllegalArgumentException("no violations to report"); + } + String count = violations.size() == 1 + ? "1 validation error detected: " + : violations.size() + " validation errors detected: "; + return count + String.join("; ", violations); + } + + /** The violation for a required member the request leaves out. */ + public static String notNull(String path) { + return "Value null at '" + path + "' failed to satisfy constraint: Member must not be null"; + } + + /** The 400 error carrying {@link #message} under the protocol's validation error code. */ + public static AwsException exception(String errorCode, List violations) { + return new AwsException(errorCode, message(violations), 400); + } + + public static AwsException exception(String errorCode, String violation) { + return exception(errorCode, List.of(violation)); + } + + /** The code most JSON services use, on both the awsJson and the restJson protocols. */ + public static AwsException validationException(List violations) { + return exception("ValidationException", violations); + } + + public static AwsException validationException(String violation) { + return validationException(List.of(violation)); + } + + /** The awsQuery protocol's code. */ + public static AwsException validationError(List violations) { + return exception("ValidationError", violations); + } + + public static AwsException validationError(String violation) { + return validationError(List.of(violation)); + } + + /** The code of REST JSON services that model their validation failure as BadRequestException. */ + public static AwsException badRequest(List violations) { + return exception("BadRequestException", violations); + } + + public static AwsException badRequest(String violation) { + return badRequest(List.of(violation)); + } + + /** The violation for a number outside {@code min} to {@code max}, or {@code null} for one inside. */ + public static String outOfRange(String path, long value, long min, long max) { + if (value < min) { + return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: " + + "Member must have value greater than or equal to " + min; + } + if (value > max) { + return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: " + + "Member must have value less than or equal to " + max; + } + return null; + } + + /** The violation for a value outside its enum, listing the allowed values in the model's order. */ + public static String notInEnum(String value, String path, List allowed) { + return "Value '" + value + "' at '" + path + "' failed to satisfy constraint: " + + "Member must satisfy enum value set: " + allowed; + } + + /** + * The violation for a list holding a value outside its members' enum. The list is checked as a + * whole, so the whole list is the offending value and the enum constraint is nested. + */ + public static String notAllInEnum(List values, String path, List allowed) { + return "Value '" + values + "' at '" + path + "' failed to satisfy constraint: " + + "Member must satisfy constraint: [Member must satisfy enum value set: " + allowed + "]"; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java new file mode 100644 index 0000000..616334a --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisioner.java @@ -0,0 +1,183 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +@ApplicationScoped +public class CloudWatchCompositeAlarmCfnProvisioner implements CfnResourceProvisioner { + private static final String TYPE = "AWS::CloudWatch::CompositeAlarm"; + private static final String SNAPSHOT = "FlociCompositeAlarmSnapshot"; + private static final String TAG_KEYS = "FlociCompositeAlarmTagKeys"; + private static final String EXPLICIT_NAME = "FlociCompositeAlarmExplicitName"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final CloudWatchMetricsService service; + + public CloudWatchCompositeAlarmCfnProvisioner(CloudWatchMetricsService service) { + this.service = service; + } + + @Override + public Set resourceTypes() { + return Set.of(TYPE); + } + + @Override + public void provision(StackResource resource, JsonNode props, ProvisionContext ctx) { + for (String unsupported : List.of("ActionsSuppressor", "ActionsSuppressorWaitPeriod", + "ActionsSuppressorExtensionPeriod")) { + if (props.has(unsupported)) { + throw new AwsException("ValidationError", "Composite action suppression is not supported", 400); + } + } + if (resource.getAttributes().containsKey(SNAPSHOT)) { + throw new AwsException("ResourceConflictException", "Alarm update cleanup is still pending", 400); + } + String explicit = ctx.resolveOptional(props, "AlarmName"); + boolean removedName = explicit == null && "true".equals(resource.getAttributes().get(EXPLICIT_NAME)); + String name = removedName ? ctx.generatePhysicalName(resource.getLogicalId(), 255, false) + : ctx.stablePhysicalName(explicit, resource.getLogicalId(), 255, false); + CompositeAlarm alarm = new CompositeAlarm(); + alarm.setAlarmName(name); + alarm.setAlarmDescription(ctx.resolveOptional(props, "AlarmDescription")); + alarm.setAlarmRule(ctx.resolveOptional(props, "AlarmRule")); + alarm.setActionsEnabled(!"false".equals(ctx.resolveOptional(props, "ActionsEnabled"))); + alarm.setTags(ctx.resolveTags(props, "Tags")); + actions(props, "AlarmActions", ctx, alarm.getAlarmActions()); + actions(props, "OKActions", ctx, alarm.getOkActions()); + actions(props, "InsufficientDataActions", ctx, alarm.getInsufficientDataActions()); + Map desiredTags = Map.copyOf(alarm.getTags()); + Map priorAttributes = Map.copyOf(resource.getAttributes()); + boolean inPlace = ctx.reusesPriorEntity(name); + String updateSnapshot = null; + if (inPlace) { + ObjectNode snapshot = MAPPER.createObjectNode().put("region", ctx.region()).put("name", name); + List previous = service.describeCompositeAlarms(List.of(name), null, ctx.region()); + if (!previous.isEmpty()) { + snapshot.set("alarm", MAPPER.valueToTree(previous.getFirst())); + } + snapshot.set("attributes", MAPPER.valueToTree(priorAttributes)); + Set changedKeys = new TreeSet<>(desiredTags.keySet()); + changedKeys.addAll(tagKeys(resource.getAttributes().get(TAG_KEYS))); + snapshot.set("changedTagKeys", MAPPER.valueToTree(changedKeys)); + updateSnapshot = snapshot.toString(); + } + service.putCompositeAlarm(alarm, ctx.region(), inPlace); + if (inPlace) { + resource.getAttributes().put(SNAPSHOT, updateSnapshot); + reconcileTags(alarm.getAlarmArn(), tagKeys(priorAttributes.get(TAG_KEYS)), desiredTags, ctx.region()); + } + resource.getAttributes().put(TAG_KEYS, MAPPER.valueToTree(desiredTags.keySet()).toString()); + resource.setPhysicalId(name); + resource.getAttributes().put("Arn", alarm.getAlarmArn()); + resource.getAttributes().put(EXPLICIT_NAME, Boolean.toString(explicit != null)); + ReplacementCleanup.record(resource, ctx, priorAttributes); + } + + private static void actions(JsonNode props, String field, ProvisionContext ctx, List target) { + JsonNode values = ctx.engine().resolveNode(props.path(field)); + if (values != null && values.isArray()) { + values.forEach(value -> target.add(value.asText())); + } + } + + private static Set tagKeys(String json) { + Set keys = new TreeSet<>(); + if (json != null) { + try { + MAPPER.readTree(json).forEach(value -> keys.add(value.asText())); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Cannot read composite alarm tag ownership", error); + } + } + return keys; + } + + private void reconcileTags(String arn, Set owned, Map desired, String region) { + List removed = owned.stream().filter(key -> !desired.containsKey(key)).toList(); + if (!removed.isEmpty()) { + service.untagResource(arn, removed, region); + } + if (!desired.isEmpty()) { + service.tagResource(arn, desired, region); + } + } + + @Override + public void delete(String type, String physicalId, String region) { + service.deleteAlarms(List.of(physicalId), region); + } + + @Override + public boolean retainsFailedUpdateState(StackResource resource) { + return resource.getAttributes().containsKey(SNAPSHOT); + } + + @Override + public boolean rollbackUpdate(StackResource resource) { + if (ReplacementCleanup.rollback(resource, this::delete)) { + return true; + } + String raw = resource.getAttributes().get(SNAPSHOT); + if (raw == null) { + return false; + } + try { + JsonNode snapshot = MAPPER.readTree(raw); + String region = snapshot.path("region").asText(); + if (snapshot.has("alarm")) { + CompositeAlarm original = MAPPER.treeToValue(snapshot.get("alarm"), CompositeAlarm.class); + Map originalTags = Map.copyOf(original.getTags()); + service.putCompositeAlarm(original, region); + Set changed = tagKeys(snapshot.path("changedTagKeys").toString()); + reconcileTags(original.getAlarmArn(), changed, originalTags.entrySet().stream() + .filter(entry -> changed.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), region); + } else { + delete(TYPE, snapshot.path("name").asText(), region); + } + resource.getAttributes().clear(); + snapshot.path("attributes").fields().forEachRemaining(entry -> + resource.getAttributes().put(entry.getKey(), entry.getValue().asText())); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Cannot read composite alarm rollback state", error); + } + return true; + } + + @Override + public boolean hasReplacementUpdate(StackResource resource) { + return ReplacementCleanup.hasReplacement(resource); + } + + @Override + public String updateCleanupPhysicalId(StackResource resource) { + return ReplacementCleanup.cleanupPhysicalId(resource); + } + + @Override + public UpdateCleanupResult completeUpdate(StackResource resource) { + if (resource.getAttributes().containsKey(SNAPSHOT) && !ReplacementCleanup.hasReplacement(resource)) { + return new UpdateCleanupResult(true, true, null, 0, null); + } + return ReplacementCleanup.complete(resource, this::delete); + } + + @Override + public void clearUpdate(StackResource resource) { + resource.getAttributes().remove(SNAPSHOT); + ReplacementCleanup.clear(resource); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java index 9ab3340..d9c1ff2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/ReplacementCleanup.java @@ -9,9 +9,11 @@ import io.github.hectorvent.floci.services.cloudformation.model.StackResource; import org.jboss.logging.Logger; import java.util.ArrayList; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; /** * The replacement lifecycle for provisioners whose replacing update simply creates the new entity @@ -121,6 +123,22 @@ public final class ReplacementCleanup { return cleanup != null && cleanup.path("displaced").size() > 0; } + /** The physical IDs of replaced resources this resource still has to delete. */ + static Set owedPhysicalIds(StackResource r) { + Set owed = new HashSet<>(); + ObjectNode cleanup = read(r); + if (cleanup == null) { + return owed; + } + for (JsonNode entry : cleanup.path("displaced")) { + String physicalId = entry.path("physicalId").asText(null); + if (physicalId != null) { + owed.add(physicalId); + } + } + return owed; + } + /** * The entity the cleanup is working on, or null when none is owed or {@code UpdateReplacePolicy: * Retain} keeps it. The engine announces one physical id per resource, so a resource owing more diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsService.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsService.java index 976a43f..a0f4005 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsService.java @@ -179,29 +179,82 @@ public class CloudWatchDashboardsService { } public Map listTagsForResource(String resourceArn, String region) { - return findByArn(resourceArn, region).map(Dashboard::getTags).orElse(Map.of()); + return requireDashboard(resourceArn, region).getTags(); } public void tagResource(String resourceArn, Map tags, String region) { - findByArn(resourceArn, region).ifPresent(dashboard -> { - dashboard.getTags().putAll(tags); - dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); - }); + Dashboard dashboard = requireDashboard(resourceArn, region); + dashboard.getTags().putAll(tags); + dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); } public void untagResource(String resourceArn, List tagKeys, String region) { - findByArn(resourceArn, region).ifPresent(dashboard -> { - tagKeys.forEach(dashboard.getTags()::remove); - dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); - }); + Dashboard dashboard = requireDashboard(resourceArn, region); + tagKeys.forEach(dashboard.getTags()::remove); + dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); + } + + /** + * Resolves the dashboard an ARN names, or reports that nothing does. The tag operations + * declare {@code ResourceNotFoundException}, which is a different shape from the + * {@code ResourceNotFound} that {@code GetDashboard} declares, + * so this is not the {@link #notFound(String)} used by the rest of this service. + */ + private Dashboard requireDashboard(String resourceArn, String region) { + return findByArn(resourceArn, region).orElseThrow(() -> + new AwsException("ResourceNotFoundException", + "Dashboard does not exist: " + resourceArn, 404)); } private java.util.Optional findByArn(String resourceArn, String region) { return dashboardStore.scan(k -> k.startsWith(region + "::")).stream() - .filter(d -> d.getDashboardArn() != null && d.getDashboardArn().equals(resourceArn)) + .filter(d -> namesDashboard(resourceArn, d.getDashboardArn())) .findFirst(); } + /** + * Whether {@code candidate} is an ARN for the dashboard whose own ARN is {@code stored}. + * + *

Both the region-ful and the regionless form resolve, deliberately, because the two + * disagree here and a caller may hold either. Dashboards are a global resource and AWS + * documents their ARN without a region on both the TagResource and the ListTagsForResource + * pages, {@code arn:aws:cloudwatch:::dashboard/}, while Floci + * mints a region-ful one. That divergence used to be invisible on this path: an ARN + * matching nothing was a silent success answering an empty tag map. Now that it is an + * error, a caller sending the documented ARN for a dashboard that exists would be told the + * dashboard does not, so the lookup accepts both shapes. + * + *

What Floci mints is left alone here on purpose. Clients may already hold the ARNs it + * has handed out, so bringing the minted shape into line with AWS is a separate change + * from making the lookup tolerant, and only the second one is needed to keep this path + * correct. + * + *

Only an absent region is accepted in place of the dashboard's own, so an ARN naming + * some other region is still a miss rather than resolving to a same-named dashboard + * elsewhere. + */ + private static boolean namesDashboard(String candidate, String stored) { + if (candidate == null || stored == null) { + return false; + } + return candidate.equals(stored) || candidate.equals(withoutRegion(stored)); + } + + /** + * Blanks the region field of an ARN, turning + * {@code arn:aws:cloudwatch:us-east-1:123:dashboard/x} into + * {@code arn:aws:cloudwatch::123:dashboard/x}. An ARN with fewer than six fields is + * returned unchanged, since there is no region field to blank. + */ + private static String withoutRegion(String arn) { + String[] fields = arn.split(":", 6); + if (fields.length < 6) { + return arn; + } + fields[3] = ""; + return String.join(":", fields); + } + private static String key(String region, String dashboardName) { return region + "::" + dashboardName; } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java index f7b0eeb..335ef51 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java @@ -275,9 +275,10 @@ public class AlarmEvaluator { private static boolean isEvaluable(MetricAlarm alarm) { return alarm.getRegion() != null + && alarm.getMetrics().isEmpty() && alarm.getNamespace() != null && !alarm.getNamespace().isBlank() && alarm.getMetricName() != null && !alarm.getMetricName().isBlank() - && alarm.getPeriod() > 0 + && alarm.getPeriod() != null && alarm.getPeriod() > 0 && alarm.getEvaluationPeriods() > 0 && alarm.getComparisonOperator() != null; } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java new file mode 100644 index 0000000..c1a5463 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifier.java @@ -0,0 +1,155 @@ +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.core.common.AwsArnUtils; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; +import io.github.hectorvent.floci.services.sns.SnsService; +import jakarta.enterprise.context.ApplicationScoped; +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; +import java.util.Map; + +/** Publishes SNS actions once per state transition, independently of scaling action retries. */ +@ApplicationScoped +public class AlarmSnsNotifier { + + /** The names CloudWatch writes in the alarm message's Region field; other regions use their ID. */ + private static final Map REGION_NAMES = Map.ofEntries( + Map.entry("us-east-1", "US East (N. Virginia)"), + Map.entry("us-east-2", "US East (Ohio)"), + Map.entry("us-west-1", "US West (N. California)"), + Map.entry("us-west-2", "US West (Oregon)"), + Map.entry("ca-central-1", "Canada (Central)"), + Map.entry("ca-west-1", "Canada West (Calgary)"), + Map.entry("sa-east-1", "South America (Sao Paulo)"), + Map.entry("eu-west-1", "EU (Ireland)"), + Map.entry("eu-west-2", "EU (London)"), + Map.entry("eu-west-3", "EU (Paris)"), + Map.entry("eu-central-1", "EU (Frankfurt)"), + Map.entry("eu-central-2", "EU (Zurich)"), + Map.entry("eu-north-1", "EU (Stockholm)"), + Map.entry("eu-south-1", "EU (Milan)"), + Map.entry("eu-south-2", "EU (Spain)"), + Map.entry("me-south-1", "Middle East (Bahrain)"), + Map.entry("me-central-1", "Middle East (UAE)"), + Map.entry("il-central-1", "Israel (Tel Aviv)"), + Map.entry("af-south-1", "Africa (Cape Town)"), + Map.entry("ap-east-1", "Asia Pacific (Hong Kong)"), + Map.entry("ap-south-1", "Asia Pacific (Mumbai)"), + Map.entry("ap-south-2", "Asia Pacific (Hyderabad)"), + Map.entry("ap-northeast-1", "Asia Pacific (Tokyo)"), + Map.entry("ap-northeast-2", "Asia Pacific (Seoul)"), + Map.entry("ap-northeast-3", "Asia Pacific (Osaka)"), + Map.entry("ap-southeast-1", "Asia Pacific (Singapore)"), + Map.entry("ap-southeast-2", "Asia Pacific (Sydney)"), + Map.entry("ap-southeast-3", "Asia Pacific (Jakarta)"), + Map.entry("ap-southeast-4", "Asia Pacific (Melbourne)")); + + private static final Logger LOG = Logger.getLogger(AlarmSnsNotifier.class); + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT).withZone(ZoneOffset.UTC); + private final SnsService snsService; + private final ObjectMapper objectMapper; + + public AlarmSnsNotifier(SnsService snsService, ObjectMapper objectMapper) { + this.snsService = snsService; + this.objectMapper = objectMapper; + } + + record Notification(String topicArn, String message, String subject, String region) {} + + List prepareTransition(MetricAlarm alarm, String oldState) { + if (!alarm.isActionsEnabled() || alarm.getStateValue().equals(oldState)) { + return List.of(); + } + List actions = switch (alarm.getStateValue()) { + case "ALARM" -> alarm.getAlarmActions(); + case "OK" -> alarm.getOkActions(); + case "INSUFFICIENT_DATA" -> alarm.getInsufficientDataActions(); + default -> List.of(); + }; + if (actions == null) { + return List.of(); + } + List notifications = new ArrayList<>(); + for (String action : actions) { + if (!AwsArnUtils.isArnFor(action, "sns")) { + continue; + } + try { + AwsArnUtils.Arn topic = AwsArnUtils.parse(action); + String subject = alarm.getStateValue() + ": \"" + alarm.getAlarmName() + "\""; + if (subject.length() > 100) { + subject = subject.substring(0, 100); + } + notifications.add(new Notification(action, message(alarm, oldState), subject, topic.region())); + } catch (Exception e) { + LOG.warnv(e, "Unable to publish alarm {0} state {1} to SNS action {2}", + alarm.getAlarmName(), alarm.getStateValue(), action); + } + } + return notifications; + } + + void publish(List notifications) { + for (Notification notification : notifications) { + try { + snsService.publish(notification.topicArn(), null, notification.message(), + notification.subject(), notification.region()); + } catch (Exception error) { + LOG.warnv(error, "Unable to publish alarm transition {0} to SNS action {1}", + notification.subject(), notification.topicArn()); + } + } + } + + private String message(MetricAlarm alarm, String oldState) throws JsonProcessingException { + AwsArnUtils.Arn arn = AwsArnUtils.parse(alarm.getAlarmArn()); + Map payload = new LinkedHashMap<>(); + payload.put("AlarmName", alarm.getAlarmName()); + payload.put("AlarmDescription", alarm.getAlarmDescription()); + payload.put("AWSAccountId", arn.accountId()); + payload.put("AlarmConfigurationUpdatedTimestamp", timestamp(alarm.getAlarmConfigurationUpdatedTimestamp())); + payload.put("NewStateValue", alarm.getStateValue()); + payload.put("NewStateReason", alarm.getStateReason()); + payload.put("StateChangeTime", timestamp(alarm.getStateUpdatedTimestamp())); + payload.put("Region", REGION_NAMES.getOrDefault(arn.region(), arn.region())); + payload.put("AlarmArn", alarm.getAlarmArn()); + payload.put("OldStateValue", oldState); + payload.put("OKActions", alarm.getOkActions()); + payload.put("AlarmActions", alarm.getAlarmActions()); + payload.put("InsufficientDataActions", alarm.getInsufficientDataActions()); + Map trigger = new LinkedHashMap<>(); + trigger.put("MetricName", alarm.getMetricName()); + trigger.put("Namespace", alarm.getNamespace()); + trigger.put("StatisticType", "Statistic"); + trigger.put("Statistic", alarm.getStatistic() != null + ? alarm.getStatistic().toUpperCase(Locale.ROOT) : null); + trigger.put("Unit", alarm.getUnit()); + trigger.put("Dimensions", alarm.getDimensions()); + trigger.put("Period", alarm.getPeriod()); + trigger.put("EvaluationPeriods", alarm.getEvaluationPeriods()); + if (alarm.getDatapointsToAlarm() != null) { + trigger.put("DatapointsToAlarm", alarm.getDatapointsToAlarm()); + } + trigger.put("ComparisonOperator", alarm.getComparisonOperator()); + trigger.put("Threshold", alarm.getThreshold()); + trigger.put("TreatMissingData", alarm.getTreatMissingData()); + trigger.put("EvaluateLowSampleCountPercentile", alarm.getEvaluateLowSampleCountPercentile() != null + ? alarm.getEvaluateLowSampleCountPercentile() : ""); + payload.put("Trigger", trigger); + return objectMapper.writeValueAsString(payload); + } + + private String timestamp(long epochSeconds) { + return TIMESTAMP_FORMAT.format(Instant.ofEpochSecond(epochSeconds)); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsException.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsException.java new file mode 100644 index 0000000..1ea39ed --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsException.java @@ -0,0 +1,34 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import io.github.hectorvent.floci.core.common.AwsException; + +/** + * A CloudWatch error whose Query code and JSON shape name differ. + * + *

CloudWatch is awsQueryCompatible: the Query {@code } and the + * {@code x-amzn-query-error} header carry the {@code awsQueryError} code + * ({@code MissingParameter}), while a JSON 1.0 or CBOR body's {@code __type} names the + * model shape ({@code MissingRequiredParameterException}). The AWS SDK v2 picks the typed + * exception from {@code __type}, so the code alone yields a generic {@code CloudWatchException}. + * Keeping {@link #getErrorCode()} as the Query code and overriding {@link #jsonType()} + * reproduces both surfaces. + */ +class CloudWatchMetricsException extends AwsException { + + private final String jsonType; + + private CloudWatchMetricsException(String errorCode, String jsonType, String message, int httpStatus) { + super(errorCode, message, httpStatus); + this.jsonType = jsonType; + } + + static CloudWatchMetricsException missingParameter(String parameter) { + return new CloudWatchMetricsException("MissingParameter", "MissingRequiredParameterException", + "The parameter " + parameter + " is required.", 400); + } + + @Override + public String jsonType() { + return jsonType; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java index d0edf31..0920b95 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java @@ -5,13 +5,21 @@ 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; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamStatisticsConfiguration; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery; 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; @@ -56,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); @@ -81,10 +90,14 @@ public class CloudWatchMetricsJsonHandler { private Response handlePutMetricData(JsonNode request, String region) { String namespace = request.path("Namespace").asText(); - LOG.infov("JSON PutMetricData raw: {0}", request); - List datums = parseMetricDataJson(request.path("MetricData")); - LOG.infov("JSON PutMetricData parsed {0} datums, sums={1}", datums.size(), - datums.stream().map(d -> d.getMetricName() + "=" + d.getSum()).toList()); + List datums = parseMetricDataJson(request.path("MetricData"), "MetricData"); + int entityIndex = 0; + for (JsonNode entityMetricData : request.path("EntityMetricData")) { + entityIndex++; + datums.addAll(parseMetricDataJson(entityMetricData.path("MetricData"), + "EntityMetricData.member." + entityIndex + ".MetricData")); + } + LOG.debugv("JSON PutMetricData parsed {0} datums for namespace {1}", datums.size(), namespace); metricsService.putMetricData(namespace, datums, region); return Response.ok(objectMapper.createObjectNode()).build(); } @@ -151,12 +164,17 @@ 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)); - alarm.setPeriod(request.path("Period").asInt(60)); + JsonNode metrics = request.path("Metrics"); + if (metrics.isArray()) { + for (JsonNode query : metrics) { + alarm.getMetrics().add(objectMapper.convertValue(query, AlarmMetricDataQuery.class)); + } + } + alarm.setPeriod(alarm.getMetrics().isEmpty() ? request.path("Period").asInt(60) : null); alarm.setUnit(request.path("Unit").asText(null)); alarm.setEvaluationPeriods(request.path("EvaluationPeriods").asInt(1)); alarm.setDatapointsToAlarm(request.hasNonNull("DatapointsToAlarm") @@ -164,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())); @@ -187,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) { @@ -199,32 +239,42 @@ public class CloudWatchMetricsJsonHandler { } String prefix = request.has("AlarmNamePrefix") ? request.path("AlarmNamePrefix").asText() : null; - List alarms = metricsService.describeAlarms(alarmNames, prefix, region); + List types = new ArrayList<>(); + request.path("AlarmTypes").forEach(value -> types.add(value.asText())); + metricsService.validateAlarmTypes(types); + List alarms = types.isEmpty() || types.contains("MetricAlarm") + ? metricsService.describeAlarms(alarmNames, prefix, region) : List.of(); ObjectNode response = objectMapper.createObjectNode(); ArrayNode arr = response.putArray("MetricAlarms"); for (MetricAlarm a : alarms) { ObjectNode node = arr.addObject(); - node.put("AlarmName", a.getAlarmName()); - if (a.getAlarmArn() != null) node.put("AlarmArn", a.getAlarmArn()); - if (a.getAlarmDescription() != null) node.put("AlarmDescription", a.getAlarmDescription()); - ArrayNode alarmActions = node.putArray("AlarmActions"); - a.getAlarmActions().forEach(alarmActions::add); - ArrayNode okActions = node.putArray("OKActions"); - a.getOkActions().forEach(okActions::add); - ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions"); - a.getInsufficientDataActions().forEach(insufficientDataActions::add); - if (a.getMetricName() != null) node.put("MetricName", a.getMetricName()); - if (a.getNamespace() != null) node.put("Namespace", a.getNamespace()); - if (a.getStatistic() != null) node.put("Statistic", a.getStatistic()); - ArrayNode dimensions = node.putArray("Dimensions"); - a.getDimensions().forEach(d -> { - ObjectNode dimNode = dimensions.addObject(); - dimNode.put("Name", d.name()); - dimNode.put("Value", d.value()); - }); - node.put("Period", a.getPeriod()); - if (a.getUnit() != null) node.put("Unit", a.getUnit()); + describeAlarmFields(node, a); + if (!a.getMetrics().isEmpty()) { + node.set("Metrics", objectMapper.valueToTree(a.getMetrics())); + } else { + if (a.getMetricName() != null) { + node.put("MetricName", a.getMetricName()); + } + if (a.getNamespace() != null) { + node.put("Namespace", a.getNamespace()); + } + if (a.getStatistic() != null) { + node.put("Statistic", a.getStatistic()); + } + ArrayNode dimensions = node.putArray("Dimensions"); + a.getDimensions().forEach(d -> { + ObjectNode dimNode = dimensions.addObject(); + dimNode.put("Name", d.name()); + dimNode.put("Value", d.value()); + }); + if (a.getPeriod() != null) { + node.put("Period", a.getPeriod()); + } + if (a.getUnit() != null) { + node.put("Unit", a.getUnit()); + } + } node.put("EvaluationPeriods", a.getEvaluationPeriods()); if (a.getDatapointsToAlarm() != null) { node.put("DatapointsToAlarm", a.getDatapointsToAlarm()); @@ -232,15 +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("LogAlarms"); return Response.ok(response).build(); } + private void describeAlarmFields(ObjectNode node, Alarm a) { + node.put("AlarmName", a.getAlarmName()); + if (a.getAlarmArn() != null) { + node.put("AlarmArn", a.getAlarmArn()); + } + if (a.getAlarmDescription() != null) { + node.put("AlarmDescription", a.getAlarmDescription()); + } + ArrayNode alarmActions = node.putArray("AlarmActions"); + a.getAlarmActions().forEach(alarmActions::add); + ArrayNode okActions = node.putArray("OKActions"); + a.getOkActions().forEach(okActions::add); + ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions"); + a.getInsufficientDataActions().forEach(insufficientDataActions::add); + node.put("ActionsEnabled", a.isActionsEnabled()); + if (a.getStateValue() != null) { + node.put("StateValue", a.getStateValue()); + } + if (a.getStateReason() != null) { + node.put("StateReason", a.getStateReason()); + } + if (a.getStateReasonData() != null) { + node.put("StateReasonData", a.getStateReasonData()); + } + node.put("StateUpdatedTimestamp", a.getStateUpdatedTimestamp()); + node.put("AlarmConfigurationUpdatedTimestamp", a.getAlarmConfigurationUpdatedTimestamp()); + } + private Response handleDeleteAlarms(JsonNode request, String region) { List alarmNames = new ArrayList<>(); JsonNode namesNode = request.path("AlarmNames"); @@ -586,12 +669,18 @@ public class CloudWatchMetricsJsonHandler { return configurations; } - private List parseMetricDataJson(JsonNode node) { + private List parseMetricDataJson(JsonNode node, String listPath) { List datums = new ArrayList<>(); if (!node.isArray()) return datums; + int index = 0; for (JsonNode item : node) { + index++; + JsonNode metricName = item.path("MetricName"); + if (!metricName.isTextual() || metricName.asText().isEmpty()) { + throw CloudWatchMetricsException.missingParameter(listPath + ".member." + index + ".MetricName"); + } MetricDatum datum = new MetricDatum(); - datum.setMetricName(item.path("MetricName").asText()); + datum.setMetricName(metricName.asText()); datum.setValue(item.path("Value").asDouble(0)); datum.setUnit(item.path("Unit").asText(null)); JsonNode ts = item.path("Timestamp"); diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java index 61584f1..5ad8ff3 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java @@ -1,5 +1,6 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics; +import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.AwsNamespaces; import io.github.hectorvent.floci.core.common.AwsQueryResponse; import io.github.hectorvent.floci.core.common.PaginatedResult; @@ -7,10 +8,17 @@ 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; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamStatisticsConfiguration; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery; 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; @@ -25,11 +33,16 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.jboss.logging.Logger; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @ApplicationScoped public class CloudWatchMetricsQueryHandler { private static final Logger LOG = Logger.getLogger(CloudWatchMetricsQueryHandler.class); + private static final Pattern ENTITY_METRIC_DATA_MEMBER = Pattern.compile("EntityMetricData\\.member\\.(\\d{1,9})\\."); private CloudWatchMetricsService metricsService; private final CloudWatchDashboardsService dashboardsService; @@ -52,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); @@ -153,7 +167,8 @@ public class CloudWatchMetricsQueryHandler { xml.elem("Unit", dp.unit()).end("member"); } xml.end("Datapoints"); - return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics", AwsNamespaces.CW, xml.build())).build(); + return Response.ok(AwsQueryResponse.envelope("GetMetricStatistics", + AwsNamespaces.CW, xml.build())).build(); } private Response handleGetMetricData(MultivaluedMap params, String region) { @@ -237,6 +252,26 @@ public class CloudWatchMetricsQueryHandler { return Response.ok(AwsQueryResponse.envelopeNoResult("PutMetricAlarm", null)).build(); } + private Response handlePutCompositeAlarm(MultivaluedMap params, String region) { + if (params.containsKey("ActionsSuppressor") || params.containsKey("ActionsSuppressorWaitPeriod") + || params.containsKey("ActionsSuppressorExtensionPeriod")) { + throw new AwsException("ValidationError", "Composite action suppression is not supported", 400); + } + CompositeAlarm alarm = new CompositeAlarm(); + parseAlarmFields(params, alarm); + alarm.setAlarmRule(params.getFirst("AlarmRule")); + metricsService.putCompositeAlarm(alarm, region); + return Response.ok(AwsQueryResponse.envelopeNoResult("PutCompositeAlarm", null)).build(); + } + + private static List alarmMembers(MultivaluedMap params, String key) { + List values = new ArrayList<>(); + for (int i = 1; params.getFirst(key + ".member." + i) != null; i++) { + values.add(params.getFirst(key + ".member." + i)); + } + return values; + } + private Response handleDescribeAlarms(MultivaluedMap params, String region) { List alarmNames = new ArrayList<>(); for (int i = 1; ; i++) { @@ -246,13 +281,24 @@ public class CloudWatchMetricsQueryHandler { } String prefix = params.getFirst("AlarmNamePrefix"); - List alarms = metricsService.describeAlarms(alarmNames, prefix, region); + List types = alarmMembers(params, "AlarmTypes"); + metricsService.validateAlarmTypes(types); + List alarms = types.isEmpty() || types.contains("MetricAlarm") + ? metricsService.describeAlarms(alarmNames, prefix, region) : List.of(); var xml = new XmlBuilder().start("MetricAlarms"); for (MetricAlarm a : alarms) { toAlarmXml(xml, a); } - xml.end("MetricAlarms"); + xml.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(); } @@ -590,42 +636,63 @@ public class CloudWatchMetricsQueryHandler { // ──────────────────────────── Parsing Helpers ──────────────────────────── private List parseMetricData(MultivaluedMap params) { - LOG.infov("Query PutMetricData params: {0}", params); + List datums = parseMetricData(params, "MetricData"); + // An empty EntityMetricData entry serializes to no keys, so its index is absent + // while later entries keep theirs: iterate the indexes present, not 1..first gap. + SortedSet entityIndexes = new TreeSet<>(); + for (String key : params.keySet()) { + Matcher matcher = ENTITY_METRIC_DATA_MEMBER.matcher(key); + if (matcher.lookingAt()) { + entityIndexes.add(Integer.parseInt(matcher.group(1))); + } + } + for (int index : entityIndexes) { + datums.addAll(parseMetricData(params, "EntityMetricData.member." + index + ".MetricData")); + } + LOG.debugv("Query PutMetricData parsed {0} datums", datums.size()); + return datums; + } + + private List parseMetricData(MultivaluedMap params, String listPrefix) { List datums = new ArrayList<>(); for (int i = 1; ; i++) { - String metricName = params.getFirst("MetricData.member." + i + ".MetricName"); - if (metricName == null) { - break; + String memberPrefix = listPrefix + ".member." + i + "."; + String metricName = params.getFirst(memberPrefix + "MetricName"); + if (metricName == null || metricName.isEmpty()) { + if (metricName == null && params.keySet().stream().noneMatch(key -> key.startsWith(memberPrefix))) { + break; + } + throw CloudWatchMetricsException.missingParameter(memberPrefix + "MetricName"); } MetricDatum datum = new MetricDatum(); datum.setMetricName(metricName); - datum.setUnit(params.getFirst("MetricData.member." + i + ".Unit")); + datum.setUnit(params.getFirst(listPrefix + ".member." + i + ".Unit")); - String valueStr = params.getFirst("MetricData.member." + i + ".Value"); + String valueStr = params.getFirst(listPrefix + ".member." + i + ".Value"); if (valueStr != null) { datum.setValue(parseDouble(valueStr, 0)); } - String tsStr = params.getFirst("MetricData.member." + i + ".Timestamp"); + String tsStr = params.getFirst(listPrefix + ".member." + i + ".Timestamp"); if (tsStr != null) { Instant ts = parseInstant(tsStr); datum.setTimestamp(ts != null ? ts.getEpochSecond() : 0); } // StatisticValues - String sc = params.getFirst("MetricData.member." + i + ".StatisticValues.SampleCount"); + String sc = params.getFirst(listPrefix + ".member." + i + ".StatisticValues.SampleCount"); if (sc != null) { datum.setSampleCount(parseDouble(sc, 0)); - datum.setSum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Sum"), 0)); - datum.setMinimum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Minimum"), 0)); - datum.setMaximum(parseDouble(params.getFirst("MetricData.member." + i + ".StatisticValues.Maximum"), 0)); + datum.setSum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Sum"), 0)); + datum.setMinimum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Minimum"), 0)); + datum.setMaximum(parseDouble(params.getFirst(listPrefix + ".member." + i + ".StatisticValues.Maximum"), 0)); } // Dimensions List dims = new ArrayList<>(); for (int j = 1; ; j++) { - String dimName = params.getFirst("MetricData.member." + i + ".Dimensions.member." + j + ".Name"); - String dimValue = params.getFirst("MetricData.member." + i + ".Dimensions.member." + j + ".Value"); + String dimName = params.getFirst(listPrefix + ".member." + i + ".Dimensions.member." + j + ".Name"); + String dimValue = params.getFirst(listPrefix + ".member." + i + ".Dimensions.member." + j + ".Value"); if (dimName == null) { break; } @@ -653,14 +720,12 @@ public class CloudWatchMetricsQueryHandler { private MetricAlarm parseAlarm(MultivaluedMap params) { MetricAlarm a = new MetricAlarm(); - a.setAlarmName(params.getFirst("AlarmName")); - a.setAlarmDescription(params.getFirst("AlarmDescription")); - String actionsEnabled = params.getFirst("ActionsEnabled"); - a.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled)); + parseAlarmFields(params, a); a.setMetricName(params.getFirst("MetricName")); a.setNamespace(params.getFirst("Namespace")); a.setStatistic(params.getFirst("Statistic")); - a.setPeriod(parseIntParam(params, "Period", 60)); + a.setMetrics(parseAlarmMetricQueries(params)); + a.setPeriod(a.getMetrics().isEmpty() ? parseIntParam(params, "Period", 60) : null); a.setUnit(params.getFirst("Unit")); a.setEvaluationPeriods(parseIntParam(params, "EvaluationPeriods", 1)); String datapointsToAlarm = params.getFirst("DatapointsToAlarm"); @@ -680,38 +745,100 @@ public class CloudWatchMetricsQueryHandler { } a.setDimensions(dims); - // Actions - for (int i = 1; ; i++) { - String act = params.getFirst("OKActions.member." + i); - if (act == null) break; - a.getOkActions().add(act); - } - for (int i = 1; ; i++) { - String act = params.getFirst("AlarmActions.member." + i); - if (act == null) break; - a.getAlarmActions().add(act); - } - for (int i = 1; ; i++) { - String act = params.getFirst("InsufficientDataActions.member." + i); - if (act == null) break; - a.getInsufficientDataActions().add(act); - } - - // Tags - Map tags = new LinkedHashMap<>(); - for (int i = 1; ; i++) { - String key = params.getFirst("Tags.member." + i + ".Key"); - if (key == null) break; - tags.put(key, params.getFirst("Tags.member." + i + ".Value")); - } - a.setTags(tags); - return a; } + private void parseAlarmFields(MultivaluedMap params, Alarm alarm) { + alarm.setAlarmName(params.getFirst("AlarmName")); + alarm.setAlarmDescription(params.getFirst("AlarmDescription")); + String actionsEnabled = params.getFirst("ActionsEnabled"); + alarm.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled)); + alarm.setAlarmActions(alarmMembers(params, "AlarmActions")); + alarm.setOkActions(alarmMembers(params, "OKActions")); + alarm.setInsufficientDataActions(alarmMembers(params, "InsufficientDataActions")); + Map tags = new LinkedHashMap<>(); + for (int i = 1; params.getFirst("Tags.member." + i + ".Key") != null; i++) { + tags.put(params.getFirst("Tags.member." + i + ".Key"), params.getFirst("Tags.member." + i + ".Value")); + } + alarm.setTags(tags); + } + + private List parseAlarmMetricQueries(MultivaluedMap params) { + List queries = new ArrayList<>(); + for (int index = 1; ; index++) { + String prefix = "Metrics.member." + index; + String id = params.getFirst(prefix + ".Id"); + if (id == null) { + break; + } + AlarmMetricDataQuery.MetricStat metricStat = null; + String metricPrefix = prefix + ".MetricStat.Metric"; + if (params.getFirst(metricPrefix + ".Namespace") != null) { + List dimensions = null; + for (int dimensionIndex = 1; ; dimensionIndex++) { + String dimensionPrefix = metricPrefix + ".Dimensions.member." + dimensionIndex; + String name = params.getFirst(dimensionPrefix + ".Name"); + if (name == null) { + break; + } + if (dimensions == null) { + dimensions = new ArrayList<>(); + } + dimensions.add(new AlarmMetricDataQuery.MetricDimension( + name, params.getFirst(dimensionPrefix + ".Value"))); + } + AlarmMetricDataQuery.Metric metric = new AlarmMetricDataQuery.Metric( + params.getFirst(metricPrefix + ".Namespace"), + params.getFirst(metricPrefix + ".MetricName"), dimensions); + metricStat = new AlarmMetricDataQuery.MetricStat(metric, + optionalInteger(params.getFirst(prefix + ".MetricStat.Period")), + params.getFirst(prefix + ".MetricStat.Stat"), params.getFirst(prefix + ".MetricStat.Unit")); + } + String returnData = params.getFirst(prefix + ".ReturnData"); + queries.add(new AlarmMetricDataQuery(id, params.getFirst(prefix + ".Expression"), + params.getFirst(prefix + ".Label"), returnData == null ? null : Boolean.valueOf(returnData), + optionalInteger(params.getFirst(prefix + ".Period")), + params.getFirst(prefix + ".AccountId"), metricStat)); + } + return queries; + } + + private static Integer optionalInteger(String value) { + return value == null ? null : Integer.valueOf(value); + } + private void toAlarmXml(XmlBuilder xml, MetricAlarm a) { - xml.start("member") - .elem("AlarmName", a.getAlarmName()) + xml.start("member"); + describeAlarmFields(xml, a); + if (!a.getMetrics().isEmpty()) { + toAlarmMetricsXml(xml, a.getMetrics()); + } else { + xml.start("Dimensions"); + for (Dimension d : a.getDimensions()) { + xml.start("member").elem("Name", d.name()).elem("Value", d.value()).end("member"); + } + xml.end("Dimensions") + .elem("MetricName", a.getMetricName()) + .elem("Namespace", a.getNamespace()) + .elem("Statistic", a.getStatistic()) + .elem("Period", a.getPeriod() == null ? null : a.getPeriod().toString()) + .elem("Unit", a.getUnit()); + } + + xml.elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods())) + .elem("Threshold", String.valueOf(a.getThreshold())) + .elem("ComparisonOperator", a.getComparisonOperator()) + .elem("TreatMissingData", a.getTreatMissingData()); + // Only an alarm the caller gave an M for carries the member, matching what AWS returns. + if (a.getDatapointsToAlarm() != null) { + xml.elem("DatapointsToAlarm", String.valueOf(a.getDatapointsToAlarm())); + } + + 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"); @@ -726,31 +853,41 @@ public class CloudWatchMetricsQueryHandler { xml.start("InsufficientDataActions"); a.getInsufficientDataActions().forEach(act -> xml.elem("member", act)); xml.end("InsufficientDataActions"); - xml.start("Dimensions"); - for (Dimension d : a.getDimensions()) { - xml.start("member").elem("Name", d.name()).elem("Value", d.value()).end("member"); - } - xml.end("Dimensions"); - xml.elem("StateValue", a.getStateValue()) .elem("StateReason", a.getStateReason()) .elem("StateReasonData", a.getStateReasonData()) - .elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString()) - .elem("MetricName", a.getMetricName()) - .elem("Namespace", a.getNamespace()) - .elem("Statistic", a.getStatistic()) - .elem("Period", String.valueOf(a.getPeriod())) - .elem("Unit", a.getUnit()) - .elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods())) - .elem("Threshold", String.valueOf(a.getThreshold())) - .elem("ComparisonOperator", a.getComparisonOperator()) - .elem("TreatMissingData", a.getTreatMissingData()); - // Only an alarm the caller gave an M for carries the member, matching what AWS returns. - if (a.getDatapointsToAlarm() != null) { - xml.elem("DatapointsToAlarm", String.valueOf(a.getDatapointsToAlarm())); - } + .elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString()); + } - xml.end("member"); + private void toAlarmMetricsXml(XmlBuilder xml, List metrics) { + xml.start("Metrics"); + for (AlarmMetricDataQuery query : metrics) { + xml.start("member").elem("Id", query.id()).elem("Expression", query.expression()) + .elem("Label", query.label()).elem("AccountId", query.accountId()) + .elem("Period", query.period() == null ? null : query.period().toString()) + .elem("ReturnData", query.returnData() == null ? null : query.returnData().toString()); + AlarmMetricDataQuery.MetricStat stat = query.metricStat(); + if (stat != null) { + xml.start("MetricStat"); + AlarmMetricDataQuery.Metric metric = stat.metric(); + if (metric != null) { + xml.start("Metric").elem("Namespace", metric.namespace()).elem("MetricName", metric.metricName()); + if (metric.dimensions() != null) { + xml.start("Dimensions"); + for (AlarmMetricDataQuery.MetricDimension dimension : metric.dimensions()) { + xml.start("member").elem("Name", dimension.name()).elem("Value", dimension.value()) + .end("member"); + } + xml.end("Dimensions"); + } + xml.end("Metric"); + } + xml.elem("Period", stat.period() == null ? null : stat.period().toString()) + .elem("Stat", stat.stat()).elem("Unit", stat.unit()).end("MetricStat"); + } + xml.end("member"); + } + xml.end("Metrics"); } private Instant parseInstant(String value) { diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java index 38c4a24..74e3d18 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsService.java @@ -1,10 +1,15 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics; +import com.fasterxml.jackson.core.type.TypeReference; +import io.github.hectorvent.floci.core.common.AwsArnUtils; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.RegionResolver; +import io.github.hectorvent.floci.core.common.SmithyValidationErrors; import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; import io.github.hectorvent.floci.core.storage.StorageBackend; import io.github.hectorvent.floci.core.storage.StorageFactory; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum; @@ -14,11 +19,16 @@ import jakarta.inject.Inject; import org.jboss.logging.Logger; import java.time.Instant; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -27,25 +37,41 @@ public class CloudWatchMetricsService { private static final Logger LOG = Logger.getLogger(CloudWatchMetricsService.class); + private static final List ALARM_TYPES = List.of("CompositeAlarm", "MetricAlarm", "LogAlarm"); + private final StorageBackend metricStore; - private final StorageBackend alarmStore; + private final StorageBackend alarmStore; private final RegionResolver regionResolver; + private final AlarmSnsNotifier alarmSnsNotifier; + + public CloudWatchMetricsService(StorageFactory storageFactory, RegionResolver regionResolver) { + this(storageFactory, regionResolver, null); + } @Inject - public CloudWatchMetricsService(StorageFactory storageFactory, RegionResolver regionResolver) { + public CloudWatchMetricsService(StorageFactory storageFactory, RegionResolver regionResolver, + AlarmSnsNotifier alarmSnsNotifier) { this.metricStore = storageFactory.create("cloudwatchmetrics", "cwmetrics.json", new TypeReference>() {}); this.alarmStore = storageFactory.create("cloudwatchmetrics", "cwalarms.json", - new TypeReference>() {}); + new TypeReference>() {}); this.regionResolver = regionResolver; + this.alarmSnsNotifier = alarmSnsNotifier; } CloudWatchMetricsService(StorageBackend metricStore, - StorageBackend alarmStore, + StorageBackend alarmStore, RegionResolver regionResolver) { + this(metricStore, alarmStore, regionResolver, null); + } + + CloudWatchMetricsService(StorageBackend metricStore, + StorageBackend alarmStore, + RegionResolver regionResolver, AlarmSnsNotifier alarmSnsNotifier) { this.metricStore = metricStore; this.alarmStore = alarmStore; this.regionResolver = regionResolver; + this.alarmSnsNotifier = alarmSnsNotifier; } public void putMetricData(String namespace, List datums, String region) { @@ -282,85 +308,293 @@ public class CloudWatchMetricsService { }; } - public void putMetricAlarm(MetricAlarm alarm, String region) { + public synchronized void putMetricAlarm(MetricAlarm alarm, String region) { + putAlarm(alarm, region); + evaluateComposites(region, Set.of(alarm.getAlarmName()), null); + } + + public void putCompositeAlarm(CompositeAlarm alarm, String region) { + putCompositeAlarm(alarm, region, true); + } + + public synchronized void putCompositeAlarm(CompositeAlarm alarm, String region, boolean replaceExisting) { + if (!replaceExisting && alarmStore.get(region + "::" + alarm.getAlarmName()).isPresent()) { + throw new AwsException("AlreadyExists", "Alarm already exists: " + alarm.getAlarmName(), 400); + } + Set references = alarmReferences(alarm, region); + if (references.size() > 100) { + throw new AwsException("LimitExceeded", "AlarmRule references more than 100 child alarms", 400); + } + List existing = describeCompositeAlarms(null, null, region); + for (String reference : references) { + long parents = existing.stream().filter(parent -> !parent.getAlarmName().equals(alarm.getAlarmName())) + .filter(parent -> alarmReferences(parent, region).contains(reference)) + .count(); + if (parents >= 150) { + throw new AwsException("LimitExceeded", "An alarm can be referenced by at most 150 composite alarms", 400); + } + } + boolean created = alarmStore.get(region + "::" + alarm.getAlarmName()).isEmpty(); + putAlarm(alarm, region); + if (created) { + evaluateComposites(region, Set.of(), alarm.getAlarmName()); + } + } + + private Set alarmReferences(CompositeAlarm alarm, String region) { + return CompositeAlarmRule.parse(alarm.getAlarmRule()).references().stream() + .map(reference -> AwsArnUtils.isArn(reference) ? reference + : regionResolver.buildArn("cloudwatch", region, "alarm:" + reference)) + .collect(Collectors.toSet()); + } + + private void putAlarm(Alarm alarm, String region) { + String name = alarm.getAlarmName(); + if (name == null || name.isBlank() || name.length() > 255) { + throw SmithyValidationErrors.validationError(name == null ? SmithyValidationErrors.notNull("alarmName") + : "Value '" + name + "' at 'alarmName' failed to satisfy constraint: Member must have length " + + (name.length() > 255 ? "less than or equal to 255" : "greater than or equal to 1")); + } + Alarm previous = alarmStore.get(region + "::" + name).orElse(null); + if (previous != null) { + if (!previous.getClass().equals(alarm.getClass())) { + throw new AwsException("ValidationError", "An alarm of another type already has this name", 400); + } + if (alarm instanceof CompositeAlarm) { + alarm.setStateValue(previous.getStateValue()); + alarm.setStateReason(previous.getStateReason()); + alarm.setStateReasonData(previous.getStateReasonData()); + alarm.setStateUpdatedTimestamp(previous.getStateUpdatedTimestamp()); + alarm.setTags(new HashMap<>(previous.getTags())); + } + } if (alarm.getAlarmArn() == null) { - alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + alarm.getAlarmName())); + alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + name)); } alarm.setRegion(region); alarm.setAlarmConfigurationUpdatedTimestamp(Instant.now().getEpochSecond()); - alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); - LOG.infov("PutMetricAlarm: {0} in {1}", alarm.getAlarmName(), region); + alarmStore.put(region + "::" + name, alarm); + LOG.infov("Put {0}: {1} in {2}", alarm.getClass().getSimpleName(), name, region); } - /** Every stored alarm, across all regions. Used by the background {@link AlarmEvaluator} - * tick, which has no per-request region to scope a lookup to. */ + /** Metric alarms evaluated by the existing metric evaluator. */ public List allAlarms() { - return alarmStore.scan(k -> true); + return alarmStore.scan(k -> true).stream().filter(MetricAlarm.class::isInstance) + .map(MetricAlarm.class::cast).toList(); } - public List describeAlarms(List alarmNames, String alarmNamePrefix, String region) { - String prefix = region + "::"; - List all = alarmStore.scan(k -> k.startsWith(prefix)); - - if (alarmNames != null && !alarmNames.isEmpty()) { - return all.stream().filter(a -> alarmNames.contains(a.getAlarmName())).toList(); - } - if (alarmNamePrefix != null && !alarmNamePrefix.isBlank()) { - return all.stream().filter(a -> a.getAlarmName().startsWith(alarmNamePrefix)).toList(); - } - return all; + public List describeAlarms(List names, String prefix, String region) { + return matchingAlarms(names, prefix, region).stream().filter(MetricAlarm.class::isInstance) + .map(MetricAlarm.class::cast).toList(); } - public void deleteAlarms(List alarmNames, String region) { + public void validateAlarmTypes(List types) { + if (!ALARM_TYPES.containsAll(types)) { + throw SmithyValidationErrors.validationError( + SmithyValidationErrors.notAllInEnum(types, "alarmTypes", ALARM_TYPES)); + } + } + + public List describeCompositeAlarms(List names, String prefix, String region) { + return matchingAlarms(names, prefix, region).stream().filter(CompositeAlarm.class::isInstance) + .map(CompositeAlarm.class::cast).toList(); + } + + private List matchingAlarms(List names, String prefix, String region) { + return alarmStore.scan(k -> k.startsWith(region + "::")).stream() + .filter(a -> names == null || names.isEmpty() || names.contains(a.getAlarmName())) + .filter(a -> prefix == null || a.getAlarmName().startsWith(prefix)).toList(); + } + + private Map alarmIndex(String region) { + Map alarms = new HashMap<>(); + for (Alarm alarm : alarmStore.scan(k -> k.startsWith(region + "::"))) { + alarms.put(alarm.getAlarmName(), alarm); + alarms.put(alarm.getAlarmArn(), alarm); + } + return alarms; + } + + private void evaluateComposites(String region, Set changedAlarmNames, String newAlarm) { + if (changedAlarmNames.isEmpty() && newAlarm == null) { + return; + } + Map alarms = alarmIndex(region); + Set changed = new HashSet<>(changedAlarmNames); + for (String name : changedAlarmNames) { + changed.add(regionResolver.buildArn("cloudwatch", region, "alarm:" + name)); + } + Map candidates = new HashMap<>(); + for (Alarm alarm : new HashSet<>(alarms.values())) { + if (alarm instanceof CompositeAlarm composite && !changedAlarmNames.contains(alarm.getAlarmName())) { + candidates.put(alarm.getAlarmName(), CompositeAlarmRule.parse(composite.getAlarmRule())); + } + } + Set affected = new HashSet<>(changed); + Map pending = new HashMap<>(); + boolean discovered; + do { + discovered = false; + Iterator> iterator = candidates.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry next = iterator.next(); + if (next.getKey().equals(newAlarm) || next.getValue().references().stream().anyMatch(affected::contains)) { + pending.put(next.getKey(), next.getValue()); + affected.add(next.getKey()); + affected.add(alarms.get(next.getKey()).getAlarmArn()); + iterator.remove(); + discovered = true; + } + } + } while (discovered); + Set cycles = pending.keySet().stream() + .filter(name -> isCyclic(name, alarms)).collect(Collectors.toSet()); + boolean progress; + do { + progress = false; + Iterator> iterator = pending.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry next = iterator.next(); + boolean blocked = next.getValue().references().stream().map(alarms::get) + .anyMatch(child -> child != null && pending.containsKey(child.getAlarmName())); + if (blocked || cycles.contains(next.getKey())) { + continue; + } + if (!next.getKey().equals(newAlarm) + && next.getValue().references().stream().noneMatch(changed::contains)) { + iterator.remove(); + progress = true; + continue; + } + Alarm alarm = alarms.get(next.getKey()); + boolean triggered = next.getValue().evaluate(reference -> { + Alarm child = alarms.get(reference); + return child == null ? null : child.getStateValue(); + }); + String state = triggered ? "ALARM" : "OK"; + if (!state.equals(alarm.getStateValue())) { + alarm.setStateValue(state); + alarm.setStateReason("AlarmRule evaluated to " + state); + alarm.setStateReasonData(null); + alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond()); + alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); + changed.add(alarm.getAlarmName()); + changed.add(alarm.getAlarmArn()); + } + iterator.remove(); + progress = true; + } + } while (progress && !pending.isEmpty()); + if (!pending.isEmpty()) { + LOG.debugv("Composite alarm evaluation stopped at cyclic dependencies: {0}", pending.keySet()); + } + } + + public synchronized void deleteAlarms(List alarmNames, String region) { + Map alarms = alarmIndex(region); + long composites = alarmNames.stream().map(alarms::get).filter(CompositeAlarm.class::isInstance).count(); + if (composites > 1) { + throw new AwsException("ValidationError", "DeleteAlarms accepts at most one composite alarm", 400); + } for (String name : alarmNames) { + if (isCyclic(name, alarms)) { + throw new AwsException("ValidationError", "Break the composite alarm cycle before deleting " + name, 400); + } + } + Set deletedNames = new HashSet<>(); + for (String name : alarmNames) { + if (alarmStore.get(region + "::" + name).isPresent()) { + deletedNames.add(name); + } alarmStore.delete(region + "::" + name); } + evaluateComposites(region, deletedNames, null); LOG.infov("Deleted alarms: {0} in {1}", alarmNames, region); } - public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) { - String key = region + "::" + alarmName; - MetricAlarm alarm = alarmStore.get(key) - .orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404)); - - alarm.setStateValue(stateValue); - alarm.setStateReason(stateReason); - alarm.setStateReasonData(stateReasonData); - alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond()); - - alarmStore.put(key, alarm); - LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue); + private boolean isCyclic(String name, Map alarms) { + Set visited = new HashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(name); + while (!pending.isEmpty()) { + Alarm next = alarms.get(pending.remove()); + if (next instanceof CompositeAlarm composite && visited.add(next.getAlarmName())) { + for (String reference : CompositeAlarmRule.parse(composite.getAlarmRule()).references()) { + Alarm child = alarms.get(reference); + if (child != null && child.getAlarmName().equals(name)) { + return true; + } + if (child != null) { + pending.add(child.getAlarmName()); + } + } + } + } + return false; } - public Map listTagsForResource(String resourceArn, String region) { + public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) { + List notifications = List.of(); + synchronized (this) { + String key = region + "::" + alarmName; + Alarm alarm = alarmStore.get(key) + .orElseThrow(() -> new AwsException("ResourceNotFound", "Alarm not found: " + alarmName, 404)); + + String oldState = alarm.getStateValue(); + alarm.setStateValue(stateValue); + alarm.setStateReason(stateReason); + alarm.setStateReasonData(stateReasonData); + alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond()); + + 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.publish(notifications); + } + } + + /** + * Resolves the alarm an ARN names, or reports that nothing does. + * + *

CloudWatch's three tag operations each declare {@code ResourceNotFoundException}, + * which is a different shape from the {@code ResourceNotFound} that {@code SetAlarmState} + * and {@code GetDashboard} declare; the SDK maps the two codes to two exception classes, + * so the tag path uses the longer one rather than the code used elsewhere in this service. + * + *

The message names the ARN rather than asserting it was an alarm, because both tag + * handlers route every ARN that is not a dashboard and not a metric stream here. That + * includes kinds AWS considers taggable and Floci does not serve, a Contributor Insights + * {@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 Alarm requireAlarm(String resourceArn, String region) { return alarmStore.scan(k -> k.startsWith(region + "::")) .stream() - .filter(a -> resourceArn.equals(a.getAlarmArn())) + .filter(a -> a.getAlarmArn() != null && a.getAlarmArn().equals(resourceArn)) .findFirst() - .map(MetricAlarm::getTags) - .orElse(Map.of()); + .orElseThrow(() -> new AwsException("ResourceNotFoundException", + "No CloudWatch resource matches the ARN " + resourceArn + ".", 404)); } - public void tagResource(String resourceArn, Map tags, String region) { - alarmStore.scan(k -> k.startsWith(region + "::")) - .stream() - .filter(a -> resourceArn.equals(a.getAlarmArn())) - .findFirst() - .ifPresent(alarm -> { - alarm.getTags().putAll(tags); - alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); - }); + public synchronized Map listTagsForResource(String resourceArn, String region) { + return new HashMap<>(requireAlarm(resourceArn, region).getTags()); } - public void untagResource(String resourceArn, List tagKeys, String region) { - alarmStore.scan(k -> k.startsWith(region + "::")) - .stream() - .filter(a -> resourceArn.equals(a.getAlarmArn())) - .findFirst() - .ifPresent(alarm -> { - tagKeys.forEach(alarm.getTags()::remove); - alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); - }); + public synchronized void tagResource(String resourceArn, Map tags, String region) { + Alarm alarm = requireAlarm(resourceArn, region); + alarm.getTags().putAll(tags); + alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); + } + + public synchronized void untagResource(String resourceArn, List tagKeys, String region) { + Alarm alarm = requireAlarm(resourceArn, region); + tagKeys.forEach(alarm.getTags()::remove); + alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); } // ──────────────────────────── Helpers ──────────────────────────── diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java new file mode 100644 index 0000000..8d2d97e --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmRule.java @@ -0,0 +1,184 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.core.common.SmithyValidationErrors; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; + +/** CloudWatch's boolean alarm language, parsed completely before any alarm is stored. */ +final class CompositeAlarmRule { + private final String input; + private final Set references = new HashSet<>(); + private int offset; + private int elements; + + private CompositeAlarmRule(String input) { + this.input = input; + } + + static Rule parse(String input) { + if (input == null || input.isBlank() || input.length() > 10240) { + throw SmithyValidationErrors.validationError(input == null ? SmithyValidationErrors.notNull("alarmRule") + : "Value '" + input + "' at 'alarmRule' failed to satisfy constraint: Member must have length " + + (input.length() > 10240 ? "less than or equal to 10240" : "greater than or equal to 1")); + } + CompositeAlarmRule parser = new CompositeAlarmRule(input); + Expression expression = parser.or(); + parser.whitespace(); + if (parser.offset != input.length()) { + throw invalid("Invalid AlarmRule"); + } + return new Rule(expression, Set.copyOf(parser.references)); + } + + private Expression or() { + Expression left = and(); + while (word("OR")) { + Expression prior = left; + Expression right = and(); + left = states -> prior.evaluate(states) || right.evaluate(states); + } + return left; + } + + private Expression and() { + Expression left = atom(); + while (word("AND")) { + Expression prior = left; + Expression right = atom(); + left = states -> prior.evaluate(states) && right.evaluate(states); + } + return left; + } + + private Expression atom() { + boolean negate = false; + while (word("NOT")) { + negate = !negate; + } + Expression operand = primary(); + return negate ? states -> !operand.evaluate(states) : operand; + } + + private Expression primary() { + element(); + if (character('(')) { + Expression nested = or(); + require(')'); + element(); + return nested; + } + if (word("TRUE")) { + return states -> true; + } + if (word("FALSE")) { + return states -> false; + } + String state; + if (word("ALARM")) { + state = "ALARM"; + } else if (word("OK")) { + state = "OK"; + } else if (word("INSUFFICIENT_DATA")) { + state = "INSUFFICIENT_DATA"; + } else { + throw invalid("Expected an alarm state, boolean, NOT, or parenthesis"); + } + require('('); + whitespace(); + String name; + if (character('"')) { + StringBuilder quoted = new StringBuilder(); + boolean closed = false; + while (offset < input.length()) { + char next = input.charAt(offset++); + if (next == '"') { + closed = true; + break; + } + if (next == '\\') { + if (offset == input.length()) { + throw invalid("Unterminated quoted alarm name"); + } + next = input.charAt(offset++); + if (next != '\\' && next != '"') { + throw invalid("Invalid quoted alarm name escape"); + } + } + quoted.append(next); + } + if (!closed) { + throw invalid("Unterminated quoted alarm name"); + } + name = quoted.toString(); + } else { + int start = offset; + while (offset < input.length() && input.charAt(offset) != ')') { + offset++; + } + name = input.substring(start, offset).trim(); + } + if (name.isEmpty()) { + throw invalid("An alarm name or ARN is required"); + } + require(')'); + references.add(name); + return states -> state.equals(states.apply(name)); + } + + private void element() { + if (++elements > 500) { + throw invalid("AlarmRule exceeds 500 child alarms, booleans, and parentheses"); + } + } + + private boolean word(String word) { + whitespace(); + if (!input.startsWith(word, offset)) { + return false; + } + int end = offset + word.length(); + if (end < input.length() && (Character.isLetterOrDigit(input.charAt(end)) || input.charAt(end) == '_')) { + return false; + } + offset = end; + return true; + } + + private boolean character(char expected) { + whitespace(); + if (offset < input.length() && input.charAt(offset) == expected) { + offset++; + return true; + } + return false; + } + + private void require(char expected) { + if (!character(expected)) { + throw invalid("Expected '" + expected + "' in AlarmRule"); + } + } + + private void whitespace() { + while (offset < input.length() && Character.isWhitespace(input.charAt(offset))) { + offset++; + } + } + + private static AwsException invalid(String message) { + return new AwsException("ValidationError", message, 400); + } + + record Rule(Expression expression, Set references) { + boolean evaluate(Function states) { + return expression.evaluate(states); + } + } + + interface Expression { + boolean evaluate(Function states); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java new file mode 100644 index 0000000..ce81d6c --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/Alarm.java @@ -0,0 +1,87 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "alarmType", defaultImpl = MetricAlarm.class) +@JsonSubTypes({@JsonSubTypes.Type(value = MetricAlarm.class, name = "MetricAlarm"), + @JsonSubTypes.Type(value = CompositeAlarm.class, name = "CompositeAlarm")}) +public abstract class Alarm { + private String alarmName; + private String alarmArn; + private String alarmDescription; + private long alarmConfigurationUpdatedTimestamp; + private boolean actionsEnabled; + private List okActions = new ArrayList<>(); + private List alarmActions = new ArrayList<>(); + private List insufficientDataActions = new ArrayList<>(); + private String stateValue = "INSUFFICIENT_DATA"; + private String stateReason = "Unchecked"; + private String stateReasonData; + private long stateUpdatedTimestamp; + private Map tags = new HashMap<>(); + private String region; + + + protected Alarm() { + long now = Instant.now().getEpochSecond(); + this.alarmConfigurationUpdatedTimestamp = now; + this.stateUpdatedTimestamp = now; + } + + public abstract String getAlarmType(); + + public String getAlarmName() { return alarmName; } + public void setAlarmName(String alarmName) { this.alarmName = alarmName; } + + public String getAlarmArn() { return alarmArn; } + public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; } + + public String getAlarmDescription() { return alarmDescription; } + public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; } + + public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; } + public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; } + + public boolean isActionsEnabled() { return actionsEnabled; } + public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; } + + public List getOkActions() { return okActions; } + public void setOkActions(List okActions) { this.okActions = okActions; } + + public List getAlarmActions() { return alarmActions; } + public void setAlarmActions(List alarmActions) { this.alarmActions = alarmActions; } + + public List getInsufficientDataActions() { return insufficientDataActions; } + public void setInsufficientDataActions(List insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; } + + public String getStateValue() { return stateValue; } + public void setStateValue(String stateValue) { this.stateValue = stateValue; } + + public String getStateReason() { return stateReason; } + public void setStateReason(String stateReason) { this.stateReason = stateReason; } + + public String getStateReasonData() { return stateReasonData; } + public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; } + + public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; } + public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; } + + public Map getTags() { return tags; } + public void setTags(Map tags) { this.tags = tags; } + + + public String getRegion() { return region; } + public void setRegion(String region) { this.region = region; } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java new file mode 100644 index 0000000..effc9f6 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java @@ -0,0 +1,46 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; + +/** The persisted AWS wire shape of a metric-math alarm query, including optional members. */ +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AlarmMetricDataQuery( + @JsonProperty("Id") String id, + @JsonProperty("Expression") String expression, + @JsonProperty("Label") String label, + @JsonProperty("ReturnData") Boolean returnData, + @JsonProperty("Period") Integer period, + @JsonProperty("AccountId") String accountId, + @JsonProperty("MetricStat") MetricStat metricStat) { + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record MetricStat( + @JsonProperty("Metric") Metric metric, + @JsonProperty("Period") Integer period, + @JsonProperty("Stat") String stat, + @JsonProperty("Unit") String unit) { } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Metric( + @JsonProperty("Namespace") String namespace, + @JsonProperty("MetricName") String metricName, + @JsonProperty("Dimensions") List dimensions) { } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record MetricDimension( + @JsonProperty("Name") String name, + @JsonProperty("Value") String value) { } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java new file mode 100644 index 0000000..c426e31 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/CompositeAlarm.java @@ -0,0 +1,14 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class CompositeAlarm extends Alarm { + private String alarmRule; + + @Override + public String getAlarmType() { return "CompositeAlarm"; } + + public String getAlarmRule() { return alarmRule; } + public void setAlarmRule(String alarmRule) { this.alarmRule = alarmRule; } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java index e23c518..0701772 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java @@ -3,32 +3,22 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.quarkus.runtime.annotations.RegisterForReflection; -import java.time.Instant; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; @RegisterForReflection @JsonIgnoreProperties(ignoreUnknown = true) -public class MetricAlarm { - private String alarmName; - private String alarmArn; - private String alarmDescription; - private long alarmConfigurationUpdatedTimestamp; - private boolean actionsEnabled; - private List okActions = new ArrayList<>(); - private List alarmActions = new ArrayList<>(); - private List insufficientDataActions = new ArrayList<>(); - private String stateValue = "INSUFFICIENT_DATA"; - private String stateReason = "Unchecked"; - private String stateReasonData; - private long stateUpdatedTimestamp; +public class MetricAlarm extends Alarm { + @Override + public String getAlarmType() { return "MetricAlarm"; } + private String metricName; private String namespace; private String statistic; private List dimensions = new ArrayList<>(); - private int period; + /** Single-metric period; absent for alarms whose periods are defined in Metrics. */ + private Integer period; + private List metrics = new ArrayList<>(); private String unit; private int evaluationPeriods; /** @@ -43,50 +33,6 @@ public class MetricAlarm { private String comparisonOperator; private String treatMissingData = "missing"; private String evaluateLowSampleCountPercentile; - private Map tags = new HashMap<>(); - private String region; - - public MetricAlarm() { - long now = Instant.now().getEpochSecond(); - this.alarmConfigurationUpdatedTimestamp = now; - this.stateUpdatedTimestamp = now; - } - - public String getAlarmName() { return alarmName; } - public void setAlarmName(String alarmName) { this.alarmName = alarmName; } - - public String getAlarmArn() { return alarmArn; } - public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; } - - public String getAlarmDescription() { return alarmDescription; } - public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; } - - public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; } - public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; } - - public boolean isActionsEnabled() { return actionsEnabled; } - public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; } - - public List getOkActions() { return okActions; } - public void setOkActions(List okActions) { this.okActions = okActions; } - - public List getAlarmActions() { return alarmActions; } - public void setAlarmActions(List alarmActions) { this.alarmActions = alarmActions; } - - public List getInsufficientDataActions() { return insufficientDataActions; } - public void setInsufficientDataActions(List insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; } - - public String getStateValue() { return stateValue; } - public void setStateValue(String stateValue) { this.stateValue = stateValue; } - - public String getStateReason() { return stateReason; } - public void setStateReason(String stateReason) { this.stateReason = stateReason; } - - public String getStateReasonData() { return stateReasonData; } - public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; } - - public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; } - public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; } public String getMetricName() { return metricName; } public void setMetricName(String metricName) { this.metricName = metricName; } @@ -100,8 +46,13 @@ public class MetricAlarm { public List getDimensions() { return dimensions; } public void setDimensions(List dimensions) { this.dimensions = dimensions; } - public int getPeriod() { return period; } - public void setPeriod(int period) { this.period = period; } + public Integer getPeriod() { return period; } + public void setPeriod(Integer period) { this.period = period; } + + public List getMetrics() { return metrics; } + public void setMetrics(List metrics) { + this.metrics = metrics == null ? new ArrayList<>() : new ArrayList<>(metrics); + } public String getUnit() { return unit; } public void setUnit(String unit) { this.unit = unit; } @@ -124,11 +75,4 @@ public class MetricAlarm { public String getEvaluateLowSampleCountPercentile() { return evaluateLowSampleCountPercentile; } public void setEvaluateLowSampleCountPercentile(String percentile) { this.evaluateLowSampleCountPercentile = percentile; } - public Map getTags() { return tags; } - public void setTags(Map tags) { this.tags = tags; } - - /** Not part of the AWS shape; set by {@code putMetricAlarm} so a background evaluator - * that only holds the alarm object still knows which region's metric data to query. */ - public String getRegion() { return region; } - public void setRegion(String region) { this.region = region; } } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsService.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsService.java index 4f3ba3c..020ff70 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsService.java @@ -196,21 +196,30 @@ public class CloudWatchMetricStreamsService { } public Map listTagsForResource(String resourceArn, String region) { - return findByArn(resourceArn, region).map(MetricStream::getTags).orElse(Map.of()); + return requireStream(resourceArn, region).getTags(); } public void tagResource(String resourceArn, Map tags, String region) { - findByArn(resourceArn, region).ifPresent(stream -> { - stream.getTags().putAll(tags); - streamStore.put(key(region, stream.getName()), stream); - }); + MetricStream stream = requireStream(resourceArn, region); + stream.getTags().putAll(tags); + streamStore.put(key(region, stream.getName()), stream); } public void untagResource(String resourceArn, List tagKeys, String region) { - findByArn(resourceArn, region).ifPresent(stream -> { - tagKeys.forEach(stream.getTags()::remove); - streamStore.put(key(region, stream.getName()), stream); - }); + MetricStream stream = requireStream(resourceArn, region); + tagKeys.forEach(stream.getTags()::remove); + streamStore.put(key(region, stream.getName()), stream); + } + + /** + * Resolves the stream an ARN names, or reports that nothing does. The tag operations declare + * {@code ResourceNotFoundException}, the same code {@code GetMetricStream} already answers + * with, so an unknown ARN here reads the same way an unknown name does. + */ + private MetricStream requireStream(String resourceArn, String region) { + return findByArn(resourceArn, region).orElseThrow(() -> + new AwsException("ResourceNotFoundException", + "Metric stream " + resourceArn + " does not exist.", 404)); } private Optional findByArn(String resourceArn, String region) { diff --git a/src/test/java/io/github/hectorvent/floci/core/common/AwsJsonCborCloudWatchTimestampIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/core/common/AwsJsonCborCloudWatchTimestampIntegrationTest.java index 3cbf6c9..fbdc848 100644 --- a/src/test/java/io/github/hectorvent/floci/core/common/AwsJsonCborCloudWatchTimestampIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/core/common/AwsJsonCborCloudWatchTimestampIntegrationTest.java @@ -6,11 +6,13 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; import io.quarkus.test.junit.QuarkusTest; +import io.restassured.response.Response; import org.junit.jupiter.api.Test; import java.time.Instant; import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -82,6 +84,29 @@ class AwsJsonCborCloudWatchTimestampIntegrationTest { + driftSeconds + "s): " + datapoint); } + // CloudWatch is awsQueryCompatible: the header keeps the Query code while the body names + // the model shape, which is what the AWS SDK v2 uses to pick the typed exception. + @Test + void putMetricDataWithoutMetricNameReturnsTheModelledExceptionShape() throws Exception { + ObjectNode putRequest = JSON.createObjectNode(); + putRequest.put("Namespace", "CborRpcV2MissingName-" + System.nanoTime()); + putRequest.putArray("MetricData").addObject().put("Value", 1.0); + + Response response = given() + .contentType("application/cbor") + .accept("application/cbor") + .header("smithy-protocol", "rpc-v2-cbor") + .body(AwsJsonCborController.nodeToSmithyCbor(putRequest)) + .when().post("/service/GraniteServiceVersion20100801/operation/PutMetricData"); + + assertEquals(400, response.statusCode()); + assertEquals("MissingParameter;Sender", response.header("x-amzn-query-error")); + JsonNode body = CBOR.readTree(response.asByteArray()); + assertEquals("MissingRequiredParameterException", body.path("__type").asText(), body.toString()); + assertEquals("The parameter MetricData.member.1.MetricName is required.", + body.path("message").asText(), body.toString()); + } + private static JsonNode rpcV2Call(String action, ObjectNode body) throws Exception { byte[] responseCbor = given() .contentType("application/cbor") diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java index 9a01129..b131756 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CfnProvisionerFixture.java @@ -45,6 +45,7 @@ import io.github.hectorvent.floci.services.cloudformation.provisioners.AppSyncCf import io.github.hectorvent.floci.services.cloudformation.provisioners.CdkMetadataCfnProvisioner; import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudTrailCfnProvisioner; import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchCfnProvisioner; +import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchCompositeAlarmCfnProvisioner; import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchDashboardCfnProvisioner; import io.github.hectorvent.floci.services.cloudformation.provisioners.CognitoCfnProvisioner; import io.github.hectorvent.floci.services.cloudformation.provisioners.CognitoUserPoolGroupCfnProvisioner; @@ -300,6 +301,7 @@ final class CfnProvisionerFixture { } if (cloudWatchMetricsService != null) { discovered.add(new CloudWatchCfnProvisioner(cloudWatchMetricsService)); + discovered.add(new CloudWatchCompositeAlarmCfnProvisioner(cloudWatchMetricsService)); } if (cloudWatchDashboardsService != null) { discovered.add(new CloudWatchDashboardCfnProvisioner(cloudWatchDashboardsService)); diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java new file mode 100644 index 0000000..42d8805 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationCompositeAlarmIntegrationTest.java @@ -0,0 +1,190 @@ +package io.github.hectorvent.floci.services.cloudformation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.testing.CfnStackWaits; +import io.github.hectorvent.floci.testing.PartitionCleanup; +import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.*; + +@QuarkusTest +class CloudFormationCompositeAlarmIntegrationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TestHTTPResource + URI baseUri; + + @RegisterExtension + final PartitionCleanup cleanup = new PartitionCleanup(); + + @BeforeAll + static void configureRestAssured() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void failedUpdateRestoresRuleTagsAndIdentity(boolean rename) throws Exception { + String stack = unique("composite-rollback"); + String name = stack + "-alarm"; + String next = rename ? name + "-new" : name; + deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "old", "drop", "restore"), false, false)); + assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + String arn = alarm(name).get("AlarmArn").toString(); + Response described = given().contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks").formParam("StackName", stack).post("/"); + assertEquals(name, described.xmlPath().getString( + "DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Name' }.OutputValue")); + assertEquals(arn, described.xmlPath().getString( + "DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Arn' }.OutputValue")); + cw("TagResource", Map.of("ResourceARN", arn, "Tags", List.of(Map.of("Key", "external", "Value", "keep")))) + .then().statusCode(200); + deploy("UpdateStack", stack, template(next, "FALSE", Map.of("team", "new", "added", "remove"), true, false)); + assertEquals("UPDATE_ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + assertEquals("TRUE", alarm(name).get("AlarmRule")); + List> tags = cw("ListTagsForResource", Map.of("ResourceARN", arn)) + .then().statusCode(200).extract().jsonPath().getList("Tags"); + assertEquals(Map.of("team", "old", "drop", "restore", "external", "keep"), + tags.stream().collect(Collectors.toMap(t -> t.get("Key"), t -> t.get("Value")))); + if (rename) { + assertMissing(next); + } + deleteStack(stack); + assertMissing(name); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void replacementHonorsRetentionAndDeletesItsCurrentBackingAlarm(boolean retain) throws Exception { + String stack = unique("composite-replace"); + String original = stack + "-old"; + String next = stack + "-new"; + deploy("CreateStack", stack, template(original, "TRUE", Map.of(), false, retain)); + assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + deploy("UpdateStack", stack, template(next, "FALSE", Map.of(), false, retain)); + assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + assertEquals("FALSE", alarm(next).get("AlarmRule")); + if (retain) { + assertEquals("TRUE", alarm(original).get("AlarmRule")); + } else { + assertMissing(original); + } + deleteStack(stack); + assertMissing(next); + if (retain) { + assertEquals("TRUE", alarm(original).get("AlarmRule")); + cw("DeleteAlarms", Map.of("AlarmNames", List.of(original))).then().statusCode(200); + } + } + + @Test + void committedInPlaceUpdatesAllowAnotherUpdateAndReplacement() throws Exception { + String stack = unique("composite-sequence"); + String name = stack + "-alarm"; + String replacement = name + "-new"; + deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "initial"), false, false)); + assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + for (String rule : List.of("FALSE", "TRUE")) { + deploy("UpdateStack", stack, template(name, rule, Map.of("team", rule), false, false)); + assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + assertEquals(rule, alarm(name).get("AlarmRule")); + } + deploy("UpdateStack", stack, template(replacement, "FALSE", Map.of(), false, false)); + assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + assertMissing(name); + assertEquals("FALSE", alarm(replacement).get("AlarmRule")); + deleteStack(stack); + assertMissing(replacement); + } + + @Test + void createCollisionCannotOverwriteOrDeleteAnUnmanagedAlarm() throws Exception { + String stack = unique("composite-foreign"); + String name = stack + "-alarm"; + cw("PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE")).then().statusCode(200); + deploy("CreateStack", stack, template(name, "FALSE", Map.of(), false, false)); + assertEquals("ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status()); + assertEquals("TRUE", alarm(name).get("AlarmRule")); + deleteStack(stack); + assertEquals("TRUE", alarm(name).get("AlarmRule")); + cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200); + } + + private String template(String name, String rule, Map tags, boolean fail, boolean retain) + throws JsonProcessingException { + cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200)); + Map resources = new LinkedHashMap<>(); + resources.put("Alarm", Map.of("Type", "AWS::CloudWatch::CompositeAlarm", + "UpdateReplacePolicy", retain ? "Retain" : "Delete", + "Properties", Map.of("AlarmName", name, "AlarmRule", rule, + "Tags", tags.entrySet().stream().map(e -> Map.of("Key", e.getKey(), "Value", e.getValue())).toList()))); + if (fail) { + resources.put("Failure", Map.of("Type", "AWS::SecretsManager::Secret", "DependsOn", "Alarm", + "Properties", Map.of("SecretString", "explicit", "GenerateSecretString", Map.of("PasswordLength", 32)))); + } + return MAPPER.writeValueAsString(Map.of("Resources", resources, "Outputs", Map.of( + "Name", Map.of("Value", Map.of("Ref", "Alarm")), + "Arn", Map.of("Value", Map.of("Fn::GetAtt", List.of("Alarm", "Arn")))))); + } + + private void deploy(String action, String stack, String template) { + if ("CreateStack".equals(action)) { + cleanup.register(() -> given().port(baseUri.getPort()) + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DeleteStack").formParam("StackName", stack) + .post("/").then().statusCode(200)); + } + given().contentType("application/x-www-form-urlencoded").formParam("Action", action) + .formParam("StackName", stack).formParam("TemplateBody", template).post("/").then().statusCode(200); + } + + private Response cw(String operation, Map body) { + if ("PutCompositeAlarm".equals(operation)) { + String name = body.get("AlarmName").toString(); + cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200)); + } + return given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0") + .header("X-Amz-Target", "GraniteServiceVersion20100801." + operation) + .body(MAPPER.valueToTree(body).toString()).post("/"); + } + + private Map alarm(String name) throws JsonProcessingException { + List> alarms = cw("DescribeAlarms", Map.of("AlarmNames", List.of(name), + "AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200) + .extract().jsonPath().getList("CompositeAlarms"); + assertEquals(1, alarms.size(), name); + return alarms.getFirst(); + } + + private void assertMissing(String name) throws JsonProcessingException { + assertEquals(List.of(), cw("DescribeAlarms", Map.of("AlarmNames", List.of(name), + "AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200) + .extract().jsonPath().getList("CompositeAlarms")); + } + + private void deleteStack(String stack) { + given().contentType("application/x-www-form-urlencoded").formParam("Action", "DeleteStack") + .formParam("StackName", stack).post("/").then().statusCode(200); + CfnStackWaits.awaitStackDeleted(stack); + } + + private static String unique(String prefix) { + return prefix + "-" + Long.toString(System.nanoTime(), 36); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java new file mode 100644 index 0000000..c1a208a --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CloudWatchCompositeAlarmCfnProvisionerTest.java @@ -0,0 +1,89 @@ +package io.github.hectorvent.floci.services.cloudformation.provisioners; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.cloudformation.CloudFormationTemplateEngine; +import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class CloudWatchCompositeAlarmCfnProvisionerTest { + @Test + void rejectedBackingUpdateDoesNotArmRollbackToDeleteAnUnmanagedReplacement() throws Exception { + CloudWatchMetricsService service = mock(CloudWatchMetricsService.class); + CloudWatchCompositeAlarmCfnProvisioner provisioner = new CloudWatchCompositeAlarmCfnProvisioner(service); + CloudFormationTemplateEngine engine = mock(CloudFormationTemplateEngine.class); + when(engine.resolve(any())).thenAnswer(call -> { + JsonNode node = call.getArgument(0); + return node == null || node.isMissingNode() ? null : node.asText(); + }); + when(engine.resolveNode(any())).thenAnswer(call -> call.getArgument(0)); + StackResource resource = new StackResource(); + resource.setLogicalId("Alarm"); + resource.setResourceType("AWS::CloudWatch::CompositeAlarm"); + resource.setPhysicalId("kept"); + resource.getAttributes().put("Arn", "prior-arn"); + AwsException collision = new AwsException("ValidationError", "A metric alarm now has this name", 400); + doThrow(collision).when(service).putCompositeAlarm(any(), eq("us-east-1"), eq(true)); + ProvisionContext context = new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "kept"); + JsonNode props = new ObjectMapper().readTree("{\"AlarmName\":\"kept\",\"AlarmRule\":\"TRUE\"}"); + assertSame(collision, assertThrows(AwsException.class, () -> provisioner.provision(resource, props, context))); + assertFalse(provisioner.retainsFailedUpdateState(resource)); + assertFalse(provisioner.rollbackUpdate(resource)); + assertEquals("kept", resource.getPhysicalId()); + assertEquals(Map.of("Arn", "prior-arn"), resource.getAttributes()); + verify(service, never()).deleteAlarms(any(), anyString()); + } + + @Test + void inPlaceUpdateRetriesPreviouslyDisplacedAlarmCleanup() throws Exception { + CloudWatchMetricsService service = mock(CloudWatchMetricsService.class); + CloudWatchCompositeAlarmCfnProvisioner provisioner = new CloudWatchCompositeAlarmCfnProvisioner(service); + CloudFormationTemplateEngine engine = mock(CloudFormationTemplateEngine.class); + when(engine.resolve(any())).thenAnswer(call -> { + JsonNode node = call.getArgument(0); + return node == null || node.isMissingNode() ? null : node.asText(); + }); + when(engine.resolveNode(any())).thenAnswer(call -> call.getArgument(0)); + StackResource resource = new StackResource(); + resource.setLogicalId("Alarm"); + resource.setResourceType("AWS::CloudWatch::CompositeAlarm"); + resource.setPhysicalId("current"); + ReplacementCleanup.record(resource, + new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "displaced"), Map.of()); + doThrow(new IllegalStateException("delete unavailable")) + .doThrow(new IllegalStateException("delete still unavailable")).doNothing() + .when(service).deleteAlarms(List.of("displaced"), "us-east-1"); + assertFalse(provisioner.completeUpdate(resource).complete()); + provisioner.clearUpdate(resource); + assertEquals(Set.of("displaced"), ReplacementCleanup.owedPhysicalIds(resource)); + + ProvisionContext context = new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "current"); + JsonNode props = new ObjectMapper().readTree("{\"AlarmName\":\"current\",\"AlarmRule\":\"TRUE\"}"); + provisioner.provision(resource, props, context); + assertTrue(provisioner.retainsFailedUpdateState(resource)); + UpdateCleanupResult retry = provisioner.completeUpdate(resource); + assertFalse(retry.complete()); + assertEquals(2, retry.attempts()); + assertEquals("displaced", retry.previousPhysicalId()); + assertEquals("delete still unavailable", retry.failureReason()); + assertEquals(Set.of("displaced"), ReplacementCleanup.owedPhysicalIds(resource)); + assertTrue(provisioner.completeUpdate(resource).complete()); + provisioner.clearUpdate(resource); + assertFalse(provisioner.retainsFailedUpdateState(resource)); + assertTrue(ReplacementCleanup.owedPhysicalIds(resource).isEmpty()); + assertEquals("current", resource.getPhysicalId()); + verify(service, times(3)).deleteAlarms(List.of("displaced"), "us-east-1"); + verify(service, never()).deleteAlarms(List.of("current"), "us-east-1"); + } + +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/CloudWatchTagUnknownResourceIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/CloudWatchTagUnknownResourceIntegrationTest.java new file mode 100644 index 0000000..8963e06 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/CloudWatchTagUnknownResourceIntegrationTest.java @@ -0,0 +1,349 @@ +package io.github.hectorvent.floci.services.cloudwatch; + +import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +/** + * Tagging a CloudWatch resource whose ARN names nothing has to fail, on every one of the three + * tag operations and for every taggable kind CloudWatch owns. + * + *

Each kind is checked twice: once on an ARN that resolves, so the operation is known to work + * at all, and once on the same ARN with the resource name swapped for one that was never created. + * The two ARNs are byte-identical in shape, so "the ARN was malformed" cannot explain the second + * answer. Without that pairing a test asserting only the failure would also pass against a build + * whose tag operations were broken outright. + * + *

The failure to look for is {@code ResourceNotFoundException}. CloudWatch's model declares + * that code, not the shorter {@code ResourceNotFound} that {@code GetDashboard} and + * {@code SetAlarmState} answer with, for {@code TagResource}, {@code UntagResource} and + * {@code ListTagsForResource}; the SDK maps the two to different exception classes, so a caller + * catching the documented one would not see the other. + */ +@QuarkusTest +class CloudWatchTagUnknownResourceIntegrationTest { + + private static final String CW_SCOPE = + "AWS4-HMAC-SHA256 Credential=test/20260205/us-east-1/monitoring/aws4_request"; + private static final String LOGS_SCOPE = + "AWS4-HMAC-SHA256 Credential=test/20260205/us-east-1/logs/aws4_request"; + + private static final String JSON_1_0 = "application/x-amz-json-1.0"; + private static final String JSON_1_1 = "application/x-amz-json-1.1"; + + private static final String PREFIX = "arn:aws:cloudwatch:us-east-1:000000000000:"; + private static final String FIREHOSE_ARN = + "arn:aws:firehose:us-east-1:000000000000:deliverystream/metrics"; + private static final String ROLE_ARN = "arn:aws:iam::000000000000:role/metric-stream-role"; + + @BeforeAll + static void configureRestAssured() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + private static RequestSpecification query(String action) { + return given() + .contentType("application/x-www-form-urlencoded") + .header("Authorization", CW_SCOPE) + .formParam("Action", action); + } + + private static RequestSpecification json(String contentType, String scope, String target, String body) { + return given() + .contentType(contentType) + .header("Authorization", scope) + .header("X-Amz-Target", target) + .body(body); + } + + private static RequestSpecification metricsJson(String target, String body) { + return json(JSON_1_0, CW_SCOPE, "GraniteServiceVersion20100801." + target, body); + } + + private static RequestSpecification logsJson(String target, String body) { + return json(JSON_1_1, LOGS_SCOPE, "Logs_20140328." + target, body); + } + + /** The three Query tag operations against one ARN, asserting the same outcome for each. */ + private static void assertQueryTagOpsNotFound(String arn) { + query("TagResource") + .formParam("ResourceARN", arn) + .formParam("Tags.member.1.Key", "env") + .formParam("Tags.member.1.Value", "prod") + .when() + .post("/") + .then() + .statusCode(404) + .body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException")); + + query("UntagResource") + .formParam("ResourceARN", arn) + .formParam("TagKeys.member.1", "env") + .when() + .post("/") + .then() + .statusCode(404) + .body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException")); + + query("ListTagsForResource") + .formParam("ResourceARN", arn) + .when() + .post("/") + .then() + .statusCode(404) + .body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException")); + } + + /** The same three operations against an ARN that does resolve, read back through a list. */ + private static void assertQueryTagOpsSucceed(String arn) { + query("TagResource") + .formParam("ResourceARN", arn) + .formParam("Tags.member.1.Key", "env") + .formParam("Tags.member.1.Value", "prod") + .when() + .post("/") + .then() + .statusCode(200); + + query("ListTagsForResource") + .formParam("ResourceARN", arn) + .when() + .post("/") + .then() + .statusCode(200) + .body("ListTagsForResourceResponse.ListTagsForResourceResult.Tags.member.Key", + equalTo("env")) + .body("ListTagsForResourceResponse.ListTagsForResourceResult.Tags.member.Value", + equalTo("prod")); + + query("UntagResource") + .formParam("ResourceARN", arn) + .formParam("TagKeys.member.1", "env") + .when() + .post("/") + .then() + .statusCode(200); + + query("ListTagsForResource") + .formParam("ResourceARN", arn) + .when() + .post("/") + .then() + .statusCode(200) + .body(not(containsString("env"))); + } + + @Test + void alarmTagOperationsRejectAnArnThatNamesNoAlarm() { + query("PutMetricAlarm") + .formParam("AlarmName", "tag-unknown-alarm") + .formParam("MetricName", "CPUUtilization") + .formParam("Namespace", "AWS/EC2") + .formParam("ComparisonOperator", "GreaterThanThreshold") + .formParam("EvaluationPeriods", "1") + .formParam("Period", "60") + .formParam("Statistic", "Average") + .formParam("Threshold", "80") + .when() + .post("/") + .then() + .statusCode(200); + + assertQueryTagOpsSucceed(PREFIX + "alarm:tag-unknown-alarm"); + assertQueryTagOpsNotFound(PREFIX + "alarm:tag-unknown-alarm-nosuch"); + } + + @Test + void dashboardTagOperationsRejectAnArnThatNamesNoDashboard() { + query("PutDashboard") + .formParam("DashboardName", "tag-unknown-dashboard") + .formParam("DashboardBody", "{\"widgets\":[]}") + .when() + .post("/") + .then() + .statusCode(200); + + assertQueryTagOpsSucceed(PREFIX + "dashboard/tag-unknown-dashboard"); + assertQueryTagOpsNotFound(PREFIX + "dashboard/tag-unknown-dashboard-nosuch"); + } + + @Test + void metricStreamTagOperationsRejectAnArnThatNamesNoStream() { + query("PutMetricStream") + .formParam("Name", "tag-unknown-stream") + .formParam("FirehoseArn", FIREHOSE_ARN) + .formParam("RoleArn", ROLE_ARN) + .formParam("OutputFormat", "json") + .formParam("IncludeFilters.member.1.Namespace", "AWS/EC2") + .when() + .post("/") + .then() + .statusCode(200); + + assertQueryTagOpsSucceed(PREFIX + "metric-stream/tag-unknown-stream"); + assertQueryTagOpsNotFound(PREFIX + "metric-stream/tag-unknown-stream-nosuch"); + } + + /** + * The dashboard ARN AWS documents carries no region, and the one Floci mints does. A + * caller sending the documented form for a dashboard that exists must not be told the + * dashboard does not exist, which is what raising on a miss would otherwise have made of + * a shape that used to fall through to an empty tag map. + */ + @Test + void theRegionlessDashboardArnAwsDocumentsAlsoResolves() { + query("PutDashboard") + .formParam("DashboardName", "tag-regionless-dashboard") + .formParam("DashboardBody", "{\"widgets\":[]}") + .when() + .post("/") + .then() + .statusCode(200); + + assertQueryTagOpsSucceed("arn:aws:cloudwatch::000000000000:dashboard/tag-regionless-dashboard"); + + // Still only an absent region: another region's ARN remains a miss. + assertQueryTagOpsNotFound( + "arn:aws:cloudwatch:eu-west-1:000000000000:dashboard/tag-regionless-dashboard"); + } + + /** + * Both handlers route every ARN that is not a dashboard and not a metric stream to the + * alarm path, so a Contributor Insights ARN, which AWS documents as taggable and Floci + * does not serve, lands there. Reporting it as not found is right; calling it an alarm + * that was not found is not, so the message names the ARN instead. + */ + @Test + void anArnOfAKindFlociDoesNotServeIsReportedAgainstTheArn() { + query("TagResource") + .formParam("ResourceARN", PREFIX + "insight-rule/no-such-rule") + .formParam("Tags.member.1.Key", "env") + .formParam("Tags.member.1.Value", "prod") + .when() + .post("/") + .then() + .statusCode(404) + .body("ErrorResponse.Error.Code", equalTo("ResourceNotFoundException")) + .body("ErrorResponse.Error.Message", + containsString("insight-rule/no-such-rule")) + .body("ErrorResponse.Error.Message", + not(containsString("Alarm"))); + } + + /** + * CloudWatch Metrics serves the same three operations over JSON as well as Query, and + * AGENTS.md requires the two handlers not to drift. A fix applied to only one of them + * would leave the CLI and SDK v3 route still answering 200. + */ + @Test + void theJsonProtocolRejectsTheSameArns() { + metricsJson("PutMetricAlarm", """ + {"AlarmName": "tag-unknown-json-alarm", "MetricName": "CPUUtilization", + "Namespace": "AWS/EC2", "ComparisonOperator": "GreaterThanThreshold", + "EvaluationPeriods": 1, "Period": 60, "Statistic": "Average", "Threshold": 80}""") + .when() + .post("/") + .then() + .statusCode(200); + + String real = PREFIX + "alarm:tag-unknown-json-alarm"; + String ghost = PREFIX + "alarm:tag-unknown-json-alarm-nosuch"; + + metricsJson("TagResource", + "{\"ResourceARN\": \"" + real + "\", \"Tags\": [{\"Key\": \"env\", \"Value\": \"prod\"}]}") + .when() + .post("/") + .then() + .statusCode(200); + + metricsJson("ListTagsForResource", "{\"ResourceARN\": \"" + real + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Tags[0].Key", equalTo("env")) + .body("Tags[0].Value", equalTo("prod")); + + metricsJson("TagResource", + "{\"ResourceARN\": \"" + ghost + "\", \"Tags\": [{\"Key\": \"env\", \"Value\": \"prod\"}]}") + .when() + .post("/") + .then() + .statusCode(404) + .body("__type", containsString("ResourceNotFoundException")); + + metricsJson("UntagResource", "{\"ResourceARN\": \"" + ghost + "\", \"TagKeys\": [\"env\"]}") + .when() + .post("/") + .then() + .statusCode(404) + .body("__type", containsString("ResourceNotFoundException")); + + metricsJson("ListTagsForResource", "{\"ResourceARN\": \"" + ghost + "\"}") + .when() + .post("/") + .then() + .statusCode(404) + .body("__type", containsString("ResourceNotFoundException")); + } + + /** + * CloudWatch Logs serves its own TagResource, UntagResource and ListTagsForResource over + * a separate handler, and already rejects an ARN that names no log group. Pinning it keeps + * the two halves of CloudWatch's tagging from diverging again in the other direction. + */ + @Test + void logGroupTagOperationsRejectAnArnThatNamesNoGroup() { + String real = "arn:aws:logs:us-east-1:000000000000:log-group:tag-unknown-group:*"; + String ghost = "arn:aws:logs:us-east-1:000000000000:log-group:tag-unknown-group-nosuch:*"; + + logsJson("CreateLogGroup", "{\"logGroupName\": \"tag-unknown-group\"}") + .when() + .post("/") + .then() + .statusCode(200); + + logsJson("TagResource", + "{\"resourceArn\": \"" + real + "\", \"tags\": {\"env\": \"prod\"}}") + .when() + .post("/") + .then() + .statusCode(200); + + logsJson("ListTagsForResource", "{\"resourceArn\": \"" + real + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("tags.env", equalTo("prod")); + + logsJson("TagResource", + "{\"resourceArn\": \"" + ghost + "\", \"tags\": {\"env\": \"prod\"}}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", containsString("ResourceNotFoundException")); + + logsJson("UntagResource", "{\"resourceArn\": \"" + ghost + "\", \"tagKeys\": [\"env\"]}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", containsString("ResourceNotFoundException")); + + logsJson("ListTagsForResource", "{\"resourceArn\": \"" + ghost + "\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", containsString("ResourceNotFoundException")); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsServiceTest.java index be2c481..fc21abd 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/dashboards/CloudWatchDashboardsServiceTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -116,10 +117,10 @@ class CloudWatchDashboardsServiceTest { @Test void tagsGivenOnCreateAreReadableByArn() { - service.putDashboard("ops", BODY, java.util.Map.of("team", "platform"), REGION); + service.putDashboard("ops", BODY, Map.of("team", "platform"), REGION); String arn = service.getDashboard("ops", REGION).getDashboardArn(); - assertEquals(java.util.Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); + assertEquals(Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); assertTrue(CloudWatchDashboardsService.isDashboardArn(arn)); } @@ -129,12 +130,12 @@ class CloudWatchDashboardsServiceTest { */ @Test void tagsOnAReplacingPutDoNotOverwriteTheExistingOnes() { - service.putDashboard("ops", BODY, java.util.Map.of("team", "platform"), REGION); + service.putDashboard("ops", BODY, Map.of("team", "platform"), REGION); String arn = service.getDashboard("ops", REGION).getDashboardArn(); - service.putDashboard("ops", BODY, java.util.Map.of("team", "someone-else"), REGION); + service.putDashboard("ops", BODY, Map.of("team", "someone-else"), REGION); - assertEquals(java.util.Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); + assertEquals(Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); } @Test @@ -142,11 +143,75 @@ class CloudWatchDashboardsServiceTest { service.putDashboard("ops", BODY, REGION); String arn = service.getDashboard("ops", REGION).getDashboardArn(); - service.tagResource(arn, java.util.Map.of("env", "dev"), REGION); - assertEquals(java.util.Map.of("env", "dev"), service.listTagsForResource(arn, REGION)); + service.tagResource(arn, Map.of("env", "dev"), REGION); + assertEquals(Map.of("env", "dev"), service.listTagsForResource(arn, REGION)); service.untagResource(arn, List.of("env"), REGION); - assertEquals(java.util.Map.of(), service.listTagsForResource(arn, REGION)); + assertEquals(Map.of(), service.listTagsForResource(arn, REGION)); + } + + /** + * AWS documents the dashboard ARN without a region and Floci mints one with a region, so + * both forms have to resolve. Before the tag operations raised on a miss this divergence + * was invisible here: the documented ARN matched nothing and got an empty tag map back. + */ + @Test + void bothTheRegionlessAndTheRegionfulDashboardArnResolve() { + service.putDashboard("ops", BODY, REGION); + String regionful = service.getDashboard("ops", REGION).getDashboardArn(); + String regionless = "arn:aws:cloudwatch::000000000000:dashboard/ops"; + + assertEquals("arn:aws:cloudwatch:us-east-1:000000000000:dashboard/ops", regionful); + assertTrue(CloudWatchDashboardsService.isDashboardArn(regionless)); + + service.tagResource(regionless, Map.of("env", "dev"), REGION); + // Read back through the other form: one dashboard, reachable either way. + assertEquals(Map.of("env", "dev"), service.listTagsForResource(regionful, REGION)); + assertEquals(Map.of("env", "dev"), service.listTagsForResource(regionless, REGION)); + + service.untagResource(regionless, List.of("env"), REGION); + assertEquals(Map.of(), service.listTagsForResource(regionful, REGION)); + } + + /** + * Only an absent region stands in for the dashboard's own. An ARN naming a different + * region must not resolve to the same-named dashboard in this one. + */ + @Test + void anArnNamingAnotherRegionDoesNotResolve() { + service.putDashboard("ops", BODY, REGION); + String elsewhere = "arn:aws:cloudwatch:eu-west-1:000000000000:dashboard/ops"; + + AwsException e = assertThrows(AwsException.class, + () -> service.listTagsForResource(elsewhere, REGION)); + assertEquals("ResourceNotFoundException", e.getErrorCode()); + } + + /** + * An ARN naming no dashboard is an error on all three operations, not a silent no-op. The + * ARN used here differs from a working one only in the dashboard name, so the outcome + * cannot be explained by the ARN failing to parse as a dashboard ARN. + */ + @Test + void tagOperationsRejectAnArnThatNamesNoDashboard() { + service.putDashboard("ops", BODY, REGION); + String arn = service.getDashboard("ops", REGION).getDashboardArn(); + String ghost = arn.replace("dashboard/ops", "dashboard/nosuch"); + assertTrue(CloudWatchDashboardsService.isDashboardArn(ghost)); + + for (Runnable call : List.of( + () -> service.tagResource(ghost, Map.of("env", "prod"), REGION), + () -> service.untagResource(ghost, List.of("env"), REGION), + () -> service.listTagsForResource(ghost, REGION))) { + AwsException e = assertThrows(AwsException.class, call::run); + assertEquals("ResourceNotFoundException", e.getErrorCode()); + assertEquals(404, e.getHttpStatus()); + } + + // The failures wrote nothing: the real dashboard still carries only what it was given. + assertEquals(Map.of(), service.listTagsForResource(arn, REGION)); + service.tagResource(arn, Map.of("env", "dev"), REGION); + assertEquals(Map.of("env", "dev"), service.listTagsForResource(arn, REGION)); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java new file mode 100644 index 0000000..f6ebcad --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmSnsNotifierTest.java @@ -0,0 +1,170 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +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; +import org.junit.jupiter.api.Test; +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.*; +import static org.mockito.Mockito.*; + +class AlarmSnsNotifierTest { + private static final String REGION = "us-east-1"; + private static final String TOPIC = "arn:aws:sns:us-east-1:000000000000:alarms"; + private final ObjectMapper mapper = new ObjectMapper(); + private SnsService sns; + private CloudWatchMetricsService service; + private MetricAlarm alarm; + + @BeforeEach + void setUp() { + sns = mock(SnsService.class); + service = new CloudWatchMetricsService(new InMemoryStorage<>(), new InMemoryStorage<>(), + new RegionResolver(REGION, "000000000000"), new AlarmSnsNotifier(sns, mapper)); + alarm = new MetricAlarm(); + alarm.setAlarmName("test-alarm"); + alarm.setActionsEnabled(true); + alarm.setAlarmActions(List.of(TOPIC)); + alarm.setOkActions(List.of(TOPIC)); + alarm.setInsufficientDataActions(List.of(TOPIC)); + alarm.setEvaluationPeriods(3); + alarm.setStatistic("Average"); + alarm.setMetricName("Errors"); + alarm.setNamespace("Local/PoC"); + service.putMetricAlarm(alarm, REGION); + } + + @Test + void publishesAwsPayloadForEachTransitionButNotRepeatedState() throws Exception { + service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION); + service.setAlarmState("test-alarm", "ALARM", "still breached", null, REGION); + service.setAlarmState("test-alarm", "OK", "recovered", null, REGION); + service.setAlarmState("test-alarm", "INSUFFICIENT_DATA", "missing", null, REGION); + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(sns, times(3)).publish(eq(TOPIC), isNull(), messages.capture(), anyString(), eq(REGION)); + JsonNode first = mapper.readTree(messages.getAllValues().getFirst()); + assertEquals("test-alarm", first.path("AlarmName").asText()); + assertEquals("INSUFFICIENT_DATA", first.path("OldStateValue").asText()); + assertEquals("ALARM", first.path("NewStateValue").asText()); + assertEquals("000000000000", first.path("AWSAccountId").asText()); + assertEquals(alarm.getAlarmArn(), first.path("AlarmArn").asText()); + assertEquals("Errors", first.path("Trigger").path("MetricName").asText()); + assertFalse(first.path("Trigger").has("DatapointsToAlarm")); + assertEquals("AVERAGE", first.path("Trigger").path("Statistic").asText()); + assertEquals("", first.path("Trigger").path("EvaluateLowSampleCountPercentile").asText()); + assertTrue(first.path("Trigger").path("EvaluateLowSampleCountPercentile").isTextual()); + assertNull(alarm.getDatapointsToAlarm()); + assertEquals("OK", mapper.readTree(messages.getAllValues().get(2)).path("OldStateValue").asText()); + } + + @Test + void formatsBothTimestampsWithMillisAndUtcOffset() throws Exception { + alarm.setAlarmConfigurationUpdatedTimestamp(1716280510L); + service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION); + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(sns).publish(eq(TOPIC), isNull(), message.capture(), anyString(), eq(REGION)); + JsonNode payload = mapper.readTree(message.getValue()); + assertEquals("2024-05-21T08:35:10.000+0000", + payload.path("AlarmConfigurationUpdatedTimestamp").asText()); + String stateChangeTime = payload.path("StateChangeTime").asText(); + assertTrue(stateChangeTime + .matches("[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}\\+0000")); + assertEquals(alarm.getStateUpdatedTimestamp(), + Instant.parse(stateChangeTime.replace("+0000", "Z")).getEpochSecond()); + } + + @Test + void preservesExplicitDatapointsToAlarmInPayload() throws Exception { + alarm.setDatapointsToAlarm(2); + alarm.setStatistic("Maximum"); + alarm.setEvaluateLowSampleCountPercentile("ignore"); + service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION); + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(sns).publish(eq(TOPIC), isNull(), message.capture(), anyString(), eq(REGION)); + assertEquals(2, mapper.readTree(message.getValue()).path("Trigger").path("DatapointsToAlarm").intValue()); + JsonNode trigger = mapper.readTree(message.getValue()).path("Trigger"); + assertEquals("MAXIMUM", trigger.path("Statistic").asText()); + assertEquals("ignore", trigger.path("EvaluateLowSampleCountPercentile").asText()); + assertEquals(2, alarm.getDatapointsToAlarm()); + } + + @Test + void disabledActionsDoNotPublish() { + alarm.setActionsEnabled(false); + service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION); + verifyNoInteractions(sns); + assertEquals("ALARM", alarm.getStateValue()); + } + + @Test + void failedTargetDoesNotPreventOtherTargetsOrStateChange() { + String missing = "arn:aws:sns:us-east-1:000000000000:missing"; + alarm.setAlarmActions(List.of(missing, "arn:aws:autoscaling:us-east-1:000000000000:scalingPolicy:x", TOPIC)); + doThrow(new IllegalStateException("missing topic")) + .when(sns).publish(eq(missing), isNull(), anyString(), anyString(), eq(REGION)); + assertDoesNotThrow(() -> service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION)); + verify(sns).publish(eq(TOPIC), isNull(), anyString(), anyString(), eq(REGION)); + assertEquals("ALARM", alarm.getStateValue()); + } + + @Test + void usesTopicRegionAndPreservesChinaPartition() { + String topic = "arn:aws-cn:sns:cn-north-1:000000000000:alarms"; + alarm.setAlarmArn("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:test-alarm"); + alarm.setAlarmActions(List.of(topic)); + service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION); + verify(sns).publish(eq(topic), isNull(), contains("arn:aws-cn:cloudwatch:"), anyString(), eq("cn-north-1")); + } + + @Test + void blockedNotificationDoesNotHoldTheAlarmMutationLock() throws Exception { + CountDownLatch publishing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(call -> { + if (call.getArgument(3).startsWith("ALARM:")) { + publishing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + } + return "message-id"; + }).when(sns).publish(eq(TOPIC), isNull(), anyString(), anyString(), eq(REGION)); + + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future transition = executor.submit(() -> + service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION)); + try { + assertTrue(publishing.await(2, TimeUnit.SECONDS)); + Future unrelated = executor.submit(() -> { + CompositeAlarm composite = new CompositeAlarm(); + composite.setAlarmName("unrelated"); + composite.setAlarmRule("FALSE"); + service.putCompositeAlarm(composite, REGION); + service.setAlarmState("test-alarm", "OK", "recovered", null, REGION); + }); + unrelated.get(2, TimeUnit.SECONDS); + assertEquals("OK", alarm.getStateValue()); + } finally { + release.countDown(); + } + transition.get(2, TimeUnit.SECONDS); + } + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(sns, times(2)).publish(eq(TOPIC), isNull(), messages.capture(), anyString(), eq(REGION)); + assertEquals("ALARM", mapper.readTree(messages.getAllValues().getFirst()).path("NewStateValue").asText()); + assertEquals("OK", mapper.readTree(messages.getAllValues().getLast()).path("NewStateValue").asText()); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java new file mode 100644 index 0000000..0612237 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchAlarmQueryHandlerTest.java @@ -0,0 +1,45 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import io.github.hectorvent.floci.core.common.RegionResolver; +import io.github.hectorvent.floci.core.storage.InMemoryStorage; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CloudWatchAlarmQueryHandlerTest { + @ParameterizedTest + @ValueSource(strings = {"PutMetricAlarm", "PutCompositeAlarm"}) + void sharedFieldsReachBothAlarmTypes(String operation) { + String region = "us-east-1"; + CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(), + new InMemoryStorage<>(), new RegionResolver(region, "123456789012")); + CloudWatchMetricsQueryHandler handler = new CloudWatchMetricsQueryHandler(service, null, null); + MultivaluedMap request = new MultivaluedHashMap<>(); + request.putSingle("AlarmName", "shared"); + request.putSingle("AlarmRule", "FALSE"); + request.putSingle("AlarmDescription", "shared fields"); + request.putSingle("ActionsEnabled", "false"); + request.putSingle("AlarmActions.member.1", "alarm-action"); + request.putSingle("OKActions.member.1", "ok-action"); + request.putSingle("InsufficientDataActions.member.1", "missing-action"); + request.putSingle("Tags.member.1.Key", "team"); + request.putSingle("Tags.member.1.Value", "metrics"); + assertEquals(200, handler.handle(operation, request, region).getStatus()); + Alarm alarm = "PutMetricAlarm".equals(operation) + ? service.describeAlarms(List.of("shared"), null, region).getFirst() + : service.describeCompositeAlarms(List.of("shared"), null, region).getFirst(); + assertEquals("shared fields", alarm.getAlarmDescription()); + assertFalse(alarm.isActionsEnabled()); + assertEquals(List.of("alarm-action"), alarm.getAlarmActions()); + assertEquals(List.of("ok-action"), alarm.getOkActions()); + assertEquals(List.of("missing-action"), alarm.getInsufficientDataActions()); + assertEquals(Map.of("team", "metrics"), alarm.getTags()); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java new file mode 100644 index 0000000..c540198 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java @@ -0,0 +1,218 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import com.fasterxml.jackson.core.type.TypeReference; +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.RegionResolver; +import io.github.hectorvent.floci.core.common.XmlParser; +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.Alarm; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.w3c.dom.Document; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathFactory; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class CloudWatchMetricMathAlarmTest { + + private static final String REGION = "us-east-1"; + private static final String ALARM_NAME = "metric-math-round-trip"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String XML_ALARM = "/DescribeAlarmsResponse/DescribeAlarmsResult/MetricAlarms/member"; + + private CloudWatchMetricsJsonHandler jsonHandler; + private CloudWatchMetricsQueryHandler queryHandler; + + @BeforeEach + void setup() { + initialize(new InMemoryStorage<>()); + } + + private void initialize(StorageBackend alarmStore) { + RegionResolver resolver = new RegionResolver(REGION, "000000000000"); + CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(), alarmStore, resolver); + CloudWatchDashboardsService dashboards = new CloudWatchDashboardsService(new InMemoryStorage<>(), resolver); + jsonHandler = new CloudWatchMetricsJsonHandler(service, dashboards, null, MAPPER); + queryHandler = new CloudWatchMetricsQueryHandler(service, dashboards, null); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void metricMathQueriesRoundTripThroughBothProtocols(boolean queryProtocol) throws Exception { + ObjectNode request = metricMathRequest(); + put(request, queryProtocol); + assertMetricMathAlarm(request); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void metricMathQueriesSurvivePersistentStorageReload(boolean queryProtocol, @TempDir Path directory) + throws Exception { + Path storageFile = directory.resolve("alarms.json"); + TypeReference> type = new TypeReference<>() { }; + initialize(new PersistentStorage<>(storageFile, type)); + ObjectNode request = metricMathRequest(); + put(request, queryProtocol); + + PersistentStorage reloaded = new PersistentStorage<>(storageFile, type); + reloaded.load(); + assertEquals(1, reloaded.keys().size(), "the stored alarm must deserialize after a restart"); + initialize(reloaded); + assertMetricMathAlarm(request); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void switchingBetweenSingleMetricAndMetricMathReplacesThePreviousDefinition(boolean queryProtocol) + throws Exception { + ObjectNode singleMetric = singleMetricRequest(); + put(singleMetric, queryProtocol); + assertSingleMetricAlarm(300); + + ObjectNode math = metricMathRequest(); + put(math, !queryProtocol); + assertMetricMathAlarm(math); + + singleMetric.put("Period", 120); + put(singleMetric, queryProtocol); + assertSingleMetricAlarm(120); + } + + @Test + void existingSingleMetricDefaultPeriodIsPreserved() throws Exception { + ObjectNode singleMetric = singleMetricRequest(); + singleMetric.remove("Period"); + put(singleMetric, false); + assertSingleMetricAlarm(60); + } + + private void put(ObjectNode request, boolean queryProtocol) { + if (queryProtocol) { + assertEquals(200, queryHandler.handle("PutMetricAlarm", toQuery(request), REGION).getStatus()); + } else { + assertEquals(200, jsonHandler.handle("PutMetricAlarm", request, REGION).getStatus()); + } + } + + private JsonNode describeJson() { + ObjectNode request = MAPPER.createObjectNode(); + request.putArray("AlarmNames").add(ALARM_NAME); + JsonNode response = (JsonNode) jsonHandler.handle("DescribeAlarms", request, REGION).getEntity(); + assertEquals(1, response.path("MetricAlarms").size()); + return response.path("MetricAlarms").get(0); + } + + private Document describeXml() throws Exception { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("AlarmNames.member.1", ALARM_NAME); + String xml = (String) queryHandler.handle("DescribeAlarms", params, REGION).getEntity(); + return XmlParser.parseDocument(xml); + } + + private void assertMetricMathAlarm(ObjectNode request) throws Exception { + JsonNode alarm = describeJson(); + assertEquals(request.path("Metrics"), alarm.path("Metrics")); + Document document = describeXml(); + XPath xpath = XPathFactory.newInstance().newXPath(); + for (String field : new String[]{"Period", "MetricName", "Namespace", "Statistic", "ExtendedStatistic", + "Unit", "Dimensions"}) { + assertFalse(alarm.has(field), "metric-math JSON must not contain " + field); + assertEquals("0", xpath.evaluate("count(" + XML_ALARM + "/" + field + ")", document), + "metric-math XML must not contain " + field); + } + assertEquals("3", xpath.evaluate("count(" + XML_ALARM + "/Metrics/member)", document)); + assertEquals("5", xpath.evaluate(XML_ALARM + "/Threshold", document).replace(".0", "")); + assertEquals("2", xpath.evaluate(XML_ALARM + "/EvaluationPeriods", document)); + assertEquals(5.0, alarm.path("Threshold").asDouble()); + assertEquals(2, alarm.path("EvaluationPeriods").asInt()); + assertXmlMatchesJson(request.path("Metrics"), XML_ALARM + "/Metrics", document, xpath); + } + + private void assertSingleMetricAlarm(int period) throws Exception { + JsonNode alarm = describeJson(); + assertEquals(period, alarm.path("Period").asInt()); + assertEquals("AWS/ApiGateway", alarm.path("Namespace").asText()); + assertEquals("5xx", alarm.path("MetricName").asText()); + assertEquals("Sum", alarm.path("Statistic").asText()); + assertFalse(alarm.has("Metrics")); + Document document = describeXml(); + XPath xpath = XPathFactory.newInstance().newXPath(); + assertEquals(Integer.toString(period), xpath.evaluate(XML_ALARM + "/Period", document)); + assertEquals("0", xpath.evaluate("count(" + XML_ALARM + "/Metrics)", document)); + } + + private static void assertXmlMatchesJson(JsonNode value, String path, Document document, XPath xpath) + throws Exception { + if (value.isArray()) { + assertEquals(Integer.toString(value.size()), xpath.evaluate("count(" + path + "/member)", document)); + for (int index = 0; index < value.size(); index++) { + assertXmlMatchesJson(value.get(index), path + "/member[" + (index + 1) + "]", document, xpath); + } + } else if (value.isObject()) { + for (Map.Entry field : value.properties()) { + assertXmlMatchesJson(field.getValue(), path + "/" + field.getKey(), document, xpath); + } + } else { + assertEquals(value.asText(), xpath.evaluate(path, document), path); + } + } + + private static MultivaluedMap toQuery(JsonNode value) { + MultivaluedMap params = new MultivaluedHashMap<>(); + flatten(value, "", params); + return params; + } + + private static void flatten(JsonNode value, String prefix, MultivaluedMap params) { + if (value.isArray()) { + for (int index = 0; index < value.size(); index++) { + flatten(value.get(index), prefix + ".member." + (index + 1), params); + } + } else if (value.isObject()) { + for (Map.Entry field : value.properties()) { + flatten(field.getValue(), prefix.isEmpty() ? field.getKey() : prefix + "." + field.getKey(), params); + } + } else { + params.putSingle(prefix, value.asText()); + } + } + + private static ObjectNode metricMathRequest() throws Exception { + return (ObjectNode) MAPPER.readTree(""" + {"AlarmName":"metric-math-round-trip","EvaluationPeriods":2,"Threshold":5, + "ComparisonOperator":"GreaterThanThreshold","Metrics":[ + {"Id":"rate","Expression":"100 * errors / requests","ReturnData":true, + "Period":300,"Label":"Error & requests"}, + {"Id":"errors","ReturnData":false,"AccountId":"123456789012","Label":"Errors", + "MetricStat":{"Metric":{"Namespace":"AWS/ApiGateway","MetricName":"5xx", + "Dimensions":[{"Name":"ApiName","Value":"api<&>"},{"Name":"Stage","Value":"test"}]}, + "Period":300,"Stat":"Sum","Unit":"Count"}}, + {"Id":"requests","ReturnData":false,"MetricStat":{ + "Metric":{"Namespace":"AWS/ApiGateway","MetricName":"Count"},"Period":300,"Stat":"Sum"}} + ]} + """); + } + + private static ObjectNode singleMetricRequest() throws Exception { + return (ObjectNode) MAPPER.readTree(""" + {"AlarmName":"metric-math-round-trip","Namespace":"AWS/ApiGateway","MetricName":"5xx", + "Statistic":"Sum","Period":300,"EvaluationPeriods":2,"Threshold":5, + "ComparisonOperator":"GreaterThanThreshold"} + """); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsEntityMetricDataIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsEntityMetricDataIntegrationTest.java new file mode 100644 index 0000000..27d81e0 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsEntityMetricDataIntegrationTest.java @@ -0,0 +1,203 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.config.EncoderConfig; +import io.restassured.http.ContentType; +import io.restassured.parsing.Parser; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static io.restassured.RestAssured.config; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; + +/** + * PutMetricData datums nested under EntityMetricData, as the CloudWatch Agent sends them, + * must be stored under the request namespace on every protocol path. floci-io/floci#6066. + */ +@QuarkusTest +class CloudWatchMetricsEntityMetricDataIntegrationTest { + + private static final String CW_AUTH = + "AWS4-HMAC-SHA256 Credential=test/20261020/us-east-1/monitoring/aws4_request"; + private static final String JSON_1_0 = "application/x-amz-json-1.0"; + + @BeforeAll + static void registerJsonParser() { + RestAssured.registerParser(JSON_1_0, Parser.JSON); + } + + private static RequestSpecification json(String operation) { + return given() + .header("Authorization", CW_AUTH) + .contentType(JSON_1_0) + .config(config().encoderConfig(EncoderConfig.encoderConfig() + .encodeContentTypeAs(JSON_1_0, ContentType.TEXT))) + .header("X-Amz-Target", "GraniteServiceVersion20100801." + operation); + } + + private static RequestSpecification query(String action) { + return given() + .header("Authorization", CW_AUTH) + .contentType("application/x-www-form-urlencoded") + .formParam("Action", action) + .formParam("Version", "2010-08-01"); + } + + @Test + void jsonEntityMetricDataOnlyIsListedAndAggregated() { + long now = Instant.now().getEpochSecond(); + json("PutMetricData") + .body(""" + {"Namespace":"CWAgent/Json","MetricData":[], + "EntityMetricData":[{ + "Entity":{"KeyAttributes":{"Type":"AWS::Resource", + "ResourceType":"AWS::EC2::Instance","Identifier":"i-0123456789abcdef0"}, + "Attributes":{}}, + "MetricData":[ + {"MetricName":"mem_used_percent","Value":6.5,"Unit":"Percent", + "StorageResolution":60,"Timestamp":%d, + "Dimensions":[{"Name":"host","Value":"h1"}]}, + {"MetricName":"disk_used_percent","Value":0.75,"Unit":"Percent", + "StorageResolution":60,"Timestamp":%d, + "Dimensions":[{"Name":"host","Value":"h1"}]}]}], + "StrictEntityValidation":false} + """.formatted(now, now)) + .when().post("/") + .then().statusCode(200); + + json("ListMetrics") + .body("{\"Namespace\":\"CWAgent/Json\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Metrics.MetricName", containsInAnyOrder("mem_used_percent", "disk_used_percent")); + + json("GetMetricStatistics") + .body(""" + {"Namespace":"CWAgent/Json","MetricName":"mem_used_percent", + "Dimensions":[{"Name":"host","Value":"h1"}], + "StartTime":%d,"EndTime":%d,"Period":60,"Statistics":["Sum"]} + """.formatted(now - 120, now + 120)) + .when().post("/") + .then() + .statusCode(200) + .body("Datapoints.Sum", hasItem(6.5f)); + } + + @Test + void jsonTopLevelAndEntityMetricDataAreBothStored() { + json("PutMetricData") + .body(""" + {"Namespace":"CWAgent/JsonMixed", + "MetricData":[{"MetricName":"TopLevel","Value":1}], + "EntityMetricData":[{"Entity":{"KeyAttributes":{"Type":"Service", + "Name":"checkout","Environment":"prod"}}, + "MetricData":[{"MetricName":"FromEntity","Value":2}]}]} + """) + .when().post("/") + .then().statusCode(200); + + json("ListMetrics") + .body("{\"Namespace\":\"CWAgent/JsonMixed\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Metrics.MetricName", containsInAnyOrder("TopLevel", "FromEntity")); + } + + @Test + void queryTopLevelAndEntityMetricDataAreListedAndAggregated() { + Instant now = Instant.now(); + query("PutMetricData") + .formParam("Namespace", "CWAgent/Query") + .formParam("MetricData.member.1.MetricName", "TopLevel") + .formParam("MetricData.member.1.Value", "1") + .formParam("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.key", "Type") + .formParam("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.value", "AWS::Resource") + .formParam("EntityMetricData.member.1.MetricData.member.1.MetricName", "mem_used_percent") + .formParam("EntityMetricData.member.1.MetricData.member.1.Value", "6.5") + .formParam("EntityMetricData.member.1.MetricData.member.1.Timestamp", now.toString()) + .formParam("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Name", "host") + .formParam("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Value", "h1") + .formParam("StrictEntityValidation", "false") + .when().post("/") + .then().statusCode(200); + + query("ListMetrics") + .formParam("Namespace", "CWAgent/Query") + .when().post("/") + .then() + .statusCode(200) + .body("ListMetricsResponse.ListMetricsResult.Metrics.member.MetricName", + containsInAnyOrder("TopLevel", "mem_used_percent")); + + query("GetMetricStatistics") + .formParam("Namespace", "CWAgent/Query") + .formParam("MetricName", "mem_used_percent") + .formParam("Dimensions.member.1.Name", "host") + .formParam("Dimensions.member.1.Value", "h1") + .formParam("StartTime", now.minusSeconds(120).toString()) + .formParam("EndTime", now.plusSeconds(120).toString()) + .formParam("Period", "60") + .formParam("Statistics.member.1", "Sum") + .when().post("/") + .then() + .statusCode(200) + .body("GetMetricStatisticsResponse.GetMetricStatisticsResult.Datapoints.member.Sum", + equalTo("6.5")); + } + + @Test + void jsonNestedDatumWithoutMetricNameIsRejectedAndNothingIsStored() { + json("PutMetricData") + .body(""" + {"Namespace":"CWAgent/JsonInvalid", + "MetricData":[{"MetricName":"TopLevel","Value":1}], + "EntityMetricData":[{"MetricData":[{"Value":2}]}]} + """) + .when().post("/") + .then() + .statusCode(400) + .header("x-amzn-query-error", equalTo("MissingParameter;Sender")) + .body("__type", equalTo("MissingRequiredParameterException")) + .body("message", equalTo( + "The parameter EntityMetricData.member.1.MetricData.member.1.MetricName is required.")); + + json("ListMetrics") + .body("{\"Namespace\":\"CWAgent/JsonInvalid\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Metrics", empty()); + } + + @Test + void queryMemberWithoutMetricNameIsRejectedAndNothingIsStored() { + query("PutMetricData") + .formParam("Namespace", "CWAgent/QueryInvalid") + .formParam("MetricData.member.1.Value", "1") + .formParam("MetricData.member.2.MetricName", "Valid") + .formParam("MetricData.member.2.Value", "2") + .when().post("/") + .then() + .statusCode(400) + .body("ErrorResponse.Error.Code", equalTo("MissingParameter")) + .body("ErrorResponse.Error.Message", + equalTo("The parameter MetricData.member.1.MetricName is required.")); + + query("ListMetrics") + .formParam("Namespace", "CWAgent/QueryInvalid") + .when().post("/") + .then() + .statusCode(200) + .body("ListMetricsResponse.ListMetricsResult.Metrics.member.size()", equalTo(0)); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java index 89628e6..cc81e98 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandlerTest.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.JsonNode; 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.AwsException; import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService; @@ -17,6 +18,13 @@ import java.math.BigDecimal; import java.time.Instant; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Handler-level tests for CloudWatchMetricsJsonHandler. @@ -146,6 +154,114 @@ class CloudWatchMetricsJsonHandlerTest { "metric stored with 24h-ago timestamp must not appear in a 20-second window around now"); } + // The CloudWatch Agent sends an empty top-level MetricData and nests every datum under + // EntityMetricData, floci-io/floci#6066. + @Test + void putMetricData_entityMetricDataOnly_storesNestedDatumsUnderTheRequestNamespace() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "CWAgent"); + req.putArray("MetricData"); + ObjectNode entityMetricData = req.putArray("EntityMetricData").addObject(); + ObjectNode entity = entityMetricData.putObject("Entity"); + entity.putObject("KeyAttributes") + .put("Type", "AWS::Resource") + .put("ResourceType", "AWS::EC2::Instance") + .put("Identifier", "i-0123456789abcdef0"); + entity.putObject("Attributes"); + ObjectNode datum = entityMetricData.putArray("MetricData").addObject(); + datum.put("MetricName", "mem_used_percent"); + datum.put("Value", 6.43); + datum.put("Unit", "Percent"); + datum.put("StorageResolution", 60); + datum.put("Timestamp", sdkTimestamp(EPOCH_NOW)); + datum.putArray("Dimensions").addObject().put("Name", "host").put("Value", "h1"); + req.put("StrictEntityValidation", false); + + assertEquals(200, handler.handle("PutMetricData", req, REGION).getStatus()); + + ObjectNode stats = getStats("CWAgent", "mem_used_percent", "host", "h1", + EPOCH_NOW.minusSeconds(60), EPOCH_NOW.plusSeconds(60), 60); + assertEquals(1, stats.get("Datapoints").size()); + assertEquals(6.43, stats.get("Datapoints").get(0).get("Sum").asDouble()); + } + + @Test + void putMetricData_topLevelAndEntityMetricData_storesBoth() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "Mixed"); + req.putArray("MetricData").addObject() + .put("MetricName", "TopLevel").put("Value", 1.0); + ArrayNode entityMetricData = req.putArray("EntityMetricData"); + entityMetricData.addObject().putArray("MetricData").addObject() + .put("MetricName", "FirstEntity").put("Value", 2.0); + entityMetricData.addObject().putArray("MetricData").addObject() + .put("MetricName", "SecondEntity").put("Value", 3.0); + + assertEquals(200, handler.handle("PutMetricData", req, REGION).getStatus()); + + ObjectNode listReq = MAPPER.createObjectNode(); + listReq.put("Namespace", "Mixed"); + ObjectNode listed = (ObjectNode) handler.handle("ListMetrics", listReq, REGION).getEntity(); + List names = new ArrayList<>(); + listed.get("Metrics").forEach(m -> names.add(m.get("MetricName").asText())); + assertEquals(List.of("FirstEntity", "SecondEntity", "TopLevel"), names.stream().sorted().toList()); + } + + @Test + void putMetricData_entityDatumWithoutMetricName_rejectsTheWholeRequest() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "Invalid"); + req.putArray("MetricData").addObject() + .put("MetricName", "TopLevel").put("Value", 1.0); + ArrayNode nested = req.putArray("EntityMetricData").addObject().putArray("MetricData"); + nested.addObject().put("MetricName", "Valid").put("Value", 2.0); + nested.addObject().put("Value", 3.0); + + AwsException e = assertThrows(AwsException.class, + () -> handler.handle("PutMetricData", req, REGION)); + assertEquals("MissingParameter", e.getErrorCode()); + assertEquals("MissingRequiredParameterException", e.jsonType()); + assertEquals(400, e.getHttpStatus()); + assertEquals("The parameter EntityMetricData.member.1.MetricData.member.2.MetricName is required.", + e.getMessage()); + assertEquals(0, listMetricNames("Invalid").size()); + } + + @Test + void putMetricData_topLevelDatumWithEmptyMetricName_rejectsTheWholeRequest() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "InvalidTop"); + ArrayNode data = req.putArray("MetricData"); + data.addObject().put("MetricName", "Valid").put("Value", 1.0); + data.addObject().put("MetricName", "").put("Value", 2.0); + + AwsException e = assertThrows(AwsException.class, + () -> handler.handle("PutMetricData", req, REGION)); + assertEquals("MissingParameter", e.getErrorCode()); + assertEquals("The parameter MetricData.member.2.MetricName is required.", e.getMessage()); + assertEquals(0, listMetricNames("InvalidTop").size()); + } + + // AWS constrains MetricName to 1 to 255 characters with no pattern, so whitespace is a name. + @Test + void putMetricData_whitespaceMetricName_isStored() { + ObjectNode req = MAPPER.createObjectNode(); + req.put("Namespace", "WhitespaceName"); + req.putArray("MetricData").addObject().put("MetricName", " ").put("Value", 1.0); + + assertEquals(200, handler.handle("PutMetricData", req, REGION).getStatus()); + assertEquals(List.of(" "), listMetricNames("WhitespaceName")); + } + + private List listMetricNames(String namespace) { + ObjectNode listReq = MAPPER.createObjectNode(); + listReq.put("Namespace", namespace); + ObjectNode listed = (ObjectNode) handler.handle("ListMetrics", listReq, REGION).getEntity(); + List names = new ArrayList<>(); + listed.get("Metrics").forEach(m -> names.add(m.get("MetricName").asText())); + return names; + } + @Test void setAlarmState_updatesFieldsCorrectly() { putMetric("NS", "M", "type", "current", 100.0, EPOCH_NOW); @@ -234,6 +350,22 @@ class CloudWatchMetricsJsonHandlerTest { assertEquals("Count", alarm.get("Unit").asText()); } + // AWS sends every alarm collection even when it is empty. The CLI's text output writes a + // None line for each paginator result key missing from the page (floci-io/floci#6362). + @Test + void describeAlarms_returnsEmptyCompositeAndLogAlarmCollections() { + handler.handle("PutMetricAlarm", alarmRequest("MetricOnly"), REGION); + + ObjectNode response = (ObjectNode) handler.handle("DescribeAlarms", MAPPER.createObjectNode(), REGION) + .getEntity(); + + assertEquals(1, response.get("MetricAlarms").size()); + assertTrue(response.get("CompositeAlarms").isArray()); + assertTrue(response.get("CompositeAlarms").isEmpty()); + assertTrue(response.get("LogAlarms").isArray()); + assertTrue(response.get("LogAlarms").isEmpty()); + } + // An alarm the caller never gave an M for reports no DatapointsToAlarm at all. AWS omits the // member in that case, and echoing EvaluationPeriods instead reads as a change to any client // that re-plans against its own configuration, which is what floci-io/floci#3660 hit. diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java index 6f465c2..27869c6 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandlerTest.java @@ -1,6 +1,9 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics; +import io.github.hectorvent.floci.core.common.AwsException; 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.MetricDatum; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMetricStreamsService; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; import jakarta.ws.rs.core.MultivaluedHashMap; @@ -12,12 +15,15 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -123,4 +129,128 @@ class CloudWatchMetricsQueryHandlerTest { anyInt(), any(), unitCaptor.capture(), any()); assertEquals("Count", unitCaptor.getValue()); } + + // The Query serialization of EntityMetricData nests each datum two lists deep; + // floci-io/floci#6066 is the JSON-side report of the same dropped datums. + @Test + void putMetricDataStoresTopLevelAndEntityMetricDataUnderTheRequestNamespace() { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("Namespace", "CWAgent"); + params.putSingle("MetricData.member.1.MetricName", "TopLevel"); + params.putSingle("MetricData.member.1.Value", "1"); + params.putSingle("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.key", "Type"); + params.putSingle("EntityMetricData.member.1.Entity.KeyAttributes.entry.1.value", "AWS::Resource"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.MetricName", "mem_used_percent"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Value", "6.5"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Unit", "Percent"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Name", "host"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Dimensions.member.1.Value", "h1"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.MetricName", "disk_used_percent"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.Value", "0.75"); + params.putSingle("StrictEntityValidation", "false"); + + handler.handle("PutMetricData", params, REGION); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metricsService).putMetricData(eq("CWAgent"), captor.capture(), eq(REGION)); + List datums = captor.getValue(); + assertEquals(List.of("TopLevel", "mem_used_percent", "disk_used_percent"), + datums.stream().map(MetricDatum::getMetricName).toList()); + MetricDatum entityDatum = datums.get(1); + assertEquals(6.5, entityDatum.getValue()); + assertEquals("Percent", entityDatum.getUnit()); + assertEquals("host", entityDatum.getDimensions().get(0).name()); + assertEquals("h1", entityDatum.getDimensions().get(0).value()); + } + + @Test + void putMetricDataStoresEntityMetricDataAfterAnEmptyEntityEntry() { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("Namespace", "CWAgent"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.MetricName", "M"); + params.putSingle("EntityMetricData.member.2.MetricData.member.1.Value", "1"); + params.putSingle("EntityMetricData.member.10.MetricData.member.1.MetricName", "N"); + params.putSingle("EntityMetricData.member.10.MetricData.member.1.Value", "2"); + params.putSingle("StrictEntityValidation", "false"); + + handler.handle("PutMetricData", params, REGION); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metricsService).putMetricData(eq("CWAgent"), captor.capture(), eq(REGION)); + List datums = captor.getValue(); + assertEquals(List.of("M", "N"), datums.stream().map(MetricDatum::getMetricName).toList()); + assertEquals(1.0, datums.get(0).getValue()); + assertEquals(2.0, datums.get(1).getValue()); + } + + @Test + void putMetricDataRejectsAMemberWithoutMetricNameBeforeStoringAnything() { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("Namespace", "NS"); + params.putSingle("MetricData.member.1.Value", "1"); + params.putSingle("MetricData.member.2.MetricName", "Valid"); + params.putSingle("MetricData.member.2.Value", "2"); + + AwsException e = assertThrows(AwsException.class, + () -> handler.handle("PutMetricData", params, REGION)); + assertEquals("MissingParameter", e.getErrorCode()); + assertEquals("MissingRequiredParameterException", e.jsonType()); + assertEquals(400, e.getHttpStatus()); + assertEquals("The parameter MetricData.member.1.MetricName is required.", e.getMessage()); + verify(metricsService, never()).putMetricData(any(), any(), any()); + } + + @Test + void putMetricDataRejectsANestedMemberWithoutMetricNameBeforeStoringAnything() { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("Namespace", "NS"); + params.putSingle("MetricData.member.1.MetricName", "TopLevel"); + params.putSingle("MetricData.member.1.Value", "1"); + params.putSingle("EntityMetricData.member.1.MetricData.member.1.Unit", "Percent"); + params.putSingle("EntityMetricData.member.1.MetricData.member.2.MetricName", "Valid"); + + AwsException e = assertThrows(AwsException.class, + () -> handler.handle("PutMetricData", params, REGION)); + assertEquals("The parameter EntityMetricData.member.1.MetricData.member.1.MetricName is required.", + e.getMessage()); + verify(metricsService, never()).putMetricData(any(), any(), any()); + } + + @Test + void putMetricDataRejectsAnEmptyMetricNameButAcceptsWhitespace() { + MultivaluedMap empty = new MultivaluedHashMap<>(); + empty.putSingle("Namespace", "NS"); + empty.putSingle("MetricData.member.1.MetricName", ""); + empty.putSingle("MetricData.member.1.Value", "1"); + + AwsException e = assertThrows(AwsException.class, + () -> handler.handle("PutMetricData", empty, REGION)); + assertEquals("The parameter MetricData.member.1.MetricName is required.", e.getMessage()); + verify(metricsService, never()).putMetricData(any(), any(), any()); + + MultivaluedMap whitespace = new MultivaluedHashMap<>(); + whitespace.putSingle("Namespace", "NS"); + whitespace.putSingle("MetricData.member.1.MetricName", " "); + whitespace.putSingle("MetricData.member.1.Value", "1"); + + handler.handle("PutMetricData", whitespace, REGION); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metricsService).putMetricData(eq("NS"), captor.capture(), eq(REGION)); + assertEquals(List.of(" "), captor.getValue().stream().map(MetricDatum::getMetricName).toList()); + } + + @Test + void describeAlarmsReturnsEmptyCompositeAndLogAlarmCollections() { + when(metricsService.describeAlarms(any(), any(), eq(REGION))).thenReturn(List.of()); + + String xml = (String) handler.handle("DescribeAlarms", new MultivaluedHashMap<>(), REGION).getEntity(); + + assertTrue(xml.contains(""), xml); + assertTrue(xml.contains(""), xml); + assertTrue(xml.contains(""), xml); + } } diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsTagsTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsTagsTest.java index 4112eb7..db5770a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsTagsTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsTagsTest.java @@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics; 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.core.common.RegionResolver; import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService; @@ -83,6 +84,34 @@ class CloudWatchMetricsTagsTest { assertNull(tags.get("team")); } + /** + * An ARN naming no alarm is an error on all three operations, not a silent no-op. The ARN + * used here differs from a working one only in the alarm name, so the outcome cannot be + * explained by the ARN failing to parse. + */ + @Test + void tagOperationsRejectAnArnThatNamesNoAlarm() { + MetricAlarm alarm = new MetricAlarm(); + alarm.setAlarmName("test-alarm"); + alarm.setAlarmArn("arn:aws:cloudwatch:us-east-1:000000000000:alarm:test-alarm"); + service.putMetricAlarm(alarm, REGION); + String ghost = "arn:aws:cloudwatch:us-east-1:000000000000:alarm:nosuch"; + + for (Runnable call : List.of( + () -> service.tagResource(ghost, Map.of("env", "prod"), REGION), + () -> service.untagResource(ghost, List.of("env"), REGION), + () -> service.listTagsForResource(ghost, REGION))) { + AwsException e = assertThrows(AwsException.class, call::run); + assertEquals("ResourceNotFoundException", e.getErrorCode()); + assertEquals(404, e.getHttpStatus()); + } + + // The failures wrote nothing: the real alarm still carries only what it was given. + assertEquals(Map.of(), service.listTagsForResource(alarm.getAlarmArn(), REGION)); + service.tagResource(alarm.getAlarmArn(), Map.of("env", "dev"), REGION); + assertEquals(Map.of("env", "dev"), service.listTagsForResource(alarm.getAlarmArn(), REGION)); + } + @Test void tagsViaPutMetricAlarmQuery() { MultivaluedMap params = new MultivaluedHashMap<>(); diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java new file mode 100644 index 0000000..d30ad43 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmIntegrationTest.java @@ -0,0 +1,160 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.testing.PartitionCleanup; +import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.*; + +@QuarkusTest +class CompositeAlarmIntegrationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String SCOPE = + "AWS4-HMAC-SHA256 Credential=test/20261010/cn-north-1/monitoring/aws4_request"; + + @TestHTTPResource + URI baseUri; + + @RegisterExtension + final PartitionCleanup cleanup = new PartitionCleanup(); + + @BeforeAll + static void configureRestAssured() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void bothProtocolsCreateDescribeEvaluateAndDeleteRealAlarms(boolean json) throws Exception { + String prefix = "wire-composite-" + Long.toString(System.nanoTime(), 36); + String source = prefix + "-source"; + String aggregate = prefix + "-aggregate"; + call(json, "PutCompositeAlarm", Map.of("AlarmName", source, "AlarmRule", "TRUE")).then().statusCode(200); + call(json, "PutCompositeAlarm", Map.of("AlarmName", aggregate, "AlarmRule", "ALARM(\"" + source + "\")", + "ActionsEnabled", false, "AlarmDescription", "combined")).then().statusCode(200); + Response result = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate), + "AlarmTypes", List.of("CompositeAlarm"))); + result.then().statusCode(200); + if (json) { + assertEquals(List.of(), result.jsonPath().getList("LogAlarms")); + assertEquals(1, result.jsonPath().getList("CompositeAlarms").size()); + assertEquals("ALARM", result.jsonPath().getString("CompositeAlarms[0].StateValue")); + assertEquals("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate, + result.jsonPath().getString("CompositeAlarms[0].AlarmArn")); + assertFalse(result.jsonPath().getBoolean("CompositeAlarms[0].ActionsEnabled")); + } else { + assertTrue(result.asString().contains("")); + assertEquals("ALARM", result.xmlPath().getString( + "DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue")); + assertTrue(result.asString().contains("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate)); + assertEquals("false", result.xmlPath().getString( + "DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.ActionsEnabled")); + } + assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate))) + .then().statusCode(200).extract().asString().contains(aggregate)); + call(json, "SetAlarmState", Map.of("AlarmName", source, "StateValue", "OK", + "StateReason", "recovery")).then().statusCode(200); + Response recovered = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate), + "AlarmTypes", List.of("CompositeAlarm"))); + assertEquals("OK", json ? recovered.jsonPath().getString("CompositeAlarms[0].StateValue") + : recovered.xmlPath().getString( + "DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue")); + for (String name : List.of(aggregate, source)) { + call(json, "DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200); + } + Response empty = call(json, "DescribeAlarms", Map.of("AlarmNamePrefix", prefix, + "AlarmTypes", List.of("CompositeAlarm"))); + assertFalse(empty.asString().contains(aggregate)); + assertFalse(empty.asString().contains(source)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void invalidRuleAndUnsupportedSuppressionCannotCreateAnAlarm(boolean json) throws Exception { + String name = "invalid-composite-" + Long.toString(System.nanoTime(), 36); + call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE AND")) + .then().statusCode(400); + call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE", + "ActionsSuppressor", "other")).then().statusCode(400); + assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(name), + "AlarmTypes", List.of("CompositeAlarm"))).asString().contains(name)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void alarmTypeValidationAcceptsLogAlarmsAndUsesSmithyErrors(boolean json) throws Exception { + call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("LogAlarm"))).then().statusCode(200); + Response error = call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("Unknown"))); + error.then().statusCode(400); + assertEquals("1 validation error detected: Value '[Unknown]' at 'alarmTypes' failed to satisfy constraint: " + + "Member must satisfy constraint: [Member must satisfy enum value set: " + + "[CompositeAlarm, MetricAlarm, LogAlarm]]", errorMessage(json, error)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void lengthErrorsUseSmithyMessages(boolean json) throws Exception { + for (String field : List.of("AlarmName", "AlarmRule")) { + int maximum = "AlarmName".equals(field) ? 255 : 10240; + String path = "AlarmName".equals(field) ? "alarmName" : "alarmRule"; + for (String value : List.of("", "x".repeat(maximum + 1))) { + Map request = new LinkedHashMap<>(Map.of("AlarmName", "length-validation", "AlarmRule", "TRUE")); + request.put(field, value); + Response error = call(json, "PutCompositeAlarm", request); + error.then().statusCode(400); + assertEquals("1 validation error detected: Value '" + value + "' at '" + path + + "' failed to satisfy constraint: Member must have length " + + (value.isEmpty() ? "greater than or equal to 1" : "less than or equal to " + maximum), + errorMessage(json, error)); + } + } + } + + private String errorMessage(boolean json, Response response) { + return json ? response.jsonPath().getString("message") : response.xmlPath().getString("ErrorResponse.Error.Message"); + } + + private Response call(boolean json, String operation, Map body) + throws JsonProcessingException { + if ("PutCompositeAlarm".equals(operation)) { + String name = body.get("AlarmName").toString(); + cleanup.register(() -> given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0") + .header("Authorization", SCOPE) + .header("X-Amz-Target", "GraniteServiceVersion20100801.DeleteAlarms") + .body(MAPPER.valueToTree(Map.of("AlarmNames", List.of(name))).toString()) + .post("/").then().statusCode(200)); + } + if (json) { + return given().contentType("application/x-amz-json-1.0").header("Authorization", SCOPE) + .header("X-Amz-Target", "GraniteServiceVersion20100801." + operation) + .body(MAPPER.writeValueAsString(body)).post("/"); + } + Map form = new LinkedHashMap<>(); + form.put("Action", operation); + body.forEach((key, value) -> { + if (value instanceof List values) { + for (int index = 0; index < values.size(); index++) { + form.put(key + ".member." + (index + 1), values.get(index)); + } + } else { + form.put(key, value); + } + }); + return given().contentType("application/x-www-form-urlencoded").header("Authorization", SCOPE) + .formParams(form).post("/"); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java new file mode 100644 index 0000000..42797f9 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CompositeAlarmServiceTest.java @@ -0,0 +1,274 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.core.common.RegionResolver; +import io.github.hectorvent.floci.core.storage.InMemoryStorage; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; +import io.github.hectorvent.floci.testing.ThreadStates; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CompositeAlarmServiceTest { + private static final String REGION = "cn-north-1"; + private final CloudWatchMetricsService service = new CloudWatchMetricsService( + new InMemoryStorage<>(), new InMemoryStorage<>(), new RegionResolver(REGION, "123456789012")); + + @Test + void metricTransitionsPropagateThroughNamesArnsAndNestedComposites() { + MetricAlarm metric = new MetricAlarm(); + metric.setAlarmName("latency"); + service.putMetricAlarm(metric, REGION); + put("combined", "ALARM(\"" + metric.getAlarmArn() + "\") AND NOT ALARM(\"maintenance\")"); + put("outer", "ALARM(combined) OR (TRUE AND FALSE)"); + assertEquals("OK", state("outer")); + service.setAlarmState("latency", "ALARM", "high latency", null, REGION); + assertEquals("ALARM", state("combined")); + assertEquals("ALARM", state("outer")); + service.setAlarmState("latency", "OK", "recovered", null, REGION); + assertEquals("OK", state("outer")); + assertEquals(List.of(metric), service.describeAlarms(null, null, REGION)); + assertTrue(service.describeCompositeAlarms(null, null, "us-east-1").isEmpty()); + assertTrue(service.describeCompositeAlarms(List.of("combined"), null, REGION) + .getFirst().getAlarmArn().startsWith("arn:aws-cn:cloudwatch:")); + } + + @Test + void updatesPreserveStateAndTagsUntilTheNextDependencyChange() { + put("source", "FALSE"); + CompositeAlarm alarm = put("aggregate", "ALARM(source)"); + service.tagResource(alarm.getAlarmArn(), Map.of("owner", "retained"), REGION); + service.setAlarmState("aggregate", "ALARM", "manual", null, REGION); + CompositeAlarm replacement = put("aggregate", "OK(source)"); + assertEquals("ALARM", replacement.getStateValue()); + assertEquals(Map.of("owner", "retained"), replacement.getTags()); + service.setAlarmState("source", "ALARM", "dependency changed", null, REGION); + assertEquals("OK", state("aggregate")); + } + + @Test + void unrelatedWritesPreserveManualCompositeStatesAndParentStates() { + put("source", "FALSE"); + put("aggregate", "ALARM(source)"); + put("parent", "ALARM(aggregate)"); + service.setAlarmState("aggregate", "ALARM", "manual", null, REGION); + assertEquals("ALARM", state("parent")); + put("unrelated", "TRUE"); + assertEquals("ALARM", state("aggregate")); + assertEquals("ALARM", state("parent")); + MetricAlarm metric = new MetricAlarm(); + metric.setAlarmName("unrelated-metric"); + service.putMetricAlarm(metric, REGION); + assertEquals("ALARM", state("aggregate")); + service.setAlarmState("unrelated-metric", "ALARM", "unrelated", null, REGION); + assertEquals("ALARM", state("aggregate")); + service.deleteAlarms(List.of("unrelated"), REGION); + assertEquals("ALARM", state("aggregate")); + assertEquals("ALARM", state("parent")); + service.setAlarmState("source", "ALARM", "child changed", null, REGION); + service.setAlarmState("source", "OK", "child recovered", null, REGION); + assertEquals("OK", state("aggregate")); + assertEquals("OK", state("parent")); + } + + @Test + void unchangedChildStateDoesNotResetManuallySetParent() { + put("source", "FALSE"); + put("child", "ALARM(source) AND FALSE"); + put("parent", "ALARM(child)"); + service.setAlarmState("parent", "ALARM", "manual", null, REGION); + service.setAlarmState("source", "ALARM", "child condition remains false", null, REGION); + assertEquals("OK", state("child")); + assertEquals("ALARM", state("parent")); + } + + @Test + void creationAndDeletionReevaluateOnlyReferencingComposites() { + MetricAlarm metric = new MetricAlarm(); + metric.setAlarmName("late"); + put("watcher", "INSUFFICIENT_DATA(late)"); + put("outer", "ALARM(watcher)"); + assertEquals("OK", state("watcher")); + service.putMetricAlarm(metric, REGION); + assertEquals("ALARM", state("watcher")); + assertEquals("ALARM", state("outer")); + service.deleteAlarms(List.of("late"), REGION); + assertEquals("OK", state("watcher")); + assertEquals("OK", state("outer")); + } + + @Test + void tagReadIsAnIndependentSnapshotOfTheOwnedAlarm() { + CompositeAlarm alarm = put("tag-snapshot", "TRUE"); + service.tagResource(alarm.getAlarmArn(), Map.of("team", "old"), REGION); + Map read = service.listTagsForResource(alarm.getAlarmArn(), REGION); + service.tagResource(alarm.getAlarmArn(), Map.of("team", "new"), REGION); + assertEquals(Map.of("team", "old"), read); + read.put("injected", "unowned"); + assertEquals(Map.of("team", "new"), service.listTagsForResource(alarm.getAlarmArn(), REGION)); + } + + @Test + void cyclesStopEvaluationEvenBehindShortCircuitAndMustBeBrokenBeforeDelete() { + put("a", "FALSE AND ALARM(b)"); + put("b", "ALARM(a)"); + service.setAlarmState("b", "ALARM", "manual", null, REGION); + assertThrows(AwsException.class, () -> service.deleteAlarms(List.of("a"), REGION)); + assertEquals(2, service.describeCompositeAlarms(null, null, REGION).size()); + put("a", "FALSE"); + service.deleteAlarms(List.of("b"), REGION); + assertEquals("OK", state("a")); + service.deleteAlarms(List.of("a"), REGION); + assertTrue(service.describeCompositeAlarms(null, null, REGION).isEmpty()); + } + + @Test + void manuallyChangingOneMemberDoesNotResumeACyclicDependency() { + put("a", "ALARM(b)"); + put("b", "ALARM(a)"); + assertEquals("OK", state("a")); + service.setAlarmState("b", "ALARM", "manual", null, REGION); + assertEquals("OK", state("a")); + put("b", "TRUE"); + service.setAlarmState("b", "ALARM", "cycle broken", null, REGION); + assertEquals("ALARM", state("a")); + } + + @Test + void parentQuotaTreatsAChildNameAndItsArnAsTheSameAlarm() { + CompositeAlarm child = put("child", "FALSE"); + for (int index = 0; index < 150; index++) { + put("parent-" + index, "ALARM(child)"); + } + assertThrows(AwsException.class, () -> put("overflow", "ALARM(\"" + child.getAlarmArn() + "\")")); + service.deleteAlarms(List.of("parent-0"), REGION); + assertEquals("OK", put("replacement", "ALARM(\"" + child.getAlarmArn() + "\")").getStateValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"", "ALARM()", "TRUE junk", "TRUE AND", "ALARM(\"unterminated)", + "(TRUE", "ALARM(x) XOR FALSE", "ALARM(\"x\\z\")"}) + void invalidRulesDoNotReplaceAnExistingAlarm(String invalid) { + put("saved", "TRUE"); + assertThrows(AwsException.class, () -> put("saved", invalid)); + assertEquals("TRUE", service.describeCompositeAlarms(List.of("saved"), null, REGION).getFirst().getAlarmRule()); + assertEquals("ALARM", state("saved")); + } + + @Test + void quotasAndAtomicCreatePreventExcessReferencesAndForeignNameReplacement() { + String many = IntStream.range(0, 101).mapToObj(i -> "ALARM(a" + i + ")") + .collect(Collectors.joining(" OR ")); + assertThrows(AwsException.class, () -> put("too-many", many)); + put("owned", "TRUE"); + CompositeAlarm foreign = new CompositeAlarm(); + foreign.setAlarmName("owned"); + foreign.setAlarmRule("FALSE"); + assertThrows(AwsException.class, () -> service.putCompositeAlarm(foreign, REGION, false)); + assertEquals("ALARM", state("owned")); + assertThrows(AwsException.class, () -> put("deep", "(".repeat(250) + "TRUE" + ")".repeat(250))); + assertEquals("ALARM", put("many-not", "NOT ".repeat(502) + "TRUE").getStateValue()); + assertThrows(AwsException.class, () -> put("elements", "FALSE OR ".repeat(500) + "TRUE")); + } + + @Test + void mixedPersistenceLoadsLegacyMetricsAndRoundTripsComposites() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + TypeReference> type = new TypeReference<>() {}; + Map legacy = mapper.readValue( + "{\"legacy\":{\"alarmName\":\"old\",\"metricName\":\"CPU\",\"stateValue\":\"OK\"}}", type); + assertInstanceOf(MetricAlarm.class, legacy.get("legacy")); + CompositeAlarm alarm = put("new", "FALSE"); + Map restored = mapper.readValue(mapper.writeValueAsBytes(Map.of("new", alarm)), type); + assertEquals("FALSE", assertInstanceOf(CompositeAlarm.class, restored.get("new")).getAlarmRule()); + assertEquals("OK", restored.get("new").getStateValue()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void concurrentTagChangesPreserveTheUpdatedDefinition(boolean remove) throws Exception { + CountDownLatch updating = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + InMemoryStorage storage = new InMemoryStorage<>() { + @Override + public void put(String key, Alarm value) { + if (value instanceof CompositeAlarm composite && "TRUE".equals(composite.getAlarmRule())) { + updating.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new AssertionError(error); + } + } + super.put(key, value); + } + }; + CloudWatchMetricsService concurrent = new CloudWatchMetricsService(new InMemoryStorage<>(), storage, + new RegionResolver(REGION, "123456789012")); + CompositeAlarm original = new CompositeAlarm(); + original.setAlarmName("concurrent"); + original.setAlarmRule("FALSE"); + original.setTags(new HashMap<>(Map.of("team", "old"))); + concurrent.putCompositeAlarm(original, REGION); + CompositeAlarm replacement = new CompositeAlarm(); + replacement.setAlarmName("concurrent"); + replacement.setAlarmRule("TRUE"); + AtomicReference tagThread = new AtomicReference<>(); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future update = executor.submit(() -> concurrent.putCompositeAlarm(replacement, REGION)); + Future tags; + try { + assertTrue(updating.await(2, TimeUnit.SECONDS)); + tags = executor.submit(() -> { + tagThread.set(Thread.currentThread()); + if (remove) { + concurrent.untagResource(original.getAlarmArn(), List.of("team"), REGION); + } else { + concurrent.tagResource(original.getAlarmArn(), Map.of("team", "new"), REGION); + } + }); + ThreadStates.awaitBlockedOrDone(tagThread, tags); + assertFalse(tags.isDone(), "tag mutation must share the definition update lock"); + } finally { + release.countDown(); + } + update.get(2, TimeUnit.SECONDS); + tags.get(2, TimeUnit.SECONDS); + } + CompositeAlarm result = concurrent.describeCompositeAlarms(List.of("concurrent"), null, REGION).getFirst(); + assertEquals("TRUE", result.getAlarmRule()); + assertEquals(remove ? Map.of() : Map.of("team", "new"), result.getTags()); + } + + private CompositeAlarm put(String name, String rule) { + CompositeAlarm alarm = new CompositeAlarm(); + alarm.setAlarmName(name); + alarm.setAlarmRule(rule); + alarm.setActionsEnabled(true); + service.putCompositeAlarm(alarm, REGION); + return alarm; + } + + private String state(String name) { + return service.describeCompositeAlarms(List.of(name), null, REGION).getFirst().getStateValue(); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsServiceTest.java index 6648f46..66b8beb 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metricstreams/CloudWatchMetricStreamsServiceTest.java @@ -232,4 +232,29 @@ class CloudWatchMetricStreamsServiceTest { service.untagResource(arn, List.of("team"), REGION); assertFalse(service.listTagsForResource(arn, REGION).containsKey("team")); } + + /** + * An ARN naming no stream is an error on all three operations, not a silent no-op. The ARN + * used here differs from a working one only in the stream name, so the outcome cannot be + * explained by the ARN failing to parse as a metric stream ARN. + */ + @Test + void tagOperationsRejectAnArnThatNamesNoStream() { + String arn = service.putMetricStream(stream("ops"), REGION).getArn(); + String ghost = arn.replace("metric-stream/ops", "metric-stream/nosuch"); + assertTrue(CloudWatchMetricStreamsService.isMetricStreamArn(ghost)); + + for (Runnable call : List.of( + () -> service.tagResource(ghost, Map.of("env", "prod"), REGION), + () -> service.untagResource(ghost, List.of("env"), REGION), + () -> service.listTagsForResource(ghost, REGION))) { + AwsException e = assertThrows(AwsException.class, call::run); + assertEquals("ResourceNotFoundException", e.getErrorCode()); + assertEquals(404, e.getHttpStatus()); + } + + // The failures wrote nothing: the real stream still carries only what it was given. + service.tagResource(arn, Map.of("team", "platform"), REGION); + assertEquals(Map.of("team", "platform"), service.listTagsForResource(arn, REGION)); + } } diff --git a/src/test/java/io/github/hectorvent/floci/testing/CfnStackWaits.java b/src/test/java/io/github/hectorvent/floci/testing/CfnStackWaits.java new file mode 100644 index 0000000..ff2bea8 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/testing/CfnStackWaits.java @@ -0,0 +1,199 @@ +package io.github.hectorvent.floci.testing; + +import io.restassured.path.xml.XmlPath; +import io.restassured.path.xml.element.Node; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; + +import java.util.List; +import java.util.Set; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * DeleteStack answers before the stack is gone. The deletion runs on an executor, so a test + * that looks at the stack's resources right after the answer races it. + */ +public final class CfnStackWaits { + + private CfnStackWaits() { + } + + /** What DescribeStacks reported: the status and, when present, the StackStatusReason. */ + public record StackState(String status, String reason) { + } + + private static final Set TERMINAL = Set.of( + "CREATE_COMPLETE", "CREATE_FAILED", "ROLLBACK_COMPLETE", "ROLLBACK_FAILED", + "UPDATE_COMPLETE", "UPDATE_FAILED", "UPDATE_ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_FAILED", + "DELETE_COMPLETE", "DELETE_FAILED"); + + private static final Set OPERATION_STARTS = Set.of("CREATE_IN_PROGRESS", "UPDATE_IN_PROGRESS"); + + /** + * Waits until DescribeStacks reports a terminal status for a create or update and returns + * it, so the caller decides what counts as success. A DescribeStacks that is not 200 (the + * stack does not exist) fails at once with the error body rather than polling the clock out; + * ten seconds without a terminal status fails too. + */ + public static StackState awaitTerminal(String stackName) { + return awaitTerminal(stackName, null); + } + + public static StackState awaitTerminal(String stackName, String authorization) { + long deadline = System.currentTimeMillis() + 10_000; + StackState last = new StackState(null, ""); + while (System.currentTimeMillis() < deadline) { + RequestSpecification request = given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", stackName); + if (authorization != null) { + request.header("Authorization", authorization); + } + Response response = request.when().post("/"); + if (response.statusCode() != 200) { + fail("DescribeStacks " + stackName + " answered " + response.statusCode() + ": " + response.asString()); + } + XmlPath xml = response.xmlPath(); + String status = xml.getString("**.find { it.name() == 'StackStatus' }"); + String reason = xml.getString("**.find { it.name() == 'StackStatusReason' }"); + last = new StackState(status == null || status.isEmpty() ? null : status, reason == null ? "" : reason); + if (last.status() != null && TERMINAL.contains(last.status())) { + return last; + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for stack " + stackName, e); + } + } + fail("Stack " + stackName + " reached no terminal status within ten seconds, last: " + last); + return last; + } + + /** + * Waits until DescribeStacks reports {@code expectedStatus} and returns that state. Any other + * terminal status fails at once with its reason, as do a DescribeStacks that is not 200 and ten + * seconds without the expected status. + */ + public static StackState awaitStatus(String stackName, String expectedStatus) { + long deadline = System.currentTimeMillis() + 10_000; + StackState last = new StackState(null, ""); + while (System.currentTimeMillis() < deadline) { + Response response = given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", stackName) + .when().post("/"); + if (response.statusCode() != 200) { + fail("DescribeStacks " + stackName + " answered " + response.statusCode() + ": " + response.asString()); + } + XmlPath xml = response.xmlPath(); + String status = xml.getString("**.find { it.name() == 'StackStatus' }"); + String reason = xml.getString("**.find { it.name() == 'StackStatusReason' }"); + last = new StackState(status == null || status.isEmpty() ? null : status, reason == null ? "" : reason); + if (expectedStatus.equals(last.status())) { + return last; + } + if (last.status() != null && TERMINAL.contains(last.status())) { + fail("Stack " + stackName + " reached " + last.status() + " instead of " + expectedStatus + + ": " + last.reason()); + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for stack " + stackName, e); + } + } + fail("Stack " + stackName + " did not reach " + expectedStatus + " within ten seconds, last: " + last); + return last; + } + + /** + * The reason on the first resource event that failed a create or update in the stack's latest + * operation, the one its last stack-level CREATE_IN_PROGRESS or UPDATE_IN_PROGRESS began. The + * stack's own reason only names the failed resource, as on AWS, so the failure's message is read + * here. Fails when DescribeStackEvents is not 200 or no resource failed in that operation. + */ + public static String failedResourceReason(String stackName) { + return failedResourceReason(stackName, null); + } + + public static String failedResourceReason(String stackName, String authorization) { + RequestSpecification request = given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStackEvents") + .formParam("StackName", stackName); + if (authorization != null) { + request.header("Authorization", authorization); + } + Response response = request.when().post("/"); + if (response.statusCode() != 200) { + fail("DescribeStackEvents " + stackName + " answered " + response.statusCode() + ": " + response.asString()); + } + List newestFirst = response.xmlPath().getList("**.findAll { it.name() == 'member' }", Node.class); + String reason = null; + for (Node event : newestFirst) { + String status = text(event, "ResourceStatus"); + if (text(event, "StackName").equals(text(event, "LogicalResourceId")) + && text(event, "StackId").equals(text(event, "PhysicalResourceId"))) { + if (OPERATION_STARTS.contains(status)) { + break; + } + } else if ("CREATE_FAILED".equals(status) || "UPDATE_FAILED".equals(status)) { + reason = text(event, "ResourceStatusReason"); + } + } + if (reason == null) { + fail("Stack " + stackName + " has no failed resource event in its latest operation: " + response.asString()); + } + return reason; + } + + private static String text(Node event, String name) { + Node child = event.getNode(name); + return child == null ? "" : child.value(); + } + + /** + * Waits until DescribeStacks answers 400, which is how a missing stack is reported. Fails on + * DELETE_FAILED or after ten seconds. + */ + public static void awaitStackDeleted(String stackName) { + awaitStackDeleted(stackName, null); + } + + public static void awaitStackDeleted(String stackName, String authorization) { + long deadline = System.currentTimeMillis() + 10_000; + String body = ""; + while (System.currentTimeMillis() < deadline) { + RequestSpecification request = given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", stackName); + if (authorization != null) { + request.header("Authorization", authorization); + } + Response response = request.when().post("/"); + body = response.asString(); + assertThat(body, not(containsString("DELETE_FAILED"))); + if (response.statusCode() == 400) { + return; + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for stack " + stackName + " to be deleted", e); + } + } + fail("Stack " + stackName + " was not deleted within ten seconds: " + body); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/testing/PartitionCleanup.java b/src/test/java/io/github/hectorvent/floci/testing/PartitionCleanup.java new file mode 100644 index 0000000..e6a169b --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/testing/PartitionCleanup.java @@ -0,0 +1,47 @@ +package io.github.hectorvent.floci.testing; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Deletes what a test created, in reverse order, after each test. The {@code @QuarkusTest} + * emulator is shared by every test class, so a resource left behind outlives the class that made + * it: one under {@code cn-north-1} shows up in every later cross-region listing (ResourceExplorer + * scans all resources), and an IAM user or an S3 bucket shows up in the next class to list either + * one unscoped. A test that leaves such a resource registers each create here. + * + *

{@code
+ * @RegisterExtension
+ * final PartitionCleanup cleanup = new PartitionCleanup();
+ * ...
+ * createTable(region, name);
+ * cleanup.register(() -> deleteTable(region, name));
+ * }
+ * + * A failing cleanup never fails the test: it is reported on stderr so the leak is visible and + * the assertion that actually failed stays the headline. + */ +public final class PartitionCleanup implements AfterEachCallback { + + private final Deque actions = new ArrayDeque<>(); + + public void register(Runnable action) { + actions.push(action); + } + + @Override + public void afterEach(ExtensionContext context) { + while (!actions.isEmpty()) { + Runnable action = actions.pop(); + try { + action.run(); + } catch (RuntimeException | AssertionError e) { + System.err.println("PartitionCleanup: cleanup step failed after " + + context.getDisplayName() + ": " + e); + } + } + } +} diff --git a/src/test/java/io/github/hectorvent/floci/testing/ThreadStates.java b/src/test/java/io/github/hectorvent/floci/testing/ThreadStates.java new file mode 100644 index 0000000..1acdf9e --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/testing/ThreadStates.java @@ -0,0 +1,93 @@ +package io.github.hectorvent.floci.testing; + +import java.time.Duration; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** Waiting for a thread to reach a lock, for tests that force two requests to overlap. */ +public final class ThreadStates { + + /** Every state a thread is in once it has stopped running: parked in any way, or ended. */ + public static final Set PARKED_OR_DONE = Collections.unmodifiableSet(EnumSet.of( + Thread.State.BLOCKED, Thread.State.WAITING, Thread.State.TIMED_WAITING, Thread.State.TERMINATED)); + + private ThreadStates() {} + + /** + * Returns once {@code thread} is blocked entering a monitor or parked on a lock, or {@code task} has + * finished, whichever comes first. Code that serializes correctly leaves the second request blocked + * behind the first; code that does not lets it run to the end, which the caller's assertions + * then catch. Polls every 5 ms and gives up after five seconds rather than hang a broken test. + */ + public static void awaitBlockedOrDone(AtomicReference thread, Future task) + throws InterruptedException { + awaitBlockedOrDone(thread::get, task::isDone, 5); + } + + /** + * Returns once {@code thread} is blocked entering a monitor, parked on a lock, or is no longer alive. + * Same wait as the {@link Future} overload, for a test that owns the {@link Thread} directly; gives up + * after ten seconds and fails, since a thread still running unblocked by then is not overlapping the + * way the test needs. + */ + public static void awaitBlockedOrDone(Thread thread) throws InterruptedException { + assertTrue(awaitBlockedOrDone(() -> thread, () -> !thread.isAlive(), 10), + "worker blocked or finished before timeout"); + } + + private static boolean awaitBlockedOrDone(Supplier thread, BooleanSupplier done, long timeoutSeconds) + throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds); + while (System.nanoTime() < deadline && !done.getAsBoolean()) { + if (isBlocked(thread.get())) { + return true; + } + Thread.sleep(5); + } + return done.getAsBoolean() || isBlocked(thread.get()); + } + + private static boolean isBlocked(Thread thread) { + if (thread == null) { + return false; + } + Thread.State state = thread.getState(); + return state == Thread.State.BLOCKED || state == Thread.State.WAITING; + } + + /** + * Waits until {@code thread} is parked in any way (blocked on a monitor, waiting or timed + * waiting) or has ended, and returns the state it reached. Fails after ten seconds. + */ + public static Thread.State awaitParkedOrDone(Thread thread) { + return awaitState(thread, PARKED_OR_DONE, Duration.ofSeconds(10), "the thread"); + } + + /** + * Waits until {@code thread} is in one of {@code states} and returns the one it reached, checking + * every millisecond. Fails naming {@code what} and the state it was left in once {@code timeout} + * passes, so a thread that never gets there fails the test instead of hanging it. + */ + public static Thread.State awaitState(Thread thread, Set states, Duration timeout, String what) { + long deadline = System.nanoTime() + timeout.toNanos(); + Thread.State state = thread.getState(); + while (!states.contains(state)) { + if (System.nanoTime() - deadline > 0) { + fail(what + " never reached " + states + ", it was " + state); + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + state = thread.getState(); + } + return state; + } +} diff --git a/src/test/resources/cloudformation/supported-resource-types.tsv b/src/test/resources/cloudformation/supported-resource-types.tsv index 48280ea..6d47e3f 100644 --- a/src/test/resources/cloudformation/supported-resource-types.tsv +++ b/src/test/resources/cloudformation/supported-resource-types.tsv @@ -41,6 +41,7 @@ AWS::CloudFront::OriginRequestPolicy CloudFrontCfnProvisioner AWS::CloudFront::ResponseHeadersPolicy CloudFrontCfnProvisioner AWS::CloudTrail::Trail CloudTrailCfnProvisioner AWS::CloudWatch::Alarm CloudWatchCfnProvisioner +AWS::CloudWatch::CompositeAlarm CloudWatchCompositeAlarmCfnProvisioner AWS::CloudWatch::Dashboard CloudWatchDashboardCfnProvisioner AWS::CodeBuild::Project CodeBuildCfnProvisioner AWS::CodePipeline::CustomActionType CodePipelineCfnProvisioner diff --git a/tools/docs/cfn_resource_types.yaml b/tools/docs/cfn_resource_types.yaml index ba37eef..1055d74 100644 --- a/tools/docs/cfn_resource_types.yaml +++ b/tools/docs/cfn_resource_types.yaml @@ -169,6 +169,9 @@ notes: # Trailing prose appended to a row after its type list, for behaviour that belongs to the service # rather than to one type. row_notes: + AWS::CloudWatch: >- + Composite alarms have backing definitions with name Ref and Arn GetAtt, rollback, and + replacement cleanup. Composite actions are stored only; suppression properties are rejected. AWS::Lambda: >- Inline `ZipFile` packages include the `cfn-response` (Node.js) / `cfnresponse` (Python) module AWS injects for that code path, so Solutions-style custom-resource handlers work.