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

Merged
Zakaria merged 9 commits from feat/cloudwatch-upstream-fixes into main 2026-10-25 03:08:55 +00:00
7 changed files with 585 additions and 47 deletions
Showing only changes of commit ccdf32335d - Show all commits
@@ -179,29 +179,82 @@ public class CloudWatchDashboardsService {
} }
public Map<String, String> listTagsForResource(String resourceArn, String region) { public Map<String, String> 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<String, String> tags, String region) { public void tagResource(String resourceArn, Map<String, String> tags, String region) {
findByArn(resourceArn, region).ifPresent(dashboard -> { Dashboard dashboard = requireDashboard(resourceArn, region);
dashboard.getTags().putAll(tags); dashboard.getTags().putAll(tags);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard);
});
} }
public void untagResource(String resourceArn, List<String> tagKeys, String region) { public void untagResource(String resourceArn, List<String> tagKeys, String region) {
findByArn(resourceArn, region).ifPresent(dashboard -> { Dashboard dashboard = requireDashboard(resourceArn, region);
tagKeys.forEach(dashboard.getTags()::remove); tagKeys.forEach(dashboard.getTags()::remove);
dashboardStore.put(key(region, dashboard.getDashboardName()), dashboard); 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<Dashboard> findByArn(String resourceArn, String region) { private java.util.Optional<Dashboard> findByArn(String resourceArn, String region) {
return dashboardStore.scan(k -> k.startsWith(region + "::")).stream() return dashboardStore.scan(k -> k.startsWith(region + "::")).stream()
.filter(d -> d.getDashboardArn() != null && d.getDashboardArn().equals(resourceArn)) .filter(d -> namesDashboard(resourceArn, d.getDashboardArn()))
.findFirst(); .findFirst();
} }
/**
* Whether {@code candidate} is an ARN for the dashboard whose own ARN is {@code stored}.
*
* <p>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::<account-id>:dashboard/<dashboard-name>}, 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.
*
* <p>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.
*
* <p>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) { private static String key(String region, String dashboardName) {
return region + "::" + dashboardName; return region + "::" + dashboardName;
} }
@@ -332,35 +332,43 @@ public class CloudWatchMetricsService {
LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue); LOG.infov("SetAlarmState: {0} -> {1}", alarmName, stateValue);
} }
public Map<String, String> listTagsForResource(String resourceArn, String region) { /**
* Resolves the alarm an ARN names, or reports that nothing does.
*
* <p>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.
*
* <p>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 MetricAlarm requireAlarm(String resourceArn, String region) {
return alarmStore.scan(k -> k.startsWith(region + "::")) return alarmStore.scan(k -> k.startsWith(region + "::"))
.stream() .stream()
.filter(a -> resourceArn.equals(a.getAlarmArn())) .filter(a -> a.getAlarmArn() != null && a.getAlarmArn().equals(resourceArn))
.findFirst() .findFirst()
.map(MetricAlarm::getTags) .orElseThrow(() -> new AwsException("ResourceNotFoundException",
.orElse(Map.of()); "No CloudWatch resource matches the ARN " + resourceArn + ".", 404));
}
public Map<String, String> listTagsForResource(String resourceArn, String region) {
return requireAlarm(resourceArn, region).getTags();
} }
public void tagResource(String resourceArn, Map<String, String> tags, String region) { public void tagResource(String resourceArn, Map<String, String> tags, String region) {
alarmStore.scan(k -> k.startsWith(region + "::")) MetricAlarm alarm = requireAlarm(resourceArn, region);
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.findFirst()
.ifPresent(alarm -> {
alarm.getTags().putAll(tags); alarm.getTags().putAll(tags);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
});
} }
public void untagResource(String resourceArn, List<String> tagKeys, String region) { public void untagResource(String resourceArn, List<String> tagKeys, String region) {
alarmStore.scan(k -> k.startsWith(region + "::")) MetricAlarm alarm = requireAlarm(resourceArn, region);
.stream()
.filter(a -> resourceArn.equals(a.getAlarmArn()))
.findFirst()
.ifPresent(alarm -> {
tagKeys.forEach(alarm.getTags()::remove); tagKeys.forEach(alarm.getTags()::remove);
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm); alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
});
} }
// ──────────────────────────── Helpers ──────────────────────────── // ──────────────────────────── Helpers ────────────────────────────
@@ -196,21 +196,30 @@ public class CloudWatchMetricStreamsService {
} }
public Map<String, String> listTagsForResource(String resourceArn, String region) { public Map<String, String> 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<String, String> tags, String region) { public void tagResource(String resourceArn, Map<String, String> tags, String region) {
findByArn(resourceArn, region).ifPresent(stream -> { MetricStream stream = requireStream(resourceArn, region);
stream.getTags().putAll(tags); stream.getTags().putAll(tags);
streamStore.put(key(region, stream.getName()), stream); streamStore.put(key(region, stream.getName()), stream);
});
} }
public void untagResource(String resourceArn, List<String> tagKeys, String region) { public void untagResource(String resourceArn, List<String> tagKeys, String region) {
findByArn(resourceArn, region).ifPresent(stream -> { MetricStream stream = requireStream(resourceArn, region);
tagKeys.forEach(stream.getTags()::remove); tagKeys.forEach(stream.getTags()::remove);
streamStore.put(key(region, stream.getName()), stream); 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<MetricStream> findByArn(String resourceArn, String region) { private Optional<MetricStream> findByArn(String resourceArn, String region) {
@@ -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.
*
* <p>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.
*
* <p>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("<Key>env</Key>")));
}
@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"));
}
}
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -116,10 +117,10 @@ class CloudWatchDashboardsServiceTest {
@Test @Test
void tagsGivenOnCreateAreReadableByArn() { 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(); 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)); assertTrue(CloudWatchDashboardsService.isDashboardArn(arn));
} }
@@ -129,12 +130,12 @@ class CloudWatchDashboardsServiceTest {
*/ */
@Test @Test
void tagsOnAReplacingPutDoNotOverwriteTheExistingOnes() { 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(); 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 @Test
@@ -142,11 +143,75 @@ class CloudWatchDashboardsServiceTest {
service.putDashboard("ops", BODY, REGION); service.putDashboard("ops", BODY, REGION);
String arn = service.getDashboard("ops", REGION).getDashboardArn(); String arn = service.getDashboard("ops", REGION).getDashboardArn();
service.tagResource(arn, java.util.Map.of("env", "dev"), REGION); service.tagResource(arn, Map.of("env", "dev"), REGION);
assertEquals(java.util.Map.of("env", "dev"), service.listTagsForResource(arn, REGION)); assertEquals(Map.of("env", "dev"), service.listTagsForResource(arn, REGION));
service.untagResource(arn, List.of("env"), 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.<Runnable>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 @Test
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.cloudwatch.metrics;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; 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.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.core.storage.InMemoryStorage;
import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService; import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService;
@@ -83,6 +84,34 @@ class CloudWatchMetricsTagsTest {
assertNull(tags.get("team")); 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.<Runnable>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 @Test
void tagsViaPutMetricAlarmQuery() { void tagsViaPutMetricAlarmQuery() {
MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
@@ -232,4 +232,29 @@ class CloudWatchMetricStreamsServiceTest {
service.untagResource(arn, List.of("team"), REGION); service.untagResource(arn, List.of("team"), REGION);
assertFalse(service.listTagsForResource(arn, REGION).containsKey("team")); 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.<Runnable>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));
}
} }