Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb007f8b8 | |||
| 607ac0803b | |||
| a9df81a294 | |||
| fa3327bf71 | |||
| 83aafc0325 | |||
| 24314f4432 | |||
| e90978ae94 | |||
| 21625c5a1b | |||
| 2e0a438d70 | |||
| ccdf32335d | |||
| cbb95ba26c | |||
| 90d5dc9427 | |||
| a59913bb38 | |||
| 2969939a99 | |||
| 31196a0367 | |||
| 55a981069c | |||
| 13e91cbb5a | |||
| ec4664589b | |||
| 40ee0e7822 | |||
| aef858a47c | |||
| e557be1bc6 | |||
| 174cb600d4 | |||
| 2e7ebefca9 |
+117
@@ -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<MetricDataQuery> metrics = metrics();
|
||||
putMathAlarm(metrics);
|
||||
assertMathAlarm(metrics);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesReplaceQueriesAndSwitchBackToASingleMetric() {
|
||||
putSingleMetricAlarm(300);
|
||||
List<MetricDataQuery> original = metrics();
|
||||
putMathAlarm(original);
|
||||
assertMathAlarm(original);
|
||||
|
||||
List<MetricDataQuery> 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<MetricDataQuery> 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<MetricAlarm> alarms = cloudWatch.describeAlarms(request -> request.alarmNames(alarmName)).metricAlarms();
|
||||
assertThat(alarms).hasSize(1);
|
||||
return alarms.get(0);
|
||||
}
|
||||
|
||||
private void assertMathAlarm(List<MetricDataQuery> 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<MetricDataQuery> metrics() {
|
||||
return List.of(
|
||||
MetricDataQuery.builder().id("rate").expression("100 * errors / requests")
|
||||
.returnData(true).period(300).label("Error <rate> & 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/app/data
|
||||
# Optional SES SMTP relay settings (FLOCI_SERVICES_SES_SMTP_*), kept outside the repo
|
||||
# because they hold a password. Without the file, sent emails stay in the SES mailbox.
|
||||
env_file:
|
||||
- path: ${HOME}/.config/thecloud/ses-smtp.env
|
||||
required: false
|
||||
environment:
|
||||
FLOCI_SERVICES_DOCKER_NETWORK: floci_default
|
||||
FLOCI_SERVICES_RDS_PROXY_BASE_PORT: "7001"
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
CloudFront management-plane and local content-delivery emulation. Supports distribution lifecycle,
|
||||
cache policies, origin request policies, response headers policies, origin access controls, origin
|
||||
access identities, public keys, trusted key groups, CloudFront Functions, invalidations, tagging, and
|
||||
GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
viewer delivery from S3 or custom origins for every CloudFront viewer method (GET, HEAD, OPTIONS, POST,
|
||||
PUT, PATCH and DELETE).
|
||||
|
||||
**Protocol:** REST XML
|
||||
**API version:** `2020-05-31`
|
||||
@@ -150,9 +151,17 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
- CNAME aliases are globally unique. `AssociateAlias` atomically transfers an alias from its current
|
||||
owner to the target distribution. Exact aliases take precedence over the most-specific matching
|
||||
wildcard alias.
|
||||
- Viewer GET/HEAD requests, and OPTIONS requests allowed by the matched cache behavior, addressed to
|
||||
an enabled distribution's generated domain or alias are routed to the matching S3 or custom
|
||||
origin. Origin forwarding preserves the raw path; custom-origin redirects are not followed.
|
||||
- Viewer requests addressed to an enabled distribution's generated domain or alias are routed to the
|
||||
matching S3 or custom origin when the matched cache behavior's `AllowedMethods` includes the
|
||||
method; any other method returns 403 `Invalid method.`. A behavior without `AllowedMethods` allows
|
||||
GET and HEAD. Origin forwarding preserves the raw path; custom-origin redirects are not followed.
|
||||
- POST, PUT, PATCH and DELETE are forwarded to custom origins with the viewer request body and its
|
||||
`Content-Type`. The body is streamed to the origin with the viewer's `Content-Length`, or chunked
|
||||
when the viewer sent it chunked or, as HTTP/2 allows, with no length, so it is never held in
|
||||
memory. Their responses are never cached, as on AWS. Signed URL and signed cookie enforcement
|
||||
applies to them like any other method. Request bodies are bounded by the emulator-wide
|
||||
`floci.protocols.max-request-size` limit (`FLOCI_PROTOCOLS_MAX_REQUEST_SIZE`, 2048 MB by default);
|
||||
CloudFront accepts up to 64 GB, so raise it for larger uploads.
|
||||
- Every distribution is also served as `{id}.cloudfront.{host}` for each endpoint host Floci
|
||||
resolves: `localhost`, `localhost.floci.io`, `localhost.localstack.cloud`, `FLOCI_HOSTNAME` and
|
||||
every `FLOCI_DNS_EXTRA_SUFFIXES` entry. `{id}.cloudfront.localhost.floci.io` and
|
||||
@@ -160,7 +169,7 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
HTTPS certificate, so a signed URL for either can be downloaded over `https://`. See
|
||||
[Downloading over HTTPS](#downloading-over-https).
|
||||
- Origin custom headers are persisted through the CloudFront API and CloudFormation. They replace
|
||||
same-named viewer headers on custom-origin GET/HEAD/OPTIONS requests. For in-process S3 origins, a
|
||||
every same-named forwarded viewer header on custom-origin requests. For in-process S3 origins, a
|
||||
configured `Origin` header is used for S3 CORS evaluation. AWS-prohibited names, malformed
|
||||
values, inconsistent quantities, duplicates, and quota violations are rejected with modeled
|
||||
CloudFront errors when the distribution is created or updated.
|
||||
@@ -181,9 +190,48 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
service-principal bucket-policy grants (including the distribution `AWS:SourceArn`) when strict S3
|
||||
authentication is enabled. OAC `always`, `never`, and unsigned `no-override` requests follow their
|
||||
documented signing behavior; signed `no-override` viewer requests retain their authorization.
|
||||
- Cache-policy, origin-request-policy, and legacy `ForwardedValues` data-plane evaluation is not
|
||||
implemented yet. Viewer query strings therefore follow CloudFront's default behavior and are not
|
||||
forwarded to origins.
|
||||
- Requests to custom origins carry what the matched cache behavior forwards. With a cache policy,
|
||||
the viewer headers, cookies and query strings selected by the cache policy's
|
||||
`ParametersInCacheKeyAndForwardedToOrigin` and by the origin request policy are forwarded (the
|
||||
union of both). `HeaderBehavior` `none`, `whitelist`, `allViewer`,
|
||||
`allViewerAndWhitelistCloudFront` and `allExcept`, `CookieBehavior` and `QueryStringBehavior`
|
||||
`none`, `whitelist`, `allExcept` and `all` are evaluated. A cache policy that selects nothing and no
|
||||
origin request policy forwards no viewer headers, cookies or query strings, as on AWS.
|
||||
- A cache behavior without a cache policy uses its legacy `ForwardedValues`: `QueryString=true`
|
||||
forwards every query string (`QueryStringCacheKeys` only narrows the cache key), cookies follow
|
||||
`Forward` `none`, `whitelist` (with `*` and `?` wildcards) or `all`, and viewer headers are
|
||||
forwarded by default except `Accept`, `Accept-Charset`, `Accept-Language`, `Authorization`,
|
||||
`Host`, `Referer`, `User-Agent` and `X-HTTP-Method-Override`, which `Headers` can add back (`*`
|
||||
forwards all of them). An origin request policy needs a cache policy on AWS, so a behavior without
|
||||
one ignores it.
|
||||
- Whatever the policies, CloudFront sets `Host` to the origin's domain name unless the viewer's
|
||||
`Host` is forwarded, sends `User-Agent: Amazon CloudFront` unless the viewer's `User-Agent` is
|
||||
forwarded, appends the viewer address to `X-Forwarded-For`, adds a
|
||||
`Via: <viewer HTTP version> <distribution domain> (CloudFront)` hop and an `X-Amz-Cf-Id`.
|
||||
`Authorization` is always forwarded on POST, PUT, PATCH and DELETE, and on OPTIONS unless OPTIONS
|
||||
is a cached method; `Content-Type` travels with a request body. Cache policy compression settings
|
||||
normalize `Accept-Encoding` to `br,gzip`, `gzip`, `br` or `identity`. Forwarded cookies are sorted
|
||||
by name, and cookies that are not `name=value` or whose name starts with `$` are dropped.
|
||||
Hop-by-hop headers, `Expect`, `X-Edge-*`, `X-Real-IP`, `X-Forwarded-Proto` and viewer-supplied
|
||||
`CloudFront-*` headers are never forwarded.
|
||||
- Of the CloudFront request headers, `CloudFront-Forwarded-Proto`, `CloudFront-Viewer-Address` and
|
||||
`CloudFront-Viewer-Http-Version` are generated when a policy names them, or when an `allExcept`
|
||||
header behavior does not exclude them (AWS documents Managed-AllViewerExceptHostHeader as adding
|
||||
them). Device-type, geolocation, TLS, JA3/JA4 and header-order headers are not generated.
|
||||
- The eight AWS managed origin request policies are available by their AWS IDs through
|
||||
`GetOriginRequestPolicy` and `ListOriginRequestPolicies` (`Type` is `managed` or `custom`), and
|
||||
cannot be updated or deleted. AWS managed cache policies are not modeled: a behavior that
|
||||
references one forwards nothing on its account, which matches `CachingDisabled`,
|
||||
`CachingOptimized` and `CachingOptimizedForUncompressedObjects` but not `Amplify`,
|
||||
`Elemental-MediaPackage` or the `UseOriginCacheControlHeaders` policies.
|
||||
- In-process S3 origins do not apply forwarding settings: viewer query strings, cookies and headers
|
||||
do not change the S3 read.
|
||||
- Origin responses are streamed to the viewer, from in-process S3 origins and custom origins alike,
|
||||
so a large object is never held in memory. A custom origin's `Content-Length` is passed on, and a
|
||||
response it sends chunked stays chunked. Floci does not cache, so it serves objects larger than
|
||||
50 GB the way CloudFront does with caching disabled; with caching enabled, CloudFront refuses them.
|
||||
- Origin `Set-Cookie` headers always reach the viewer. With legacy `Forward=none`, AWS strips them
|
||||
from the response; Floci does not.
|
||||
- Custom origins that resolve to loopback, private, link-local, carrier-grade NAT, or other non-routable addresses are rejected by default. Development-only private origins must be explicitly allowlisted by exact hostname.
|
||||
- Response headers policies validate the AWS configuration shape and are applied after the origin
|
||||
response, including CORS preflight fields, origin override behavior, custom headers, security
|
||||
@@ -339,3 +387,6 @@ as it is.
|
||||
- CloudFormation provisioning of custom `AWS::CloudFront::ResponseHeadersPolicy` resources
|
||||
(literal custom or managed policy IDs are supported on distributions)
|
||||
- Persistent edge caching and global CDN propagation
|
||||
- Forwarding POST, PUT, PATCH and DELETE to S3 origins. AWS passes them to S3, which evaluates them
|
||||
against the bucket policy (OAC supports `PUT` and `DELETE`); Floci's in-process S3 origin answers
|
||||
them with 403 `Access Denied`, as S3 does for a request that holds no write grant.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -316,7 +316,10 @@ emulator-friendly client-credentials flow:
|
||||
Without `identity_provider`, or with `identity_provider=COGNITO`, the pool's own users sign in
|
||||
on the hosted login page; the client must list `COGNITO` in `SupportedIdentityProviders` (the
|
||||
default when none are given).
|
||||
- `GET /cognito-idp/login` shows a user name (or email) and password form. A correct password
|
||||
- `GET /cognito-idp/login` shows a user name (or email) and password form when the client lists
|
||||
`COGNITO`, and a "Continue with <provider>" button for each of its other
|
||||
`SupportedIdentityProviders`, which starts the federated sign-in above with the same authorize
|
||||
parameters. A correct password
|
||||
redirects to the registered callback with a one-time authorization code and `state`, and starts
|
||||
a one-hour browser session (the `cognito` cookie), so the next authorize request returns a code
|
||||
without the form unless it sends `prompt=login`. Wrong passwords, unknown users and
|
||||
|
||||
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> values, String path, List<String> allowed) {
|
||||
return "Value '" + values + "' at '" + path + "' failed to satisfy constraint: "
|
||||
+ "Member must satisfy constraint: [Member must satisfy enum value set: " + allowed + "]";
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package io.github.hectorvent.floci.services.cloudformation.provisioners;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ApplicationScoped
|
||||
public class CloudWatchCompositeAlarmCfnProvisioner implements CfnResourceProvisioner {
|
||||
private static final String TYPE = "AWS::CloudWatch::CompositeAlarm";
|
||||
private static final String SNAPSHOT = "FlociCompositeAlarmSnapshot";
|
||||
private static final String TAG_KEYS = "FlociCompositeAlarmTagKeys";
|
||||
private static final String EXPLICIT_NAME = "FlociCompositeAlarmExplicitName";
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final CloudWatchMetricsService service;
|
||||
|
||||
public CloudWatchCompositeAlarmCfnProvisioner(CloudWatchMetricsService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> resourceTypes() {
|
||||
return Set.of(TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provision(StackResource resource, JsonNode props, ProvisionContext ctx) {
|
||||
for (String unsupported : List.of("ActionsSuppressor", "ActionsSuppressorWaitPeriod",
|
||||
"ActionsSuppressorExtensionPeriod")) {
|
||||
if (props.has(unsupported)) {
|
||||
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
|
||||
}
|
||||
}
|
||||
if (resource.getAttributes().containsKey(SNAPSHOT)) {
|
||||
throw new AwsException("ResourceConflictException", "Alarm update cleanup is still pending", 400);
|
||||
}
|
||||
String explicit = ctx.resolveOptional(props, "AlarmName");
|
||||
boolean removedName = explicit == null && "true".equals(resource.getAttributes().get(EXPLICIT_NAME));
|
||||
String name = removedName ? ctx.generatePhysicalName(resource.getLogicalId(), 255, false)
|
||||
: ctx.stablePhysicalName(explicit, resource.getLogicalId(), 255, false);
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
alarm.setAlarmName(name);
|
||||
alarm.setAlarmDescription(ctx.resolveOptional(props, "AlarmDescription"));
|
||||
alarm.setAlarmRule(ctx.resolveOptional(props, "AlarmRule"));
|
||||
alarm.setActionsEnabled(!"false".equals(ctx.resolveOptional(props, "ActionsEnabled")));
|
||||
alarm.setTags(ctx.resolveTags(props, "Tags"));
|
||||
actions(props, "AlarmActions", ctx, alarm.getAlarmActions());
|
||||
actions(props, "OKActions", ctx, alarm.getOkActions());
|
||||
actions(props, "InsufficientDataActions", ctx, alarm.getInsufficientDataActions());
|
||||
Map<String, String> desiredTags = Map.copyOf(alarm.getTags());
|
||||
Map<String, String> priorAttributes = Map.copyOf(resource.getAttributes());
|
||||
boolean inPlace = ctx.reusesPriorEntity(name);
|
||||
String updateSnapshot = null;
|
||||
if (inPlace) {
|
||||
ObjectNode snapshot = MAPPER.createObjectNode().put("region", ctx.region()).put("name", name);
|
||||
List<CompositeAlarm> previous = service.describeCompositeAlarms(List.of(name), null, ctx.region());
|
||||
if (!previous.isEmpty()) {
|
||||
snapshot.set("alarm", MAPPER.valueToTree(previous.getFirst()));
|
||||
}
|
||||
snapshot.set("attributes", MAPPER.valueToTree(priorAttributes));
|
||||
Set<String> changedKeys = new TreeSet<>(desiredTags.keySet());
|
||||
changedKeys.addAll(tagKeys(resource.getAttributes().get(TAG_KEYS)));
|
||||
snapshot.set("changedTagKeys", MAPPER.valueToTree(changedKeys));
|
||||
updateSnapshot = snapshot.toString();
|
||||
}
|
||||
service.putCompositeAlarm(alarm, ctx.region(), inPlace);
|
||||
if (inPlace) {
|
||||
resource.getAttributes().put(SNAPSHOT, updateSnapshot);
|
||||
reconcileTags(alarm.getAlarmArn(), tagKeys(priorAttributes.get(TAG_KEYS)), desiredTags, ctx.region());
|
||||
}
|
||||
resource.getAttributes().put(TAG_KEYS, MAPPER.valueToTree(desiredTags.keySet()).toString());
|
||||
resource.setPhysicalId(name);
|
||||
resource.getAttributes().put("Arn", alarm.getAlarmArn());
|
||||
resource.getAttributes().put(EXPLICIT_NAME, Boolean.toString(explicit != null));
|
||||
ReplacementCleanup.record(resource, ctx, priorAttributes);
|
||||
}
|
||||
|
||||
private static void actions(JsonNode props, String field, ProvisionContext ctx, List<String> target) {
|
||||
JsonNode values = ctx.engine().resolveNode(props.path(field));
|
||||
if (values != null && values.isArray()) {
|
||||
values.forEach(value -> target.add(value.asText()));
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> tagKeys(String json) {
|
||||
Set<String> keys = new TreeSet<>();
|
||||
if (json != null) {
|
||||
try {
|
||||
MAPPER.readTree(json).forEach(value -> keys.add(value.asText()));
|
||||
} catch (JsonProcessingException error) {
|
||||
throw new IllegalStateException("Cannot read composite alarm tag ownership", error);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void reconcileTags(String arn, Set<String> owned, Map<String, String> desired, String region) {
|
||||
List<String> removed = owned.stream().filter(key -> !desired.containsKey(key)).toList();
|
||||
if (!removed.isEmpty()) {
|
||||
service.untagResource(arn, removed, region);
|
||||
}
|
||||
if (!desired.isEmpty()) {
|
||||
service.tagResource(arn, desired, region);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String type, String physicalId, String region) {
|
||||
service.deleteAlarms(List.of(physicalId), region);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean retainsFailedUpdateState(StackResource resource) {
|
||||
return resource.getAttributes().containsKey(SNAPSHOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean rollbackUpdate(StackResource resource) {
|
||||
if (ReplacementCleanup.rollback(resource, this::delete)) {
|
||||
return true;
|
||||
}
|
||||
String raw = resource.getAttributes().get(SNAPSHOT);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JsonNode snapshot = MAPPER.readTree(raw);
|
||||
String region = snapshot.path("region").asText();
|
||||
if (snapshot.has("alarm")) {
|
||||
CompositeAlarm original = MAPPER.treeToValue(snapshot.get("alarm"), CompositeAlarm.class);
|
||||
Map<String, String> originalTags = Map.copyOf(original.getTags());
|
||||
service.putCompositeAlarm(original, region);
|
||||
Set<String> changed = tagKeys(snapshot.path("changedTagKeys").toString());
|
||||
reconcileTags(original.getAlarmArn(), changed, originalTags.entrySet().stream()
|
||||
.filter(entry -> changed.contains(entry.getKey()))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), region);
|
||||
} else {
|
||||
delete(TYPE, snapshot.path("name").asText(), region);
|
||||
}
|
||||
resource.getAttributes().clear();
|
||||
snapshot.path("attributes").fields().forEachRemaining(entry ->
|
||||
resource.getAttributes().put(entry.getKey(), entry.getValue().asText()));
|
||||
} catch (JsonProcessingException error) {
|
||||
throw new IllegalStateException("Cannot read composite alarm rollback state", error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasReplacementUpdate(StackResource resource) {
|
||||
return ReplacementCleanup.hasReplacement(resource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String updateCleanupPhysicalId(StackResource resource) {
|
||||
return ReplacementCleanup.cleanupPhysicalId(resource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UpdateCleanupResult completeUpdate(StackResource resource) {
|
||||
if (resource.getAttributes().containsKey(SNAPSHOT) && !ReplacementCleanup.hasReplacement(resource)) {
|
||||
return new UpdateCleanupResult(true, true, null, 0, null);
|
||||
}
|
||||
return ReplacementCleanup.complete(resource, this::delete);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearUpdate(StackResource resource) {
|
||||
resource.getAttributes().remove(SNAPSHOT);
|
||||
ReplacementCleanup.clear(resource);
|
||||
}
|
||||
}
|
||||
+18
@@ -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<String> owedPhysicalIds(StackResource r) {
|
||||
Set<String> 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
|
||||
|
||||
+3
-2
@@ -468,7 +468,7 @@ public class CloudFrontController {
|
||||
@QueryParam("Type") String type) {
|
||||
try {
|
||||
Page<OriginRequestPolicy> page = page(
|
||||
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems)),
|
||||
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems), type),
|
||||
maxItems, OriginRequestPolicy::getId);
|
||||
|
||||
XmlBuilder xml = new XmlBuilder()
|
||||
@@ -479,7 +479,8 @@ public class CloudFrontController {
|
||||
.start("Items");
|
||||
for (OriginRequestPolicy p : page.items()) {
|
||||
xml.start("OriginRequestPolicySummary")
|
||||
.elem("Type", "custom")
|
||||
.elem("Type", CloudFrontService.isManagedOriginRequestPolicy(p.getId())
|
||||
? "managed" : "custom")
|
||||
.raw(xmlOriginRequestPolicyResponse(p))
|
||||
.end("OriginRequestPolicySummary");
|
||||
}
|
||||
|
||||
+168
-64
@@ -3,6 +3,7 @@ package io.github.hectorvent.floci.services.cloudfront;
|
||||
import io.github.hectorvent.floci.core.common.SsrfProtection;
|
||||
import org.apache.hc.client5.http.DnsResolver;
|
||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
|
||||
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.config.TlsConfig;
|
||||
@@ -13,33 +14,35 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner;
|
||||
import org.apache.hc.client5.http.protocol.HttpClientContext;
|
||||
import org.apache.hc.client5.http.routing.RoutingSupport;
|
||||
import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
|
||||
import org.apache.hc.core5.http.ClassicHttpResponse;
|
||||
import org.apache.hc.core5.http.Header;
|
||||
import org.apache.hc.core5.http.HttpEntity;
|
||||
import org.apache.hc.core5.http.HttpException;
|
||||
import org.apache.hc.core5.http.HttpVersion;
|
||||
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
|
||||
import org.apache.hc.core5.http.io.entity.InputStreamEntity;
|
||||
import org.apache.hc.core5.http2.HttpVersionPolicy;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpHeaders;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -49,8 +52,11 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CloudFrontOriginHttpClient.class);
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(30);
|
||||
static final int MAX_CONNECTIONS = 100;
|
||||
|
||||
private final CloseableHttpClient client;
|
||||
|
||||
@@ -84,8 +90,10 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
.setDnsResolver(validatingResolver)
|
||||
.setDefaultConnectionConfig(connectionConfig)
|
||||
.setDefaultTlsConfig(tlsConfig)
|
||||
.setMaxConnTotal(100)
|
||||
.setMaxConnPerRoute(20);
|
||||
// CloudFront has no per-origin connection limit, and a streamed response holds its
|
||||
// connection for the whole transfer, so one origin may use the whole pool.
|
||||
.setMaxConnTotal(MAX_CONNECTIONS)
|
||||
.setMaxConnPerRoute(MAX_CONNECTIONS);
|
||||
if (sslContext != null) {
|
||||
connectionManagerBuilder.setTlsSocketStrategy(ClientTlsStrategyBuilder.create()
|
||||
.setSslContext(sslContext)
|
||||
@@ -111,28 +119,159 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
.build();
|
||||
}
|
||||
|
||||
HttpResponse<byte[]> send(HttpRequest request, HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
throws IOException, InterruptedException {
|
||||
return send(request, Map.of(), bodyHandler);
|
||||
/**
|
||||
* An origin response whose body has not been read. The caller reads {@code body} as it needs it
|
||||
* and closes it, which releases the connection. {@code contentLength} is the length the body
|
||||
* arrives with, {@code -1} when it comes chunked or with none.
|
||||
*/
|
||||
record OpenResponse(int statusCode, HttpHeaders headers, long contentLength, InputStream body) {
|
||||
}
|
||||
|
||||
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
||||
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
/**
|
||||
* Sends {@code request} with {@code forwardedHeaders} added and {@code requestBody}, when it is
|
||||
* not {@code null}, streamed as the entity, with {@code requestBodyLength} as its
|
||||
* {@code Content-Length}, or chunked when the length is negative. Each origin header replaces
|
||||
* every same-named request or forwarded header, so a viewer cannot repeat a header to smuggle its
|
||||
* own value past an origin custom header. The response comes back before its body is read, so
|
||||
* neither body has to fit in memory.
|
||||
*/
|
||||
OpenResponse open(HttpRequest request,
|
||||
List<CloudFrontOriginRequestBuilder.Header> forwardedHeaders,
|
||||
Map<String, String> originHeaders, InputStream requestBody, long requestBodyLength)
|
||||
throws IOException, InterruptedException {
|
||||
// No entity content type: the caller's Content-Type request header is sent as is.
|
||||
HttpUriRequestBase originRequest = originRequest(request, forwardedHeaders, originHeaders,
|
||||
requestBody != null ? new InputStreamEntity(requestBody, requestBodyLength, null) : null);
|
||||
ClassicHttpResponse response;
|
||||
try {
|
||||
response = client.executeOpen(RoutingSupport.determineHost(originRequest), originRequest, context(request));
|
||||
} catch (HttpException e) {
|
||||
throw new IOException("CloudFront origin request has no target host", e);
|
||||
} catch (InterruptedIOException e) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("CloudFront origin request interrupted");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
HttpEntity entity = response.getEntity();
|
||||
InputStream body = entity != null
|
||||
? new ResponseBody(entity.getContent(), originRequest, response)
|
||||
: new ResponseBody(InputStream.nullInputStream(), null, response);
|
||||
// The entity's length follows the framing the body arrives with; with no entity, as for a
|
||||
// HEAD, the header describes the body a GET would get.
|
||||
long contentLength = entity != null ? entity.getContentLength() : headerContentLength(response);
|
||||
return new OpenResponse(response.getCode(), responseHeaders(response), contentLength, body);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
originRequest.cancel();
|
||||
closeAborted(response);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An origin response body that releases its connection when it is closed. Closed at its end, the
|
||||
* connection goes back to the pool. Closed before its end, as when a viewer goes away or a custom
|
||||
* error page replaces it, the request is cancelled and the connection closed, since closing the
|
||||
* response normally reads the rest of the body to reuse the connection, however long it is.
|
||||
*/
|
||||
private static final class ResponseBody extends FilterInputStream {
|
||||
|
||||
private final HttpUriRequestBase request;
|
||||
private final ClassicHttpResponse response;
|
||||
private boolean finished;
|
||||
|
||||
ResponseBody(InputStream content, HttpUriRequestBase request, ClassicHttpResponse response) {
|
||||
super(content);
|
||||
this.request = request;
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
int b = super.read();
|
||||
finished |= b < 0;
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
int read = super.read(buffer, offset, length);
|
||||
finished |= read < 0;
|
||||
return read;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (finished || request == null) {
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
response.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
request.cancel();
|
||||
closeAborted(response);
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes a response whose request was cancelled; reading the rest of it fails, as it should. */
|
||||
private static void closeAborted(ClassicHttpResponse response) {
|
||||
try {
|
||||
response.close();
|
||||
} catch (IOException e) {
|
||||
LOG.debugv("Closed an unfinished CloudFront origin response: {0}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static long headerContentLength(ClassicHttpResponse response) {
|
||||
Header header = response.getFirstHeader("Content-Length");
|
||||
if (header == null) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(header.getValue().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.debugv("Ignoring an invalid custom-origin Content-Length: {0}", e.getMessage());
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private HttpUriRequestBase originRequest(HttpRequest request,
|
||||
List<CloudFrontOriginRequestBuilder.Header> forwardedHeaders,
|
||||
Map<String, String> originHeaders, HttpEntity entity)
|
||||
throws IOException, InterruptedException {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("CloudFront origin request interrupted");
|
||||
}
|
||||
if (request.bodyPublisher().filter(publisher -> publisher.contentLength() != 0).isPresent()) {
|
||||
throw new IOException("CloudFront origin request bodies are not supported");
|
||||
throw new IOException("CloudFront origin request bodies must be passed separately");
|
||||
}
|
||||
|
||||
ClassicRequestBuilder builder = ClassicRequestBuilder.create(request.method())
|
||||
.setUri(request.uri())
|
||||
.setVersion(HttpVersion.HTTP_1_1);
|
||||
request.headers().map().forEach((name, values) ->
|
||||
values.forEach(value -> builder.addHeader(name, value)));
|
||||
originHeaders.forEach(builder::setHeader);
|
||||
// A request that can be cancelled, so a response closed before its end is not read to the end.
|
||||
HttpUriRequestBase builder = new HttpUriRequestBase(request.method(), request.uri());
|
||||
builder.setVersion(HttpVersion.HTTP_1_1);
|
||||
Set<String> replaced = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||
replaced.addAll(originHeaders.keySet());
|
||||
request.headers().map().forEach((name, values) -> {
|
||||
if (!replaced.contains(name)) {
|
||||
values.forEach(value -> builder.addHeader(name, value));
|
||||
}
|
||||
});
|
||||
for (CloudFrontOriginRequestBuilder.Header header : forwardedHeaders) {
|
||||
if (!replaced.contains(header.name())) {
|
||||
builder.addHeader(header.name(), header.value());
|
||||
}
|
||||
}
|
||||
originHeaders.forEach(builder::addHeader);
|
||||
if (entity != null) {
|
||||
builder.setEntity(entity);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static HttpClientContext context(HttpRequest request) {
|
||||
HttpClientContext context = HttpClientContext.create();
|
||||
Duration responseTimeout = request.timeout().orElse(RESPONSE_TIMEOUT);
|
||||
context.setRequestConfig(RequestConfig.custom()
|
||||
@@ -142,28 +281,16 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
.setContentCompressionEnabled(false)
|
||||
.setProtocolUpgradeEnabled(false)
|
||||
.build());
|
||||
return context;
|
||||
}
|
||||
|
||||
try {
|
||||
return client.execute(builder.build(), context, response -> {
|
||||
Map<String, List<String>> responseHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (Header header : response.getHeaders()) {
|
||||
responseHeaders.computeIfAbsent(header.getName(), ignored -> new ArrayList<>())
|
||||
.add(header.getValue());
|
||||
}
|
||||
byte[] body = response.getEntity() != null
|
||||
? EntityUtils.toByteArray(response.getEntity())
|
||||
: new byte[0];
|
||||
return new PinnedHttpResponse(
|
||||
response.getCode(), request,
|
||||
HttpHeaders.of(responseHeaders, (name, value) -> true),
|
||||
body, request.uri());
|
||||
});
|
||||
} catch (InterruptedIOException e) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("CloudFront origin request interrupted");
|
||||
}
|
||||
throw e;
|
||||
private static HttpHeaders responseHeaders(ClassicHttpResponse response) {
|
||||
Map<String, List<String>> responseHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
|
||||
for (Header header : response.getHeaders()) {
|
||||
responseHeaders.computeIfAbsent(header.getName(), ignored -> new ArrayList<>())
|
||||
.add(header.getValue());
|
||||
}
|
||||
return HttpHeaders.of(responseHeaders, (name, value) -> true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -171,29 +298,6 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
client.close();
|
||||
}
|
||||
|
||||
private record PinnedHttpResponse(
|
||||
int statusCode,
|
||||
HttpRequest request,
|
||||
HttpHeaders headers,
|
||||
byte[] body,
|
||||
URI uri) implements HttpResponse<byte[]> {
|
||||
|
||||
@Override
|
||||
public Optional<HttpResponse<byte[]>> previousResponse() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SSLSession> sslSession() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpClient.Version version() {
|
||||
return HttpClient.Version.HTTP_1_1;
|
||||
}
|
||||
}
|
||||
|
||||
private record ValidatingDnsResolver(DnsResolver delegate, Set<String> allowedHosts)
|
||||
implements DnsResolver {
|
||||
|
||||
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Decides what CloudFront sends to a custom origin for one viewer request: the viewer headers,
|
||||
* cookies and query strings the matched cache behavior forwards, and the headers CloudFront adds on
|
||||
* its own. Implemented to the CloudFront Developer Guide:
|
||||
*
|
||||
* <ul>
|
||||
* <li>With a cache policy, only what the cache policy's
|
||||
* {@code ParametersInCacheKeyAndForwardedToOrigin} or the origin request policy selects is
|
||||
* forwarded; the two selections are unioned.</li>
|
||||
* <li>Without a cache policy (legacy cache settings), {@code ForwardedValues} applies, and viewer
|
||||
* headers are forwarded by default except the ones the custom-origin request table says
|
||||
* CloudFront removes.</li>
|
||||
* <li>Every origin request carries {@code User-Agent: Amazon CloudFront} unless the viewer's
|
||||
* {@code User-Agent} is forwarded, an {@code X-Forwarded-For} ending in the viewer address,
|
||||
* CloudFront's {@code Via} hop and an {@code X-Amz-Cf-Id}. {@code Host} is the origin's own
|
||||
* name unless the viewer's {@code Host} is forwarded.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This class holds no state and does no I/O. Origin custom headers are applied afterwards by the
|
||||
* transport, which lets them replace same-named viewer headers.
|
||||
*/
|
||||
final class CloudFrontOriginRequestBuilder {
|
||||
|
||||
static final String CLOUDFRONT_USER_AGENT = "Amazon CloudFront";
|
||||
|
||||
private static final Set<String> BODY_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
|
||||
/**
|
||||
* Never copied from a viewer request: hop-by-hop and framing fields the transport owns, and
|
||||
* fields CloudFront removes or sets itself whatever the policy says.
|
||||
*/
|
||||
private static final Set<String> NEVER_FORWARDED = Set.of(
|
||||
"connection", "keep-alive", "proxy-connection", "proxy-authenticate",
|
||||
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
|
||||
"content-length", "expect", "x-amz-cf-id", "x-real-ip", "x-forwarded-proto");
|
||||
/** Removed by legacy cache settings unless the behavior's {@code Headers} names them. */
|
||||
private static final Set<String> LEGACY_REMOVED = Set.of(
|
||||
"accept", "accept-charset", "accept-language", "authorization", "host", "referer",
|
||||
"user-agent", "x-http-method-override");
|
||||
private static final String FORWARDED_PROTO = "CloudFront-Forwarded-Proto";
|
||||
private static final String VIEWER_ADDRESS = "CloudFront-Viewer-Address";
|
||||
private static final String VIEWER_HTTP_VERSION = "CloudFront-Viewer-Http-Version";
|
||||
/** The CloudFront request headers Floci can derive from a local viewer connection. */
|
||||
private static final List<String> GENERATED_HEADERS =
|
||||
List.of(FORWARDED_PROTO, VIEWER_ADDRESS, VIEWER_HTTP_VERSION);
|
||||
|
||||
private CloudFrontOriginRequestBuilder() {
|
||||
}
|
||||
|
||||
/** One request header field, in the order it was received or will be sent. */
|
||||
record Header(String name, String value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer request as CloudFront received it. {@code rawQuery} has CloudFront's signing
|
||||
* parameters removed already; {@code httpVersion} is the viewer's protocol version, such as
|
||||
* {@code 1.1} or {@code 2.0}.
|
||||
*/
|
||||
record ViewerRequest(String method, List<Header> headers, String rawQuery,
|
||||
String clientAddress, int clientPort, String scheme, String httpVersion) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarding configuration of the cache behavior that matched the request. A behavior
|
||||
* without a cache policy uses legacy cache settings, where only {@code forwardedValues} applies.
|
||||
* The policy maps use the shapes {@link CloudFrontPolicyConfigCodec} stores.
|
||||
*/
|
||||
record Forwarding(boolean legacy, Map<?, ?> cachePolicyParameters,
|
||||
Map<?, ?> originRequestPolicy, Map<?, ?> forwardedValues,
|
||||
boolean optionsCached) {
|
||||
|
||||
static Forwarding legacy(Map<?, ?> forwardedValues, boolean optionsCached) {
|
||||
return new Forwarding(true, null, null, forwardedValues, optionsCached);
|
||||
}
|
||||
|
||||
static Forwarding policies(Map<?, ?> cachePolicyParameters, Map<?, ?> originRequestPolicy,
|
||||
boolean optionsCached) {
|
||||
return new Forwarding(false, cachePolicyParameters, originRequestPolicy, null,
|
||||
optionsCached);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What CloudFront sends to the origin before origin custom headers are applied. {@code rawQuery}
|
||||
* is {@code null} when no query string is forwarded. A {@code Host} header is present only when
|
||||
* the viewer's {@code Host} is forwarded.
|
||||
*/
|
||||
record OriginRequest(List<Header> headers, String rawQuery) {
|
||||
|
||||
static OriginRequest empty() {
|
||||
return new OriginRequest(List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
static OriginRequest build(ViewerRequest viewer, Forwarding forwarding, String viaHost) {
|
||||
Selections selections = forwarding.legacy()
|
||||
? legacySelections(forwarding.forwardedValues())
|
||||
: policySelections(forwarding.cachePolicyParameters(),
|
||||
forwarding.originRequestPolicy());
|
||||
Set<String> connectionTokens = connectionTokens(viewer.headers());
|
||||
boolean forwardsAuthorization = BODY_METHODS.contains(viewer.method())
|
||||
|| ("OPTIONS".equals(viewer.method()) && !forwarding.optionsCached());
|
||||
|
||||
List<Header> headers = new ArrayList<>();
|
||||
List<String> cookieHeaders = new ArrayList<>();
|
||||
List<String> forwardedFor = new ArrayList<>();
|
||||
List<String> via = new ArrayList<>();
|
||||
List<String> acceptEncoding = new ArrayList<>();
|
||||
boolean userAgentForwarded = false;
|
||||
for (Header header : viewer.headers()) {
|
||||
String name = normalize(header.name());
|
||||
if (name.isEmpty() || NEVER_FORWARDED.contains(name) || connectionTokens.contains(name)
|
||||
|| name.startsWith("x-edge-") || name.startsWith("cloudfront-")) {
|
||||
continue;
|
||||
}
|
||||
switch (name) {
|
||||
case "cookie" -> cookieHeaders.add(header.value());
|
||||
case "x-forwarded-for" -> forwardedFor.add(header.value());
|
||||
case "via" -> {
|
||||
if (selections.forwardsHeader(name)) {
|
||||
via.add(header.value());
|
||||
}
|
||||
}
|
||||
case "accept-encoding" -> {
|
||||
if (selections.normalizesAcceptEncoding()) {
|
||||
acceptEncoding.add(header.value());
|
||||
} else if (selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
case "authorization" -> {
|
||||
if (forwardsAuthorization || selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
case "content-type" -> {
|
||||
if (BODY_METHODS.contains(viewer.method()) || selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
if (selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
userAgentForwarded |= "user-agent".equals(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userAgentForwarded) {
|
||||
headers.add(new Header("User-Agent", CLOUDFRONT_USER_AGENT));
|
||||
}
|
||||
if (!acceptEncoding.isEmpty()) {
|
||||
String normalized = normalizedAcceptEncoding(acceptEncoding, selections);
|
||||
if (normalized != null) {
|
||||
headers.add(new Header("Accept-Encoding", normalized));
|
||||
}
|
||||
}
|
||||
String cookies = forwardedCookies(cookieHeaders, selections.cookies());
|
||||
if (cookies != null) {
|
||||
headers.add(new Header("Cookie", cookies));
|
||||
}
|
||||
for (String generated : GENERATED_HEADERS) {
|
||||
String value = selections.generatesHeader(normalize(generated))
|
||||
? generatedValue(generated, viewer) : null;
|
||||
if (value != null) {
|
||||
headers.add(new Header(generated, value));
|
||||
}
|
||||
}
|
||||
String clientForwardedFor = forwardedFor(forwardedFor, viewer.clientAddress());
|
||||
if (clientForwardedFor != null) {
|
||||
headers.add(new Header("X-Forwarded-For", clientForwardedFor));
|
||||
}
|
||||
via.add(viewerProtocolVersion(viewer.httpVersion()) + " " + viaHost + " (CloudFront)");
|
||||
headers.add(new Header("Via", String.join(", ", via)));
|
||||
headers.add(new Header("X-Amz-Cf-Id", requestId()));
|
||||
|
||||
return new OriginRequest(List.copyOf(headers),
|
||||
forwardedQuery(viewer.rawQuery(), selections.queryStrings()));
|
||||
}
|
||||
|
||||
private static Selections policySelections(Map<?, ?> cachePolicyParameters,
|
||||
Map<?, ?> originRequestPolicy) {
|
||||
List<Selection> headers = new ArrayList<>();
|
||||
List<Selection> cookies = new ArrayList<>();
|
||||
List<Selection> queryStrings = new ArrayList<>();
|
||||
for (Map<?, ?> source : new Map<?, ?>[] {cachePolicyParameters, originRequestPolicy}) {
|
||||
if (source == null) {
|
||||
continue;
|
||||
}
|
||||
headers.add(Selection.of(source.get("HeadersConfig"), "HeaderBehavior", "Headers", true));
|
||||
cookies.add(Selection.of(source.get("CookiesConfig"), "CookieBehavior", "Cookies", false));
|
||||
queryStrings.add(Selection.of(
|
||||
source.get("QueryStringsConfig"), "QueryStringBehavior", "QueryStrings", false));
|
||||
}
|
||||
boolean gzip = cachePolicyParameters != null
|
||||
&& isTrue(cachePolicyParameters.get("EnableAcceptEncodingGzip"));
|
||||
boolean brotli = cachePolicyParameters != null
|
||||
&& isTrue(cachePolicyParameters.get("EnableAcceptEncodingBrotli"));
|
||||
return new Selections(false, headers, false, cookies, queryStrings, gzip, brotli,
|
||||
gzip || brotli);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy {@code ForwardedValues}: headers are forwarded by default apart from the ones CloudFront
|
||||
* removes, {@code Headers} adds names back (or everything with {@code *}), cookie names may use
|
||||
* {@code *} and {@code ?} wildcards, and {@code QueryString=true} forwards every query string
|
||||
* ({@code QueryStringCacheKeys} only narrows the cache key).
|
||||
*/
|
||||
private static Selections legacySelections(Map<?, ?> forwardedValues) {
|
||||
List<String> headerNames = stringList(forwardedValues, "Headers");
|
||||
boolean allHeaders = headerNames.contains("*");
|
||||
Selection headers = allHeaders
|
||||
? new Selection("all", List.of(), true, false)
|
||||
: new Selection("whitelist", headerNames, true, false);
|
||||
|
||||
String cookiesForward = forwardedValues != null && forwardedValues.get("CookiesForward") != null
|
||||
? forwardedValues.get("CookiesForward").toString() : "none";
|
||||
Selection cookies = new Selection(cookiesForward, stringList(forwardedValues, "CookieNames"),
|
||||
false, true);
|
||||
|
||||
boolean queryString = forwardedValues != null && isTrue(forwardedValues.get("QueryString"));
|
||||
Selection queryStrings = new Selection(queryString ? "all" : "none", List.of(), false, false);
|
||||
|
||||
return new Selections(true, List.of(headers), !allHeaders, List.of(cookies),
|
||||
List.of(queryStrings), true, true, !headers.includes("accept-encoding"));
|
||||
}
|
||||
|
||||
private static Set<String> connectionTokens(List<Header> headers) {
|
||||
Set<String> tokens = new HashSet<>();
|
||||
for (Header header : headers) {
|
||||
if ("connection".equals(normalize(header.name())) && header.value() != null) {
|
||||
for (String token : header.value().split(",")) {
|
||||
String normalized = normalize(token);
|
||||
if (!normalized.isEmpty()) {
|
||||
tokens.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept-Encoding normalization: the formats caching is enabled for that the viewer accepts, as
|
||||
* {@code br,gzip}, {@code gzip} or {@code br}. A cache policy sends {@code identity} when the
|
||||
* viewer accepts none of them; legacy settings drop the header instead.
|
||||
*/
|
||||
private static String normalizedAcceptEncoding(List<String> values, Selections selections) {
|
||||
Set<String> accepted = new HashSet<>();
|
||||
for (String value : values) {
|
||||
for (String coding : value.split(",")) {
|
||||
int parameters = coding.indexOf(';');
|
||||
String name = normalize(parameters >= 0 ? coding.substring(0, parameters) : coding);
|
||||
if (!name.isEmpty()) {
|
||||
accepted.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean brotli = selections.brotli() && accepted.contains("br");
|
||||
boolean gzip = selections.gzip() && accepted.contains("gzip");
|
||||
if (brotli && gzip) {
|
||||
return "br,gzip";
|
||||
}
|
||||
if (brotli) {
|
||||
return "br";
|
||||
}
|
||||
if (gzip) {
|
||||
return "gzip";
|
||||
}
|
||||
return selections.legacy() ? null : "identity";
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer cookies the behavior forwards, sorted by name as CloudFront sorts them. Pairs that are
|
||||
* not {@code name=value}, and names starting with {@code $}, are dropped.
|
||||
*/
|
||||
private static String forwardedCookies(List<String> cookieHeaders, List<Selection> selections) {
|
||||
List<String[]> cookies = new ArrayList<>();
|
||||
for (String cookieHeader : cookieHeaders) {
|
||||
if (cookieHeader == null) {
|
||||
continue;
|
||||
}
|
||||
for (String pair : cookieHeader.split(";")) {
|
||||
int eq = pair.indexOf('=');
|
||||
String name = eq > 0 ? pair.substring(0, eq).trim() : "";
|
||||
if (name.isEmpty() || name.startsWith("$")) {
|
||||
continue;
|
||||
}
|
||||
if (selections.stream().anyMatch(selection -> selection.includes(name))) {
|
||||
cookies.add(new String[] {name, pair.substring(eq + 1).trim()});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cookies.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
cookies.sort(Comparator.comparing(cookie -> cookie[0]));
|
||||
List<String> pairs = new ArrayList<>(cookies.size());
|
||||
for (String[] cookie : cookies) {
|
||||
pairs.add(cookie[0] + "=" + cookie[1]);
|
||||
}
|
||||
return String.join("; ", pairs);
|
||||
}
|
||||
|
||||
/** The raw query pairs the behavior forwards, in viewer order and with their original encoding. */
|
||||
private static String forwardedQuery(String rawQuery, List<Selection> selections) {
|
||||
if (rawQuery == null || rawQuery.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (selections.stream().anyMatch(selection -> "all".equals(selection.behavior()))) {
|
||||
return rawQuery;
|
||||
}
|
||||
List<String> kept = new ArrayList<>();
|
||||
for (String pair : rawQuery.split("&")) {
|
||||
if (pair.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int eq = pair.indexOf('=');
|
||||
String name = decodeQueryName(eq >= 0 ? pair.substring(0, eq) : pair);
|
||||
if (selections.stream().anyMatch(selection -> selection.includes(name))) {
|
||||
kept.add(pair);
|
||||
}
|
||||
}
|
||||
return kept.isEmpty() ? null : String.join("&", kept);
|
||||
}
|
||||
|
||||
private static String decodeQueryName(String rawName) {
|
||||
try {
|
||||
return URLDecoder.decode(rawName, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
// A name that is not valid percent-encoding can only match a policy entry literally.
|
||||
return rawName;
|
||||
}
|
||||
}
|
||||
|
||||
private static String generatedValue(String header, ViewerRequest viewer) {
|
||||
return switch (header) {
|
||||
case FORWARDED_PROTO -> viewer.scheme() != null
|
||||
? viewer.scheme().toLowerCase(Locale.ROOT) : null;
|
||||
case VIEWER_ADDRESS -> viewer.clientAddress() != null && viewer.clientPort() > 0
|
||||
? viewer.clientAddress() + ":" + viewer.clientPort() : null;
|
||||
case VIEWER_HTTP_VERSION -> viewer.httpVersion();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** CloudFront appends the viewer's address to any {@code X-Forwarded-For} the viewer sent. */
|
||||
private static String forwardedFor(List<String> viewerValues, String clientAddress) {
|
||||
List<String> values = new ArrayList<>(viewerValues);
|
||||
if (clientAddress != null && !clientAddress.isBlank()) {
|
||||
values.add(clientAddress);
|
||||
}
|
||||
return values.isEmpty() ? null : String.join(",", values);
|
||||
}
|
||||
|
||||
private static String viewerProtocolVersion(String httpVersion) {
|
||||
return httpVersion == null || httpVersion.isBlank() ? "1.1" : httpVersion;
|
||||
}
|
||||
|
||||
/** A request identifier shaped like CloudFront's: 40 random bytes, URL-safe Base64. */
|
||||
private static String requestId() {
|
||||
byte[] bytes = new byte[40];
|
||||
ThreadLocalRandom.current().nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static boolean isTrue(Object value) {
|
||||
return value instanceof Boolean bool ? bool : value != null && "true".equalsIgnoreCase(value.toString());
|
||||
}
|
||||
|
||||
private static List<String> stringList(Map<?, ?> map, String key) {
|
||||
Object value = map != null ? map.get(key) : null;
|
||||
if (!(value instanceof List<?> list)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> strings = new ArrayList<>(list.size());
|
||||
for (Object item : list) {
|
||||
if (item != null) {
|
||||
strings.add(item.toString());
|
||||
}
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static String normalize(String name) {
|
||||
return name == null ? "" : name.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** The resolved selections of one request, unioned across the policies that apply. */
|
||||
private record Selections(boolean legacy, List<Selection> headers, boolean legacyDefaults,
|
||||
List<Selection> cookies, List<Selection> queryStrings,
|
||||
boolean gzip, boolean brotli, boolean normalizesAcceptEncoding) {
|
||||
|
||||
/**
|
||||
* Whether a viewer header is forwarded: a policy selects it, or legacy settings forward it
|
||||
* by default.
|
||||
*/
|
||||
boolean forwardsHeader(String normalizedName) {
|
||||
if (headers.stream().anyMatch(selection -> selection.includes(normalizedName))) {
|
||||
return true;
|
||||
}
|
||||
return legacyDefaults && !LEGACY_REMOVED.contains(normalizedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether CloudFront adds one of its own request headers: a whitelist or
|
||||
* {@code allViewerAndWhitelistCloudFront} list names it, or {@code allExcept} does not
|
||||
* exclude it (the managed AllViewerExceptHostHeader policy is documented to add them).
|
||||
*/
|
||||
boolean generatesHeader(String normalizedName) {
|
||||
for (Selection selection : headers) {
|
||||
boolean generated = switch (selection.behavior()) {
|
||||
case "whitelist", "allViewerAndWhitelistCloudFront" -> selection.lists(normalizedName);
|
||||
case "allExcept" -> !selection.lists(normalizedName);
|
||||
default -> false;
|
||||
};
|
||||
if (generated) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One policy's choice of names of one kind: headers, cookies or query strings. Header names match
|
||||
* case-insensitively, cookie and query string names case-sensitively. Legacy cookie allowlists may
|
||||
* use {@code *} and {@code ?} wildcards.
|
||||
*/
|
||||
private record Selection(String behavior, List<String> names, boolean ignoreCase,
|
||||
boolean wildcards) {
|
||||
|
||||
static Selection of(Object block, String behaviorKey, String listKey, boolean ignoreCase) {
|
||||
if (!(block instanceof Map<?, ?> map) || map.get(behaviorKey) == null) {
|
||||
return new Selection("none", List.of(), ignoreCase, false);
|
||||
}
|
||||
return new Selection(map.get(behaviorKey).toString(), stringList(map, listKey),
|
||||
ignoreCase, false);
|
||||
}
|
||||
|
||||
boolean includes(String name) {
|
||||
return switch (behavior) {
|
||||
case "whitelist" -> lists(name);
|
||||
case "all", "allViewer", "allViewerAndWhitelistCloudFront" -> true;
|
||||
case "allExcept" -> !lists(name);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
boolean lists(String name) {
|
||||
for (String candidate : names) {
|
||||
boolean matches = wildcards
|
||||
? wildcardMatches(candidate, name)
|
||||
: ignoreCase ? candidate.equalsIgnoreCase(name) : candidate.equals(name);
|
||||
if (matches) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean wildcardMatches(String pattern, String name) {
|
||||
return wildcardMatches(pattern, 0, name, 0);
|
||||
}
|
||||
|
||||
private static boolean wildcardMatches(String pattern, int p, String name, int n) {
|
||||
if (p == pattern.length()) {
|
||||
return n == name.length();
|
||||
}
|
||||
char c = pattern.charAt(p);
|
||||
if (c == '*') {
|
||||
for (int i = n; i <= name.length(); i++) {
|
||||
if (wildcardMatches(pattern, p + 1, name, i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return n < name.length() && (c == '?' || c == name.charAt(n))
|
||||
&& wildcardMatches(pattern, p + 1, name, n + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -166,6 +166,35 @@ public final class CloudFrontRequestRouter {
|
||||
return allowedMethodsOrDefault(dflt != null ? dflt.getAllowedMethods() : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings that decide what the cache behavior serving a normalized path forwards to its
|
||||
* origin: cache policy, origin request policy, legacy {@code ForwardedValues} and cached methods.
|
||||
*/
|
||||
public record BehaviorForwarding(String cachePolicyId, String originRequestPolicyId,
|
||||
Map<String, Object> forwardedValues,
|
||||
List<String> cachedMethods) {
|
||||
}
|
||||
|
||||
/** Returns the forwarding settings of the cache behavior that serves a normalized path. */
|
||||
public static BehaviorForwarding matchForwarding(DistributionConfig config, String normalizedPath) {
|
||||
List<CacheBehavior> behaviors = config.getCacheBehaviors();
|
||||
if (behaviors != null) {
|
||||
for (CacheBehavior behavior : behaviors) {
|
||||
if (pathPatternMatches(behavior.getPathPattern(), normalizedPath)) {
|
||||
return new BehaviorForwarding(behavior.getCachePolicyId(),
|
||||
behavior.getOriginRequestPolicyId(), behavior.getForwardedValues(),
|
||||
behavior.getCachedMethods());
|
||||
}
|
||||
}
|
||||
}
|
||||
DefaultCacheBehavior dflt = config.getDefaultCacheBehavior();
|
||||
if (dflt == null) {
|
||||
return new BehaviorForwarding(null, null, null, null);
|
||||
}
|
||||
return new BehaviorForwarding(dflt.getCachePolicyId(), dflt.getOriginRequestPolicyId(),
|
||||
dflt.getForwardedValues(), dflt.getCachedMethods());
|
||||
}
|
||||
|
||||
private static List<String> allowedMethodsOrDefault(List<String> methods) {
|
||||
return methods == null || methods.isEmpty() ? List.of("GET", "HEAD") : methods;
|
||||
}
|
||||
|
||||
+168
-3
@@ -59,6 +59,7 @@ public class CloudFrontService {
|
||||
Set.of("s3", "mediastore", "mediapackagev2", "lambda");
|
||||
private static final Set<String> OAC_SIGNING_BEHAVIORS =
|
||||
Set.of("always", "never", "no-override");
|
||||
private static final Set<String> FUNCTION_RUNTIMES = Set.of("cloudfront-js-1.0", "cloudfront-js-2.0");
|
||||
private static final int MAX_ORIGIN_CUSTOM_HEADERS = 30;
|
||||
private static final int MAX_CUSTOM_HEADER_NAME_LENGTH = 256;
|
||||
private static final int MAX_CUSTOM_HEADER_VALUE_LENGTH = 1_783;
|
||||
@@ -83,6 +84,24 @@ public class CloudFrontService {
|
||||
"60669652-455b-4ae9-85a4-c4c02393f86c";
|
||||
private static final Map<String, ResponseHeadersPolicy> MANAGED_RESPONSE_HEADERS_POLICIES =
|
||||
managedResponseHeadersPolicies();
|
||||
static final String MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID =
|
||||
"216adef6-5c7f-47e4-b989-5492eafa07d3";
|
||||
static final String MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID =
|
||||
"33f36d7e-f396-46d9-90e0-52428a34d9dc";
|
||||
static final String MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID =
|
||||
"b689b0a8-53d0-40ab-baf2-68738e2966ac";
|
||||
static final String MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID =
|
||||
"59781a5b-3903-41f3-afcb-af62929ccde1";
|
||||
static final String MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID =
|
||||
"88a5eaf4-2fd4-4709-b370-b4c650ea3fcf";
|
||||
static final String MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID =
|
||||
"775133bc-15f2-49f9-abea-afb2e0bf67d2";
|
||||
static final String MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID =
|
||||
"bf0718e1-ba1e-49d1-88b1-f726733018ae";
|
||||
static final String MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID =
|
||||
"acba4595-bd28-49b8-b9fe-13317c0390fa";
|
||||
private static final Map<String, OriginRequestPolicy> MANAGED_ORIGIN_REQUEST_POLICIES =
|
||||
managedOriginRequestPolicies();
|
||||
|
||||
private final StorageBackend<String, Distribution> distStore;
|
||||
private final StorageBackend<String, List<Invalidation>> invalidationStore;
|
||||
@@ -543,14 +562,19 @@ public class CloudFrontService {
|
||||
}
|
||||
|
||||
public OriginRequestPolicy getOriginRequestPolicy(String id) {
|
||||
return orpStore.get(id).orElseThrow(() ->
|
||||
new AwsException("NoSuchOriginRequestPolicy",
|
||||
return orpStore.get(id)
|
||||
.or(() -> Optional.ofNullable(MANAGED_ORIGIN_REQUEST_POLICIES.get(id)))
|
||||
.orElseThrow(() -> new AwsException("NoSuchOriginRequestPolicy",
|
||||
"The specified origin request policy does not exist.", 404));
|
||||
}
|
||||
|
||||
public synchronized OriginRequestPolicy updateOriginRequestPolicy(String id, String ifMatch,
|
||||
OriginRequestPolicy updated) {
|
||||
OriginRequestPolicy existing = getOriginRequestPolicy(id);
|
||||
if (isManagedOriginRequestPolicy(id)) {
|
||||
throw new AwsException("IllegalUpdate",
|
||||
"AWS managed origin request policies cannot be updated.", 400);
|
||||
}
|
||||
if (!existing.getEtag().equals(ifMatch)) {
|
||||
throw new AwsException("InvalidIfMatchVersion",
|
||||
"The If-Match version is missing or not valid for the resource.", 400);
|
||||
@@ -564,6 +588,10 @@ public class CloudFrontService {
|
||||
|
||||
public synchronized void deleteOriginRequestPolicy(String id, String ifMatch) {
|
||||
OriginRequestPolicy existing = getOriginRequestPolicy(id);
|
||||
if (isManagedOriginRequestPolicy(id)) {
|
||||
throw new AwsException("IllegalDelete",
|
||||
"AWS managed origin request policies cannot be deleted.", 400);
|
||||
}
|
||||
if (!existing.getEtag().equals(ifMatch)) {
|
||||
throw new AwsException("InvalidIfMatchVersion",
|
||||
"The If-Match version is missing or not valid for the resource.", 400);
|
||||
@@ -577,12 +605,34 @@ public class CloudFrontService {
|
||||
}
|
||||
|
||||
public List<OriginRequestPolicy> listOriginRequestPolicies(String marker, int maxItems) {
|
||||
List<OriginRequestPolicy> all = new ArrayList<>(orpStore.scan(k -> true));
|
||||
return listOriginRequestPolicies(marker, maxItems, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists custom and AWS managed origin request policies; {@code type} narrows the list to the
|
||||
* AWS lowercase {@code managed} or {@code custom} value.
|
||||
*/
|
||||
public List<OriginRequestPolicy> listOriginRequestPolicies(String marker, int maxItems, String type) {
|
||||
if (type != null && !"custom".equals(type) && !"managed".equals(type)) {
|
||||
throw new AwsException("InvalidArgument",
|
||||
"Origin request policy Type must be managed or custom.", 400);
|
||||
}
|
||||
List<OriginRequestPolicy> all = new ArrayList<>();
|
||||
if (!"managed".equals(type)) {
|
||||
all.addAll(orpStore.scan(k -> true));
|
||||
}
|
||||
if (!"custom".equals(type)) {
|
||||
all.addAll(MANAGED_ORIGIN_REQUEST_POLICIES.values());
|
||||
}
|
||||
all.sort((a, b) -> a.getName() != null && b.getName() != null
|
||||
? a.getName().compareTo(b.getName()) : a.getId().compareTo(b.getId()));
|
||||
return paginate(all, marker, maxItems, OriginRequestPolicy::getId);
|
||||
}
|
||||
|
||||
static boolean isManagedOriginRequestPolicy(String id) {
|
||||
return MANAGED_ORIGIN_REQUEST_POLICIES.containsKey(id);
|
||||
}
|
||||
|
||||
// ── Response Headers Policies ─────────────────────────────────────────────
|
||||
|
||||
public synchronized ResponseHeadersPolicy createResponseHeadersPolicy(ResponseHeadersPolicy policy) {
|
||||
@@ -840,6 +890,112 @@ public class CloudFrontService {
|
||||
return Map.copyOf(policies);
|
||||
}
|
||||
|
||||
/**
|
||||
* The AWS managed origin request policies, with the IDs and settings the CloudFront Developer
|
||||
* Guide lists under "Use managed origin request policies".
|
||||
*/
|
||||
private static Map<String, OriginRequestPolicy> managedOriginRequestPolicies() {
|
||||
Map<String, OriginRequestPolicy> policies = new LinkedHashMap<>();
|
||||
policies.put(MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID, "Managed-AllViewer",
|
||||
"Includes all values (headers, cookies, and query strings) from the viewer request",
|
||||
selection("HeaderBehavior", "allViewer", "Headers", List.of()),
|
||||
selection("CookieBehavior", "all", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID,
|
||||
managedOriginRequestPolicy(
|
||||
MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID,
|
||||
"Managed-AllViewerAndCloudFrontHeaders-2022-06",
|
||||
"Includes all values from the viewer request and all CloudFront headers "
|
||||
+ "released through June 2022",
|
||||
selection("HeaderBehavior", "allViewerAndWhitelistCloudFront", "Headers",
|
||||
List.of("CloudFront-Forwarded-Proto", "CloudFront-Is-Android-Viewer",
|
||||
"CloudFront-Is-Desktop-Viewer", "CloudFront-Is-IOS-Viewer",
|
||||
"CloudFront-Is-Mobile-Viewer", "CloudFront-Is-SmartTV-Viewer",
|
||||
"CloudFront-Is-Tablet-Viewer", "CloudFront-Viewer-Address",
|
||||
"CloudFront-Viewer-ASN", "CloudFront-Viewer-City",
|
||||
"CloudFront-Viewer-Country", "CloudFront-Viewer-Country-Name",
|
||||
"CloudFront-Viewer-Country-Region",
|
||||
"CloudFront-Viewer-Country-Region-Name",
|
||||
"CloudFront-Viewer-Http-Version", "CloudFront-Viewer-Latitude",
|
||||
"CloudFront-Viewer-Longitude", "CloudFront-Viewer-Metro-Code",
|
||||
"CloudFront-Viewer-Postal-Code", "CloudFront-Viewer-Time-Zone",
|
||||
"CloudFront-Viewer-TLS")),
|
||||
selection("CookieBehavior", "all", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID,
|
||||
managedOriginRequestPolicy(
|
||||
MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID,
|
||||
"Managed-AllViewerExceptHostHeader",
|
||||
"Includes all values from the viewer request except the Host header",
|
||||
selection("HeaderBehavior", "allExcept", "Headers", List.of("Host")),
|
||||
selection("CookieBehavior", "all", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID, "Managed-CORS-CustomOrigin",
|
||||
"Includes the header that enables CORS requests when the origin is a custom origin",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of("Origin")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID, "Managed-CORS-S3Origin",
|
||||
"Includes the headers that enable CORS requests when the origin is an Amazon S3 bucket",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of(
|
||||
"Origin", "Access-Control-Request-Headers", "Access-Control-Request-Method")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID,
|
||||
"Managed-Elemental-MediaTailor-PersonalizedManifests",
|
||||
"For use with an origin that is an AWS Elemental MediaTailor endpoint",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of(
|
||||
"Origin", "Access-Control-Request-Headers", "Access-Control-Request-Method",
|
||||
"User-Agent", "X-Forwarded-For")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID, "Managed-HostHeaderOnly",
|
||||
"Includes only the Host header from the viewer request",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of("Host")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID, "Managed-UserAgentRefererHeaders",
|
||||
"Includes only the User-Agent and Referer headers",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of("User-Agent", "Referer")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
return Map.copyOf(policies);
|
||||
}
|
||||
|
||||
private static OriginRequestPolicy managedOriginRequestPolicy(
|
||||
String id, String name, String comment, Map<String, Object> headers,
|
||||
Map<String, Object> cookies, Map<String, Object> queryStrings) {
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("HeadersConfig", headers);
|
||||
config.put("CookiesConfig", cookies);
|
||||
config.put("QueryStringsConfig", queryStrings);
|
||||
OriginRequestPolicy policy = new OriginRequestPolicy();
|
||||
policy.setId(id);
|
||||
policy.setName(name);
|
||||
policy.setComment(comment);
|
||||
policy.setEtag("E23ZP02F085DFQ");
|
||||
policy.setLastModifiedTime(Instant.EPOCH);
|
||||
policy.setConfig(Map.copyOf(config));
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** One policy selection block, in the shape {@link CloudFrontPolicyConfigCodec} stores. */
|
||||
private static Map<String, Object> selection(String behaviorKey, String behavior,
|
||||
String listKey, List<String> names) {
|
||||
Map<String, Object> block = new LinkedHashMap<>();
|
||||
block.put(behaviorKey, behavior);
|
||||
if (!names.isEmpty()) {
|
||||
block.put(listKey, List.copyOf(names));
|
||||
}
|
||||
return Map.copyOf(block);
|
||||
}
|
||||
|
||||
private static ResponseHeadersPolicy managedResponseHeadersPolicy(
|
||||
String id, String name, String comment, Map<String, Object> config) {
|
||||
ResponseHeadersPolicy policy = new ResponseHeadersPolicy();
|
||||
@@ -1072,6 +1228,7 @@ public class CloudFrontService {
|
||||
// ── CloudFront Functions ──────────────────────────────────────────────────
|
||||
|
||||
public synchronized CloudFrontFunction createFunction(CloudFrontFunction fn) {
|
||||
validateFunctionRuntime(fn.getRuntime());
|
||||
fn.setStage("DEVELOPMENT");
|
||||
fn.setStatus("UNPUBLISHED");
|
||||
fn.setEtag(UUID.randomUUID().toString());
|
||||
@@ -1096,6 +1253,7 @@ public class CloudFrontService {
|
||||
|
||||
public synchronized CloudFrontFunction updateFunction(String name, String ifMatch,
|
||||
CloudFrontFunction updated) {
|
||||
validateFunctionRuntime(updated.getRuntime());
|
||||
CloudFrontFunction existing = describeFunction(name, null);
|
||||
if (!existing.getEtag().equals(ifMatch)) {
|
||||
throw new AwsException("InvalidIfMatchVersion",
|
||||
@@ -1168,6 +1326,13 @@ public class CloudFrontService {
|
||||
return effectiveStage;
|
||||
}
|
||||
|
||||
private static void validateFunctionRuntime(String runtime) {
|
||||
if (runtime != null && !FUNCTION_RUNTIMES.contains(runtime)) {
|
||||
throw new AwsException("InvalidArgument",
|
||||
"The parameter Runtime must be cloudfront-js-1.0 or cloudfront-js-2.0.", 400);
|
||||
}
|
||||
}
|
||||
|
||||
private static AwsException invalidFunctionStage(String stage) {
|
||||
return new AwsException("InvalidArgument",
|
||||
"The parameter Stage must be DEVELOPMENT or LIVE: " + stage, 400);
|
||||
|
||||
+332
-82
@@ -10,24 +10,36 @@ import io.github.hectorvent.floci.services.cloudfront.model.OriginAccessControl;
|
||||
import io.github.hectorvent.floci.services.s3.S3Service;
|
||||
import io.github.hectorvent.floci.services.s3.model.S3Object;
|
||||
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import io.vertx.core.http.HttpVersion;
|
||||
import io.vertx.core.net.SocketAddress;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HEAD;
|
||||
import jakarta.ws.rs.OPTIONS;
|
||||
import jakarta.ws.rs.PATCH;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jboss.resteasy.reactive.server.core.CurrentRequestManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.PushbackInputStream;
|
||||
import java.net.IDN;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -53,14 +65,19 @@ import java.util.Set;
|
||||
* <ul>
|
||||
* <li>S3 origins are read in-process through {@link S3Service} (the bucket is derived from the
|
||||
* origin domain name).</li>
|
||||
* <li>Custom origins are fetched over HTTP(S) with {@link java.net.http.HttpClient}.</li>
|
||||
* <li>Custom origins are fetched over HTTP(S) with {@link CloudFrontOriginHttpClient}, carrying the
|
||||
* viewer headers, cookies and query strings the matched behavior forwards, as decided by
|
||||
* {@link CloudFrontOriginRequestBuilder}.</li>
|
||||
* <li>When an origin returns an error status, a matching {@code CustomErrorResponse} is applied —
|
||||
* most importantly the single-page-app fallback that rewrites 403/404 to 200 {@code /index.html}.
|
||||
* If the configured error page is itself missing, the received status is returned (no loop).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Viewer-protocol-policy enforcement is intentionally out of scope for this layer: the emulator
|
||||
* is HTTP-first. GET/HEAD are served, and OPTIONS is served only when the matched behavior allows it.
|
||||
* is HTTP-first. Every CloudFront viewer method (GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE) is
|
||||
* served when the matched behavior's {@code AllowedMethods} includes it. POST, PUT, PATCH and DELETE
|
||||
* are forwarded to custom origins with the viewer request body and {@code Authorization} header; they
|
||||
* are not forwarded to in-process S3 origins.
|
||||
*/
|
||||
@Path("/_cloudfront/{distId}")
|
||||
public class CloudFrontServingController {
|
||||
@@ -80,6 +97,8 @@ public class CloudFrontServingController {
|
||||
|
||||
private static final java.util.Set<String> CLOUDFRONT_SIGNING_PARAMS =
|
||||
java.util.Set.of("Expires", "Signature", "Key-Pair-Id", "Policy", "Hash-Algorithm");
|
||||
/** Viewer methods that can carry a request body and that CloudFront never caches. */
|
||||
private static final Set<String> BODY_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
|
||||
|
||||
private final CloudFrontService service;
|
||||
private final S3Service s3Service;
|
||||
@@ -117,7 +136,7 @@ public class CloudFrontServingController {
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), "GET", null, null,
|
||||
request.getHeader("Pragma"));
|
||||
request.getHeader("Pragma"), null);
|
||||
}
|
||||
|
||||
@HEAD
|
||||
@@ -131,7 +150,7 @@ public class CloudFrontServingController {
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), "HEAD", null, null,
|
||||
request.getHeader("Pragma"));
|
||||
request.getHeader("Pragma"), null);
|
||||
}
|
||||
|
||||
@OPTIONS
|
||||
@@ -147,7 +166,86 @@ public class CloudFrontServingController {
|
||||
request.getHeader("Origin"), "OPTIONS",
|
||||
request.getHeader("Access-Control-Request-Method"),
|
||||
request.getHeader("Access-Control-Request-Headers"),
|
||||
request.getHeader("Pragma"));
|
||||
request.getHeader("Pragma"), null);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response post(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
|
||||
return serveWithBody(distId, "POST", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response put(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
|
||||
return serveWithBody(distId, "PUT", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response patch(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
|
||||
return serveWithBody(distId, "PATCH", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response delete(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, InputStream body) {
|
||||
return serveWithBody(distId, "DELETE", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
private Response serveWithBody(String distId, String method, HttpHeaders headers,
|
||||
UriInfo uriInfo, InputStream body) {
|
||||
HttpServerRequest request = currentVertxRequest.getCurrent().request();
|
||||
String rawViewerPath = rawViewerPath(request.uri());
|
||||
return serve(distId, rawViewerPath, decodedViewerPath(rawViewerPath),
|
||||
uriInfo.getRequestUri().getScheme(),
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), method, null, null,
|
||||
request.getHeader("Pragma"),
|
||||
viewerBody(body, headers.getHeaderString("Transfer-Encoding"),
|
||||
headers.getHeaderString(HttpHeaders.CONTENT_LENGTH)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer's request body and its length. It is {@code -1} when the body comes chunked, which
|
||||
* takes precedence over any {@code Content-Length} as HTTP has it, and otherwise the
|
||||
* {@code Content-Length}. With neither header an HTTP/1.1 request has no body, but an HTTP/2 one
|
||||
* can still send one, so the body is looked at: one that is already at its end is empty, and one
|
||||
* that is not is forwarded chunked.
|
||||
*/
|
||||
static ViewerBody viewerBody(InputStream body, String transferEncoding, String contentLength) {
|
||||
InputStream stream = body != null ? body : InputStream.nullInputStream();
|
||||
if (transferEncoding != null) {
|
||||
return new ViewerBody(stream, -1);
|
||||
}
|
||||
if (contentLength != null && !contentLength.isBlank()) {
|
||||
try {
|
||||
return new ViewerBody(stream, Long.parseLong(contentLength.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.debugv("Treating an invalid viewer Content-Length as chunked: {0}", e.getMessage());
|
||||
return new ViewerBody(stream, -1);
|
||||
}
|
||||
}
|
||||
PushbackInputStream peekable = new PushbackInputStream(stream, 1);
|
||||
try {
|
||||
int first = peekable.read();
|
||||
if (first < 0) {
|
||||
return new ViewerBody(InputStream.nullInputStream(), 0);
|
||||
}
|
||||
peekable.unread(first);
|
||||
} catch (IOException e) {
|
||||
LOG.debugv("Could not read the start of a viewer request body: {0}", e.getMessage());
|
||||
}
|
||||
return new ViewerBody(peekable, -1);
|
||||
}
|
||||
|
||||
private Response serve(String distId, String rawViewerPath, String decodedViewerPath,
|
||||
@@ -156,7 +254,8 @@ public class CloudFrontServingController {
|
||||
String viewerOrigin, String method,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders,
|
||||
String pragma) {
|
||||
String pragma,
|
||||
ViewerBody viewerBody) {
|
||||
boolean includeBody = !"HEAD".equals(method);
|
||||
boolean preflightRequest = "OPTIONS".equals(method)
|
||||
&& viewerOrigin != null && !viewerOrigin.isBlank()
|
||||
@@ -199,11 +298,17 @@ public class CloudFrontServingController {
|
||||
|
||||
OriginResponse origin = route(dist, normalized, rawViewerPath, decodedViewerPath,
|
||||
viewerScheme, viewerAuthorization, method, viewerOrigin,
|
||||
accessControlRequestMethod, accessControlRequestHeaders);
|
||||
accessControlRequestMethod, accessControlRequestHeaders, viewerBody);
|
||||
|
||||
if (origin.status() >= 400) {
|
||||
Response fallback = applyCustomError(
|
||||
dist, origin, viewerScheme, viewerAuthorization, includeBody, directives);
|
||||
Response fallback;
|
||||
try {
|
||||
fallback = applyCustomError(
|
||||
dist, origin, viewerScheme, viewerAuthorization, includeBody, directives);
|
||||
} catch (RuntimeException e) {
|
||||
origin.closeStream();
|
||||
throw e;
|
||||
}
|
||||
if (fallback != null) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -345,7 +450,8 @@ public class CloudFrontServingController {
|
||||
String viewerScheme, String viewerAuthorization,
|
||||
String method, String viewerOrigin,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders) {
|
||||
String accessControlRequestHeaders,
|
||||
ViewerBody viewerBody) {
|
||||
DistributionConfig config = distribution.getConfig();
|
||||
String originId = CloudFrontRequestRouter.matchTargetOriginId(config, normalized);
|
||||
Origin origin = CloudFrontRequestRouter.findOrigin(config, originId);
|
||||
@@ -357,6 +463,14 @@ public class CloudFrontServingController {
|
||||
return fetchS3Preflight(origin, viewerOrigin, accessControlRequestMethod,
|
||||
accessControlRequestHeaders);
|
||||
}
|
||||
if (BODY_METHODS.contains(method)) {
|
||||
// AWS forwards these methods and S3 evaluates them against the bucket policy (OAC
|
||||
// supports PUT and DELETE). The in-process origin authorizes reads only, so a write is
|
||||
// answered as S3 answers a request that holds no write grant.
|
||||
LOG.debugv("CloudFront does not forward {0} requests to in-process S3 origin {1}",
|
||||
method, origin.getId());
|
||||
return OriginResponse.error(403, "Access Denied");
|
||||
}
|
||||
String key = CloudFrontRequestRouter.resolveOriginKey(
|
||||
origin.getOriginPath(), decodedViewerPath, config.getDefaultRootObject());
|
||||
return fetchFromS3(
|
||||
@@ -364,11 +478,86 @@ public class CloudFrontServingController {
|
||||
}
|
||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(
|
||||
origin.getOriginPath(), rawViewerPath, config.getDefaultRootObject());
|
||||
// CloudFront forwards viewer query strings only when selected by a cache policy,
|
||||
// origin request policy, or legacy ForwardedValues configuration. Those policy
|
||||
// semantics are not modeled in the data plane yet, so the AWS default is to omit them.
|
||||
return fetchFromCustomOrigin(origin, forwardUri, null, viewerScheme, method,
|
||||
viewerOrigin, accessControlRequestMethod, accessControlRequestHeaders);
|
||||
CloudFrontOriginRequestBuilder.OriginRequest originRequest =
|
||||
CloudFrontOriginRequestBuilder.build(viewerRequest(method, viewerScheme),
|
||||
forwarding(config, normalized), distribution.getDomainName());
|
||||
return fetchFromCustomOrigin(
|
||||
origin, forwardUri, originRequest, viewerScheme, method, viewerBody);
|
||||
}
|
||||
|
||||
/** The live viewer request, with CloudFront's signing parameters removed from its query. */
|
||||
private CloudFrontOriginRequestBuilder.ViewerRequest viewerRequest(
|
||||
String method, String viewerScheme) {
|
||||
HttpServerRequest request = currentVertxRequest.getCurrent().request();
|
||||
List<CloudFrontOriginRequestBuilder.Header> headers = new ArrayList<>();
|
||||
for (Map.Entry<String, String> header : request.headers()) {
|
||||
headers.add(new CloudFrontOriginRequestBuilder.Header(header.getKey(), header.getValue()));
|
||||
}
|
||||
String appQuery = stripCloudFrontSigningParams(request.query());
|
||||
SocketAddress client = request.remoteAddress();
|
||||
return new CloudFrontOriginRequestBuilder.ViewerRequest(method, headers,
|
||||
appQuery.isEmpty() ? null : appQuery,
|
||||
client != null ? client.hostAddress() : null,
|
||||
client != null ? client.port() : -1,
|
||||
viewerScheme, httpVersion(request.version()));
|
||||
}
|
||||
|
||||
private static String httpVersion(HttpVersion version) {
|
||||
if (version == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (version) {
|
||||
case HTTP_1_0 -> "1.0";
|
||||
case HTTP_1_1 -> "1.1";
|
||||
case HTTP_2 -> "2.0";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves what the matched behavior forwards: legacy {@code ForwardedValues} when it has no cache
|
||||
* policy, otherwise its cache policy parameters and origin request policy.
|
||||
*/
|
||||
private CloudFrontOriginRequestBuilder.Forwarding forwarding(
|
||||
DistributionConfig config, String normalized) {
|
||||
CloudFrontRequestRouter.BehaviorForwarding behavior =
|
||||
CloudFrontRequestRouter.matchForwarding(config, normalized);
|
||||
boolean optionsCached = behavior.cachedMethods() != null
|
||||
&& behavior.cachedMethods().contains("OPTIONS");
|
||||
if (behavior.cachePolicyId() == null || behavior.cachePolicyId().isBlank()) {
|
||||
return CloudFrontOriginRequestBuilder.Forwarding.legacy(
|
||||
behavior.forwardedValues(), optionsCached);
|
||||
}
|
||||
return CloudFrontOriginRequestBuilder.Forwarding.policies(
|
||||
cachePolicyParameters(behavior.cachePolicyId()),
|
||||
originRequestPolicyConfig(behavior.originRequestPolicyId()), optionsCached);
|
||||
}
|
||||
|
||||
private Map<?, ?> cachePolicyParameters(String cachePolicyId) {
|
||||
try {
|
||||
Map<String, Object> config = service.getCachePolicy(cachePolicyId).getConfig();
|
||||
return config != null
|
||||
&& config.get("ParametersInCacheKeyAndForwardedToOrigin") instanceof Map<?, ?> parameters
|
||||
? parameters : null;
|
||||
} catch (AwsException e) {
|
||||
// AWS managed cache policies are not modeled. The common ones (CachingDisabled,
|
||||
// CachingOptimized) forward no viewer values, which is what an unknown id contributes.
|
||||
LOG.debugv("Cache policy {0} is not defined; it forwards no viewer values", cachePolicyId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> originRequestPolicyConfig(String originRequestPolicyId) {
|
||||
if (originRequestPolicyId == null || originRequestPolicyId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return service.getOriginRequestPolicy(originRequestPolicyId).getConfig();
|
||||
} catch (AwsException e) {
|
||||
// Distribution writes do not validate this reference, so keep serving without the policy
|
||||
// but make the dangling id visible.
|
||||
LOG.warnv("Distribution references missing origin request policy {0}", originRequestPolicyId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OriginResponse fetchFromS3(
|
||||
@@ -386,10 +575,12 @@ public class CloudFrontServingController {
|
||||
authorizeS3OriginRead(
|
||||
distribution, origin, bucket, key, viewerAuthorization);
|
||||
if (includeBody) {
|
||||
S3Object obj = s3Service.getObject(bucket, key);
|
||||
byte[] data = obj.getData() != null ? obj.getData() : new byte[0];
|
||||
// The body is streamed to the viewer, so an object of any size is served without
|
||||
// being read into the heap; the metadata and the stream come from one snapshot.
|
||||
S3Service.ObjectRead read = s3Service.openObject(bucket, key, null);
|
||||
S3Object obj = read.object();
|
||||
response = new OriginResponse(
|
||||
200, contentType(obj), data, data.length, s3ObjectHeaders(obj));
|
||||
200, contentType(obj), null, read.body(), obj.getSize(), s3ObjectHeaders(obj));
|
||||
} else {
|
||||
S3Object meta = s3Service.headObject(bucket, key);
|
||||
response = new OriginResponse(
|
||||
@@ -398,8 +589,13 @@ public class CloudFrontServingController {
|
||||
} catch (AwsException e) {
|
||||
response = OriginResponse.error(e.getHttpStatus(), e.getMessage());
|
||||
}
|
||||
return withS3CorsHeaders(
|
||||
bucket, origin, includeBody ? "GET" : "HEAD", response);
|
||||
try {
|
||||
return withS3CorsHeaders(
|
||||
bucket, origin, includeBody ? "GET" : "HEAD", response);
|
||||
} catch (RuntimeException e) {
|
||||
response.closeStream();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void authorizeS3OriginRead(
|
||||
@@ -490,15 +686,17 @@ public class CloudFrontServingController {
|
||||
"This CORS request is not allowed."));
|
||||
}
|
||||
|
||||
/** Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path. */
|
||||
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri, String rawQuery,
|
||||
String viewerScheme, String method, String viewerOrigin,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders) {
|
||||
/**
|
||||
* Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path;
|
||||
* {@code originRequest} carries the forwarded query and headers.
|
||||
*/
|
||||
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri,
|
||||
CloudFrontOriginRequestBuilder.OriginRequest originRequest,
|
||||
String viewerScheme, String method, ViewerBody viewerBody) {
|
||||
boolean includeBody = !"HEAD".equals(method);
|
||||
try {
|
||||
URI target = buildCustomOriginUri(
|
||||
origin, viewerScheme, forwardUri, rawQuery);
|
||||
origin, viewerScheme, forwardUri, originRequest.rawQuery());
|
||||
HttpRequest.Builder rb = HttpRequest.newBuilder()
|
||||
.uri(target)
|
||||
.timeout(Duration.ofSeconds(30));
|
||||
@@ -517,18 +715,19 @@ public class CloudFrontServingController {
|
||||
}
|
||||
}
|
||||
rb.method(method, HttpRequest.BodyPublishers.noBody());
|
||||
if ("OPTIONS".equals(method)) {
|
||||
addRequestHeader(rb, "Origin", viewerOrigin);
|
||||
addRequestHeader(rb, "Access-Control-Request-Method", accessControlRequestMethod);
|
||||
addRequestHeader(rb, "Access-Control-Request-Headers", accessControlRequestHeaders);
|
||||
}
|
||||
HttpResponse<byte[]> resp = httpClient.send(
|
||||
rb.build(), originHeaders, HttpResponse.BodyHandlers.ofByteArray());
|
||||
// Both bodies are streamed, so neither has to fit in memory: the viewer's goes out with its
|
||||
// own Content-Length, or chunked as it came, and the origin's comes back as it arrives.
|
||||
ViewerBody requestBody = originRequestBody(method, viewerBody);
|
||||
CloudFrontOriginHttpClient.OpenResponse resp = httpClient.open(rb.build(), originRequest.headers(),
|
||||
originHeaders, requestBody != null ? requestBody.stream() : null,
|
||||
requestBody != null ? requestBody.length() : -1);
|
||||
String ct = resp.headers().firstValue("content-type").orElse(DEFAULT_CONTENT_TYPE);
|
||||
byte[] body = resp.body() != null ? resp.body() : new byte[0];
|
||||
long contentLength = includeBody ? body.length : responseContentLength(resp);
|
||||
return new OriginResponse(resp.statusCode(), ct, includeBody ? body : null,
|
||||
contentLength, resp.headers().map());
|
||||
long contentLength = resp.contentLength();
|
||||
if (!includeBody) {
|
||||
resp.body().close();
|
||||
return new OriginResponse(resp.statusCode(), ct, null, contentLength, resp.headers().map());
|
||||
}
|
||||
return new OriginResponse(resp.statusCode(), ct, null, resp.body(), contentLength, resp.headers().map());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
LOG.warnv("CloudFront custom origin fetch was interrupted for host {0}",
|
||||
@@ -542,6 +741,24 @@ public class CloudFrontServingController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The body CloudFront sends to the origin: POST, PUT and PATCH always carry one (possibly empty, so
|
||||
* the origin still sees {@code Content-Length: 0}); DELETE carries one only when the viewer sent it.
|
||||
*/
|
||||
private static ViewerBody originRequestBody(String method, ViewerBody viewerBody) {
|
||||
if (viewerBody == null || !BODY_METHODS.contains(method)) {
|
||||
return null;
|
||||
}
|
||||
if ("DELETE".equals(method) && viewerBody.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
return viewerBody;
|
||||
}
|
||||
|
||||
/** A viewer request body, read as it is forwarded, and its length, {@code -1} when it comes chunked. */
|
||||
record ViewerBody(InputStream stream, long length) {
|
||||
}
|
||||
|
||||
private static String originAccessIdentityId(Origin origin) {
|
||||
Map<String, String> config = origin.getS3OriginConfig();
|
||||
String value = config != null ? config.get("OriginAccessIdentity") : null;
|
||||
@@ -639,15 +856,6 @@ public class CloudFrontServingController {
|
||||
return normalized.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static long responseContentLength(HttpResponse<?> response) {
|
||||
try {
|
||||
return response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.debugv("Ignoring invalid custom-origin Content-Length: {0}", e.getMessage());
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeOriginName(Origin origin) {
|
||||
try {
|
||||
return normalizeOriginHost(origin.getDomainName());
|
||||
@@ -688,8 +896,7 @@ public class CloudFrontServingController {
|
||||
String pagePath = str(cer.get("ResponsePagePath"));
|
||||
if (pagePath.isBlank()) {
|
||||
// ResponseCode override with no custom page: keep the origin body, change the status.
|
||||
return toResponse(new OriginResponse(responseCode, origin.contentType(), origin.body(),
|
||||
origin.contentLength(), origin.headers()), includeBody, directives);
|
||||
return toResponse(origin.withStatus(responseCode), includeBody, directives);
|
||||
}
|
||||
|
||||
String errNormalized = CloudFrontRequestRouter.normalizePath(pagePath);
|
||||
@@ -699,6 +906,8 @@ public class CloudFrontServingController {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The custom error page replaces the origin's error response, which is not sent.
|
||||
origin.closeStream();
|
||||
OriginResponse page;
|
||||
if (CloudFrontRequestRouter.isS3Origin(errOrigin)) {
|
||||
String key = CloudFrontRequestRouter.resolveOriginKey(errOrigin.getOriginPath(), errNormalized, null);
|
||||
@@ -707,16 +916,15 @@ public class CloudFrontServingController {
|
||||
} else {
|
||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(errOrigin.getOriginPath(), errNormalized, null);
|
||||
page = fetchFromCustomOrigin(
|
||||
errOrigin, forwardUri, null, viewerScheme,
|
||||
includeBody ? "GET" : "HEAD", null, null, null);
|
||||
errOrigin, forwardUri, CloudFrontOriginRequestBuilder.OriginRequest.empty(),
|
||||
viewerScheme, includeBody ? "GET" : "HEAD", null);
|
||||
}
|
||||
if (page.status() >= 400) {
|
||||
// Custom error page unavailable → return the status received from the error-page origin
|
||||
// (AWS behavior), without recursively applying custom error handling.
|
||||
return toResponse(page, includeBody, directives);
|
||||
}
|
||||
return toResponse(new OriginResponse(responseCode, page.contentType(), page.body(),
|
||||
page.contentLength(), page.headers()), includeBody, directives);
|
||||
return toResponse(page.withStatus(responseCode), includeBody, directives);
|
||||
}
|
||||
|
||||
private Map<String, Object> matchCustomError(DistributionConfig config, int status) {
|
||||
@@ -734,21 +942,43 @@ public class CloudFrontServingController {
|
||||
|
||||
private Response toResponse(OriginResponse origin, boolean includeBody,
|
||||
ResponseHeadersPolicyConfigCodec.Directives directives) {
|
||||
Response.ResponseBuilder rb = Response.status(origin.status());
|
||||
HeaderCollection collected = collectResponseHeaders(origin, includeBody);
|
||||
applyResponseHeadersPolicy(collected.headers(), directives, collected.forbiddenPolicyHeaders());
|
||||
for (HeaderValues header : collected.headers().values()) {
|
||||
if ("date".equalsIgnoreCase(header.name())) {
|
||||
currentVertxRequest.getCurrent().response()
|
||||
.putHeader(header.name(), header.values());
|
||||
} else {
|
||||
header.values().forEach(value -> rb.header(header.name(), value));
|
||||
try {
|
||||
Response.ResponseBuilder rb = Response.status(origin.status());
|
||||
HeaderCollection collected = collectResponseHeaders(origin, includeBody);
|
||||
applyResponseHeadersPolicy(collected.headers(), directives, collected.forbiddenPolicyHeaders());
|
||||
for (HeaderValues header : collected.headers().values()) {
|
||||
if ("date".equalsIgnoreCase(header.name())) {
|
||||
currentVertxRequest.getCurrent().response()
|
||||
.putHeader(header.name(), header.values());
|
||||
} else {
|
||||
header.values().forEach(value -> rb.header(header.name(), value));
|
||||
}
|
||||
}
|
||||
if (includeBody && origin.stream() != null) {
|
||||
// A disconnected viewer can skip StreamingOutput entirely, so its finally block
|
||||
// alone cannot release the origin connection.
|
||||
CurrentRequestManager.get().registerCompletionCallback(ignored -> origin.closeStream());
|
||||
InputStream body = origin.stream();
|
||||
StreamingOutput entity = output -> {
|
||||
try (InputStream in = body) {
|
||||
in.transferTo(output);
|
||||
}
|
||||
};
|
||||
rb.entity(entity);
|
||||
} else {
|
||||
if (origin.stream() != null) {
|
||||
// A body that will not be sent, as for a HEAD, still holds its source open.
|
||||
origin.closeStream();
|
||||
}
|
||||
if (includeBody && origin.body() != null) {
|
||||
rb.entity(origin.body());
|
||||
}
|
||||
}
|
||||
return rb.build();
|
||||
} catch (RuntimeException e) {
|
||||
origin.closeStream();
|
||||
throw e;
|
||||
}
|
||||
if (includeBody && origin.body() != null) {
|
||||
rb.entity(origin.body());
|
||||
}
|
||||
return rb.build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,7 +1014,8 @@ public class CloudFrontServingController {
|
||||
if (origin.contentType() != null) {
|
||||
setHeader(headers, "Content-Type", origin.contentType());
|
||||
}
|
||||
if (!includeBody && origin.contentLength() >= 0) {
|
||||
// A byte body sets its own length; a HEAD response and a streamed body need it stated.
|
||||
if ((!includeBody || origin.stream() != null) && origin.contentLength() >= 0) {
|
||||
setHeader(headers, "Content-Length", Long.toString(origin.contentLength()));
|
||||
}
|
||||
return new HeaderCollection(headers, forbiddenPolicyHeaders);
|
||||
@@ -935,12 +1166,7 @@ public class CloudFrontServingController {
|
||||
"Access-Control-Request-Method"));
|
||||
}
|
||||
});
|
||||
return new OriginResponse(
|
||||
response.status(),
|
||||
response.contentType(),
|
||||
response.body(),
|
||||
response.contentLength(),
|
||||
headers);
|
||||
return response.withHeaders(headers);
|
||||
}
|
||||
|
||||
private static String originCustomHeader(Origin origin, String requestedName) {
|
||||
@@ -1006,12 +1232,6 @@ public class CloudFrontServingController {
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void addRequestHeader(HttpRequest.Builder request, String name, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
request.header(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object value) {
|
||||
return value != null ? value.toString() : "";
|
||||
}
|
||||
@@ -1039,12 +1259,42 @@ public class CloudFrontServingController {
|
||||
Set<String> forbiddenPolicyHeaders) {
|
||||
}
|
||||
|
||||
/** The result of fetching from an origin, including end-to-end response headers. */
|
||||
private record OriginResponse(int status, String contentType, byte[] body, long contentLength,
|
||||
Map<String, List<String>> headers) {
|
||||
/**
|
||||
* The result of fetching from an origin, including end-to-end response headers. An origin's
|
||||
* content, from an S3 object or a custom origin, is an open {@code stream} instead of
|
||||
* {@code body}, so it never passes through the heap; the viewer response writes and closes it.
|
||||
* {@code body} holds the bytes of a response Floci makes itself, such as an error.
|
||||
*/
|
||||
private record OriginResponse(int status, String contentType, byte[] body, InputStream stream,
|
||||
long contentLength, Map<String, List<String>> headers) {
|
||||
OriginResponse(int status, String contentType, byte[] body, long contentLength,
|
||||
Map<String, List<String>> headers) {
|
||||
this(status, contentType, body, null, contentLength, headers);
|
||||
}
|
||||
|
||||
static OriginResponse error(int status, String message) {
|
||||
byte[] b = message == null ? new byte[0] : message.getBytes(StandardCharsets.UTF_8);
|
||||
return new OriginResponse(status, MediaType.TEXT_PLAIN, b, b.length, Map.of());
|
||||
}
|
||||
|
||||
OriginResponse withStatus(int newStatus) {
|
||||
return new OriginResponse(newStatus, contentType, body, stream, contentLength, headers);
|
||||
}
|
||||
|
||||
OriginResponse withHeaders(Map<String, List<String>> newHeaders) {
|
||||
return new OriginResponse(status, contentType, body, stream, contentLength, newHeaders);
|
||||
}
|
||||
|
||||
/** Closes the stream of a response that will not be written. */
|
||||
void closeStream() {
|
||||
if (stream == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stream.close();
|
||||
} catch (IOException e) {
|
||||
LOG.debugv("Could not close an unsent origin body: {0}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-10
@@ -179,29 +179,82 @@ public class CloudWatchDashboardsService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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<String> 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<Dashboard> 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}.
|
||||
*
|
||||
* <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) {
|
||||
return region + "::" + dashboardName;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+155
@@ -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<String, String> 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<Notification> prepareTransition(MetricAlarm alarm, String oldState) {
|
||||
if (!alarm.isActionsEnabled() || alarm.getStateValue().equals(oldState)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> 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<Notification> 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<Notification> notifications) {
|
||||
for (Notification notification : notifications) {
|
||||
try {
|
||||
snsService.publish(notification.topicArn(), null, notification.message(),
|
||||
notification.subject(), notification.region());
|
||||
} catch (Exception error) {
|
||||
LOG.warnv(error, "Unable to publish alarm transition {0} to SNS action {1}",
|
||||
notification.subject(), notification.topicArn());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String message(MetricAlarm alarm, String oldState) throws JsonProcessingException {
|
||||
AwsArnUtils.Arn arn = AwsArnUtils.parse(alarm.getAlarmArn());
|
||||
Map<String, Object> 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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
+34
@@ -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.
|
||||
*
|
||||
* <p>CloudWatch is awsQueryCompatible: the Query {@code <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;
|
||||
}
|
||||
}
|
||||
+127
-38
@@ -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<MetricDatum> 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<MetricDatum> 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<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
|
||||
List<String> types = new ArrayList<>();
|
||||
request.path("AlarmTypes").forEach(value -> types.add(value.asText()));
|
||||
metricsService.validateAlarmTypes(types);
|
||||
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
|
||||
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
|
||||
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
ArrayNode arr = response.putArray("MetricAlarms");
|
||||
for (MetricAlarm a : alarms) {
|
||||
ObjectNode node = arr.addObject();
|
||||
node.put("AlarmName", a.getAlarmName());
|
||||
if (a.getAlarmArn() != null) node.put("AlarmArn", a.getAlarmArn());
|
||||
if (a.getAlarmDescription() != null) node.put("AlarmDescription", a.getAlarmDescription());
|
||||
ArrayNode alarmActions = node.putArray("AlarmActions");
|
||||
a.getAlarmActions().forEach(alarmActions::add);
|
||||
ArrayNode okActions = node.putArray("OKActions");
|
||||
a.getOkActions().forEach(okActions::add);
|
||||
ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions");
|
||||
a.getInsufficientDataActions().forEach(insufficientDataActions::add);
|
||||
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<String> alarmNames = new ArrayList<>();
|
||||
JsonNode namesNode = request.path("AlarmNames");
|
||||
@@ -586,12 +669,18 @@ public class CloudWatchMetricsJsonHandler {
|
||||
return configurations;
|
||||
}
|
||||
|
||||
private List<MetricDatum> parseMetricDataJson(JsonNode node) {
|
||||
private List<MetricDatum> parseMetricDataJson(JsonNode node, String listPath) {
|
||||
List<MetricDatum> 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");
|
||||
|
||||
+207
-70
@@ -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<String, String> params, String region) {
|
||||
@@ -237,6 +252,26 @@ public class CloudWatchMetricsQueryHandler {
|
||||
return Response.ok(AwsQueryResponse.envelopeNoResult("PutMetricAlarm", null)).build();
|
||||
}
|
||||
|
||||
private Response handlePutCompositeAlarm(MultivaluedMap<String, String> params, String region) {
|
||||
if (params.containsKey("ActionsSuppressor") || params.containsKey("ActionsSuppressorWaitPeriod")
|
||||
|| params.containsKey("ActionsSuppressorExtensionPeriod")) {
|
||||
throw new AwsException("ValidationError", "Composite action suppression is not supported", 400);
|
||||
}
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
parseAlarmFields(params, alarm);
|
||||
alarm.setAlarmRule(params.getFirst("AlarmRule"));
|
||||
metricsService.putCompositeAlarm(alarm, region);
|
||||
return Response.ok(AwsQueryResponse.envelopeNoResult("PutCompositeAlarm", null)).build();
|
||||
}
|
||||
|
||||
private static List<String> alarmMembers(MultivaluedMap<String, String> params, String key) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (int i = 1; params.getFirst(key + ".member." + i) != null; i++) {
|
||||
values.add(params.getFirst(key + ".member." + i));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private Response handleDescribeAlarms(MultivaluedMap<String, String> params, String region) {
|
||||
List<String> alarmNames = new ArrayList<>();
|
||||
for (int i = 1; ; i++) {
|
||||
@@ -246,13 +281,24 @@ public class CloudWatchMetricsQueryHandler {
|
||||
}
|
||||
String prefix = params.getFirst("AlarmNamePrefix");
|
||||
|
||||
List<MetricAlarm> alarms = metricsService.describeAlarms(alarmNames, prefix, region);
|
||||
List<String> types = alarmMembers(params, "AlarmTypes");
|
||||
metricsService.validateAlarmTypes(types);
|
||||
List<MetricAlarm> alarms = types.isEmpty() || types.contains("MetricAlarm")
|
||||
? metricsService.describeAlarms(alarmNames, prefix, region) : List.of();
|
||||
|
||||
var xml = new XmlBuilder().start("MetricAlarms");
|
||||
for (MetricAlarm a : alarms) {
|
||||
toAlarmXml(xml, a);
|
||||
}
|
||||
xml.end("MetricAlarms");
|
||||
xml.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<MetricDatum> parseMetricData(MultivaluedMap<String, String> params) {
|
||||
LOG.infov("Query PutMetricData params: {0}", params);
|
||||
List<MetricDatum> 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<Integer> 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<MetricDatum> parseMetricData(MultivaluedMap<String, String> params, String listPrefix) {
|
||||
List<MetricDatum> 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<Dimension> 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<String, String> params) {
|
||||
MetricAlarm a = new MetricAlarm();
|
||||
a.setAlarmName(params.getFirst("AlarmName"));
|
||||
a.setAlarmDescription(params.getFirst("AlarmDescription"));
|
||||
String actionsEnabled = params.getFirst("ActionsEnabled");
|
||||
a.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
|
||||
parseAlarmFields(params, a);
|
||||
a.setMetricName(params.getFirst("MetricName"));
|
||||
a.setNamespace(params.getFirst("Namespace"));
|
||||
a.setStatistic(params.getFirst("Statistic"));
|
||||
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<String, String> tags = new LinkedHashMap<>();
|
||||
for (int i = 1; ; i++) {
|
||||
String key = params.getFirst("Tags.member." + i + ".Key");
|
||||
if (key == null) break;
|
||||
tags.put(key, params.getFirst("Tags.member." + i + ".Value"));
|
||||
}
|
||||
a.setTags(tags);
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
private void parseAlarmFields(MultivaluedMap<String, String> params, Alarm alarm) {
|
||||
alarm.setAlarmName(params.getFirst("AlarmName"));
|
||||
alarm.setAlarmDescription(params.getFirst("AlarmDescription"));
|
||||
String actionsEnabled = params.getFirst("ActionsEnabled");
|
||||
alarm.setActionsEnabled(actionsEnabled == null || Boolean.parseBoolean(actionsEnabled));
|
||||
alarm.setAlarmActions(alarmMembers(params, "AlarmActions"));
|
||||
alarm.setOkActions(alarmMembers(params, "OKActions"));
|
||||
alarm.setInsufficientDataActions(alarmMembers(params, "InsufficientDataActions"));
|
||||
Map<String, String> tags = new LinkedHashMap<>();
|
||||
for (int i = 1; params.getFirst("Tags.member." + i + ".Key") != null; i++) {
|
||||
tags.put(params.getFirst("Tags.member." + i + ".Key"), params.getFirst("Tags.member." + i + ".Value"));
|
||||
}
|
||||
alarm.setTags(tags);
|
||||
}
|
||||
|
||||
private List<AlarmMetricDataQuery> parseAlarmMetricQueries(MultivaluedMap<String, String> params) {
|
||||
List<AlarmMetricDataQuery> queries = new ArrayList<>();
|
||||
for (int index = 1; ; index++) {
|
||||
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<AlarmMetricDataQuery.MetricDimension> 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<AlarmMetricDataQuery> 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) {
|
||||
|
||||
+291
-57
@@ -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<String> ALARM_TYPES = List.of("CompositeAlarm", "MetricAlarm", "LogAlarm");
|
||||
|
||||
private final StorageBackend<String, MetricDatum> metricStore;
|
||||
private final StorageBackend<String, MetricAlarm> alarmStore;
|
||||
private final StorageBackend<String, Alarm> alarmStore;
|
||||
private final RegionResolver regionResolver;
|
||||
private final AlarmSnsNotifier alarmSnsNotifier;
|
||||
|
||||
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<Map<String, MetricDatum>>() {});
|
||||
this.alarmStore = storageFactory.create("cloudwatchmetrics", "cwalarms.json",
|
||||
new TypeReference<Map<String, MetricAlarm>>() {});
|
||||
new TypeReference<Map<String, Alarm>>() {});
|
||||
this.regionResolver = regionResolver;
|
||||
this.alarmSnsNotifier = alarmSnsNotifier;
|
||||
}
|
||||
|
||||
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
|
||||
StorageBackend<String, MetricAlarm> alarmStore,
|
||||
StorageBackend<String, Alarm> alarmStore,
|
||||
RegionResolver regionResolver) {
|
||||
this(metricStore, alarmStore, regionResolver, null);
|
||||
}
|
||||
|
||||
CloudWatchMetricsService(StorageBackend<String, MetricDatum> metricStore,
|
||||
StorageBackend<String, Alarm> alarmStore,
|
||||
RegionResolver regionResolver, AlarmSnsNotifier alarmSnsNotifier) {
|
||||
this.metricStore = metricStore;
|
||||
this.alarmStore = alarmStore;
|
||||
this.regionResolver = regionResolver;
|
||||
this.alarmSnsNotifier = alarmSnsNotifier;
|
||||
}
|
||||
|
||||
public void putMetricData(String namespace, List<MetricDatum> 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<String> references = alarmReferences(alarm, region);
|
||||
if (references.size() > 100) {
|
||||
throw new AwsException("LimitExceeded", "AlarmRule references more than 100 child alarms", 400);
|
||||
}
|
||||
List<CompositeAlarm> existing = describeCompositeAlarms(null, null, region);
|
||||
for (String reference : references) {
|
||||
long parents = existing.stream().filter(parent -> !parent.getAlarmName().equals(alarm.getAlarmName()))
|
||||
.filter(parent -> alarmReferences(parent, region).contains(reference))
|
||||
.count();
|
||||
if (parents >= 150) {
|
||||
throw new AwsException("LimitExceeded", "An alarm can be referenced by at most 150 composite alarms", 400);
|
||||
}
|
||||
}
|
||||
boolean created = alarmStore.get(region + "::" + alarm.getAlarmName()).isEmpty();
|
||||
putAlarm(alarm, region);
|
||||
if (created) {
|
||||
evaluateComposites(region, Set.of(), alarm.getAlarmName());
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> alarmReferences(CompositeAlarm alarm, String region) {
|
||||
return CompositeAlarmRule.parse(alarm.getAlarmRule()).references().stream()
|
||||
.map(reference -> AwsArnUtils.isArn(reference) ? reference
|
||||
: regionResolver.buildArn("cloudwatch", region, "alarm:" + reference))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private void putAlarm(Alarm alarm, String region) {
|
||||
String name = alarm.getAlarmName();
|
||||
if (name == null || name.isBlank() || name.length() > 255) {
|
||||
throw SmithyValidationErrors.validationError(name == null ? SmithyValidationErrors.notNull("alarmName")
|
||||
: "Value '" + name + "' at 'alarmName' failed to satisfy constraint: Member must have length "
|
||||
+ (name.length() > 255 ? "less than or equal to 255" : "greater than or equal to 1"));
|
||||
}
|
||||
Alarm previous = alarmStore.get(region + "::" + name).orElse(null);
|
||||
if (previous != null) {
|
||||
if (!previous.getClass().equals(alarm.getClass())) {
|
||||
throw new AwsException("ValidationError", "An alarm of another type already has this name", 400);
|
||||
}
|
||||
if (alarm instanceof CompositeAlarm) {
|
||||
alarm.setStateValue(previous.getStateValue());
|
||||
alarm.setStateReason(previous.getStateReason());
|
||||
alarm.setStateReasonData(previous.getStateReasonData());
|
||||
alarm.setStateUpdatedTimestamp(previous.getStateUpdatedTimestamp());
|
||||
alarm.setTags(new HashMap<>(previous.getTags()));
|
||||
}
|
||||
}
|
||||
if (alarm.getAlarmArn() == null) {
|
||||
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + alarm.getAlarmName()));
|
||||
alarm.setAlarmArn(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
|
||||
}
|
||||
alarm.setRegion(region);
|
||||
alarm.setAlarmConfigurationUpdatedTimestamp(Instant.now().getEpochSecond());
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
LOG.infov("PutMetricAlarm: {0} in {1}", alarm.getAlarmName(), region);
|
||||
alarmStore.put(region + "::" + name, alarm);
|
||||
LOG.infov("Put {0}: {1} in {2}", alarm.getClass().getSimpleName(), name, region);
|
||||
}
|
||||
|
||||
/** Every stored alarm, across all regions. Used by the background {@link AlarmEvaluator}
|
||||
* tick, which has no per-request region to scope a lookup to. */
|
||||
/** Metric alarms evaluated by the existing metric evaluator. */
|
||||
public List<MetricAlarm> allAlarms() {
|
||||
return alarmStore.scan(k -> true);
|
||||
return alarmStore.scan(k -> true).stream().filter(MetricAlarm.class::isInstance)
|
||||
.map(MetricAlarm.class::cast).toList();
|
||||
}
|
||||
|
||||
public List<MetricAlarm> describeAlarms(List<String> alarmNames, String alarmNamePrefix, String region) {
|
||||
String prefix = region + "::";
|
||||
List<MetricAlarm> all = alarmStore.scan(k -> k.startsWith(prefix));
|
||||
|
||||
if (alarmNames != null && !alarmNames.isEmpty()) {
|
||||
return all.stream().filter(a -> alarmNames.contains(a.getAlarmName())).toList();
|
||||
}
|
||||
if (alarmNamePrefix != null && !alarmNamePrefix.isBlank()) {
|
||||
return all.stream().filter(a -> a.getAlarmName().startsWith(alarmNamePrefix)).toList();
|
||||
}
|
||||
return all;
|
||||
public List<MetricAlarm> describeAlarms(List<String> names, String prefix, String region) {
|
||||
return matchingAlarms(names, prefix, region).stream().filter(MetricAlarm.class::isInstance)
|
||||
.map(MetricAlarm.class::cast).toList();
|
||||
}
|
||||
|
||||
public void deleteAlarms(List<String> alarmNames, String region) {
|
||||
public void validateAlarmTypes(List<String> types) {
|
||||
if (!ALARM_TYPES.containsAll(types)) {
|
||||
throw SmithyValidationErrors.validationError(
|
||||
SmithyValidationErrors.notAllInEnum(types, "alarmTypes", ALARM_TYPES));
|
||||
}
|
||||
}
|
||||
|
||||
public List<CompositeAlarm> describeCompositeAlarms(List<String> names, String prefix, String region) {
|
||||
return matchingAlarms(names, prefix, region).stream().filter(CompositeAlarm.class::isInstance)
|
||||
.map(CompositeAlarm.class::cast).toList();
|
||||
}
|
||||
|
||||
private List<Alarm> matchingAlarms(List<String> names, String prefix, String region) {
|
||||
return alarmStore.scan(k -> k.startsWith(region + "::")).stream()
|
||||
.filter(a -> names == null || names.isEmpty() || names.contains(a.getAlarmName()))
|
||||
.filter(a -> prefix == null || a.getAlarmName().startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
private Map<String, Alarm> alarmIndex(String region) {
|
||||
Map<String, Alarm> alarms = new HashMap<>();
|
||||
for (Alarm alarm : alarmStore.scan(k -> k.startsWith(region + "::"))) {
|
||||
alarms.put(alarm.getAlarmName(), alarm);
|
||||
alarms.put(alarm.getAlarmArn(), alarm);
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
|
||||
private void evaluateComposites(String region, Set<String> changedAlarmNames, String newAlarm) {
|
||||
if (changedAlarmNames.isEmpty() && newAlarm == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Alarm> alarms = alarmIndex(region);
|
||||
Set<String> changed = new HashSet<>(changedAlarmNames);
|
||||
for (String name : changedAlarmNames) {
|
||||
changed.add(regionResolver.buildArn("cloudwatch", region, "alarm:" + name));
|
||||
}
|
||||
Map<String, CompositeAlarmRule.Rule> candidates = new HashMap<>();
|
||||
for (Alarm alarm : new HashSet<>(alarms.values())) {
|
||||
if (alarm instanceof CompositeAlarm composite && !changedAlarmNames.contains(alarm.getAlarmName())) {
|
||||
candidates.put(alarm.getAlarmName(), CompositeAlarmRule.parse(composite.getAlarmRule()));
|
||||
}
|
||||
}
|
||||
Set<String> affected = new HashSet<>(changed);
|
||||
Map<String, CompositeAlarmRule.Rule> pending = new HashMap<>();
|
||||
boolean discovered;
|
||||
do {
|
||||
discovered = false;
|
||||
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = candidates.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
|
||||
if (next.getKey().equals(newAlarm) || next.getValue().references().stream().anyMatch(affected::contains)) {
|
||||
pending.put(next.getKey(), next.getValue());
|
||||
affected.add(next.getKey());
|
||||
affected.add(alarms.get(next.getKey()).getAlarmArn());
|
||||
iterator.remove();
|
||||
discovered = true;
|
||||
}
|
||||
}
|
||||
} while (discovered);
|
||||
Set<String> cycles = pending.keySet().stream()
|
||||
.filter(name -> isCyclic(name, alarms)).collect(Collectors.toSet());
|
||||
boolean progress;
|
||||
do {
|
||||
progress = false;
|
||||
Iterator<Map.Entry<String, CompositeAlarmRule.Rule>> iterator = pending.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, CompositeAlarmRule.Rule> next = iterator.next();
|
||||
boolean blocked = next.getValue().references().stream().map(alarms::get)
|
||||
.anyMatch(child -> child != null && pending.containsKey(child.getAlarmName()));
|
||||
if (blocked || cycles.contains(next.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (!next.getKey().equals(newAlarm)
|
||||
&& next.getValue().references().stream().noneMatch(changed::contains)) {
|
||||
iterator.remove();
|
||||
progress = true;
|
||||
continue;
|
||||
}
|
||||
Alarm alarm = alarms.get(next.getKey());
|
||||
boolean triggered = next.getValue().evaluate(reference -> {
|
||||
Alarm child = alarms.get(reference);
|
||||
return child == null ? null : child.getStateValue();
|
||||
});
|
||||
String state = triggered ? "ALARM" : "OK";
|
||||
if (!state.equals(alarm.getStateValue())) {
|
||||
alarm.setStateValue(state);
|
||||
alarm.setStateReason("AlarmRule evaluated to " + state);
|
||||
alarm.setStateReasonData(null);
|
||||
alarm.setStateUpdatedTimestamp(Instant.now().getEpochSecond());
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
changed.add(alarm.getAlarmName());
|
||||
changed.add(alarm.getAlarmArn());
|
||||
}
|
||||
iterator.remove();
|
||||
progress = true;
|
||||
}
|
||||
} while (progress && !pending.isEmpty());
|
||||
if (!pending.isEmpty()) {
|
||||
LOG.debugv("Composite alarm evaluation stopped at cyclic dependencies: {0}", pending.keySet());
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void deleteAlarms(List<String> alarmNames, String region) {
|
||||
Map<String, Alarm> alarms = alarmIndex(region);
|
||||
long composites = alarmNames.stream().map(alarms::get).filter(CompositeAlarm.class::isInstance).count();
|
||||
if (composites > 1) {
|
||||
throw new AwsException("ValidationError", "DeleteAlarms accepts at most one composite alarm", 400);
|
||||
}
|
||||
for (String name : alarmNames) {
|
||||
if (isCyclic(name, alarms)) {
|
||||
throw new AwsException("ValidationError", "Break the composite alarm cycle before deleting " + name, 400);
|
||||
}
|
||||
}
|
||||
Set<String> deletedNames = new HashSet<>();
|
||||
for (String name : alarmNames) {
|
||||
if (alarmStore.get(region + "::" + name).isPresent()) {
|
||||
deletedNames.add(name);
|
||||
}
|
||||
alarmStore.delete(region + "::" + name);
|
||||
}
|
||||
evaluateComposites(region, deletedNames, null);
|
||||
LOG.infov("Deleted alarms: {0} in {1}", alarmNames, region);
|
||||
}
|
||||
|
||||
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<String, Alarm> alarms) {
|
||||
Set<String> visited = new HashSet<>();
|
||||
ArrayDeque<String> pending = new ArrayDeque<>();
|
||||
pending.add(name);
|
||||
while (!pending.isEmpty()) {
|
||||
Alarm next = alarms.get(pending.remove());
|
||||
if (next instanceof CompositeAlarm composite && visited.add(next.getAlarmName())) {
|
||||
for (String reference : CompositeAlarmRule.parse(composite.getAlarmRule()).references()) {
|
||||
Alarm child = alarms.get(reference);
|
||||
if (child != null && child.getAlarmName().equals(name)) {
|
||||
return true;
|
||||
}
|
||||
if (child != null) {
|
||||
pending.add(child.getAlarmName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Map<String, String> listTagsForResource(String resourceArn, String region) {
|
||||
public void setAlarmState(String alarmName, String stateValue, String stateReason, String stateReasonData, String region) {
|
||||
List<AlarmSnsNotifier.Notification> notifications = List.of();
|
||||
synchronized (this) {
|
||||
String key = region + "::" + alarmName;
|
||||
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.
|
||||
*
|
||||
* <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 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<String, String> 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<String, String> listTagsForResource(String resourceArn, String region) {
|
||||
return new HashMap<>(requireAlarm(resourceArn, region).getTags());
|
||||
}
|
||||
|
||||
public void untagResource(String resourceArn, List<String> 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<String, String> 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<String> tagKeys, String region) {
|
||||
Alarm alarm = requireAlarm(resourceArn, region);
|
||||
tagKeys.forEach(alarm.getTags()::remove);
|
||||
alarmStore.put(region + "::" + alarm.getAlarmName(), alarm);
|
||||
}
|
||||
|
||||
// ──────────────────────────── Helpers ────────────────────────────
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.SmithyValidationErrors;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** CloudWatch's boolean alarm language, parsed completely before any alarm is stored. */
|
||||
final class CompositeAlarmRule {
|
||||
private final String input;
|
||||
private final Set<String> references = new HashSet<>();
|
||||
private int offset;
|
||||
private int elements;
|
||||
|
||||
private CompositeAlarmRule(String input) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
static Rule parse(String input) {
|
||||
if (input == null || input.isBlank() || input.length() > 10240) {
|
||||
throw SmithyValidationErrors.validationError(input == null ? SmithyValidationErrors.notNull("alarmRule")
|
||||
: "Value '" + input + "' at 'alarmRule' failed to satisfy constraint: Member must have length "
|
||||
+ (input.length() > 10240 ? "less than or equal to 10240" : "greater than or equal to 1"));
|
||||
}
|
||||
CompositeAlarmRule parser = new CompositeAlarmRule(input);
|
||||
Expression expression = parser.or();
|
||||
parser.whitespace();
|
||||
if (parser.offset != input.length()) {
|
||||
throw invalid("Invalid AlarmRule");
|
||||
}
|
||||
return new Rule(expression, Set.copyOf(parser.references));
|
||||
}
|
||||
|
||||
private Expression or() {
|
||||
Expression left = and();
|
||||
while (word("OR")) {
|
||||
Expression prior = left;
|
||||
Expression right = and();
|
||||
left = states -> prior.evaluate(states) || right.evaluate(states);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression and() {
|
||||
Expression left = atom();
|
||||
while (word("AND")) {
|
||||
Expression prior = left;
|
||||
Expression right = atom();
|
||||
left = states -> prior.evaluate(states) && right.evaluate(states);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private Expression atom() {
|
||||
boolean negate = false;
|
||||
while (word("NOT")) {
|
||||
negate = !negate;
|
||||
}
|
||||
Expression operand = primary();
|
||||
return negate ? states -> !operand.evaluate(states) : operand;
|
||||
}
|
||||
|
||||
private Expression primary() {
|
||||
element();
|
||||
if (character('(')) {
|
||||
Expression nested = or();
|
||||
require(')');
|
||||
element();
|
||||
return nested;
|
||||
}
|
||||
if (word("TRUE")) {
|
||||
return states -> true;
|
||||
}
|
||||
if (word("FALSE")) {
|
||||
return states -> false;
|
||||
}
|
||||
String state;
|
||||
if (word("ALARM")) {
|
||||
state = "ALARM";
|
||||
} else if (word("OK")) {
|
||||
state = "OK";
|
||||
} else if (word("INSUFFICIENT_DATA")) {
|
||||
state = "INSUFFICIENT_DATA";
|
||||
} else {
|
||||
throw invalid("Expected an alarm state, boolean, NOT, or parenthesis");
|
||||
}
|
||||
require('(');
|
||||
whitespace();
|
||||
String name;
|
||||
if (character('"')) {
|
||||
StringBuilder quoted = new StringBuilder();
|
||||
boolean closed = false;
|
||||
while (offset < input.length()) {
|
||||
char next = input.charAt(offset++);
|
||||
if (next == '"') {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if (next == '\\') {
|
||||
if (offset == input.length()) {
|
||||
throw invalid("Unterminated quoted alarm name");
|
||||
}
|
||||
next = input.charAt(offset++);
|
||||
if (next != '\\' && next != '"') {
|
||||
throw invalid("Invalid quoted alarm name escape");
|
||||
}
|
||||
}
|
||||
quoted.append(next);
|
||||
}
|
||||
if (!closed) {
|
||||
throw invalid("Unterminated quoted alarm name");
|
||||
}
|
||||
name = quoted.toString();
|
||||
} else {
|
||||
int start = offset;
|
||||
while (offset < input.length() && input.charAt(offset) != ')') {
|
||||
offset++;
|
||||
}
|
||||
name = input.substring(start, offset).trim();
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
throw invalid("An alarm name or ARN is required");
|
||||
}
|
||||
require(')');
|
||||
references.add(name);
|
||||
return states -> state.equals(states.apply(name));
|
||||
}
|
||||
|
||||
private void element() {
|
||||
if (++elements > 500) {
|
||||
throw invalid("AlarmRule exceeds 500 child alarms, booleans, and parentheses");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean word(String word) {
|
||||
whitespace();
|
||||
if (!input.startsWith(word, offset)) {
|
||||
return false;
|
||||
}
|
||||
int end = offset + word.length();
|
||||
if (end < input.length() && (Character.isLetterOrDigit(input.charAt(end)) || input.charAt(end) == '_')) {
|
||||
return false;
|
||||
}
|
||||
offset = end;
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean character(char expected) {
|
||||
whitespace();
|
||||
if (offset < input.length() && input.charAt(offset) == expected) {
|
||||
offset++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void require(char expected) {
|
||||
if (!character(expected)) {
|
||||
throw invalid("Expected '" + expected + "' in AlarmRule");
|
||||
}
|
||||
}
|
||||
|
||||
private void whitespace() {
|
||||
while (offset < input.length() && Character.isWhitespace(input.charAt(offset))) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
|
||||
private static AwsException invalid(String message) {
|
||||
return new AwsException("ValidationError", message, 400);
|
||||
}
|
||||
|
||||
record Rule(Expression expression, Set<String> references) {
|
||||
boolean evaluate(Function<String, String> states) {
|
||||
return expression.evaluate(states);
|
||||
}
|
||||
}
|
||||
|
||||
interface Expression {
|
||||
boolean evaluate(Function<String, String> states);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RegisterForReflection
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY,
|
||||
property = "alarmType", defaultImpl = MetricAlarm.class)
|
||||
@JsonSubTypes({@JsonSubTypes.Type(value = MetricAlarm.class, name = "MetricAlarm"),
|
||||
@JsonSubTypes.Type(value = CompositeAlarm.class, name = "CompositeAlarm")})
|
||||
public abstract class Alarm {
|
||||
private String alarmName;
|
||||
private String alarmArn;
|
||||
private String alarmDescription;
|
||||
private long alarmConfigurationUpdatedTimestamp;
|
||||
private boolean actionsEnabled;
|
||||
private List<String> okActions = new ArrayList<>();
|
||||
private List<String> alarmActions = new ArrayList<>();
|
||||
private List<String> insufficientDataActions = new ArrayList<>();
|
||||
private String stateValue = "INSUFFICIENT_DATA";
|
||||
private String stateReason = "Unchecked";
|
||||
private String stateReasonData;
|
||||
private long stateUpdatedTimestamp;
|
||||
private Map<String, String> tags = new HashMap<>();
|
||||
private String region;
|
||||
|
||||
|
||||
protected Alarm() {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
this.alarmConfigurationUpdatedTimestamp = now;
|
||||
this.stateUpdatedTimestamp = now;
|
||||
}
|
||||
|
||||
public abstract String getAlarmType();
|
||||
|
||||
public String getAlarmName() { return alarmName; }
|
||||
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
|
||||
|
||||
public String getAlarmArn() { return alarmArn; }
|
||||
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
|
||||
|
||||
public String getAlarmDescription() { return alarmDescription; }
|
||||
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
|
||||
|
||||
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
|
||||
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
|
||||
|
||||
public boolean isActionsEnabled() { return actionsEnabled; }
|
||||
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
|
||||
|
||||
public List<String> getOkActions() { return okActions; }
|
||||
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
|
||||
|
||||
public List<String> getAlarmActions() { return alarmActions; }
|
||||
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
|
||||
|
||||
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
|
||||
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
|
||||
|
||||
public String getStateValue() { return stateValue; }
|
||||
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
|
||||
|
||||
public String getStateReason() { return stateReason; }
|
||||
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
|
||||
|
||||
public String getStateReasonData() { return stateReasonData; }
|
||||
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
|
||||
|
||||
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
|
||||
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
|
||||
|
||||
public Map<String, String> getTags() { return tags; }
|
||||
public void setTags(Map<String, String> tags) { this.tags = tags; }
|
||||
|
||||
|
||||
public String getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
}
|
||||
+46
@@ -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<MetricDimension> dimensions) { }
|
||||
|
||||
@RegisterForReflection
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record MetricDimension(
|
||||
@JsonProperty("Name") String name,
|
||||
@JsonProperty("Value") String value) { }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics.model;
|
||||
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
@RegisterForReflection
|
||||
public class CompositeAlarm extends Alarm {
|
||||
private String alarmRule;
|
||||
|
||||
@Override
|
||||
public String getAlarmType() { return "CompositeAlarm"; }
|
||||
|
||||
public String getAlarmRule() { return alarmRule; }
|
||||
public void setAlarmRule(String alarmRule) { this.alarmRule = alarmRule; }
|
||||
}
|
||||
+14
-70
@@ -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<String> okActions = new ArrayList<>();
|
||||
private List<String> alarmActions = new ArrayList<>();
|
||||
private List<String> insufficientDataActions = new ArrayList<>();
|
||||
private String stateValue = "INSUFFICIENT_DATA";
|
||||
private String stateReason = "Unchecked";
|
||||
private String stateReasonData;
|
||||
private long stateUpdatedTimestamp;
|
||||
public class MetricAlarm extends Alarm {
|
||||
@Override
|
||||
public String getAlarmType() { return "MetricAlarm"; }
|
||||
|
||||
private String metricName;
|
||||
private String namespace;
|
||||
private String statistic;
|
||||
private List<Dimension> dimensions = new ArrayList<>();
|
||||
private int period;
|
||||
/** Single-metric period; absent for alarms whose periods are defined in Metrics. */
|
||||
private Integer period;
|
||||
private List<AlarmMetricDataQuery> 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<String, String> tags = new HashMap<>();
|
||||
private String region;
|
||||
|
||||
public MetricAlarm() {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
this.alarmConfigurationUpdatedTimestamp = now;
|
||||
this.stateUpdatedTimestamp = now;
|
||||
}
|
||||
|
||||
public String getAlarmName() { return alarmName; }
|
||||
public void setAlarmName(String alarmName) { this.alarmName = alarmName; }
|
||||
|
||||
public String getAlarmArn() { return alarmArn; }
|
||||
public void setAlarmArn(String alarmArn) { this.alarmArn = alarmArn; }
|
||||
|
||||
public String getAlarmDescription() { return alarmDescription; }
|
||||
public void setAlarmDescription(String alarmDescription) { this.alarmDescription = alarmDescription; }
|
||||
|
||||
public long getAlarmConfigurationUpdatedTimestamp() { return alarmConfigurationUpdatedTimestamp; }
|
||||
public void setAlarmConfigurationUpdatedTimestamp(long timestamp) { this.alarmConfigurationUpdatedTimestamp = timestamp; }
|
||||
|
||||
public boolean isActionsEnabled() { return actionsEnabled; }
|
||||
public void setActionsEnabled(boolean actionsEnabled) { this.actionsEnabled = actionsEnabled; }
|
||||
|
||||
public List<String> getOkActions() { return okActions; }
|
||||
public void setOkActions(List<String> okActions) { this.okActions = okActions; }
|
||||
|
||||
public List<String> getAlarmActions() { return alarmActions; }
|
||||
public void setAlarmActions(List<String> alarmActions) { this.alarmActions = alarmActions; }
|
||||
|
||||
public List<String> getInsufficientDataActions() { return insufficientDataActions; }
|
||||
public void setInsufficientDataActions(List<String> insufficientDataActions) { this.insufficientDataActions = insufficientDataActions; }
|
||||
|
||||
public String getStateValue() { return stateValue; }
|
||||
public void setStateValue(String stateValue) { this.stateValue = stateValue; }
|
||||
|
||||
public String getStateReason() { return stateReason; }
|
||||
public void setStateReason(String stateReason) { this.stateReason = stateReason; }
|
||||
|
||||
public String getStateReasonData() { return stateReasonData; }
|
||||
public void setStateReasonData(String stateReasonData) { this.stateReasonData = stateReasonData; }
|
||||
|
||||
public long getStateUpdatedTimestamp() { return stateUpdatedTimestamp; }
|
||||
public void setStateUpdatedTimestamp(long stateUpdatedTimestamp) { this.stateUpdatedTimestamp = stateUpdatedTimestamp; }
|
||||
|
||||
public String getMetricName() { return metricName; }
|
||||
public void setMetricName(String metricName) { this.metricName = metricName; }
|
||||
@@ -100,8 +46,13 @@ public class MetricAlarm {
|
||||
public List<Dimension> getDimensions() { return dimensions; }
|
||||
public void setDimensions(List<Dimension> 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<AlarmMetricDataQuery> getMetrics() { return metrics; }
|
||||
public void setMetrics(List<AlarmMetricDataQuery> 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<String, String> getTags() { return tags; }
|
||||
public void setTags(Map<String, String> tags) { this.tags = tags; }
|
||||
|
||||
/** Not part of the AWS shape; set by {@code putMetricAlarm} so a background evaluator
|
||||
* that only holds the alarm object still knows which region's metric data to query. */
|
||||
public String getRegion() { return region; }
|
||||
public void setRegion(String region) { this.region = region; }
|
||||
}
|
||||
|
||||
+18
-9
@@ -196,21 +196,30 @@ public class CloudWatchMetricStreamsService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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<String> 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<MetricStream> findByArn(String resourceArn, String region) {
|
||||
|
||||
+57
-19
@@ -50,6 +50,7 @@ public class CognitoHostedLoginController {
|
||||
private static final Duration SESSION_LIFETIME = Duration.ofHours(1);
|
||||
private static final Duration AUTHORIZATION_CODE_LIFETIME = Duration.ofMinutes(5);
|
||||
private static final String COGNITO_PROVIDER = "COGNITO";
|
||||
private static final String NO_PROVIDERS = "This app client has no identity providers to sign in with.";
|
||||
|
||||
private final CognitoService cognitoService;
|
||||
private final CognitoFederationStateStore stateStore;
|
||||
@@ -83,16 +84,16 @@ public class CognitoHostedLoginController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues an authorize request for the pool's own users: an existing session gets a code
|
||||
* straight away, anyone else is sent to the login page.
|
||||
* Continues an authorize request that names no external provider: an existing session gets a
|
||||
* code straight away, anyone else is sent to the login page, which offers the pool's own
|
||||
* password form and a button for each of the client's other identity providers.
|
||||
*/
|
||||
Response authorizeWithCognito(ContainerRequestContext requestContext, UserPoolClient client,
|
||||
AuthorizeRequest request, String prompt, String sessionId) {
|
||||
if (!client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER)) {
|
||||
return errorPage("This app client does not offer sign-in with a user name and password "
|
||||
+ "(COGNITO is not one of its identity providers).");
|
||||
if (!offersCognito(client) && externalProviders(client).isEmpty()) {
|
||||
return errorPage(NO_PROVIDERS);
|
||||
}
|
||||
if (!"login".equals(prompt)) {
|
||||
if (offersCognito(client) && !"login".equals(prompt)) {
|
||||
Optional<CognitoUser> signedIn = sessionUser(client, sessionId);
|
||||
if (signedIn.isPresent()) {
|
||||
return redirectWithCode(client, request, signedIn.get(), null);
|
||||
@@ -121,7 +122,7 @@ public class CognitoHostedLoginController {
|
||||
return error;
|
||||
}
|
||||
String csrf = randomToken();
|
||||
return loginForm((UserPoolClient) validated, request, csrf, null, null, 200)
|
||||
return loginForm(requestContext, (UserPoolClient) validated, request, csrf, null, null, 200)
|
||||
.cookie(cookie(requestContext, CSRF_COOKIE, csrf, -1))
|
||||
.build();
|
||||
}
|
||||
@@ -141,6 +142,10 @@ public class CognitoHostedLoginController {
|
||||
return error;
|
||||
}
|
||||
UserPoolClient client = (UserPoolClient) validated;
|
||||
if (!offersCognito(client)) {
|
||||
return errorPage("This app client does not offer sign-in with a user name and password "
|
||||
+ "(COGNITO is not one of its identity providers).");
|
||||
}
|
||||
String csrf = form.getFirst("_csrf");
|
||||
if (csrf == null || csrfCookie == null || !constantTimeEquals(csrf, csrfCookie)) {
|
||||
return errorPage("Your sign-in form expired. Go back to the app and sign in again.");
|
||||
@@ -149,14 +154,14 @@ public class CognitoHostedLoginController {
|
||||
String username = trimToNull(form.getFirst("username"));
|
||||
String password = form.getFirst("password");
|
||||
if (username == null || password == null || password.isEmpty()) {
|
||||
return loginForm(client, request, csrf, username, "Enter your user name and password.", 400).build();
|
||||
return loginForm(requestContext, client, request, csrf, username, "Enter your user name and password.", 400).build();
|
||||
}
|
||||
|
||||
CognitoUser user;
|
||||
try {
|
||||
user = cognitoService.authenticateHostedLogin(client, username, password);
|
||||
} catch (AwsException e) {
|
||||
return loginForm(client, request, csrf, username, describe(e), 400).build();
|
||||
return loginForm(requestContext, client, request, csrf, username, describe(e), 400).build();
|
||||
}
|
||||
|
||||
String sessionId = stateStore.putHostedSession(new CognitoHostedSession(
|
||||
@@ -253,9 +258,8 @@ public class CognitoHostedLoginController {
|
||||
if (!client.getCallbackURLs().contains(request.redirectUri())) {
|
||||
return errorPage("redirect_uri is not registered for this app client.");
|
||||
}
|
||||
if (!client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER)) {
|
||||
return errorPage("This app client does not offer sign-in with a user name and password "
|
||||
+ "(COGNITO is not one of its identity providers).");
|
||||
if (!offersCognito(client) && externalProviders(client).isEmpty()) {
|
||||
return errorPage(NO_PROVIDERS);
|
||||
}
|
||||
String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod());
|
||||
if (pkceError != null) {
|
||||
@@ -264,6 +268,16 @@ public class CognitoHostedLoginController {
|
||||
return client;
|
||||
}
|
||||
|
||||
private static boolean offersCognito(UserPoolClient client) {
|
||||
return client.getSupportedIdentityProviders().contains(COGNITO_PROVIDER);
|
||||
}
|
||||
|
||||
private static List<String> externalProviders(UserPoolClient client) {
|
||||
return client.getSupportedIdentityProviders().stream()
|
||||
.filter(provider -> !COGNITO_PROVIDER.equals(provider))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static String pkceError(String codeChallenge, String codeChallengeMethod) {
|
||||
if (codeChallenge == null) {
|
||||
return codeChallengeMethod == null ? null : "code_challenge_method was sent without code_challenge.";
|
||||
@@ -300,8 +314,9 @@ public class CognitoHostedLoginController {
|
||||
};
|
||||
}
|
||||
|
||||
private Response.ResponseBuilder loginForm(UserPoolClient client, AuthorizeRequest request, String csrf,
|
||||
String username, String error, int status) {
|
||||
private Response.ResponseBuilder loginForm(ContainerRequestContext requestContext, UserPoolClient client,
|
||||
AuthorizeRequest request, String csrf, String username,
|
||||
String error, int status) {
|
||||
UserPool pool = cognitoService.describeUserPool(client.getUserPoolId());
|
||||
boolean email = pool.getUsernameAttributes() != null && pool.getUsernameAttributes().contains("email");
|
||||
StringBuilder hidden = new StringBuilder();
|
||||
@@ -312,19 +327,32 @@ public class CognitoHostedLoginController {
|
||||
hidden.append("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">");
|
||||
String appName = client.getClientName() == null || client.getClientName().isBlank()
|
||||
? pool.getName() : client.getClientName();
|
||||
String body = """
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">to continue to <strong>%s</strong></p>
|
||||
%s
|
||||
// Each external provider starts the federated flow with the same authorize parameters.
|
||||
StringBuilder providers = new StringBuilder();
|
||||
for (String provider : externalProviders(client)) {
|
||||
Map<String, String> query = new LinkedHashMap<>(request.asQuery());
|
||||
query.put("identity_provider", provider);
|
||||
providers.append("<a class=\"provider\" href=\"").append(escape(authorizePath(requestContext) + "?" + query(query)))
|
||||
.append("\">Continue with ").append(escape(provider)).append("</a>");
|
||||
}
|
||||
String passwordForm = !offersCognito(client) ? "" : """
|
||||
<form method="post" action="">
|
||||
%s
|
||||
<label>%s<input name="username" type="%s" autocomplete="username" value="%s" required autofocus></label>
|
||||
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
""".formatted(hidden, email ? "Email" : "User name", email ? "email" : "text",
|
||||
escape(username == null ? "" : username));
|
||||
String divider = providers.isEmpty() || passwordForm.isEmpty() ? "" : "<p class=\"divider\"><span>or</span></p>";
|
||||
String body = """
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">to continue to <strong>%s</strong></p>
|
||||
%s
|
||||
%s%s<div class="providers">%s</div>
|
||||
""".formatted(escape(appName),
|
||||
error == null ? "" : "<p class=\"error\" role=\"alert\">" + escape(error) + "</p>",
|
||||
hidden, email ? "Email" : "User name", email ? "email" : "text", escape(username == null ? "" : username));
|
||||
passwordForm, divider, providers);
|
||||
return Response.status(status).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"))
|
||||
.entity(page("Sign in", body))
|
||||
.header("Cache-Control", "no-store")
|
||||
@@ -369,6 +397,11 @@ public class CognitoHostedLoginController {
|
||||
input { font:inherit; padding:10px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); }
|
||||
input:focus { outline:2px solid var(--accent); outline-offset:1px; }
|
||||
button { font:inherit; font-weight:600; padding:10px 12px; border:0; border-radius:6px; background:var(--accent); color:var(--on-accent); cursor:pointer; }
|
||||
.providers { display:grid; gap:10px; }
|
||||
.provider { display:block; padding:10px 12px; border:1px solid var(--border); border-radius:6px; color:var(--text); font-weight:600; text-align:center; text-decoration:none; overflow-wrap:anywhere; }
|
||||
.provider:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.divider { display:flex; align-items:center; gap:10px; margin:16px 0; color:var(--muted); font-size:13px; }
|
||||
.divider::before, .divider::after { content:""; flex:1; border-top:1px solid var(--border); }
|
||||
.error { margin:0 0 16px; padding:10px 12px; border-radius:6px; background:var(--error-bg); color:var(--error); overflow-wrap:anywhere; }
|
||||
footer { margin-top:20px; color:var(--muted); font-size:12px; text-align:center; }
|
||||
</style>
|
||||
@@ -391,6 +424,11 @@ public class CognitoHostedLoginController {
|
||||
return "https".equalsIgnoreCase(requestContext.getUriInfo().getRequestUri().getScheme());
|
||||
}
|
||||
|
||||
private static String authorizePath(ContainerRequestContext requestContext) {
|
||||
boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null;
|
||||
return customDomain ? "/oauth2/authorize" : "/cognito-idp/oauth2/authorize";
|
||||
}
|
||||
|
||||
/** Custom domains serve the pages at /login and /logout; everything else under /cognito-idp/. */
|
||||
private static String pagePath(ContainerRequestContext requestContext, String page) {
|
||||
boolean customDomain = requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY) != null;
|
||||
|
||||
@@ -3,13 +3,19 @@ package io.github.hectorvent.floci.services.cognito.model;
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Information about a revoked token for tracking and validation purposes.
|
||||
* Used to implement AWS Cognito AdminUserGlobalSignOut token revocation behavior.
|
||||
*
|
||||
* <p>{@code expired} is derived from {@code expiresAt}, so it is not stored. Files written before
|
||||
* that still carry it, and are read with it ignored instead of being discarded as corrupt.
|
||||
*/
|
||||
@RegisterForReflection
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class RevokedTokenInfo {
|
||||
|
||||
private final String jti;
|
||||
@@ -62,6 +68,7 @@ public class RevokedTokenInfo {
|
||||
/**
|
||||
* Check if this revocation record has expired and can be cleaned up.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public boolean isExpired() {
|
||||
return System.currentTimeMillis() / 1000L > expiresAt;
|
||||
}
|
||||
|
||||
@@ -1332,6 +1332,23 @@ public class S3Service implements Resettable, ResourceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** An object's metadata together with an open stream of its body; the caller closes it. */
|
||||
public record ObjectRead(S3Object object, InputStream body) implements java.io.Closeable {
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
body.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #getObject}, but the body is an open stream instead of a byte array, so a large
|
||||
* object is served without being read into the heap.
|
||||
*/
|
||||
public ObjectRead openObject(String bucketName, String key, String versionId) {
|
||||
S3Object object = getObjectMetadata(bucketName, key, versionId);
|
||||
return new ObjectRead(object, openObjectStream(bucketName, key, versionId));
|
||||
}
|
||||
|
||||
public InputStream openObjectStream(String bucketName, String key, String versionId) {
|
||||
getObjectMetadata(bucketName, key, versionId);
|
||||
String bucketOwnerAccount = resolveBucketEntry(bucketName)
|
||||
|
||||
+25
@@ -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")
|
||||
|
||||
+2
@@ -45,6 +45,7 @@ import io.github.hectorvent.floci.services.cloudformation.provisioners.AppSyncCf
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CdkMetadataCfnProvisioner;
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudTrailCfnProvisioner;
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchCfnProvisioner;
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchCompositeAlarmCfnProvisioner;
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CloudWatchDashboardCfnProvisioner;
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CognitoCfnProvisioner;
|
||||
import io.github.hectorvent.floci.services.cloudformation.provisioners.CognitoUserPoolGroupCfnProvisioner;
|
||||
@@ -300,6 +301,7 @@ final class CfnProvisionerFixture {
|
||||
}
|
||||
if (cloudWatchMetricsService != null) {
|
||||
discovered.add(new CloudWatchCfnProvisioner(cloudWatchMetricsService));
|
||||
discovered.add(new CloudWatchCompositeAlarmCfnProvisioner(cloudWatchMetricsService));
|
||||
}
|
||||
if (cloudWatchDashboardsService != null) {
|
||||
discovered.add(new CloudWatchDashboardCfnProvisioner(cloudWatchDashboardsService));
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package io.github.hectorvent.floci.services.cloudformation;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.testing.CfnStackWaits;
|
||||
import io.github.hectorvent.floci.testing.PartitionCleanup;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.common.http.TestHTTPResource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.Response;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@QuarkusTest
|
||||
class CloudFormationCompositeAlarmIntegrationTest {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@TestHTTPResource
|
||||
URI baseUri;
|
||||
|
||||
@RegisterExtension
|
||||
final PartitionCleanup cleanup = new PartitionCleanup();
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void failedUpdateRestoresRuleTagsAndIdentity(boolean rename) throws Exception {
|
||||
String stack = unique("composite-rollback");
|
||||
String name = stack + "-alarm";
|
||||
String next = rename ? name + "-new" : name;
|
||||
deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "old", "drop", "restore"), false, false));
|
||||
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
String arn = alarm(name).get("AlarmArn").toString();
|
||||
Response described = given().contentType("application/x-www-form-urlencoded")
|
||||
.formParam("Action", "DescribeStacks").formParam("StackName", stack).post("/");
|
||||
assertEquals(name, described.xmlPath().getString(
|
||||
"DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Name' }.OutputValue"));
|
||||
assertEquals(arn, described.xmlPath().getString(
|
||||
"DescribeStacksResponse.DescribeStacksResult.Stacks.member.Outputs.member.find { it.OutputKey == 'Arn' }.OutputValue"));
|
||||
cw("TagResource", Map.of("ResourceARN", arn, "Tags", List.of(Map.of("Key", "external", "Value", "keep"))))
|
||||
.then().statusCode(200);
|
||||
deploy("UpdateStack", stack, template(next, "FALSE", Map.of("team", "new", "added", "remove"), true, false));
|
||||
assertEquals("UPDATE_ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals("TRUE", alarm(name).get("AlarmRule"));
|
||||
List<Map<String, String>> tags = cw("ListTagsForResource", Map.of("ResourceARN", arn))
|
||||
.then().statusCode(200).extract().jsonPath().getList("Tags");
|
||||
assertEquals(Map.of("team", "old", "drop", "restore", "external", "keep"),
|
||||
tags.stream().collect(Collectors.toMap(t -> t.get("Key"), t -> t.get("Value"))));
|
||||
if (rename) {
|
||||
assertMissing(next);
|
||||
}
|
||||
deleteStack(stack);
|
||||
assertMissing(name);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void replacementHonorsRetentionAndDeletesItsCurrentBackingAlarm(boolean retain) throws Exception {
|
||||
String stack = unique("composite-replace");
|
||||
String original = stack + "-old";
|
||||
String next = stack + "-new";
|
||||
deploy("CreateStack", stack, template(original, "TRUE", Map.of(), false, retain));
|
||||
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
deploy("UpdateStack", stack, template(next, "FALSE", Map.of(), false, retain));
|
||||
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals("FALSE", alarm(next).get("AlarmRule"));
|
||||
if (retain) {
|
||||
assertEquals("TRUE", alarm(original).get("AlarmRule"));
|
||||
} else {
|
||||
assertMissing(original);
|
||||
}
|
||||
deleteStack(stack);
|
||||
assertMissing(next);
|
||||
if (retain) {
|
||||
assertEquals("TRUE", alarm(original).get("AlarmRule"));
|
||||
cw("DeleteAlarms", Map.of("AlarmNames", List.of(original))).then().statusCode(200);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedInPlaceUpdatesAllowAnotherUpdateAndReplacement() throws Exception {
|
||||
String stack = unique("composite-sequence");
|
||||
String name = stack + "-alarm";
|
||||
String replacement = name + "-new";
|
||||
deploy("CreateStack", stack, template(name, "TRUE", Map.of("team", "initial"), false, false));
|
||||
assertEquals("CREATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
for (String rule : List.of("FALSE", "TRUE")) {
|
||||
deploy("UpdateStack", stack, template(name, rule, Map.of("team", rule), false, false));
|
||||
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals(rule, alarm(name).get("AlarmRule"));
|
||||
}
|
||||
deploy("UpdateStack", stack, template(replacement, "FALSE", Map.of(), false, false));
|
||||
assertEquals("UPDATE_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertMissing(name);
|
||||
assertEquals("FALSE", alarm(replacement).get("AlarmRule"));
|
||||
deleteStack(stack);
|
||||
assertMissing(replacement);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createCollisionCannotOverwriteOrDeleteAnUnmanagedAlarm() throws Exception {
|
||||
String stack = unique("composite-foreign");
|
||||
String name = stack + "-alarm";
|
||||
cw("PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE")).then().statusCode(200);
|
||||
deploy("CreateStack", stack, template(name, "FALSE", Map.of(), false, false));
|
||||
assertEquals("ROLLBACK_COMPLETE", CfnStackWaits.awaitTerminal(stack).status());
|
||||
assertEquals("TRUE", alarm(name).get("AlarmRule"));
|
||||
deleteStack(stack);
|
||||
assertEquals("TRUE", alarm(name).get("AlarmRule"));
|
||||
cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200);
|
||||
}
|
||||
|
||||
private String template(String name, String rule, Map<String, String> tags, boolean fail, boolean retain)
|
||||
throws JsonProcessingException {
|
||||
cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200));
|
||||
Map<String, Object> resources = new LinkedHashMap<>();
|
||||
resources.put("Alarm", Map.of("Type", "AWS::CloudWatch::CompositeAlarm",
|
||||
"UpdateReplacePolicy", retain ? "Retain" : "Delete",
|
||||
"Properties", Map.of("AlarmName", name, "AlarmRule", rule,
|
||||
"Tags", tags.entrySet().stream().map(e -> Map.of("Key", e.getKey(), "Value", e.getValue())).toList())));
|
||||
if (fail) {
|
||||
resources.put("Failure", Map.of("Type", "AWS::SecretsManager::Secret", "DependsOn", "Alarm",
|
||||
"Properties", Map.of("SecretString", "explicit", "GenerateSecretString", Map.of("PasswordLength", 32))));
|
||||
}
|
||||
return MAPPER.writeValueAsString(Map.of("Resources", resources, "Outputs", Map.of(
|
||||
"Name", Map.of("Value", Map.of("Ref", "Alarm")),
|
||||
"Arn", Map.of("Value", Map.of("Fn::GetAtt", List.of("Alarm", "Arn"))))));
|
||||
}
|
||||
|
||||
private void deploy(String action, String stack, String template) {
|
||||
if ("CreateStack".equals(action)) {
|
||||
cleanup.register(() -> given().port(baseUri.getPort())
|
||||
.contentType("application/x-www-form-urlencoded")
|
||||
.formParam("Action", "DeleteStack").formParam("StackName", stack)
|
||||
.post("/").then().statusCode(200));
|
||||
}
|
||||
given().contentType("application/x-www-form-urlencoded").formParam("Action", action)
|
||||
.formParam("StackName", stack).formParam("TemplateBody", template).post("/").then().statusCode(200);
|
||||
}
|
||||
|
||||
private Response cw(String operation, Map<String, ?> body) {
|
||||
if ("PutCompositeAlarm".equals(operation)) {
|
||||
String name = body.get("AlarmName").toString();
|
||||
cleanup.register(() -> cw("DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200));
|
||||
}
|
||||
return given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0")
|
||||
.header("X-Amz-Target", "GraniteServiceVersion20100801." + operation)
|
||||
.body(MAPPER.valueToTree(body).toString()).post("/");
|
||||
}
|
||||
|
||||
private Map<String, Object> alarm(String name) throws JsonProcessingException {
|
||||
List<Map<String, Object>> alarms = cw("DescribeAlarms", Map.of("AlarmNames", List.of(name),
|
||||
"AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200)
|
||||
.extract().jsonPath().getList("CompositeAlarms");
|
||||
assertEquals(1, alarms.size(), name);
|
||||
return alarms.getFirst();
|
||||
}
|
||||
|
||||
private void assertMissing(String name) throws JsonProcessingException {
|
||||
assertEquals(List.of(), cw("DescribeAlarms", Map.of("AlarmNames", List.of(name),
|
||||
"AlarmTypes", List.of("CompositeAlarm"))).then().statusCode(200)
|
||||
.extract().jsonPath().getList("CompositeAlarms"));
|
||||
}
|
||||
|
||||
private void deleteStack(String stack) {
|
||||
given().contentType("application/x-www-form-urlencoded").formParam("Action", "DeleteStack")
|
||||
.formParam("StackName", stack).post("/").then().statusCode(200);
|
||||
CfnStackWaits.awaitStackDeleted(stack);
|
||||
}
|
||||
|
||||
private static String unique(String prefix) {
|
||||
return prefix + "-" + Long.toString(System.nanoTime(), 36);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package io.github.hectorvent.floci.services.cloudformation.provisioners;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.services.cloudformation.CloudFormationTemplateEngine;
|
||||
import io.github.hectorvent.floci.services.cloudformation.model.StackResource;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class CloudWatchCompositeAlarmCfnProvisionerTest {
|
||||
@Test
|
||||
void rejectedBackingUpdateDoesNotArmRollbackToDeleteAnUnmanagedReplacement() throws Exception {
|
||||
CloudWatchMetricsService service = mock(CloudWatchMetricsService.class);
|
||||
CloudWatchCompositeAlarmCfnProvisioner provisioner = new CloudWatchCompositeAlarmCfnProvisioner(service);
|
||||
CloudFormationTemplateEngine engine = mock(CloudFormationTemplateEngine.class);
|
||||
when(engine.resolve(any())).thenAnswer(call -> {
|
||||
JsonNode node = call.getArgument(0);
|
||||
return node == null || node.isMissingNode() ? null : node.asText();
|
||||
});
|
||||
when(engine.resolveNode(any())).thenAnswer(call -> call.getArgument(0));
|
||||
StackResource resource = new StackResource();
|
||||
resource.setLogicalId("Alarm");
|
||||
resource.setResourceType("AWS::CloudWatch::CompositeAlarm");
|
||||
resource.setPhysicalId("kept");
|
||||
resource.getAttributes().put("Arn", "prior-arn");
|
||||
AwsException collision = new AwsException("ValidationError", "A metric alarm now has this name", 400);
|
||||
doThrow(collision).when(service).putCompositeAlarm(any(), eq("us-east-1"), eq(true));
|
||||
ProvisionContext context = new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "kept");
|
||||
JsonNode props = new ObjectMapper().readTree("{\"AlarmName\":\"kept\",\"AlarmRule\":\"TRUE\"}");
|
||||
assertSame(collision, assertThrows(AwsException.class, () -> provisioner.provision(resource, props, context)));
|
||||
assertFalse(provisioner.retainsFailedUpdateState(resource));
|
||||
assertFalse(provisioner.rollbackUpdate(resource));
|
||||
assertEquals("kept", resource.getPhysicalId());
|
||||
assertEquals(Map.of("Arn", "prior-arn"), resource.getAttributes());
|
||||
verify(service, never()).deleteAlarms(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inPlaceUpdateRetriesPreviouslyDisplacedAlarmCleanup() throws Exception {
|
||||
CloudWatchMetricsService service = mock(CloudWatchMetricsService.class);
|
||||
CloudWatchCompositeAlarmCfnProvisioner provisioner = new CloudWatchCompositeAlarmCfnProvisioner(service);
|
||||
CloudFormationTemplateEngine engine = mock(CloudFormationTemplateEngine.class);
|
||||
when(engine.resolve(any())).thenAnswer(call -> {
|
||||
JsonNode node = call.getArgument(0);
|
||||
return node == null || node.isMissingNode() ? null : node.asText();
|
||||
});
|
||||
when(engine.resolveNode(any())).thenAnswer(call -> call.getArgument(0));
|
||||
StackResource resource = new StackResource();
|
||||
resource.setLogicalId("Alarm");
|
||||
resource.setResourceType("AWS::CloudWatch::CompositeAlarm");
|
||||
resource.setPhysicalId("current");
|
||||
ReplacementCleanup.record(resource,
|
||||
new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "displaced"), Map.of());
|
||||
doThrow(new IllegalStateException("delete unavailable"))
|
||||
.doThrow(new IllegalStateException("delete still unavailable")).doNothing()
|
||||
.when(service).deleteAlarms(List.of("displaced"), "us-east-1");
|
||||
assertFalse(provisioner.completeUpdate(resource).complete());
|
||||
provisioner.clearUpdate(resource);
|
||||
assertEquals(Set.of("displaced"), ReplacementCleanup.owedPhysicalIds(resource));
|
||||
|
||||
ProvisionContext context = new ProvisionContext(engine, "us-east-1", "000000000000", "stack", "current");
|
||||
JsonNode props = new ObjectMapper().readTree("{\"AlarmName\":\"current\",\"AlarmRule\":\"TRUE\"}");
|
||||
provisioner.provision(resource, props, context);
|
||||
assertTrue(provisioner.retainsFailedUpdateState(resource));
|
||||
UpdateCleanupResult retry = provisioner.completeUpdate(resource);
|
||||
assertFalse(retry.complete());
|
||||
assertEquals(2, retry.attempts());
|
||||
assertEquals("displaced", retry.previousPhysicalId());
|
||||
assertEquals("delete still unavailable", retry.failureReason());
|
||||
assertEquals(Set.of("displaced"), ReplacementCleanup.owedPhysicalIds(resource));
|
||||
assertTrue(provisioner.completeUpdate(resource).complete());
|
||||
provisioner.clearUpdate(resource);
|
||||
assertFalse(provisioner.retainsFailedUpdateState(resource));
|
||||
assertTrue(ReplacementCleanup.owedPhysicalIds(resource).isEmpty());
|
||||
assertEquals("current", resource.getPhysicalId());
|
||||
verify(service, times(3)).deleteAlarms(List.of("displaced"), "us-east-1");
|
||||
verify(service, never()).deleteAlarms(List.of("current"), "us-east-1");
|
||||
}
|
||||
|
||||
}
|
||||
+751
@@ -1,39 +1,70 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.CacheBehavior;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.CachePolicy;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.DefaultCacheBehavior;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.Distribution;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.DistributionConfig;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.KeyGroup;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.Origin;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.OriginRequestPolicy;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.PublicKey;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.ResponseHeadersPolicy;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.QuarkusTestProfile;
|
||||
import io.quarkus.test.junit.TestProfile;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.response.Response;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.Signature;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@QuarkusTest
|
||||
@TestProfile(CloudFrontCustomOriginServingTest.PrivateOriginProfile.class)
|
||||
class CloudFrontCustomOriginServingTest {
|
||||
|
||||
/** The AWS managed CachingDisabled cache policy, which forwards no viewer values. */
|
||||
private static final String CACHING_DISABLED = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
|
||||
|
||||
@Inject
|
||||
CloudFrontService cloudFrontService;
|
||||
|
||||
@@ -220,6 +251,726 @@ class CloudFrontCustomOriginServingTest {
|
||||
removeOnlyResponse.getHeader("Date"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsWriteMethodsWithTheirBodyToTheOrigin() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
String json = "{\"name\":\"widget\",\"note\":\"café\"}";
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/json; charset=UTF-8")
|
||||
.body(json.getBytes(StandardCharsets.UTF_8))
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(201)
|
||||
.header("X-Origin-Method", equalTo("POST"))
|
||||
.body(equalTo("created"));
|
||||
ReceivedRequest post = received.get();
|
||||
assertEquals("POST", post.method());
|
||||
assertEquals("/api/things", post.path());
|
||||
assertEquals("application/json; charset=UTF-8", post.header("Content-Type"));
|
||||
assertEquals(Integer.toString(json.getBytes(StandardCharsets.UTF_8).length),
|
||||
post.header("Content-Length"));
|
||||
assertArrayEquals(json.getBytes(StandardCharsets.UTF_8), post.body());
|
||||
|
||||
byte[] binary = new byte[] {0, 1, 2, (byte) 0xff, (byte) 0xfe, 10, 13};
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/octet-stream")
|
||||
.body(binary)
|
||||
.when()
|
||||
.put("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("PUT"));
|
||||
assertEquals("PUT", received.get().method());
|
||||
assertTrue(received.get().header("Content-Type").startsWith("application/octet-stream"));
|
||||
assertArrayEquals(binary, received.get().body());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/x-www-form-urlencoded")
|
||||
.body("name=widget&size=2")
|
||||
.when()
|
||||
.patch("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("PATCH"));
|
||||
assertEquals("PATCH", received.get().method());
|
||||
assertTrue(received.get().header("Content-Type")
|
||||
.startsWith("application/x-www-form-urlencoded"));
|
||||
assertEquals("name=widget&size=2",
|
||||
new String(received.get().body(), StandardCharsets.UTF_8));
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.when()
|
||||
.delete("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("DELETE"));
|
||||
assertEquals("DELETE", received.get().method());
|
||||
assertEquals("/api/things/1", received.get().path());
|
||||
assertEquals(0, received.get().body().length);
|
||||
assertNull(received.get().header("Content-Type"));
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.when()
|
||||
.post("/api/empty")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
assertEquals("0", received.get().header("Content-Length"));
|
||||
assertEquals(5, hits.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamsALargeOriginResponseWithItsLength() throws Exception {
|
||||
byte[] video = randomBytes(3 * 1024 * 1024 + 1);
|
||||
startServingOrigin(video, true);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
Response response = given().header("Host", created.getDomainName()).when().get("/video.bin");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(Integer.toString(video.length), response.header("Content-Length"));
|
||||
assertArrayEquals(video, response.asByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesAChunkedOriginResponseThroughChunked() throws Exception {
|
||||
byte[] video = randomBytes(3 * 1024 * 1024 + 1);
|
||||
startServingOrigin(video, false);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
Response response = given().header("Host", created.getDomainName()).when().get("/video.bin");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertNull(response.header("Content-Length"), "an origin that sent no length was given one");
|
||||
assertArrayEquals(video, response.asByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamsALargeViewerBodyToTheOriginWithItsLength() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
startRecordingOrigin(received, new AtomicInteger());
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
byte[] upload = randomBytes(3 * 1024 * 1024 + 1);
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/octet-stream")
|
||||
.body(upload)
|
||||
.when()
|
||||
.put("/uploads/video.bin")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
assertEquals(Integer.toString(upload.length), received.get().header("Content-Length"));
|
||||
assertArrayEquals(upload, received.get().body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsAChunkedViewerBodyChunked() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
startRecordingOrigin(received, new AtomicInteger());
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
byte[] upload = randomBytes(200_001);
|
||||
|
||||
int status = assertTimeoutPreemptively(Duration.ofSeconds(60),
|
||||
() -> postChunked(created.getDomainName(), "/uploads", upload));
|
||||
|
||||
assertEquals(201, status);
|
||||
assertEquals("chunked", received.get().header("Transfer-Encoding"));
|
||||
assertNull(received.get().header("Content-Length"));
|
||||
assertArrayEquals(upload, received.get().body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasesOriginConnectionsWhenViewersDisconnectBeforeOriginHeaders() throws Exception {
|
||||
BlockingQueue<CountDownLatch> pendingResponses = new LinkedBlockingQueue<>();
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
CountDownLatch sendResponse = new CountDownLatch(1);
|
||||
pendingResponses.add(sendResponse);
|
||||
try {
|
||||
if (!sendResponse.await(10, TimeUnit.SECONDS)) {
|
||||
throw new IOException("Timed out waiting for viewer disconnect");
|
||||
}
|
||||
byte[] body = "origin-ok".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Origin response interrupted", e);
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
});
|
||||
originServer.createContext("/after-disconnects", exchange -> {
|
||||
byte[] body = "origin-ok".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
for (int i = 0; i < CloudFrontOriginHttpClient.MAX_CONNECTIONS + 10; i++) {
|
||||
disconnectViewerBeforeOriginHeaders(created.getDomainName(), i, pendingResponses);
|
||||
}
|
||||
|
||||
given().header("Host", created.getDomainName()).when().get("/after-disconnects")
|
||||
.then().statusCode(200).body(equalTo("origin-ok"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasesTheOriginConnectionOfEveryResponseItDoesNotSend() throws Exception {
|
||||
// The origin client keeps a fixed number of connections. An error response replaced by a custom
|
||||
// error page, or a HEAD's, that kept its connection would leave a request past that number
|
||||
// waiting for one.
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
boolean errorPage = "/errors/404.html".equals(exchange.getRequestURI().getRawPath());
|
||||
byte[] body = (errorPage ? "error-page" : "x".repeat(100_000)).getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
||||
boolean head = "HEAD".equals(exchange.getRequestMethod());
|
||||
exchange.sendResponseHeaders(errorPage ? 200 : 404, head ? -1 : body.length);
|
||||
if (!head) {
|
||||
exchange.getResponseBody().write(body);
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
Map<String, Object> errorPage = new LinkedHashMap<>(Map.of(
|
||||
"ErrorCode", "404", "ResponseCode", "200", "ResponsePagePath", "/errors/404.html"));
|
||||
Distribution distribution = customOriginDistribution(allMethodsBehavior());
|
||||
distribution.getConfig().setCustomErrorResponses(List.of(errorPage));
|
||||
Distribution created = cloudFrontService.createDistribution(distribution, Map.of());
|
||||
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(60), () -> {
|
||||
for (int i = 0; i < CloudFrontOriginHttpClient.MAX_CONNECTIONS + 10; i++) {
|
||||
given().header("Host", created.getDomainName()).when().get("/missing/" + i)
|
||||
.then().statusCode(200).body(equalTo("error-page"));
|
||||
given().header("Host", created.getDomainName()).when().head("/missing/" + i)
|
||||
.then().statusCode(200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsTheViewerAuthorizationOnWriteMethodsOnly() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) {
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Authorization", "Bearer viewer-token")
|
||||
.when()
|
||||
.request(method, "/api/things/1")
|
||||
.then()
|
||||
.header("X-Origin-Method", equalTo(method));
|
||||
assertEquals("Bearer viewer-token", received.get().header("Authorization"), method);
|
||||
}
|
||||
|
||||
// CloudFront removes Authorization from GET and HEAD requests.
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Authorization", "Bearer viewer-token")
|
||||
.when()
|
||||
.get("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("GET"));
|
||||
assertNull(received.get().header("Authorization"));
|
||||
assertEquals(5, hits.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void customErrorPageForAWriteIsFetchedWithoutTheViewerAuthorization() throws Exception {
|
||||
Map<String, String> authorizationByRequest = new ConcurrentHashMap<>();
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
String request = exchange.getRequestMethod() + " "
|
||||
+ exchange.getRequestURI().getRawPath();
|
||||
authorizationByRequest.put(request,
|
||||
String.valueOf(exchange.getRequestHeaders().getFirst("Authorization")));
|
||||
boolean errorPage = "GET /errors/500.html".equals(request);
|
||||
byte[] body = (errorPage ? "error-page" : "failed").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
||||
exchange.sendResponseHeaders(errorPage ? 200 : 500, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
|
||||
Map<String, Object> errorPage = new LinkedHashMap<>(Map.of(
|
||||
"ErrorCode", "500", "ResponseCode", "503", "ResponsePagePath", "/errors/500.html"));
|
||||
Distribution distribution = customOriginDistribution(allMethodsBehavior());
|
||||
distribution.getConfig().setCustomErrorResponses(List.of(errorPage));
|
||||
Distribution created = cloudFrontService.createDistribution(distribution, Map.of());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Authorization", "Bearer viewer-token")
|
||||
.contentType("application/json")
|
||||
.body("{}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(503)
|
||||
.body(equalTo("error-page"));
|
||||
assertEquals(Map.of(
|
||||
"POST /api/things", "Bearer viewer-token",
|
||||
"GET /errors/500.html", "null"), authorizationByRequest);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMethodsTheMatchedBehaviorDoesNotAllowWithoutContactingTheOrigin() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
|
||||
CacheBehavior readOnly = new CacheBehavior();
|
||||
readOnly.setPathPattern("/static/*");
|
||||
readOnly.setTargetOriginId("custom-origin");
|
||||
readOnly.setViewerProtocolPolicy("allow-all");
|
||||
readOnly.setAllowedMethods(List.of("GET", "HEAD"));
|
||||
DistributionConfig config = customOriginDistribution(allMethodsBehavior()).getConfig();
|
||||
config.setCacheBehaviors(List.of(readOnly));
|
||||
Distribution distribution = new Distribution();
|
||||
distribution.setConfig(config);
|
||||
Distribution created = cloudFrontService.createDistribution(distribution, Map.of());
|
||||
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) {
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.body("ignored")
|
||||
.when()
|
||||
.request(method, "/static/app.js")
|
||||
.then()
|
||||
.statusCode(403)
|
||||
.body(equalTo("Invalid method."));
|
||||
}
|
||||
assertEquals(0, hits.get());
|
||||
|
||||
// A behavior without AllowedMethods keeps CloudFront's GET/HEAD minimum.
|
||||
Distribution defaultsOnly = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(defaultBehavior("custom-origin")), Map.of());
|
||||
given()
|
||||
.header("Host", defaultsOnly.getDomainName())
|
||||
.body("ignored")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(403)
|
||||
.body(equalTo("Invalid method."));
|
||||
assertEquals(0, hits.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedBehaviorsRequireASignatureForWriteMethods() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keyPair = generator.generateKeyPair();
|
||||
PublicKey publicKey = new PublicKey();
|
||||
publicKey.setName("pk-" + suffix);
|
||||
publicKey.setCallerReference("cr-" + suffix);
|
||||
publicKey.setEncodedKey("-----BEGIN PUBLIC KEY-----\n"
|
||||
+ Base64.getMimeEncoder().encodeToString(keyPair.getPublic().getEncoded())
|
||||
+ "\n-----END PUBLIC KEY-----");
|
||||
publicKey = cloudFrontService.createPublicKey(publicKey);
|
||||
KeyGroup keyGroup = new KeyGroup();
|
||||
keyGroup.setName("kg-" + suffix);
|
||||
keyGroup.setItems(List.of(publicKey.getId()));
|
||||
keyGroup = cloudFrontService.createKeyGroup(keyGroup);
|
||||
|
||||
DefaultCacheBehavior behavior = allMethodsBehavior();
|
||||
behavior.setTrustedKeyGroups(List.of(keyGroup.getId()));
|
||||
behavior.setCachePolicyId(CACHING_DISABLED);
|
||||
behavior.setOriginRequestPolicyId(
|
||||
CloudFrontService.MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(behavior), Map.of());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/json")
|
||||
.body("{\"unsigned\":true}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(403);
|
||||
assertEquals(0, hits.get());
|
||||
|
||||
long expires = Instant.now().getEpochSecond() + 3600;
|
||||
String policy = "{\"Statement\":[{\"Resource\":\"*\",\"Condition\":{\"DateLessThan\":"
|
||||
+ "{\"AWS:EpochTime\":" + expires + "}}}]}";
|
||||
Signature signer = Signature.getInstance("SHA1withRSA");
|
||||
signer.initSign(keyPair.getPrivate());
|
||||
signer.update(policy.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.queryParam("color", "blue")
|
||||
.queryParam("Policy", cfBase64(policy.getBytes(StandardCharsets.UTF_8)))
|
||||
.queryParam("Signature", cfBase64(signer.sign()))
|
||||
.queryParam("Key-Pair-Id", publicKey.getId())
|
||||
.contentType("application/json")
|
||||
.body("{\"signed\":true}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
assertEquals(1, hits.get());
|
||||
assertEquals("{\"signed\":true}",
|
||||
new String(received.get().body(), StandardCharsets.UTF_8));
|
||||
// AllViewer forwards every query string, but never CloudFront's signing parameters.
|
||||
assertEquals("color=blue", received.get().query());
|
||||
assertEquals(created.getDomainName(), received.get().header("Host"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allViewerExceptHostHeaderForwardsViewerCookiesHeadersAndQuery() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
DefaultCacheBehavior behavior = policyBehavior(CACHING_DISABLED,
|
||||
CloudFrontService.MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(behavior, originVerifyHeader()), Map.of());
|
||||
|
||||
Response response = given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Cookie", "session=abc123; csrf=xyz")
|
||||
.header("Authorization", "Bearer t")
|
||||
.header("X-Custom", "custom")
|
||||
.header("User-Agent", "viewer-agent/1.0")
|
||||
.header("X-Origin-Verify", "forged")
|
||||
.header("X-Origin-Verify", "forged-again")
|
||||
.when()
|
||||
.get("/api/me?x=1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract().response();
|
||||
|
||||
ReceivedRequest request = received.get();
|
||||
assertEquals("/api/me", request.path());
|
||||
assertEquals("x=1", request.query());
|
||||
assertEquals("csrf=xyz; session=abc123", request.header("Cookie"));
|
||||
assertEquals("Bearer t", request.header("Authorization"));
|
||||
assertEquals("custom", request.header("X-Custom"));
|
||||
assertEquals("viewer-agent/1.0", request.header("User-Agent"));
|
||||
assertEquals(List.of("s3cr3t"), request.headers().get("X-Origin-Verify"));
|
||||
assertEquals("127.0.0.1:" + originServer.getAddress().getPort(), request.header("Host"));
|
||||
assertEquals("1.1 " + created.getDomainName() + " (CloudFront)", request.header("Via"));
|
||||
assertFalse(request.header("X-Forwarded-For").isBlank());
|
||||
assertEquals(56, request.header("X-Amz-Cf-Id").length());
|
||||
assertEquals("http", request.header("CloudFront-Forwarded-Proto"));
|
||||
assertEquals(List.of("session=renewed; Path=/; HttpOnly"),
|
||||
response.getHeaders().getValues("Set-Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachePolicyWithoutAnOriginRequestPolicyForwardsNoViewerValues() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
DefaultCacheBehavior behavior = policyBehavior(CACHING_DISABLED, null);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(behavior, originVerifyHeader()), Map.of());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Cookie", "session=abc123")
|
||||
.header("Authorization", "Bearer t")
|
||||
.header("X-Custom", "custom")
|
||||
.header("User-Agent", "viewer-agent/1.0")
|
||||
.when()
|
||||
.get("/api/me?x=1")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
ReceivedRequest get = received.get();
|
||||
assertNull(get.query());
|
||||
assertNull(get.header("Cookie"));
|
||||
assertNull(get.header("Authorization"));
|
||||
assertNull(get.header("X-Custom"));
|
||||
assertEquals("Amazon CloudFront", get.header("User-Agent"));
|
||||
assertEquals("s3cr3t", get.header("X-Origin-Verify"));
|
||||
assertEquals("127.0.0.1:" + originServer.getAddress().getPort(), get.header("Host"));
|
||||
|
||||
// CloudFront never strips Authorization from methods it does not cache.
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Cookie", "session=abc123")
|
||||
.header("Authorization", "Bearer t")
|
||||
.contentType("application/json")
|
||||
.body("{}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
ReceivedRequest post = received.get();
|
||||
assertEquals("Bearer t", post.header("Authorization"));
|
||||
assertTrue(post.header("Content-Type").startsWith("application/json"));
|
||||
assertNull(post.header("Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customPoliciesFilterCookiesHeadersAndQueryStrings() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
|
||||
CachePolicy cachePolicy = new CachePolicy();
|
||||
cachePolicy.setName("forwarding-cache-policy-" + System.nanoTime());
|
||||
cachePolicy.setConfig(CloudFrontPolicyConfigCodec.parseCachePolicy("""
|
||||
<CachePolicyConfig>
|
||||
<MinTTL>0</MinTTL><DefaultTTL>0</DefaultTTL><MaxTTL>0</MaxTTL>
|
||||
<ParametersInCacheKeyAndForwardedToOrigin>
|
||||
<EnableAcceptEncodingGzip>false</EnableAcceptEncodingGzip>
|
||||
<HeadersConfig><HeaderBehavior>whitelist</HeaderBehavior>
|
||||
<Headers><Quantity>1</Quantity><Items><Name>Accept-Language</Name></Items></Headers>
|
||||
</HeadersConfig>
|
||||
<CookiesConfig><CookieBehavior>none</CookieBehavior></CookiesConfig>
|
||||
<QueryStringsConfig><QueryStringBehavior>none</QueryStringBehavior></QueryStringsConfig>
|
||||
</ParametersInCacheKeyAndForwardedToOrigin>
|
||||
</CachePolicyConfig>
|
||||
"""));
|
||||
cachePolicy = cloudFrontService.createCachePolicy(cachePolicy);
|
||||
OriginRequestPolicy originRequestPolicy = new OriginRequestPolicy();
|
||||
originRequestPolicy.setName("forwarding-origin-policy-" + System.nanoTime());
|
||||
originRequestPolicy.setConfig(CloudFrontPolicyConfigCodec.parseOriginRequestPolicy("""
|
||||
<OriginRequestPolicyConfig>
|
||||
<HeadersConfig><HeaderBehavior>whitelist</HeaderBehavior>
|
||||
<Headers><Quantity>1</Quantity><Items><Name>X-Custom</Name></Items></Headers>
|
||||
</HeadersConfig>
|
||||
<CookiesConfig><CookieBehavior>whitelist</CookieBehavior>
|
||||
<Cookies><Quantity>1</Quantity><Items><Name>session</Name></Items></Cookies>
|
||||
</CookiesConfig>
|
||||
<QueryStringsConfig><QueryStringBehavior>allExcept</QueryStringBehavior>
|
||||
<QueryStrings><Quantity>1</Quantity><Items><Name>utm_source</Name></Items></QueryStrings>
|
||||
</QueryStringsConfig>
|
||||
</OriginRequestPolicyConfig>
|
||||
"""));
|
||||
originRequestPolicy = cloudFrontService.createOriginRequestPolicy(originRequestPolicy);
|
||||
Distribution created = cloudFrontService.createDistribution(customOriginDistribution(
|
||||
policyBehavior(cachePolicy.getId(), originRequestPolicy.getId())), Map.of());
|
||||
|
||||
given()
|
||||
.urlEncodingEnabled(false)
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Cookie", "tracking=1; session=abc; theme=dark")
|
||||
.header("X-Custom", "custom")
|
||||
.header("X-Other", "other")
|
||||
.header("Accept-Language", "en-GB")
|
||||
.when()
|
||||
.get("/search?q=red%20shoes&utm_source=ad&page=2")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
ReceivedRequest request = received.get();
|
||||
assertEquals("q=red%20shoes&page=2", request.query());
|
||||
assertEquals("session=abc", request.header("Cookie"));
|
||||
assertEquals("custom", request.header("X-Custom"));
|
||||
assertEquals("en-GB", request.header("Accept-Language"));
|
||||
assertNull(request.header("X-Other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyForwardedValuesSelectQueryCookiesAndHeaders() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
Map<String, Object> forwardedValues = new LinkedHashMap<>();
|
||||
forwardedValues.put("QueryString", true);
|
||||
forwardedValues.put("CookiesForward", "whitelist");
|
||||
forwardedValues.put("CookieNames", List.of("session"));
|
||||
forwardedValues.put("Headers", List.of("Authorization"));
|
||||
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
|
||||
behavior.setForwardedValues(forwardedValues);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(behavior), Map.of());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Cookie", "session=abc; other=1")
|
||||
.header("Authorization", "Bearer t")
|
||||
.header("X-Custom", "custom")
|
||||
.header("Accept-Language", "en-GB")
|
||||
.header("User-Agent", "viewer-agent/1.0")
|
||||
.when()
|
||||
.get("/legacy?x=1&y=2")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
|
||||
ReceivedRequest request = received.get();
|
||||
assertEquals("x=1&y=2", request.query());
|
||||
assertEquals("session=abc", request.header("Cookie"));
|
||||
assertEquals("Bearer t", request.header("Authorization"));
|
||||
assertEquals("custom", request.header("X-Custom"));
|
||||
assertNull(request.header("Accept-Language"));
|
||||
assertEquals("Amazon CloudFront", request.header("User-Agent"));
|
||||
assertEquals("127.0.0.1:" + originServer.getAddress().getPort(), request.header("Host"));
|
||||
}
|
||||
|
||||
private static void disconnectViewerBeforeOriginHeaders(String host, int requestIndex,
|
||||
BlockingQueue<CountDownLatch> pendingResponses)
|
||||
throws Exception {
|
||||
CountDownLatch sendResponse = null;
|
||||
try (Socket viewer = new Socket("127.0.0.1", RestAssured.port)) {
|
||||
viewer.setSoLinger(true, 0);
|
||||
viewer.getOutputStream().write(("GET /disconnect/" + requestIndex + " HTTP/1.1\r\nHost: "
|
||||
+ host + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
sendResponse = pendingResponses.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(sendResponse, "No origin connection for viewer " + requestIndex);
|
||||
} finally {
|
||||
if (sendResponse != null) {
|
||||
sendResponse.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startRecordingOrigin(AtomicReference<ReceivedRequest> received, AtomicInteger hits)
|
||||
throws IOException {
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
hits.incrementAndGet();
|
||||
received.set(new ReceivedRequest(
|
||||
exchange.getRequestMethod(),
|
||||
exchange.getRequestURI().getRawPath(),
|
||||
exchange.getRequestURI().getRawQuery(),
|
||||
exchange.getRequestHeaders(),
|
||||
exchange.getRequestBody().readAllBytes()));
|
||||
byte[] body = ("POST".equals(exchange.getRequestMethod()) ? "created" : "ok")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
||||
exchange.getResponseHeaders().add("X-Origin-Method", exchange.getRequestMethod());
|
||||
exchange.getResponseHeaders().add("Set-Cookie", "session=renewed; Path=/; HttpOnly");
|
||||
exchange.sendResponseHeaders(
|
||||
"POST".equals(exchange.getRequestMethod()) ? 201 : 200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
}
|
||||
|
||||
/** An origin that answers every GET with {@code body}, with its length or chunked. */
|
||||
private void startServingOrigin(byte[] body, boolean withLength) throws IOException {
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/octet-stream");
|
||||
exchange.sendResponseHeaders(200, withLength ? body.length : 0);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs {@code body} with {@code Transfer-Encoding: chunked} over a raw connection, since an HTTP
|
||||
* client library buffers a stream it is given and sends its length instead.
|
||||
*/
|
||||
private static int postChunked(String host, String path, byte[] body) throws IOException {
|
||||
try (Socket socket = new Socket("localhost", RestAssured.port)) {
|
||||
// A response that never comes fails the read instead of leaving it waiting.
|
||||
socket.setSoTimeout(30_000);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(("POST " + path + " HTTP/1.1\r\nHost: " + host
|
||||
+ "\r\nContent-Type: application/octet-stream\r\nTransfer-Encoding: chunked"
|
||||
+ "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
for (int offset = 0; offset < body.length; offset += 8192) {
|
||||
int size = Math.min(8192, body.length - offset);
|
||||
out.write((Integer.toHexString(size) + "\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
out.write(body, offset, size);
|
||||
out.write("\r\n".getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
|
||||
out.flush();
|
||||
String statusLine = new BufferedReader(
|
||||
new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)).readLine();
|
||||
return Integer.parseInt(statusLine.split(" ")[1]);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int size) {
|
||||
byte[] bytes = new byte[size];
|
||||
new Random(size).nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private Distribution customOriginDistribution(DefaultCacheBehavior behavior) {
|
||||
return customOriginDistribution(behavior, null);
|
||||
}
|
||||
|
||||
private Distribution customOriginDistribution(DefaultCacheBehavior behavior,
|
||||
List<Map<String, String>> customHeaders) {
|
||||
Origin customOrigin = new Origin();
|
||||
customOrigin.setId("custom-origin");
|
||||
customOrigin.setDomainName("127.0.0.1");
|
||||
customOrigin.setCustomOriginConfig(customOriginConfig(originServer.getAddress().getPort()));
|
||||
customOrigin.setCustomHeaders(customHeaders);
|
||||
DistributionConfig config = new DistributionConfig();
|
||||
config.setEnabled(true);
|
||||
config.setOrigins(List.of(customOrigin));
|
||||
config.setDefaultCacheBehavior(behavior);
|
||||
Distribution distribution = new Distribution();
|
||||
distribution.setConfig(config);
|
||||
return distribution;
|
||||
}
|
||||
|
||||
private static List<Map<String, String>> originVerifyHeader() {
|
||||
return List.of(new LinkedHashMap<>(Map.of(
|
||||
"HeaderName", "X-Origin-Verify", "HeaderValue", "s3cr3t")));
|
||||
}
|
||||
|
||||
private static DefaultCacheBehavior policyBehavior(String cachePolicyId,
|
||||
String originRequestPolicyId) {
|
||||
DefaultCacheBehavior behavior = allMethodsBehavior();
|
||||
behavior.setCachePolicyId(cachePolicyId);
|
||||
behavior.setOriginRequestPolicyId(originRequestPolicyId);
|
||||
return behavior;
|
||||
}
|
||||
|
||||
private static DefaultCacheBehavior allMethodsBehavior() {
|
||||
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
|
||||
behavior.setAllowedMethods(List.of("GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"));
|
||||
behavior.setCachedMethods(List.of("GET", "HEAD"));
|
||||
return behavior;
|
||||
}
|
||||
|
||||
private static String cfBase64(byte[] bytes) {
|
||||
return Base64.getEncoder().encodeToString(bytes)
|
||||
.replace('+', '-').replace('=', '_').replace('/', '~');
|
||||
}
|
||||
|
||||
private record ReceivedRequest(String method, String path, String query, Headers headers,
|
||||
byte[] body) {
|
||||
String header(String name) {
|
||||
return headers.getFirst(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, AtomicReference<String> receivedQuery,
|
||||
AtomicReference<String> receivedPath,
|
||||
AtomicReference<String> receivedCustomHeader,
|
||||
|
||||
+29
@@ -273,6 +273,7 @@ class CloudFrontDistributionServingTest {
|
||||
.header("x-amz-meta-source", equalTo("origin"))
|
||||
.header("x-amz-server-side-encryption", nullValue())
|
||||
.header("x-amz-checksum-sha256", nullValue())
|
||||
.header("Content-Length", equalTo(Integer.toString(body.getBytes(StandardCharsets.UTF_8).length)))
|
||||
.body(equalTo(body));
|
||||
|
||||
given().header("Host", dist.getDomainName()).when().head("/asset.txt")
|
||||
@@ -1424,6 +1425,34 @@ class CloudFrontDistributionServingTest {
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeMethodsAreNotForwardedToInProcessS3Origins() {
|
||||
String suffix = suffix();
|
||||
String bucket = "cf-writes-" + suffix;
|
||||
createBucket(bucket);
|
||||
putObject(bucket, "doc.txt", "ORIGINAL-" + suffix, "text/plain");
|
||||
|
||||
DefaultCacheBehavior dflt = defaultBehavior("only-origin");
|
||||
dflt.setAllowedMethods(List.of("GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"));
|
||||
DistributionConfig config = new DistributionConfig();
|
||||
config.setEnabled(true);
|
||||
config.setOrigins(List.of(s3Origin("only-origin", bucket)));
|
||||
config.setDefaultCacheBehavior(dflt);
|
||||
Distribution distribution = cloudFrontService.createDistribution(distribution(config), Map.of());
|
||||
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) {
|
||||
given()
|
||||
.header("Host", distribution.getDomainName())
|
||||
.body("REPLACED-" + suffix)
|
||||
.when().request(method, "/doc.txt")
|
||||
.then().statusCode(403)
|
||||
.body(equalTo("Access Denied"));
|
||||
}
|
||||
|
||||
given().header("Host", distribution.getDomainName()).when().get("/doc.txt")
|
||||
.then().statusCode(200).body(equalTo("ORIGINAL-" + suffix));
|
||||
}
|
||||
|
||||
@Test
|
||||
void managedPreflightPoliciesEmitOnlyAllowOriginForSimpleRequests() {
|
||||
String suffix = suffix();
|
||||
|
||||
+68
@@ -109,6 +109,38 @@ class CloudFrontManagementProtocolTest {
|
||||
equalTo("InvalidArgument")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFunctionRejectsAnUnknownRuntime() {
|
||||
String name = "runtime-test-" + UUID.randomUUID();
|
||||
String createBody = """
|
||||
<CreateFunctionRequest xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
||||
<Name>%s</Name>
|
||||
<FunctionConfig>
|
||||
<Comment>routing function</Comment>
|
||||
<Runtime>python3</Runtime>
|
||||
</FunctionConfig>
|
||||
<FunctionCode>function handler(event) { return event.request; }</FunctionCode>
|
||||
</CreateFunctionRequest>
|
||||
""".formatted(name);
|
||||
|
||||
given()
|
||||
.contentType("application/xml")
|
||||
.body(createBody)
|
||||
.when()
|
||||
.post(API + "function")
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body(hasXPath(
|
||||
"//*[local-name()='Code']/text()",
|
||||
equalTo("InvalidArgument")));
|
||||
|
||||
given()
|
||||
.when()
|
||||
.get(API + "function/" + name + "/describe")
|
||||
.then()
|
||||
.statusCode(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void describeAndPublishFunctionPreserveBothStages() {
|
||||
String name = "describe-test-" + UUID.randomUUID();
|
||||
@@ -409,6 +441,42 @@ class CloudFrontManagementProtocolTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void managedOriginRequestPoliciesAreReadableListableAndImmutable() {
|
||||
String exceptHost = "b689b0a8-53d0-40ab-baf2-68738e2966ac";
|
||||
given()
|
||||
.when()
|
||||
.get(API + "origin-request-policy/" + exceptHost)
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body(hasXPath("//*[local-name()='Name']/text()",
|
||||
equalTo("Managed-AllViewerExceptHostHeader")))
|
||||
.body(hasXPath("//*[local-name()='HeaderBehavior']/text()", equalTo("allExcept")))
|
||||
.body(hasXPath("//*[local-name()='Headers']//*[local-name()='Name']/text()",
|
||||
equalTo("Host")))
|
||||
.body(hasXPath("//*[local-name()='CookieBehavior']/text()", equalTo("all")))
|
||||
.body(hasXPath("//*[local-name()='QueryStringBehavior']/text()", equalTo("all")));
|
||||
|
||||
given()
|
||||
.queryParam("Type", "managed")
|
||||
.when()
|
||||
.get(API + "origin-request-policy")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body(hasXPath("/*/*[local-name()='Quantity']/text()", equalTo("8")))
|
||||
.body(hasXPath("//*[local-name()='OriginRequestPolicySummary']"
|
||||
+ "[.//*[local-name()='Id' and text()='" + exceptHost + "']]"
|
||||
+ "/*[local-name()='Type']/text()", equalTo("managed")));
|
||||
|
||||
given()
|
||||
.header("If-Match", "E23ZP02F085DFQ")
|
||||
.when()
|
||||
.delete(API + "origin-request-policy/" + exceptHost)
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body(hasXPath("//*[local-name()='Code']/text()", equalTo("IllegalDelete")));
|
||||
}
|
||||
|
||||
private static String cachePolicyBody(String name, String comment, String defaultTtl, String cookie) {
|
||||
return """
|
||||
<CachePolicyConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
||||
|
||||
+170
-23
@@ -16,12 +16,15 @@ import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SNIServerName;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.security.Security;
|
||||
@@ -29,12 +32,18 @@ import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CloudFrontOriginHttpClientTest {
|
||||
|
||||
@@ -46,8 +55,7 @@ class CloudFrontOriginHttpClientTest {
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of())) {
|
||||
HttpRequest request = request("http://blocked.invalid:" + server.getAddress().getPort() + "/");
|
||||
|
||||
assertThrows(UnknownHostException.class,
|
||||
() -> client.send(request, HttpResponse.BodyHandlers.ofByteArray()));
|
||||
assertThrows(UnknownHostException.class, () -> get(client, request));
|
||||
assertEquals(0, hits.get());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
@@ -60,9 +68,7 @@ class CloudFrontOriginHttpClientTest {
|
||||
InetAddress.getByName("8.8.8.8"),
|
||||
InetAddress.getByName("127.0.0.1"));
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(resolver, List.of())) {
|
||||
assertThrows(UnknownHostException.class, () -> client.send(
|
||||
request("http://mixed.invalid:8080/"),
|
||||
HttpResponse.BodyHandlers.ofByteArray()));
|
||||
assertThrows(UnknownHostException.class, () -> get(client, request("http://mixed.invalid:8080/")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,12 +105,11 @@ class CloudFrontOriginHttpClientTest {
|
||||
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver, List.of("rebind.invalid"))) {
|
||||
HttpResponse<byte[]> response = client.send(
|
||||
request("http://rebind.invalid:" + server.getAddress().getPort() + "/"),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
CloudFrontOriginHttpClient.OpenResponse response = get(client,
|
||||
request("http://rebind.invalid:" + server.getAddress().getPort() + "/"));
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertArrayEquals("origin-ok".getBytes(StandardCharsets.UTF_8), response.body());
|
||||
assertArrayEquals("origin-ok".getBytes(StandardCharsets.UTF_8), readAll(response));
|
||||
assertEquals(1, resolutions.get());
|
||||
assertEquals(1, hits.get());
|
||||
assertEquals("rebind.invalid:" + server.getAddress().getPort(), hostHeader.get());
|
||||
@@ -131,9 +136,9 @@ class CloudFrontOriginHttpClientTest {
|
||||
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of("redirect.invalid"))) {
|
||||
HttpResponse<byte[]> response = client.send(
|
||||
request("http://redirect.invalid:" + server.getAddress().getPort() + "/start"),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
CloudFrontOriginHttpClient.OpenResponse response = get(client,
|
||||
request("http://redirect.invalid:" + server.getAddress().getPort() + "/start"));
|
||||
readAll(response);
|
||||
|
||||
assertEquals(302, response.statusCode());
|
||||
assertEquals(0, redirectedHits.get());
|
||||
@@ -162,10 +167,7 @@ class CloudFrontOriginHttpClientTest {
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
client.send(
|
||||
request,
|
||||
Map.of("X-Origin-Verify", "configured-value"),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
readAll(client.open(request, List.of(), Map.of("X-Origin-Verify", "configured-value"), null, -1));
|
||||
|
||||
assertEquals(List.of("configured-value"), receivedValues.get());
|
||||
} finally {
|
||||
@@ -173,6 +175,47 @@ class CloudFrontOriginHttpClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheBodyWithItsExactLengthAndTheCallerContentType() throws Exception {
|
||||
AtomicReference<String> method = new AtomicReference<>();
|
||||
AtomicReference<String> contentType = new AtomicReference<>();
|
||||
AtomicReference<String> contentLength = new AtomicReference<>();
|
||||
AtomicReference<byte[]> body = new AtomicReference<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
method.set(exchange.getRequestMethod());
|
||||
contentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
|
||||
contentLength.set(exchange.getRequestHeaders().getFirst("Content-Length"));
|
||||
body.set(exchange.getRequestBody().readAllBytes());
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
byte[] payload = "{\"a\":1}".getBytes(StandardCharsets.UTF_8);
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of("origin.invalid"))) {
|
||||
HttpRequest request = HttpRequest.newBuilder(java.net.URI.create(
|
||||
"http://origin.invalid:" + server.getAddress().getPort() + "/items"))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Content-Type", "application/json")
|
||||
.method("PUT", HttpRequest.BodyPublishers.noBody())
|
||||
.build();
|
||||
|
||||
CloudFrontOriginHttpClient.OpenResponse response = client.open(
|
||||
request, List.of(), Map.of(), new ByteArrayInputStream(payload), payload.length);
|
||||
readAll(response);
|
||||
|
||||
assertEquals(204, response.statusCode());
|
||||
assertEquals("PUT", method.get());
|
||||
assertEquals("application/json", contentType.get());
|
||||
assertEquals(Integer.toString(payload.length), contentLength.get());
|
||||
assertArrayEquals(payload, body.get());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void httpsPinningPreservesSniAndHostnameVerification() throws Exception {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
@@ -209,21 +252,125 @@ class CloudFrontOriginHttpClientTest {
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver, List.of("origin.invalid", "wrong.invalid"), clientContext)) {
|
||||
int port = server.getAddress().getPort();
|
||||
HttpResponse<byte[]> response = client.send(
|
||||
request("https://origin.invalid:" + port + "/"),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
CloudFrontOriginHttpClient.OpenResponse response = get(client,
|
||||
request("https://origin.invalid:" + port + "/"));
|
||||
readAll(response);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("origin.invalid:" + port, hostHeader.get());
|
||||
assertEquals("origin.invalid", requestedSni.get());
|
||||
assertThrows(IOException.class, () -> client.send(
|
||||
request("https://wrong.invalid:" + port + "/"),
|
||||
HttpResponse.BodyHandlers.ofByteArray()));
|
||||
assertThrows(IOException.class, () -> get(client, request("https://wrong.invalid:" + port + "/")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamsARequestBodyChunkedWhenItsLengthIsUnknown() throws Exception {
|
||||
AtomicReference<String> transferEncoding = new AtomicReference<>();
|
||||
AtomicReference<byte[]> body = new AtomicReference<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
transferEncoding.set(exchange.getRequestHeaders().getFirst("Transfer-Encoding"));
|
||||
body.set(exchange.getRequestBody().readAllBytes());
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
byte[] payload = new byte[100_000];
|
||||
new Random(1).nextBytes(payload);
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of("origin.invalid"))) {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(
|
||||
"http://origin.invalid:" + server.getAddress().getPort() + "/items"))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.method("POST", HttpRequest.BodyPublishers.noBody())
|
||||
.build();
|
||||
|
||||
readAll(client.open(request, List.of(), Map.of(), new ByteArrayInputStream(payload), -1));
|
||||
|
||||
assertEquals("chunked", transferEncoding.get());
|
||||
assertArrayEquals(payload, body.get());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingAResponseBeforeItsEndDoesNotReadTheRestOfIt() throws Exception {
|
||||
long size = 512L * 1024 * 1024;
|
||||
AtomicLong sent = new AtomicLong();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
byte[] chunk = new byte[64 * 1024];
|
||||
exchange.sendResponseHeaders(200, size);
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
for (long written = 0; written < size; written += chunk.length) {
|
||||
out.write(chunk);
|
||||
sent.addAndGet(chunk.length);
|
||||
}
|
||||
} catch (IOException expected) {
|
||||
// The client went away mid-body, which is what the test does.
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of("origin.invalid"))) {
|
||||
CloudFrontOriginHttpClient.OpenResponse response = get(client,
|
||||
request("http://origin.invalid:" + server.getAddress().getPort() + "/big"));
|
||||
assertEquals(size, response.contentLength());
|
||||
response.body().readNBytes(1024);
|
||||
response.body().close();
|
||||
|
||||
assertTrue(done.await(30, TimeUnit.SECONDS), "the origin is still sending");
|
||||
assertTrue(sent.get() < size / 2, "closing read " + (sent.get() >> 20) + " MiB of " + (size >> 20));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aResponseReadToItsEndLeavesItsConnectionForTheNextRequest() throws Exception {
|
||||
List<InetSocketAddress> peers = new CopyOnWriteArrayList<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
peers.add(exchange.getRemoteAddress());
|
||||
byte[] body = "origin-ok".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of("origin.invalid"))) {
|
||||
String uri = "http://origin.invalid:" + server.getAddress().getPort() + "/";
|
||||
readAll(get(client, request(uri)));
|
||||
readAll(get(client, request(uri)));
|
||||
|
||||
assertEquals(2, peers.size());
|
||||
assertEquals(peers.get(0), peers.get(1), "the second request opened a new connection");
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static CloudFrontOriginHttpClient.OpenResponse get(CloudFrontOriginHttpClient client,
|
||||
HttpRequest request) throws Exception {
|
||||
return client.open(request, List.of(), Map.of(), null, -1);
|
||||
}
|
||||
|
||||
private static byte[] readAll(CloudFrontOriginHttpClient.OpenResponse response) throws IOException {
|
||||
try (InputStream body = response.body()) {
|
||||
return body.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpServer server(AtomicInteger hits) throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.Forwarding;
|
||||
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.Header;
|
||||
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.OriginRequest;
|
||||
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.ViewerRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CloudFrontOriginRequestBuilderTest {
|
||||
|
||||
private static final String VIA_HOST = "d111111abcdef8.cloudfront.net";
|
||||
|
||||
@Test
|
||||
void cachePolicyWithoutForwardingSendsOnlyTheHeadersCloudFrontAdds() {
|
||||
OriginRequest request = build(viewer("GET", "x=1&y=2",
|
||||
"Host", "d111111abcdef8.cloudfront.net",
|
||||
"User-Agent", "curl/8.0",
|
||||
"Cookie", "session=abc123",
|
||||
"Authorization", "Bearer t",
|
||||
"X-Custom", "custom",
|
||||
"Origin", "https://viewer.example",
|
||||
"X-Forwarded-For", "203.0.113.9"),
|
||||
Forwarding.policies(parameters(none("Header"), none("Cookie"), none("QueryString")),
|
||||
null, false));
|
||||
|
||||
assertNull(request.rawQuery());
|
||||
assertEquals(List.of("User-Agent", "X-Forwarded-For", "Via", "X-Amz-Cf-Id"), names(request));
|
||||
assertEquals("Amazon CloudFront", value(request, "User-Agent"));
|
||||
assertEquals("203.0.113.9,127.0.0.1", value(request, "X-Forwarded-For"));
|
||||
assertEquals("1.1 " + VIA_HOST + " (CloudFront)", value(request, "Via"));
|
||||
assertEquals(56, value(request, "X-Amz-Cf-Id").length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allExceptForwardsEveryViewerValueButTheListedHeaders() {
|
||||
Map<String, Object> allViewerExceptHost = orp(
|
||||
selection("HeaderBehavior", "allExcept", "Headers", "host"),
|
||||
selection("CookieBehavior", "all", "Cookies"),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings"));
|
||||
|
||||
OriginRequest request = build(viewer("GET", "x=1&encoded=a%2Fb",
|
||||
"Host", "d111111abcdef8.cloudfront.net",
|
||||
"User-Agent", "curl/8.0",
|
||||
"Cookie", "session=abc123; csrf=xyz",
|
||||
"Authorization", "Bearer t",
|
||||
"X-Custom", "custom"),
|
||||
Forwarding.policies(parameters(none("Header"), none("Cookie"), none("QueryString")),
|
||||
allViewerExceptHost, false));
|
||||
|
||||
assertEquals("x=1&encoded=a%2Fb", request.rawQuery());
|
||||
assertNull(value(request, "Host"));
|
||||
assertEquals("curl/8.0", value(request, "User-Agent"));
|
||||
assertEquals("Bearer t", value(request, "Authorization"));
|
||||
assertEquals("custom", value(request, "X-Custom"));
|
||||
assertEquals("csrf=xyz; session=abc123", value(request, "Cookie"));
|
||||
// AWS documents AllViewerExceptHostHeader as adding CloudFront's protocol, version and
|
||||
// location headers; Floci generates the ones it can derive locally.
|
||||
assertEquals("http", value(request, "CloudFront-Forwarded-Proto"));
|
||||
assertEquals("1.1", value(request, "CloudFront-Viewer-Http-Version"));
|
||||
assertEquals("127.0.0.1:54321", value(request, "CloudFront-Viewer-Address"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allViewerForwardsTheViewerHostAndAddsNoCloudFrontHeaders() {
|
||||
OriginRequest request = build(viewer("GET", null,
|
||||
"Host", "d111111abcdef8.cloudfront.net",
|
||||
"Accept-Language", "en-GB"),
|
||||
Forwarding.policies(null, orp(
|
||||
selection("HeaderBehavior", "allViewer", "Headers"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings")), false));
|
||||
|
||||
assertEquals("d111111abcdef8.cloudfront.net", value(request, "Host"));
|
||||
assertEquals("en-GB", value(request, "Accept-Language"));
|
||||
assertTrue(names(request).stream().noneMatch(name -> name.startsWith("CloudFront-")),
|
||||
names(request).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allViewerAndWhitelistCloudFrontGeneratesOnlyListedSupportedHeaders() {
|
||||
OriginRequest request = build(viewerOverHttp2("https",
|
||||
"X-Custom", "custom",
|
||||
"CloudFront-Viewer-Country", "XX",
|
||||
"CloudFront-Viewer-Address", "198.51.100.1:1"),
|
||||
Forwarding.policies(null, orp(
|
||||
selection("HeaderBehavior", "allViewerAndWhitelistCloudFront", "Headers",
|
||||
"CloudFront-Viewer-Address", "CloudFront-Viewer-Country",
|
||||
"CloudFront-Forwarded-Proto"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings")), false));
|
||||
|
||||
assertEquals("custom", value(request, "X-Custom"));
|
||||
assertEquals("127.0.0.1:54321", value(request, "CloudFront-Viewer-Address"));
|
||||
assertEquals("https", value(request, "CloudFront-Forwarded-Proto"));
|
||||
assertNull(value(request, "CloudFront-Viewer-Country"), "viewer-supplied values are dropped");
|
||||
assertNull(value(request, "CloudFront-Viewer-Http-Version"), "not listed");
|
||||
assertEquals("2.0 " + VIA_HOST + " (CloudFront)", value(request, "Via"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whitelistedHeadersMatchCaseInsensitivelyAndUnionWithTheCachePolicy() {
|
||||
OriginRequest request = build(viewer("GET", null,
|
||||
"authorization", "Bearer t",
|
||||
"x-custom", "custom",
|
||||
"X-Other", "other",
|
||||
"Referer", "https://viewer.example/"),
|
||||
Forwarding.policies(
|
||||
parameters(selection("HeaderBehavior", "whitelist", "Headers", "Authorization"),
|
||||
none("Cookie"), none("QueryString")),
|
||||
orp(selection("HeaderBehavior", "whitelist", "Headers", "X-CUSTOM"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings")),
|
||||
false));
|
||||
|
||||
assertEquals("Bearer t", value(request, "Authorization"));
|
||||
assertEquals("custom", value(request, "X-Custom"));
|
||||
assertNull(value(request, "X-Other"));
|
||||
assertNull(value(request, "Referer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieBehaviorsSelectSortAndDropMalformedCookies() {
|
||||
ViewerRequest viewer = viewer("GET", null,
|
||||
"Cookie", "session=abc; Session=upper; $Version=1; theme=dark; broken",
|
||||
"Cookie", "csrf=xyz");
|
||||
|
||||
assertNull(value(build(viewer, cookies("none")), "Cookie"));
|
||||
assertEquals("session=abc",
|
||||
value(build(viewer, cookies("whitelist", "session", "missing")), "Cookie"));
|
||||
assertEquals("Session=upper; csrf=xyz; theme=dark",
|
||||
value(build(viewer, cookies("allExcept", "session")), "Cookie"));
|
||||
assertEquals("Session=upper; csrf=xyz; session=abc; theme=dark",
|
||||
value(build(viewer, cookies("all")), "Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryStringBehaviorsKeepViewerOrderAndEncoding() {
|
||||
ViewerRequest viewer = viewer("GET", "b=2&a=1&c=x%20y&flag&a=3", "Host", "d.example");
|
||||
|
||||
assertNull(build(viewer, queryStrings("none")).rawQuery());
|
||||
assertEquals("b=2&a=1&c=x%20y&flag&a=3", build(viewer, queryStrings("all")).rawQuery());
|
||||
assertEquals("a=1&flag&a=3", build(viewer, queryStrings("whitelist", "a", "flag")).rawQuery());
|
||||
assertEquals("b=2&c=x%20y&flag", build(viewer, queryStrings("allExcept", "a")).rawQuery());
|
||||
assertNull(build(viewer, queryStrings("whitelist", "A")).rawQuery(),
|
||||
"query string names are case-sensitive");
|
||||
|
||||
OriginRequest union = build(viewer, Forwarding.policies(
|
||||
parameters(none("Header"), none("Cookie"),
|
||||
selection("QueryStringBehavior", "whitelist", "QueryStrings", "b")),
|
||||
orp(selection("HeaderBehavior", "none", "Headers"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", "whitelist", "QueryStrings", "c")),
|
||||
false));
|
||||
assertEquals("b=2&c=x%20y", union.rawQuery());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacySettingsForwardDefaultHeadersAndRemoveTheDocumentedOnes() {
|
||||
OriginRequest request = build(viewer("GET", "x=1",
|
||||
"Host", "d111111abcdef8.cloudfront.net",
|
||||
"User-Agent", "curl/8.0",
|
||||
"Accept", "text/html",
|
||||
"Accept-Language", "en-GB",
|
||||
"Referer", "https://viewer.example/",
|
||||
"Authorization", "Bearer t",
|
||||
"Cookie", "session=abc",
|
||||
"X-HTTP-Method-Override", "DELETE",
|
||||
"X-Custom", "custom",
|
||||
"Origin", "https://viewer.example",
|
||||
"If-None-Match", "\"etag\"",
|
||||
"Via", "1.1 proxy.example",
|
||||
"Accept-Encoding", "gzip, deflate"),
|
||||
Forwarding.legacy(null, false));
|
||||
|
||||
assertNull(request.rawQuery());
|
||||
assertEquals("custom", value(request, "X-Custom"));
|
||||
assertEquals("https://viewer.example", value(request, "Origin"));
|
||||
assertEquals("\"etag\"", value(request, "If-None-Match"));
|
||||
assertEquals("gzip", value(request, "Accept-Encoding"));
|
||||
assertEquals("Amazon CloudFront", value(request, "User-Agent"));
|
||||
assertEquals("1.1 proxy.example, 1.1 " + VIA_HOST + " (CloudFront)", value(request, "Via"));
|
||||
for (String removed : List.of("Host", "Accept", "Accept-Language", "Referer", "Authorization",
|
||||
"Cookie", "X-HTTP-Method-Override")) {
|
||||
assertNull(value(request, removed), removed);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyHeaderListsAddRemovedHeadersAndAWildcardForwardsAll() {
|
||||
ViewerRequest viewer = viewer("GET", null,
|
||||
"Host", "d111111abcdef8.cloudfront.net",
|
||||
"User-Agent", "curl/8.0",
|
||||
"Authorization", "Bearer t",
|
||||
"Accept-Language", "en-GB",
|
||||
"Accept-Encoding", "gzip, deflate",
|
||||
"X-Custom", "custom");
|
||||
|
||||
OriginRequest listed = build(viewer, Forwarding.legacy(
|
||||
forwardedValues(false, "none", List.of(), List.of("Authorization", "accept-language")),
|
||||
false));
|
||||
assertEquals("Bearer t", value(listed, "Authorization"));
|
||||
assertEquals("en-GB", value(listed, "Accept-Language"));
|
||||
assertEquals("custom", value(listed, "X-Custom"));
|
||||
assertNull(value(listed, "Host"));
|
||||
|
||||
OriginRequest all = build(viewer, Forwarding.legacy(
|
||||
forwardedValues(false, "none", List.of(), List.of("*")), false));
|
||||
assertEquals("d111111abcdef8.cloudfront.net", value(all, "Host"));
|
||||
assertEquals("curl/8.0", value(all, "User-Agent"));
|
||||
assertEquals("Bearer t", value(all, "Authorization"));
|
||||
assertEquals("gzip, deflate", value(all, "Accept-Encoding"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyCookiesAndQueryStringFollowForwardedValues() {
|
||||
ViewerRequest viewer = viewer("GET", "a=1&b=2",
|
||||
"Cookie", "userid_42=me; userid_=empty; uid9=x; theme=dark");
|
||||
|
||||
OriginRequest wildcard = build(viewer, Forwarding.legacy(
|
||||
forwardedValues(true, "whitelist", List.of("userid_*", "uid?"), List.of()), false));
|
||||
assertEquals("uid9=x; userid_=empty; userid_42=me", value(wildcard, "Cookie"));
|
||||
// QueryString=true forwards every parameter; QueryStringCacheKeys only narrows the cache key.
|
||||
Map<String, Object> keyed = forwardedValues(true, "all", List.of(), List.of());
|
||||
keyed.put("QueryStringCacheKeys", List.of("a"));
|
||||
OriginRequest all = build(viewer, Forwarding.legacy(keyed, false));
|
||||
assertEquals("a=1&b=2", all.rawQuery());
|
||||
assertEquals("theme=dark; uid9=x; userid_=empty; userid_42=me", value(all, "Cookie"));
|
||||
|
||||
OriginRequest none = build(viewer, Forwarding.legacy(
|
||||
forwardedValues(false, "none", List.of(), List.of()), false));
|
||||
assertNull(none.rawQuery());
|
||||
assertNull(value(none, "Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationIsAlwaysForwardedForMethodsCloudFrontDoesNotCache() {
|
||||
Forwarding nothing = Forwarding.policies(null, null, false);
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE", "OPTIONS")) {
|
||||
assertEquals("Bearer t", value(build(viewer(method, null, "Authorization", "Bearer t"),
|
||||
nothing), "Authorization"), method);
|
||||
}
|
||||
assertNull(value(build(viewer("GET", null, "Authorization", "Bearer t"), nothing),
|
||||
"Authorization"));
|
||||
assertNull(value(build(viewer("HEAD", null, "Authorization", "Bearer t"), nothing),
|
||||
"Authorization"));
|
||||
assertNull(value(build(viewer("OPTIONS", null, "Authorization", "Bearer t"),
|
||||
Forwarding.policies(null, null, true)), "Authorization"),
|
||||
"a cached OPTIONS response drops Authorization");
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeTravelsWithARequestBodyWhateverThePolicy() {
|
||||
Forwarding nothing = Forwarding.policies(null, null, false);
|
||||
assertEquals("application/json", value(build(
|
||||
viewer("POST", null, "Content-Type", "application/json"), nothing), "Content-Type"));
|
||||
assertNull(value(build(
|
||||
viewer("GET", null, "Content-Type", "application/json"), nothing), "Content-Type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hopByHopAndCloudFrontOwnedHeadersAreNeverForwarded() {
|
||||
OriginRequest request = build(viewer("POST", null,
|
||||
"Connection", "keep-alive, X-Hop",
|
||||
"X-Hop", "hop",
|
||||
"Keep-Alive", "timeout=5",
|
||||
"Proxy-Authorization", "Basic x",
|
||||
"TE", "trailers",
|
||||
"Upgrade", "websocket",
|
||||
"Transfer-Encoding", "chunked",
|
||||
"Content-Length", "10",
|
||||
"Expect", "100-continue",
|
||||
"X-Amz-Cf-Id", "forged",
|
||||
"X-Edge-Location", "forged",
|
||||
"X-Real-IP", "10.0.0.1",
|
||||
"X-Forwarded-Proto", "https",
|
||||
"X-Kept", "kept"),
|
||||
Forwarding.policies(null, orp(
|
||||
selection("HeaderBehavior", "allViewer", "Headers"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings")), false));
|
||||
|
||||
assertEquals("kept", value(request, "X-Kept"));
|
||||
for (String dropped : List.of("Connection", "X-Hop", "Keep-Alive", "Proxy-Authorization", "TE",
|
||||
"Upgrade", "Transfer-Encoding", "Content-Length", "Expect", "X-Edge-Location",
|
||||
"X-Real-IP", "X-Forwarded-Proto")) {
|
||||
assertNull(value(request, dropped), dropped);
|
||||
}
|
||||
assertNotEquals("forged", value(request, "X-Amz-Cf-Id"));
|
||||
assertNotEquals(value(request, "X-Amz-Cf-Id"),
|
||||
value(build(viewer("GET", null), Forwarding.policies(null, null, false)), "X-Amz-Cf-Id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachePolicyCompressionSettingsNormalizeAcceptEncoding() {
|
||||
Map<String, Object> both = parameters(none("Header"), none("Cookie"), none("QueryString"));
|
||||
both.put("EnableAcceptEncodingGzip", "true");
|
||||
both.put("EnableAcceptEncodingBrotli", "true");
|
||||
Map<String, Object> gzipOnly = parameters(none("Header"), none("Cookie"), none("QueryString"));
|
||||
gzipOnly.put("EnableAcceptEncodingGzip", "true");
|
||||
|
||||
assertEquals("br,gzip", value(build(viewer("GET", null, "Accept-Encoding", "gzip, deflate, br;q=0.9"),
|
||||
Forwarding.policies(both, null, false)), "Accept-Encoding"));
|
||||
assertEquals("gzip", value(build(viewer("GET", null, "Accept-Encoding", "gzip, br"),
|
||||
Forwarding.policies(gzipOnly, null, false)), "Accept-Encoding"));
|
||||
assertEquals("identity", value(build(viewer("GET", null, "Accept-Encoding", "deflate"),
|
||||
Forwarding.policies(both, null, false)), "Accept-Encoding"));
|
||||
assertNull(value(build(viewer("GET", null), Forwarding.policies(both, null, false)),
|
||||
"Accept-Encoding"));
|
||||
assertEquals("gzip, deflate", value(build(viewer("GET", null, "Accept-Encoding", "gzip, deflate"),
|
||||
Forwarding.policies(null, orp(
|
||||
selection("HeaderBehavior", "allViewer", "Headers"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings")), false)),
|
||||
"Accept-Encoding"));
|
||||
}
|
||||
|
||||
private static OriginRequest build(ViewerRequest viewer, Forwarding forwarding) {
|
||||
return CloudFrontOriginRequestBuilder.build(viewer, forwarding, VIA_HOST);
|
||||
}
|
||||
|
||||
private static ViewerRequest viewer(String method, String rawQuery, String... headerPairs) {
|
||||
return new ViewerRequest(method, headers(headerPairs), rawQuery, "127.0.0.1", 54321,
|
||||
"http", "1.1");
|
||||
}
|
||||
|
||||
private static ViewerRequest viewerOverHttp2(String scheme, String... headerPairs) {
|
||||
return new ViewerRequest("GET", headers(headerPairs), null, "127.0.0.1", 54321, scheme, "2.0");
|
||||
}
|
||||
|
||||
private static List<Header> headers(String... headerPairs) {
|
||||
List<Header> headers = new ArrayList<>();
|
||||
for (int i = 0; i < headerPairs.length; i += 2) {
|
||||
headers.add(new Header(headerPairs[i], headerPairs[i + 1]));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static Forwarding cookies(String behavior, String... names) {
|
||||
return Forwarding.policies(null, orp(
|
||||
selection("HeaderBehavior", "none", "Headers"),
|
||||
selection("CookieBehavior", behavior, "Cookies", names),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings")), false);
|
||||
}
|
||||
|
||||
private static Forwarding queryStrings(String behavior, String... names) {
|
||||
return Forwarding.policies(null, orp(
|
||||
selection("HeaderBehavior", "none", "Headers"),
|
||||
selection("CookieBehavior", "none", "Cookies"),
|
||||
selection("QueryStringBehavior", behavior, "QueryStrings", names)), false);
|
||||
}
|
||||
|
||||
private static Map<String, Object> orp(Map<String, Object> headers, Map<String, Object> cookies,
|
||||
Map<String, Object> queryStrings) {
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("HeadersConfig", headers);
|
||||
config.put("CookiesConfig", cookies);
|
||||
config.put("QueryStringsConfig", queryStrings);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static Map<String, Object> parameters(Map<String, Object> headers,
|
||||
Map<String, Object> cookies,
|
||||
Map<String, Object> queryStrings) {
|
||||
return orp(headers, cookies, queryStrings);
|
||||
}
|
||||
|
||||
private static Map<String, Object> none(String kind) {
|
||||
return selection(kind + "Behavior", "none", kind + "s");
|
||||
}
|
||||
|
||||
private static Map<String, Object> selection(String behaviorKey, String behavior, String listKey,
|
||||
String... names) {
|
||||
Map<String, Object> block = new LinkedHashMap<>();
|
||||
block.put(behaviorKey, behavior);
|
||||
if (names.length > 0) {
|
||||
block.put(listKey, List.of(names));
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
private static Map<String, Object> forwardedValues(boolean queryString, String cookiesForward,
|
||||
List<String> cookieNames, List<String> headers) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
values.put("QueryString", queryString);
|
||||
values.put("CookiesForward", cookiesForward);
|
||||
values.put("CookieNames", cookieNames);
|
||||
values.put("Headers", headers);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static List<String> names(OriginRequest request) {
|
||||
return request.headers().stream().map(Header::name).toList();
|
||||
}
|
||||
|
||||
private static String value(OriginRequest request, String name) {
|
||||
List<String> values = request.headers().stream()
|
||||
.filter(header -> header.name().equalsIgnoreCase(name))
|
||||
.map(Header::value)
|
||||
.toList();
|
||||
assertTrue(values.size() <= 1, name + " appears more than once: " + values);
|
||||
return values.isEmpty() ? null : values.getFirst();
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -7,6 +7,7 @@ import io.github.hectorvent.floci.services.cloudfront.model.Origin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -121,6 +122,32 @@ class CloudFrontRequestRouterTest {
|
||||
assertNull(CloudFrontRequestRouter.matchResponseHeadersPolicyId(cfg, "/index.html"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchForwardingReturnsTheSelectedBehaviorsPoliciesAndForwardedValues() {
|
||||
CacheBehavior api = behavior("api/*", "api-origin");
|
||||
api.setCachePolicyId("api-cache-policy");
|
||||
api.setOriginRequestPolicyId("api-origin-request-policy");
|
||||
api.setCachedMethods(List.of("GET", "HEAD", "OPTIONS"));
|
||||
DefaultCacheBehavior dflt = defaultBehavior("default-origin");
|
||||
dflt.setForwardedValues(Map.of("QueryString", true));
|
||||
|
||||
DistributionConfig cfg = new DistributionConfig();
|
||||
cfg.setCacheBehaviors(List.of(api));
|
||||
cfg.setDefaultCacheBehavior(dflt);
|
||||
|
||||
CloudFrontRequestRouter.BehaviorForwarding matched =
|
||||
CloudFrontRequestRouter.matchForwarding(cfg, "/api/data");
|
||||
assertEquals("api-cache-policy", matched.cachePolicyId());
|
||||
assertEquals("api-origin-request-policy", matched.originRequestPolicyId());
|
||||
assertEquals(List.of("GET", "HEAD", "OPTIONS"), matched.cachedMethods());
|
||||
assertNull(matched.forwardedValues());
|
||||
|
||||
CloudFrontRequestRouter.BehaviorForwarding fallback =
|
||||
CloudFrontRequestRouter.matchForwarding(cfg, "/index.html");
|
||||
assertNull(fallback.cachePolicyId());
|
||||
assertEquals(Map.of("QueryString", true), fallback.forwardedValues());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchAllowedMethodsUsesTheSelectedBehaviorAndDefaultsToGetHead() {
|
||||
CacheBehavior api = behavior("api/*", "api-origin");
|
||||
@@ -175,7 +202,7 @@ class CloudFrontRequestRouterTest {
|
||||
Origin s3 = new Origin();
|
||||
s3.setId("s3-origin");
|
||||
s3.setDomainName("my-bucket.s3.us-east-1.amazonaws.com");
|
||||
s3.setS3OriginConfig(java.util.Map.of("originAccessIdentity", ""));
|
||||
s3.setS3OriginConfig(Map.of("originAccessIdentity", ""));
|
||||
DistributionConfig cfg = new DistributionConfig();
|
||||
cfg.setOrigins(List.of(s3));
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.DefaultCacheBehavior;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.Distribution;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.DistributionConfig;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.Origin;
|
||||
import io.github.hectorvent.floci.services.s3.S3Service;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.QuarkusTestProfile;
|
||||
import io.quarkus.test.junit.TestProfile;
|
||||
import io.restassured.response.Response;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* An S3 origin whose objects live on disk, where CloudFront streams the object file to the viewer
|
||||
* instead of reading it into memory first.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestProfile(CloudFrontS3DiskOriginServingTest.S3OnDiskProfile.class)
|
||||
class CloudFrontS3DiskOriginServingTest {
|
||||
|
||||
private static final String REGION = "us-east-1";
|
||||
|
||||
@Inject
|
||||
S3Service s3Service;
|
||||
|
||||
@Inject
|
||||
CloudFrontService cloudFrontService;
|
||||
|
||||
@Test
|
||||
void streamsAnObjectFileWithItsLength() {
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
String bucket = "cf-disk-origin-" + suffix;
|
||||
// Larger than any one buffer on the way, so the body arrives over many writes.
|
||||
byte[] body = new byte[3 * 1024 * 1024 + 1];
|
||||
new Random(5080).nextBytes(body);
|
||||
s3Service.createBucket(bucket, REGION);
|
||||
try {
|
||||
s3Service.putObject(bucket, "video.bin", body, "application/octet-stream", Map.of());
|
||||
s3Service.putObject(bucket, "index.html", ("INDEX-ON-DISK-" + suffix).getBytes(StandardCharsets.UTF_8),
|
||||
"text/html", Map.of());
|
||||
Distribution dist = distributionFor(bucket);
|
||||
|
||||
Response get = given().header("Host", dist.getDomainName()).when().get("/video.bin");
|
||||
|
||||
assertEquals(200, get.statusCode());
|
||||
assertEquals(Integer.toString(body.length), get.header("Content-Length"));
|
||||
assertArrayEquals(body, get.asByteArray());
|
||||
given().header("Host", dist.getDomainName()).when().head("/video.bin")
|
||||
.then().statusCode(200)
|
||||
.header("Content-Length", equalTo(Integer.toString(body.length)));
|
||||
// The single-page-app fallback serves its error page through the same stream.
|
||||
given().header("Host", dist.getDomainName()).when().get("/missing/route")
|
||||
.then().statusCode(200)
|
||||
.header("Content-Length", equalTo(Integer.toString(("INDEX-ON-DISK-" + suffix).length())))
|
||||
.body(containsString("INDEX-ON-DISK-" + suffix));
|
||||
} finally {
|
||||
// The bucket is on disk and outlives the test, so it is removed rather than left for the next run.
|
||||
s3Service.deleteObject(bucket, "video.bin");
|
||||
s3Service.deleteObject(bucket, "index.html");
|
||||
s3Service.deleteBucket(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
private Distribution distributionFor(String bucket) {
|
||||
Origin origin = new Origin();
|
||||
origin.setId("disk-origin");
|
||||
origin.setDomainName(bucket + ".s3." + REGION + ".amazonaws.com");
|
||||
origin.setS3OriginConfig(new LinkedHashMap<>(Map.of("OriginAccessIdentity", "")));
|
||||
DefaultCacheBehavior behavior = new DefaultCacheBehavior();
|
||||
behavior.setTargetOriginId(origin.getId());
|
||||
behavior.setViewerProtocolPolicy("allow-all");
|
||||
Map<String, Object> spaFallback = new LinkedHashMap<>();
|
||||
spaFallback.put("ErrorCode", "404");
|
||||
spaFallback.put("ResponseCode", "200");
|
||||
spaFallback.put("ResponsePagePath", "/index.html");
|
||||
DistributionConfig config = new DistributionConfig();
|
||||
config.setEnabled(true);
|
||||
config.setOrigins(List.of(origin));
|
||||
config.setDefaultCacheBehavior(behavior);
|
||||
config.setCustomErrorResponses(List.of(spaFallback));
|
||||
Distribution distribution = new Distribution();
|
||||
distribution.setConfig(config);
|
||||
return cloudFrontService.createDistribution(distribution, Map.of());
|
||||
}
|
||||
|
||||
public static final class S3OnDiskProfile implements QuarkusTestProfile {
|
||||
@Override
|
||||
public Map<String, String> getConfigOverrides() {
|
||||
return Map.of(
|
||||
"floci.storage.services.s3.mode", "persistent",
|
||||
"floci.storage.persistent-path", "target/cloudfront-s3-disk-origin-it");
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -164,6 +164,54 @@ class CloudFrontServiceTest {
|
||||
assertNotEquals(development.getEtag(), live.getEtag());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFunction_unknownRuntime_isRejected() {
|
||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||
CloudFrontFunction function = new CloudFrontFunction();
|
||||
function.setName("routing");
|
||||
function.setRuntime("python3");
|
||||
|
||||
AwsException error = assertThrows(AwsException.class, () -> service.createFunction(function));
|
||||
|
||||
assertEquals("InvalidArgument", error.getErrorCode());
|
||||
assertEquals(400, error.getHttpStatus());
|
||||
assertThrows(AwsException.class, () -> service.describeFunction("routing", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFunction_supportedRuntimes_areAccepted() {
|
||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||
for (String runtime : List.of("cloudfront-js-1.0", "cloudfront-js-2.0")) {
|
||||
CloudFrontFunction function = new CloudFrontFunction();
|
||||
function.setName("routing-" + runtime);
|
||||
function.setRuntime(runtime);
|
||||
|
||||
assertEquals(runtime, service.createFunction(function).getRuntime());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateFunction_unknownRuntime_isRejectedAndKeepsTheFunction() {
|
||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||
CloudFrontFunction function = new CloudFrontFunction();
|
||||
function.setName("routing");
|
||||
function.setFunctionCode("first");
|
||||
function.setRuntime("cloudfront-js-2.0");
|
||||
String etag = service.createFunction(function).getEtag();
|
||||
CloudFrontFunction update = new CloudFrontFunction();
|
||||
update.setFunctionCode("second");
|
||||
update.setRuntime("python3");
|
||||
|
||||
AwsException error = assertThrows(AwsException.class,
|
||||
() -> service.updateFunction("routing", etag, update));
|
||||
|
||||
assertEquals("InvalidArgument", error.getErrorCode());
|
||||
CloudFrontFunction kept = service.describeFunction("routing", null);
|
||||
assertEquals("first", kept.getFunctionCode());
|
||||
assertEquals("cloudfront-js-2.0", kept.getRuntime());
|
||||
assertEquals(etag, kept.getEtag());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listFunctionsWithoutStageReturnsDevelopmentAndLive() {
|
||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||
@@ -982,6 +1030,40 @@ class CloudFrontServiceTest {
|
||||
assertAws("NoSuchOriginRequestPolicy", () -> service.getOriginRequestPolicy(policy.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesTheAwsManagedOriginRequestPoliciesAsImmutable() {
|
||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||
|
||||
OriginRequestPolicy exceptHost = service.getOriginRequestPolicy(
|
||||
CloudFrontService.MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID);
|
||||
assertEquals("Managed-AllViewerExceptHostHeader", exceptHost.getName());
|
||||
assertEquals(Map.of("HeaderBehavior", "allExcept", "Headers", List.of("Host")),
|
||||
exceptHost.getConfig().get("HeadersConfig"));
|
||||
assertEquals(Map.of("CookieBehavior", "all"), exceptHost.getConfig().get("CookiesConfig"));
|
||||
assertEquals(Map.of("QueryStringBehavior", "all"),
|
||||
exceptHost.getConfig().get("QueryStringsConfig"));
|
||||
assertEquals(Map.of("HeaderBehavior", "allViewer"), service.getOriginRequestPolicy(
|
||||
CloudFrontService.MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID)
|
||||
.getConfig().get("HeadersConfig"));
|
||||
assertEquals(Map.of("HeaderBehavior", "whitelist", "Headers", List.of("Host")),
|
||||
service.getOriginRequestPolicy(
|
||||
CloudFrontService.MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID)
|
||||
.getConfig().get("HeadersConfig"));
|
||||
|
||||
OriginRequestPolicy custom =
|
||||
service.createOriginRequestPolicy(namedOriginRequestPolicy("custom-orp"));
|
||||
assertEquals(8, service.listOriginRequestPolicies(null, 100, "managed").size());
|
||||
assertEquals(List.of(custom.getId()), service.listOriginRequestPolicies(null, 100, "custom")
|
||||
.stream().map(OriginRequestPolicy::getId).toList());
|
||||
assertEquals(9, service.listOriginRequestPolicies(null, 100, null).size());
|
||||
assertAws("InvalidArgument", () -> service.listOriginRequestPolicies(null, 100, "MANAGED"));
|
||||
|
||||
assertAws("IllegalUpdate", () -> service.updateOriginRequestPolicy(exceptHost.getId(),
|
||||
exceptHost.getEtag(), namedOriginRequestPolicy("replacement")));
|
||||
assertAws("IllegalDelete",
|
||||
() -> service.deleteOriginRequestPolicy(exceptHost.getId(), exceptHost.getEtag()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesAnUnattachedCachePolicy() {
|
||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** How the length of a viewer's request body is taken before it is forwarded to a custom origin. */
|
||||
class CloudFrontViewerBodyTest {
|
||||
|
||||
private static final byte[] DATA = "a viewer upload".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
void aChunkedBodyIsForwardedChunkedWhateverItsContentLength() throws IOException {
|
||||
CloudFrontServingController.ViewerBody body =
|
||||
CloudFrontServingController.viewerBody(new ByteArrayInputStream(DATA), "chunked", "3");
|
||||
|
||||
assertEquals(-1, body.length());
|
||||
assertArrayEquals(DATA, readAll(body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyWithAContentLengthKeepsIt() throws IOException {
|
||||
CloudFrontServingController.ViewerBody body = CloudFrontServingController.viewerBody(
|
||||
new ByteArrayInputStream(DATA), null, Integer.toString(DATA.length));
|
||||
|
||||
assertEquals(DATA.length, body.length());
|
||||
assertArrayEquals(DATA, readAll(body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInvalidContentLengthIsForwardedChunked() {
|
||||
assertEquals(-1, CloudFrontServingController.viewerBody(
|
||||
new ByteArrayInputStream(DATA), null, "not-a-length").length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequestWithNeitherHeaderAndNoBodyIsEmpty() {
|
||||
assertEquals(0, CloudFrontServingController.viewerBody(InputStream.nullInputStream(), null, null).length());
|
||||
assertEquals(0, CloudFrontServingController.viewerBody(null, null, null).length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyWithNeitherHeaderAsHttp2AllowsIsForwardedChunkedAndWhole() throws IOException {
|
||||
CloudFrontServingController.ViewerBody body =
|
||||
CloudFrontServingController.viewerBody(new ByteArrayInputStream(DATA), null, null);
|
||||
|
||||
assertEquals(-1, body.length());
|
||||
assertArrayEquals(DATA, readAll(body), "the byte read to look at the body was lost");
|
||||
}
|
||||
|
||||
private static byte[] readAll(CloudFrontServingController.ViewerBody body) throws IOException {
|
||||
try (InputStream stream = body.stream()) {
|
||||
return stream.readAllBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
+349
@@ -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"));
|
||||
}
|
||||
}
|
||||
+73
-8
@@ -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.<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
|
||||
|
||||
+170
@@ -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<String> 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<String> 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<String> 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.<String>getArgument(3).startsWith("ALARM:")) {
|
||||
publishing.countDown();
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
return "message-id";
|
||||
}).when(sns).publish(eq(TOPIC), isNull(), anyString(), anyString(), eq(REGION));
|
||||
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
Future<?> transition = executor.submit(() ->
|
||||
service.setAlarmState("test-alarm", "ALARM", "breached", null, REGION));
|
||||
try {
|
||||
assertTrue(publishing.await(2, TimeUnit.SECONDS));
|
||||
Future<?> unrelated = executor.submit(() -> {
|
||||
CompositeAlarm composite = new CompositeAlarm();
|
||||
composite.setAlarmName("unrelated");
|
||||
composite.setAlarmRule("FALSE");
|
||||
service.putCompositeAlarm(composite, REGION);
|
||||
service.setAlarmState("test-alarm", "OK", "recovered", null, REGION);
|
||||
});
|
||||
unrelated.get(2, TimeUnit.SECONDS);
|
||||
assertEquals("OK", alarm.getStateValue());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
transition.get(2, TimeUnit.SECONDS);
|
||||
}
|
||||
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
|
||||
verify(sns, times(2)).publish(eq(TOPIC), isNull(), messages.capture(), anyString(), eq(REGION));
|
||||
assertEquals("ALARM", mapper.readTree(messages.getAllValues().getFirst()).path("NewStateValue").asText());
|
||||
assertEquals("OK", mapper.readTree(messages.getAllValues().getLast()).path("NewStateValue").asText());
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CloudWatchAlarmQueryHandlerTest {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"PutMetricAlarm", "PutCompositeAlarm"})
|
||||
void sharedFieldsReachBothAlarmTypes(String operation) {
|
||||
String region = "us-east-1";
|
||||
CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(),
|
||||
new InMemoryStorage<>(), new RegionResolver(region, "123456789012"));
|
||||
CloudWatchMetricsQueryHandler handler = new CloudWatchMetricsQueryHandler(service, null, null);
|
||||
MultivaluedMap<String, String> request = new MultivaluedHashMap<>();
|
||||
request.putSingle("AlarmName", "shared");
|
||||
request.putSingle("AlarmRule", "FALSE");
|
||||
request.putSingle("AlarmDescription", "shared fields");
|
||||
request.putSingle("ActionsEnabled", "false");
|
||||
request.putSingle("AlarmActions.member.1", "alarm-action");
|
||||
request.putSingle("OKActions.member.1", "ok-action");
|
||||
request.putSingle("InsufficientDataActions.member.1", "missing-action");
|
||||
request.putSingle("Tags.member.1.Key", "team");
|
||||
request.putSingle("Tags.member.1.Value", "metrics");
|
||||
assertEquals(200, handler.handle(operation, request, region).getStatus());
|
||||
Alarm alarm = "PutMetricAlarm".equals(operation)
|
||||
? service.describeAlarms(List.of("shared"), null, region).getFirst()
|
||||
: service.describeCompositeAlarms(List.of("shared"), null, region).getFirst();
|
||||
assertEquals("shared fields", alarm.getAlarmDescription());
|
||||
assertFalse(alarm.isActionsEnabled());
|
||||
assertEquals(List.of("alarm-action"), alarm.getAlarmActions());
|
||||
assertEquals(List.of("ok-action"), alarm.getOkActions());
|
||||
assertEquals(List.of("missing-action"), alarm.getInsufficientDataActions());
|
||||
assertEquals(Map.of("team", "metrics"), alarm.getTags());
|
||||
}
|
||||
}
|
||||
+218
@@ -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<String, Alarm> 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<Map<String, Alarm>> type = new TypeReference<>() { };
|
||||
initialize(new PersistentStorage<>(storageFile, type));
|
||||
ObjectNode request = metricMathRequest();
|
||||
put(request, queryProtocol);
|
||||
|
||||
PersistentStorage<String, Alarm> reloaded = new PersistentStorage<>(storageFile, type);
|
||||
reloaded.load();
|
||||
assertEquals(1, reloaded.keys().size(), "the stored alarm must deserialize after a restart");
|
||||
initialize(reloaded);
|
||||
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<String, String> 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<String, JsonNode> field : value.properties()) {
|
||||
assertXmlMatchesJson(field.getValue(), path + "/" + field.getKey(), document, xpath);
|
||||
}
|
||||
} else {
|
||||
assertEquals(value.asText(), xpath.evaluate(path, document), path);
|
||||
}
|
||||
}
|
||||
|
||||
private static MultivaluedMap<String, String> toQuery(JsonNode value) {
|
||||
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
|
||||
flatten(value, "", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static void flatten(JsonNode value, String prefix, MultivaluedMap<String, String> 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<String, JsonNode> 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 <rate> & 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"}
|
||||
""");
|
||||
}
|
||||
}
|
||||
+203
@@ -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));
|
||||
}
|
||||
}
|
||||
+132
@@ -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<String> 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<String> listMetricNames(String namespace) {
|
||||
ObjectNode listReq = MAPPER.createObjectNode();
|
||||
listReq.put("Namespace", namespace);
|
||||
ObjectNode listed = (ObjectNode) handler.handle("ListMetrics", listReq, REGION).getEntity();
|
||||
List<String> 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.
|
||||
|
||||
+130
@@ -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<String, String> 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<List<MetricDatum>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(metricsService).putMetricData(eq("CWAgent"), captor.capture(), eq(REGION));
|
||||
List<MetricDatum> 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<String, String> 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<List<MetricDatum>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(metricsService).putMetricData(eq("CWAgent"), captor.capture(), eq(REGION));
|
||||
List<MetricDatum> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<List<MetricDatum>> 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("<MetricAlarms></MetricAlarms>"), xml);
|
||||
assertTrue(xml.contains("<CompositeAlarms></CompositeAlarms>"), xml);
|
||||
assertTrue(xml.contains("<LogAlarms></LogAlarms>"), xml);
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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.<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
|
||||
void tagsViaPutMetricAlarmQuery() {
|
||||
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.testing.PartitionCleanup;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.common.http.TestHTTPResource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.Response;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@QuarkusTest
|
||||
class CompositeAlarmIntegrationTest {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final String SCOPE =
|
||||
"AWS4-HMAC-SHA256 Credential=test/20261010/cn-north-1/monitoring/aws4_request";
|
||||
|
||||
@TestHTTPResource
|
||||
URI baseUri;
|
||||
|
||||
@RegisterExtension
|
||||
final PartitionCleanup cleanup = new PartitionCleanup();
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void bothProtocolsCreateDescribeEvaluateAndDeleteRealAlarms(boolean json) throws Exception {
|
||||
String prefix = "wire-composite-" + Long.toString(System.nanoTime(), 36);
|
||||
String source = prefix + "-source";
|
||||
String aggregate = prefix + "-aggregate";
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", source, "AlarmRule", "TRUE")).then().statusCode(200);
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", aggregate, "AlarmRule", "ALARM(\"" + source + "\")",
|
||||
"ActionsEnabled", false, "AlarmDescription", "combined")).then().statusCode(200);
|
||||
Response result = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate),
|
||||
"AlarmTypes", List.of("CompositeAlarm")));
|
||||
result.then().statusCode(200);
|
||||
if (json) {
|
||||
assertEquals(List.of(), result.jsonPath().getList("LogAlarms"));
|
||||
assertEquals(1, result.jsonPath().getList("CompositeAlarms").size());
|
||||
assertEquals("ALARM", result.jsonPath().getString("CompositeAlarms[0].StateValue"));
|
||||
assertEquals("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate,
|
||||
result.jsonPath().getString("CompositeAlarms[0].AlarmArn"));
|
||||
assertFalse(result.jsonPath().getBoolean("CompositeAlarms[0].ActionsEnabled"));
|
||||
} else {
|
||||
assertTrue(result.asString().contains("<LogAlarms>"));
|
||||
assertEquals("ALARM", result.xmlPath().getString(
|
||||
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue"));
|
||||
assertTrue(result.asString().contains("arn:aws-cn:cloudwatch:cn-north-1:000000000000:alarm:" + aggregate));
|
||||
assertEquals("false", result.xmlPath().getString(
|
||||
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.ActionsEnabled"));
|
||||
}
|
||||
assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate)))
|
||||
.then().statusCode(200).extract().asString().contains(aggregate));
|
||||
call(json, "SetAlarmState", Map.of("AlarmName", source, "StateValue", "OK",
|
||||
"StateReason", "recovery")).then().statusCode(200);
|
||||
Response recovered = call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(aggregate),
|
||||
"AlarmTypes", List.of("CompositeAlarm")));
|
||||
assertEquals("OK", json ? recovered.jsonPath().getString("CompositeAlarms[0].StateValue")
|
||||
: recovered.xmlPath().getString(
|
||||
"DescribeAlarmsResponse.DescribeAlarmsResult.CompositeAlarms.member.StateValue"));
|
||||
for (String name : List.of(aggregate, source)) {
|
||||
call(json, "DeleteAlarms", Map.of("AlarmNames", List.of(name))).then().statusCode(200);
|
||||
}
|
||||
Response empty = call(json, "DescribeAlarms", Map.of("AlarmNamePrefix", prefix,
|
||||
"AlarmTypes", List.of("CompositeAlarm")));
|
||||
assertFalse(empty.asString().contains(aggregate));
|
||||
assertFalse(empty.asString().contains(source));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void invalidRuleAndUnsupportedSuppressionCannotCreateAnAlarm(boolean json) throws Exception {
|
||||
String name = "invalid-composite-" + Long.toString(System.nanoTime(), 36);
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE AND"))
|
||||
.then().statusCode(400);
|
||||
call(json, "PutCompositeAlarm", Map.of("AlarmName", name, "AlarmRule", "TRUE",
|
||||
"ActionsSuppressor", "other")).then().statusCode(400);
|
||||
assertFalse(call(json, "DescribeAlarms", Map.of("AlarmNames", List.of(name),
|
||||
"AlarmTypes", List.of("CompositeAlarm"))).asString().contains(name));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void alarmTypeValidationAcceptsLogAlarmsAndUsesSmithyErrors(boolean json) throws Exception {
|
||||
call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("LogAlarm"))).then().statusCode(200);
|
||||
Response error = call(json, "DescribeAlarms", Map.of("AlarmTypes", List.of("Unknown")));
|
||||
error.then().statusCode(400);
|
||||
assertEquals("1 validation error detected: Value '[Unknown]' at 'alarmTypes' failed to satisfy constraint: "
|
||||
+ "Member must satisfy constraint: [Member must satisfy enum value set: "
|
||||
+ "[CompositeAlarm, MetricAlarm, LogAlarm]]", errorMessage(json, error));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void lengthErrorsUseSmithyMessages(boolean json) throws Exception {
|
||||
for (String field : List.of("AlarmName", "AlarmRule")) {
|
||||
int maximum = "AlarmName".equals(field) ? 255 : 10240;
|
||||
String path = "AlarmName".equals(field) ? "alarmName" : "alarmRule";
|
||||
for (String value : List.of("", "x".repeat(maximum + 1))) {
|
||||
Map<String, String> request = new LinkedHashMap<>(Map.of("AlarmName", "length-validation", "AlarmRule", "TRUE"));
|
||||
request.put(field, value);
|
||||
Response error = call(json, "PutCompositeAlarm", request);
|
||||
error.then().statusCode(400);
|
||||
assertEquals("1 validation error detected: Value '" + value + "' at '" + path
|
||||
+ "' failed to satisfy constraint: Member must have length "
|
||||
+ (value.isEmpty() ? "greater than or equal to 1" : "less than or equal to " + maximum),
|
||||
errorMessage(json, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String errorMessage(boolean json, Response response) {
|
||||
return json ? response.jsonPath().getString("message") : response.xmlPath().getString("ErrorResponse.Error.Message");
|
||||
}
|
||||
|
||||
private Response call(boolean json, String operation, Map<String, ?> body)
|
||||
throws JsonProcessingException {
|
||||
if ("PutCompositeAlarm".equals(operation)) {
|
||||
String name = body.get("AlarmName").toString();
|
||||
cleanup.register(() -> given().port(baseUri.getPort()).contentType("application/x-amz-json-1.0")
|
||||
.header("Authorization", SCOPE)
|
||||
.header("X-Amz-Target", "GraniteServiceVersion20100801.DeleteAlarms")
|
||||
.body(MAPPER.valueToTree(Map.of("AlarmNames", List.of(name))).toString())
|
||||
.post("/").then().statusCode(200));
|
||||
}
|
||||
if (json) {
|
||||
return given().contentType("application/x-amz-json-1.0").header("Authorization", SCOPE)
|
||||
.header("X-Amz-Target", "GraniteServiceVersion20100801." + operation)
|
||||
.body(MAPPER.writeValueAsString(body)).post("/");
|
||||
}
|
||||
Map<String, Object> form = new LinkedHashMap<>();
|
||||
form.put("Action", operation);
|
||||
body.forEach((key, value) -> {
|
||||
if (value instanceof List<?> values) {
|
||||
for (int index = 0; index < values.size(); index++) {
|
||||
form.put(key + ".member." + (index + 1), values.get(index));
|
||||
}
|
||||
} else {
|
||||
form.put(key, value);
|
||||
}
|
||||
});
|
||||
return given().contentType("application/x-www-form-urlencoded").header("Authorization", SCOPE)
|
||||
.formParams(form).post("/");
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package io.github.hectorvent.floci.services.cloudwatch.metrics;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.core.common.RegionResolver;
|
||||
import io.github.hectorvent.floci.core.storage.InMemoryStorage;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Alarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.CompositeAlarm;
|
||||
import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm;
|
||||
import io.github.hectorvent.floci.testing.ThreadStates;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CompositeAlarmServiceTest {
|
||||
private static final String REGION = "cn-north-1";
|
||||
private final CloudWatchMetricsService service = new CloudWatchMetricsService(
|
||||
new InMemoryStorage<>(), new InMemoryStorage<>(), new RegionResolver(REGION, "123456789012"));
|
||||
|
||||
@Test
|
||||
void metricTransitionsPropagateThroughNamesArnsAndNestedComposites() {
|
||||
MetricAlarm metric = new MetricAlarm();
|
||||
metric.setAlarmName("latency");
|
||||
service.putMetricAlarm(metric, REGION);
|
||||
put("combined", "ALARM(\"" + metric.getAlarmArn() + "\") AND NOT ALARM(\"maintenance\")");
|
||||
put("outer", "ALARM(combined) OR (TRUE AND FALSE)");
|
||||
assertEquals("OK", state("outer"));
|
||||
service.setAlarmState("latency", "ALARM", "high latency", null, REGION);
|
||||
assertEquals("ALARM", state("combined"));
|
||||
assertEquals("ALARM", state("outer"));
|
||||
service.setAlarmState("latency", "OK", "recovered", null, REGION);
|
||||
assertEquals("OK", state("outer"));
|
||||
assertEquals(List.of(metric), service.describeAlarms(null, null, REGION));
|
||||
assertTrue(service.describeCompositeAlarms(null, null, "us-east-1").isEmpty());
|
||||
assertTrue(service.describeCompositeAlarms(List.of("combined"), null, REGION)
|
||||
.getFirst().getAlarmArn().startsWith("arn:aws-cn:cloudwatch:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesPreserveStateAndTagsUntilTheNextDependencyChange() {
|
||||
put("source", "FALSE");
|
||||
CompositeAlarm alarm = put("aggregate", "ALARM(source)");
|
||||
service.tagResource(alarm.getAlarmArn(), Map.of("owner", "retained"), REGION);
|
||||
service.setAlarmState("aggregate", "ALARM", "manual", null, REGION);
|
||||
CompositeAlarm replacement = put("aggregate", "OK(source)");
|
||||
assertEquals("ALARM", replacement.getStateValue());
|
||||
assertEquals(Map.of("owner", "retained"), replacement.getTags());
|
||||
service.setAlarmState("source", "ALARM", "dependency changed", null, REGION);
|
||||
assertEquals("OK", state("aggregate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrelatedWritesPreserveManualCompositeStatesAndParentStates() {
|
||||
put("source", "FALSE");
|
||||
put("aggregate", "ALARM(source)");
|
||||
put("parent", "ALARM(aggregate)");
|
||||
service.setAlarmState("aggregate", "ALARM", "manual", null, REGION);
|
||||
assertEquals("ALARM", state("parent"));
|
||||
put("unrelated", "TRUE");
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
assertEquals("ALARM", state("parent"));
|
||||
MetricAlarm metric = new MetricAlarm();
|
||||
metric.setAlarmName("unrelated-metric");
|
||||
service.putMetricAlarm(metric, REGION);
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
service.setAlarmState("unrelated-metric", "ALARM", "unrelated", null, REGION);
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
service.deleteAlarms(List.of("unrelated"), REGION);
|
||||
assertEquals("ALARM", state("aggregate"));
|
||||
assertEquals("ALARM", state("parent"));
|
||||
service.setAlarmState("source", "ALARM", "child changed", null, REGION);
|
||||
service.setAlarmState("source", "OK", "child recovered", null, REGION);
|
||||
assertEquals("OK", state("aggregate"));
|
||||
assertEquals("OK", state("parent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedChildStateDoesNotResetManuallySetParent() {
|
||||
put("source", "FALSE");
|
||||
put("child", "ALARM(source) AND FALSE");
|
||||
put("parent", "ALARM(child)");
|
||||
service.setAlarmState("parent", "ALARM", "manual", null, REGION);
|
||||
service.setAlarmState("source", "ALARM", "child condition remains false", null, REGION);
|
||||
assertEquals("OK", state("child"));
|
||||
assertEquals("ALARM", state("parent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationAndDeletionReevaluateOnlyReferencingComposites() {
|
||||
MetricAlarm metric = new MetricAlarm();
|
||||
metric.setAlarmName("late");
|
||||
put("watcher", "INSUFFICIENT_DATA(late)");
|
||||
put("outer", "ALARM(watcher)");
|
||||
assertEquals("OK", state("watcher"));
|
||||
service.putMetricAlarm(metric, REGION);
|
||||
assertEquals("ALARM", state("watcher"));
|
||||
assertEquals("ALARM", state("outer"));
|
||||
service.deleteAlarms(List.of("late"), REGION);
|
||||
assertEquals("OK", state("watcher"));
|
||||
assertEquals("OK", state("outer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tagReadIsAnIndependentSnapshotOfTheOwnedAlarm() {
|
||||
CompositeAlarm alarm = put("tag-snapshot", "TRUE");
|
||||
service.tagResource(alarm.getAlarmArn(), Map.of("team", "old"), REGION);
|
||||
Map<String, String> read = service.listTagsForResource(alarm.getAlarmArn(), REGION);
|
||||
service.tagResource(alarm.getAlarmArn(), Map.of("team", "new"), REGION);
|
||||
assertEquals(Map.of("team", "old"), read);
|
||||
read.put("injected", "unowned");
|
||||
assertEquals(Map.of("team", "new"), service.listTagsForResource(alarm.getAlarmArn(), REGION));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cyclesStopEvaluationEvenBehindShortCircuitAndMustBeBrokenBeforeDelete() {
|
||||
put("a", "FALSE AND ALARM(b)");
|
||||
put("b", "ALARM(a)");
|
||||
service.setAlarmState("b", "ALARM", "manual", null, REGION);
|
||||
assertThrows(AwsException.class, () -> service.deleteAlarms(List.of("a"), REGION));
|
||||
assertEquals(2, service.describeCompositeAlarms(null, null, REGION).size());
|
||||
put("a", "FALSE");
|
||||
service.deleteAlarms(List.of("b"), REGION);
|
||||
assertEquals("OK", state("a"));
|
||||
service.deleteAlarms(List.of("a"), REGION);
|
||||
assertTrue(service.describeCompositeAlarms(null, null, REGION).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manuallyChangingOneMemberDoesNotResumeACyclicDependency() {
|
||||
put("a", "ALARM(b)");
|
||||
put("b", "ALARM(a)");
|
||||
assertEquals("OK", state("a"));
|
||||
service.setAlarmState("b", "ALARM", "manual", null, REGION);
|
||||
assertEquals("OK", state("a"));
|
||||
put("b", "TRUE");
|
||||
service.setAlarmState("b", "ALARM", "cycle broken", null, REGION);
|
||||
assertEquals("ALARM", state("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parentQuotaTreatsAChildNameAndItsArnAsTheSameAlarm() {
|
||||
CompositeAlarm child = put("child", "FALSE");
|
||||
for (int index = 0; index < 150; index++) {
|
||||
put("parent-" + index, "ALARM(child)");
|
||||
}
|
||||
assertThrows(AwsException.class, () -> put("overflow", "ALARM(\"" + child.getAlarmArn() + "\")"));
|
||||
service.deleteAlarms(List.of("parent-0"), REGION);
|
||||
assertEquals("OK", put("replacement", "ALARM(\"" + child.getAlarmArn() + "\")").getStateValue());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", "ALARM()", "TRUE junk", "TRUE AND", "ALARM(\"unterminated)",
|
||||
"(TRUE", "ALARM(x) XOR FALSE", "ALARM(\"x\\z\")"})
|
||||
void invalidRulesDoNotReplaceAnExistingAlarm(String invalid) {
|
||||
put("saved", "TRUE");
|
||||
assertThrows(AwsException.class, () -> put("saved", invalid));
|
||||
assertEquals("TRUE", service.describeCompositeAlarms(List.of("saved"), null, REGION).getFirst().getAlarmRule());
|
||||
assertEquals("ALARM", state("saved"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void quotasAndAtomicCreatePreventExcessReferencesAndForeignNameReplacement() {
|
||||
String many = IntStream.range(0, 101).mapToObj(i -> "ALARM(a" + i + ")")
|
||||
.collect(Collectors.joining(" OR "));
|
||||
assertThrows(AwsException.class, () -> put("too-many", many));
|
||||
put("owned", "TRUE");
|
||||
CompositeAlarm foreign = new CompositeAlarm();
|
||||
foreign.setAlarmName("owned");
|
||||
foreign.setAlarmRule("FALSE");
|
||||
assertThrows(AwsException.class, () -> service.putCompositeAlarm(foreign, REGION, false));
|
||||
assertEquals("ALARM", state("owned"));
|
||||
assertThrows(AwsException.class, () -> put("deep", "(".repeat(250) + "TRUE" + ")".repeat(250)));
|
||||
assertEquals("ALARM", put("many-not", "NOT ".repeat(502) + "TRUE").getStateValue());
|
||||
assertThrows(AwsException.class, () -> put("elements", "FALSE OR ".repeat(500) + "TRUE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedPersistenceLoadsLegacyMetricsAndRoundTripsComposites() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
TypeReference<Map<String, Alarm>> type = new TypeReference<>() {};
|
||||
Map<String, Alarm> legacy = mapper.readValue(
|
||||
"{\"legacy\":{\"alarmName\":\"old\",\"metricName\":\"CPU\",\"stateValue\":\"OK\"}}", type);
|
||||
assertInstanceOf(MetricAlarm.class, legacy.get("legacy"));
|
||||
CompositeAlarm alarm = put("new", "FALSE");
|
||||
Map<String, Alarm> restored = mapper.readValue(mapper.writeValueAsBytes(Map.of("new", alarm)), type);
|
||||
assertEquals("FALSE", assertInstanceOf(CompositeAlarm.class, restored.get("new")).getAlarmRule());
|
||||
assertEquals("OK", restored.get("new").getStateValue());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void concurrentTagChangesPreserveTheUpdatedDefinition(boolean remove) throws Exception {
|
||||
CountDownLatch updating = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
InMemoryStorage<String, Alarm> storage = new InMemoryStorage<>() {
|
||||
@Override
|
||||
public void put(String key, Alarm value) {
|
||||
if (value instanceof CompositeAlarm composite && "TRUE".equals(composite.getAlarmRule())) {
|
||||
updating.countDown();
|
||||
try {
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(error);
|
||||
}
|
||||
}
|
||||
super.put(key, value);
|
||||
}
|
||||
};
|
||||
CloudWatchMetricsService concurrent = new CloudWatchMetricsService(new InMemoryStorage<>(), storage,
|
||||
new RegionResolver(REGION, "123456789012"));
|
||||
CompositeAlarm original = new CompositeAlarm();
|
||||
original.setAlarmName("concurrent");
|
||||
original.setAlarmRule("FALSE");
|
||||
original.setTags(new HashMap<>(Map.of("team", "old")));
|
||||
concurrent.putCompositeAlarm(original, REGION);
|
||||
CompositeAlarm replacement = new CompositeAlarm();
|
||||
replacement.setAlarmName("concurrent");
|
||||
replacement.setAlarmRule("TRUE");
|
||||
AtomicReference<Thread> tagThread = new AtomicReference<>();
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
Future<?> update = executor.submit(() -> concurrent.putCompositeAlarm(replacement, REGION));
|
||||
Future<?> tags;
|
||||
try {
|
||||
assertTrue(updating.await(2, TimeUnit.SECONDS));
|
||||
tags = executor.submit(() -> {
|
||||
tagThread.set(Thread.currentThread());
|
||||
if (remove) {
|
||||
concurrent.untagResource(original.getAlarmArn(), List.of("team"), REGION);
|
||||
} else {
|
||||
concurrent.tagResource(original.getAlarmArn(), Map.of("team", "new"), REGION);
|
||||
}
|
||||
});
|
||||
ThreadStates.awaitBlockedOrDone(tagThread, tags);
|
||||
assertFalse(tags.isDone(), "tag mutation must share the definition update lock");
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
update.get(2, TimeUnit.SECONDS);
|
||||
tags.get(2, TimeUnit.SECONDS);
|
||||
}
|
||||
CompositeAlarm result = concurrent.describeCompositeAlarms(List.of("concurrent"), null, REGION).getFirst();
|
||||
assertEquals("TRUE", result.getAlarmRule());
|
||||
assertEquals(remove ? Map.of() : Map.of("team", "new"), result.getTags());
|
||||
}
|
||||
|
||||
private CompositeAlarm put(String name, String rule) {
|
||||
CompositeAlarm alarm = new CompositeAlarm();
|
||||
alarm.setAlarmName(name);
|
||||
alarm.setAlarmRule(rule);
|
||||
alarm.setActionsEnabled(true);
|
||||
service.putCompositeAlarm(alarm, REGION);
|
||||
return alarm;
|
||||
}
|
||||
|
||||
private String state(String name) {
|
||||
return service.describeCompositeAlarms(List.of(name), null, REGION).getFirst().getStateValue();
|
||||
}
|
||||
}
|
||||
+25
@@ -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.<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));
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -23,6 +23,7 @@ import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -171,11 +172,32 @@ class CognitoHostedLoginIntegrationTest {
|
||||
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid"],"CallbackURLs":["%s"],
|
||||
"SupportedIdentityProviders":["CompanyIdp"]}
|
||||
""".formatted(poolId, CALLBACK)).path("UserPoolClient").path("ClientId").asText();
|
||||
given().redirects().follow(false)
|
||||
String federatedLogin = given().redirects().follow(false)
|
||||
.queryParam("client_id", federatedOnly).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
|
||||
.queryParam("state", "app-state")
|
||||
.when().get("/cognito-idp/oauth2/authorize")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
String federatedPage = given().redirects().follow(false).urlEncodingEnabled(false).get(federatedLogin)
|
||||
.then().statusCode(200).extract().asString();
|
||||
assertTrue(federatedPage.contains(">Continue with CompanyIdp</a>"), federatedPage);
|
||||
assertTrue(federatedPage.contains("identity_provider=CompanyIdp"), "the button starts the federated flow");
|
||||
assertTrue(federatedPage.contains("state=app-state"), "the button keeps the app's authorize parameters");
|
||||
assertFalse(federatedPage.contains("name=\"password\""), "no password form without COGNITO");
|
||||
given().redirects().follow(false)
|
||||
.formParam("client_id", federatedOnly).formParam("redirect_uri", CALLBACK).formParam("response_type", "code")
|
||||
.formParam("username", email).formParam("password", PASSWORD)
|
||||
.when().post("/cognito-idp/login")
|
||||
.then().statusCode(400).body(containsString("COGNITO is not one of its identity providers"));
|
||||
|
||||
cognitoJson("UpdateUserPoolClient", """
|
||||
{"UserPoolId":"%s","ClientId":"%s","ClientName":"hosted-web","AllowedOAuthFlowsUserPoolClient":true,
|
||||
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid","email"],
|
||||
"CallbackURLs":["%s"],"LogoutURLs":["%s"],"SupportedIdentityProviders":["COGNITO","CompanyIdp"],
|
||||
"ExplicitAuthFlows":["ALLOW_REFRESH_TOKEN_AUTH"]}
|
||||
""".formatted(poolId, clientId, CALLBACK, SIGNED_OUT));
|
||||
String both = loginPage().asString();
|
||||
assertTrue(both.contains("name=\"password\"") && both.contains(">Continue with CompanyIdp</a>"), both);
|
||||
|
||||
given().redirects().follow(false)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
|
||||
.queryParam("code_challenge", "abc").queryParam("code_challenge_method", "plain")
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package io.github.hectorvent.floci.services.cognito;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.services.cognito.model.RevokedTokenInfo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/** Revoked tokens must survive a restart, which reads them back with a strict ObjectMapper. */
|
||||
class RevokedTokenInfoPersistenceTest {
|
||||
|
||||
private static final TypeReference<Map<String, RevokedTokenInfo>> TYPE = new TypeReference<>() {
|
||||
};
|
||||
private final ObjectMapper strictMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void roundTripsWithoutTheDerivedExpiredField() throws Exception {
|
||||
RevokedTokenInfo info = new RevokedTokenInfo("jti-1", "refresh", "alice", "us-east-1_pool", 1_000L, 4_102_444_800L);
|
||||
|
||||
String json = strictMapper.writeValueAsString(Map.of("k", info));
|
||||
RevokedTokenInfo read = strictMapper.readValue(json, TYPE).get("k");
|
||||
|
||||
assertFalse(json.contains("\"expired\""), json);
|
||||
assertEquals("jti-1", read.getJti());
|
||||
assertEquals(4_102_444_800L, read.getExpiresAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsFilesWrittenWithTheExpiredField() throws Exception {
|
||||
String saved = """
|
||||
{"000000000000/revoked:us-east-1_pool:jti-1":{"jti":"jti-1","tokenType":"refresh","username":"alice",
|
||||
"userPoolId":"us-east-1_pool","revokedAt":1792858112669,"expiresAt":1824394112,"expired":false}}
|
||||
""";
|
||||
|
||||
RevokedTokenInfo read = strictMapper.readValue(saved, TYPE).get("000000000000/revoked:us-east-1_pool:jti-1");
|
||||
|
||||
assertEquals("alice", read.getUsername());
|
||||
assertEquals(1824394112L, read.getExpiresAt());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<Node> 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("<StackStatus>DELETE_FAILED</StackStatus>")));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @RegisterExtension
|
||||
* final PartitionCleanup cleanup = new PartitionCleanup();
|
||||
* ...
|
||||
* createTable(region, name);
|
||||
* cleanup.register(() -> deleteTable(region, name));
|
||||
* }</pre>
|
||||
*
|
||||
* 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<Runnable> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Thread.State> 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> 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> 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<Thread.State> 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;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ AWS::CloudFront::OriginRequestPolicy CloudFrontCfnProvisioner
|
||||
AWS::CloudFront::ResponseHeadersPolicy CloudFrontCfnProvisioner
|
||||
AWS::CloudTrail::Trail CloudTrailCfnProvisioner
|
||||
AWS::CloudWatch::Alarm CloudWatchCfnProvisioner
|
||||
AWS::CloudWatch::CompositeAlarm CloudWatchCompositeAlarmCfnProvisioner
|
||||
AWS::CloudWatch::Dashboard CloudWatchDashboardCfnProvisioner
|
||||
AWS::CodeBuild::Project CodeBuildCfnProvisioner
|
||||
AWS::CodePipeline::CustomActionType CodePipelineCfnProvisioner
|
||||
|
||||
|
@@ -169,6 +169,9 @@ notes:
|
||||
# Trailing prose appended to a row after its type list, for behaviour that belongs to the service
|
||||
# rather than to one type.
|
||||
row_notes:
|
||||
AWS::CloudWatch: >-
|
||||
Composite alarms have backing definitions with name Ref and Arn GetAtt, rollback, and
|
||||
replacement cleanup. Composite actions are stored only; suppression properties are rejected.
|
||||
AWS::Lambda: >-
|
||||
Inline `ZipFile` packages include the `cfn-response` (Node.js) / `cfnresponse` (Python) module
|
||||
AWS injects for that code path, so Solutions-style custom-resource handlers work.
|
||||
|
||||
Reference in New Issue
Block a user