Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00b3af3813 | |||
| d8d7dd021b | |||
| eaf5ca6ade | |||
| cc64729599 | |||
| 9f17f72cc3 | |||
| d8d94a773d | |||
| 957579c8b7 | |||
| d9a1a36dc8 | |||
| 5d4b010710 | |||
| 2486869b6b | |||
| 327173c8f6 | |||
| dcb007f8b8 | |||
| 607ac0803b | |||
| a9df81a294 | |||
| fa3327bf71 | |||
| 83aafc0325 | |||
| 24314f4432 | |||
| e90978ae94 | |||
| 21625c5a1b | |||
| 2e0a438d70 | |||
| ccdf32335d | |||
| cbb95ba26c | |||
| 90d5dc9427 | |||
| a59913bb38 | |||
| 2969939a99 | |||
| 31196a0367 | |||
| 55a981069c | |||
| 13e91cbb5a | |||
| ec4664589b | |||
| 40ee0e7822 | |||
| aef858a47c | |||
| e557be1bc6 | |||
| 174cb600d4 | |||
| 2e7ebefca9 | |||
| f4bbb6fab4 | |||
| e7fb8bb8d6 |
@@ -1,21 +1,17 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/floci-black.svg#gh-light-mode-only" alt="Floci" width="500" />
|
||||
<img src="docs/assets/floci-white.svg#gh-dark-mode-only" alt="Floci" width="500" />
|
||||
<img src="docs/assets/thecloud-logo.png" alt="TheCloud" width="160" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">TheCloud AWS runtime</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Any Cloud. Locally.</strong><br />
|
||||
Light, fluffy, and always free<br />
|
||||
The local AWS emulator behind TheCloud.<br />
|
||||
No account. No auth token. No feature gates. Just <code>docker compose up</code>.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/floci-io/floci/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci?label=latest%20release&color=blue" alt="Latest Release"></a>
|
||||
<a href="https://github.com/floci-io/floci/actions/workflows/release.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci/release.yml?label=build" alt="Build Status"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci"><img src="https://img.shields.io/docker/pulls/floci/floci?label=docker%20pulls" alt="Docker Pulls"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci"><img src="https://img.shields.io/docker/image-size/floci/floci/latest?label=image%20size" alt="Docker Image Size"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
|
||||
<a href="https://github.com/floci-io/floci/stargazers"><img src="https://img.shields.io/github/stars/floci-io/floci?style=flat" alt="GitHub Stars"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,41 +20,43 @@
|
||||
<a href="#supported-services">Services</a> ·
|
||||
<a href="#sdk-integration">SDKs</a> ·
|
||||
<a href="#testcontainers">Testcontainers</a> ·
|
||||
<a href="#migrating-from-localstack">Migration</a> ·
|
||||
<a href="https://floci.io/floci/">Docs</a>
|
||||
<a href="#migrating-from-localstack">Migration</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is Floci?
|
||||
## What is this?
|
||||
|
||||
Floci is a free, open-source local AWS emulator for development, testing, and CI.
|
||||
TheCloud's AWS runtime is a local AWS emulator for development, testing, and CI. It is the runtime behind [TheCloud](https://gitea.zacksolutions.dev/Zakaria/floci-ui), a self-hosted cloud console.
|
||||
|
||||
It gives you AWS-shaped services on your machine without requiring a cloud account, an auth token, or paid feature gates. Point your AWS SDK, CLI, Terraform, CDK, OpenTofu, or test suite at `http://localhost:4566` and keep your existing workflows.
|
||||
It gives you AWS-shaped services on your machine without a cloud account, an auth token, or paid feature gates. Point your AWS SDK, CLI, Terraform, CDK, OpenTofu, or test suite at `http://localhost:4566` and keep your existing workflows.
|
||||
|
||||
Already using LocalStack? Floci is a drop-in replacement: swap the image and keep going. See [Migrating from LocalStack](#migrating-from-localstack).
|
||||
It is a fork of the open-source [Floci](https://github.com/floci-io/floci) emulator. Upstream fixes are brought in by cherry-picking from Floci's `main`, alongside TheCloud's own changes, for example:
|
||||
|
||||
Floci is the AWS member of the [Floci](https://github.com/floci-io) emulator family, named after [floccus](https://en.wikipedia.org/wiki/Cirrocumulus_floccus), the cloud formation that looks like popcorn.
|
||||
- Cognito hosted login with identity-provider buttons, and `PreventUserExistenceErrors` honoured on app clients.
|
||||
- CloudFront distributions served at `<id>.cloudfront.localhost`.
|
||||
- SES delivering real email through an SMTP relay that signs in with a password (Gmail app passwords work).
|
||||
- HTTP API JWT authorizers that verify tokens from this runtime's own Cognito pools.
|
||||
|
||||
| Emulator | Cloud | Port |
|
||||
|---|---|:---:|
|
||||
| **[floci](https://github.com/floci-io/floci)** | **AWS** | **4566** |
|
||||
| [floci-az](https://github.com/floci-io/floci-az) | Azure | 4577 |
|
||||
| [floci-gcp](https://github.com/floci-io/floci-gcp) | GCP | 4588 |
|
||||
| [floci-oci](https://github.com/floci-io/floci-oci) | OCI | 4599 |
|
||||
Companion runtimes for other clouds come from upstream: [floci-az](https://github.com/floci-io/floci-az) for Azure on port 4577 and [floci-gcp](https://github.com/floci-io/floci-gcp) for GCP on port 4588.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to run Floci is with the official [CLI](https://github.com/floci-io/floci-cli)
|
||||
Build and start the runtime from this repository:
|
||||
|
||||
```bash
|
||||
floci start
|
||||
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git
|
||||
cd FlociClone
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Export the AWS environment variables:
|
||||
|
||||
```bash
|
||||
eval $(floci env)
|
||||
export AWS_ENDPOINT_URL=http://localhost:4566
|
||||
export AWS_DEFAULT_REGION=us-east-1
|
||||
export AWS_ACCESS_KEY_ID=test
|
||||
export AWS_SECRET_ACCESS_KEY=test
|
||||
```
|
||||
|
||||
Use your existing AWS tools normally:
|
||||
@@ -75,121 +73,41 @@ aws dynamodb create-table \
|
||||
aws dynamodb list-tables
|
||||
```
|
||||
|
||||
### Watch it run
|
||||
|
||||
This short demo shows the CLI flow: start Floci, export the local AWS environment, run standard AWS CLI commands, and stop the emulator.
|
||||
|
||||
https://github.com/user-attachments/assets/b55714dc-ef36-40ae-a734-cd2cadc288a8
|
||||
|
||||
All AWS services are available at `http://localhost:4566`. Any region works. Credentials can be any non-empty values unless you explicitly enable stricter service-specific auth checks.
|
||||
|
||||
<details>
|
||||
<summary>Prefer Docker Compose?</summary>
|
||||
<summary>Prefer <code>docker run</code>, or dev mode?</summary>
|
||||
|
||||
Create a `compose.yaml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
floci:
|
||||
image: floci/floci:latest
|
||||
ports:
|
||||
- "4566:4566"
|
||||
```
|
||||
|
||||
Start Floci:
|
||||
Build the image, then run it with the Docker socket so container-backed services work:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
docker build -f docker/Dockerfile -t floci-floci:latest .
|
||||
docker run -d --name floci -p 4566:4566 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
floci-floci:latest
|
||||
```
|
||||
|
||||
Then configure your AWS environment manually:
|
||||
Or run it in Quarkus dev mode, which needs JDK 25:
|
||||
|
||||
```bash
|
||||
export AWS_ENDPOINT_URL=http://localhost:4566
|
||||
export AWS_DEFAULT_REGION=us-east-1
|
||||
export AWS_ACCESS_KEY_ID=test
|
||||
export AWS_SECRET_ACCESS_KEY=test
|
||||
./mvnw quarkus:dev
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Using the old <code>hectorvent/floci</code> image?</summary>
|
||||
|
||||
Update your image name:
|
||||
|
||||
```yaml
|
||||
# Before
|
||||
image: hectorvent/floci:latest
|
||||
|
||||
# After
|
||||
image: floci/floci:latest
|
||||
```
|
||||
|
||||
The old `hectorvent/floci` repository no longer receives updates.
|
||||
|
||||
</details>
|
||||
|
||||
## Web Console
|
||||
|
||||
Floci ships a browser console for inspecting the resources in your local emulator.
|
||||
[TheCloud](https://gitea.zacksolutions.dev/Zakaria/floci-ui) is the console for this runtime. It runs as its own app on port 4500, talks to the runtime at `FLOCI_ENDPOINT`, and covers the services listed in its README.
|
||||
|
||||
**Open it at:** `http://localhost:4566/_floci/ui`
|
||||
|
||||
Nothing runs at boot. The first request pulls the console image, starts it as a sidecar container on Floci's Docker network, hands it Floci's own reachable address plus the standard AWS environment, polls its health endpoint, and redirects the browser once it reports it can reach Floci. The sidecar's port is bound by Docker, so it needs no `ports:` entry of your own, and its logs are streamed into CloudWatch Logs under `/floci/ui`.
|
||||
|
||||
Starting a container needs the Docker socket:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
floci:
|
||||
image: floci/floci:latest
|
||||
ports:
|
||||
- "4566:4566"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
The runtime can also start a console for you as a sidecar container on first visit to `http://localhost:4566/_floci/ui`. That needs the Docker socket, and the image is set with `FLOCI_SERVICES_UI_IMAGE`; point it at your TheCloud build to use it.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `FLOCI_SERVICES_UI_ENABLED` | `true` | Enable the console sidecar |
|
||||
| `FLOCI_SERVICES_UI_IMAGE` | `floci/floci-ui:latest` | Console image to run |
|
||||
| `FLOCI_SERVICES_UI_IMAGE` | `floci/floci-ui:latest` | Console image to run, for example `floci/floci-ui:local` for a local TheCloud build |
|
||||
| `FLOCI_SERVICES_UI_CONTAINER_NAME` | `floci-ui` | Name of the sidecar container |
|
||||
| `FLOCI_SERVICES_UI_PORT` | `4500` | Host port the console is published on |
|
||||
| `FLOCI_SERVICES_UI_KEEP_RUNNING_ON_SHUTDOWN` | `false` | Leave the sidecar running when Floci stops |
|
||||
|
||||
### Running a different console
|
||||
|
||||
The console is not fixed to the one Floci ships. Any console implementing the [Floci console contract](https://floci.io/floci/ui/console-contract/) runs with nothing but an image name: listen on the port in `PORT`, serve `GET /api/health`, talk to Floci at `AWS_ENDPOINT_URL`.
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
FLOCI_SERVICES_UI_IMAGE: acme/my-console:1.0
|
||||
```
|
||||
|
||||
A console that differs from the contract's defaults says so in its own `io.floci.console.*` image labels, so its operators do not have to. [StackPort](https://github.com/DaviReisVieira/stackport), for example, listens on 8080 rather than 4500; on an image that predates the labels, say it by hand:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
FLOCI_SERVICES_UI_IMAGE: davireis/stackport:latest
|
||||
FLOCI_SERVICES_UI_CONTAINER_NAME: floci-stackport
|
||||
FLOCI_SERVICES_UI_PORT: "8080"
|
||||
FLOCI_SERVICES_UI_INTERNAL_PORT: "8080"
|
||||
```
|
||||
|
||||
[Floci Dash](https://github.com/ofsazib/floci-dash) is another. It honours `PORT`, so only the two things it does differ on need naming:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
FLOCI_SERVICES_UI_IMAGE: ghcr.io/ofsazib/floci-dash:latest
|
||||
FLOCI_SERVICES_UI_CONTAINER_NAME: floci-dash
|
||||
FLOCI_SERVICES_UI_ENDPOINT_ENV: FLOCI_URL
|
||||
FLOCI_SERVICES_UI_STATUS_PATH: /api/healthz
|
||||
```
|
||||
|
||||
The endpoint itself is never configured by hand: Floci resolves its own reachable address at start time and injects it as `AWS_ENDPOINT_URL`.
|
||||
|
||||
Full reference: [Web Console](https://floci.io/floci/ui/) and [Console Contract v1](https://floci.io/floci/ui/console-contract/).
|
||||
| `FLOCI_SERVICES_UI_KEEP_RUNNING_ON_SHUTDOWN` | `false` | Leave the sidecar running when the runtime stops |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -212,14 +130,7 @@ Lambda, RDS, Neptune, ElastiCache, MSK, MSK Connect, ECS, EC2, EKS, OpenSearch,
|
||||
|
||||
Point standard AWS clients at `http://localhost:4566`. Existing credentials, regions, SDKs, CLI commands, and IaC workflows stay familiar.
|
||||
|
||||
Terraform can provision AWS-shaped resources locally through Floci using the standard HashiCorp AWS provider. See the [Terraform with Floci guide](docs/getting-started/terraform.md) for provider configuration, resource examples, and optional emulated S3 state.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Fast enough for CI</strong></summary>
|
||||
|
||||
The native image starts in milliseconds and keeps idle memory low, making it practical for local development and test pipelines.
|
||||
Terraform can provision AWS-shaped resources locally through this runtime using the standard HashiCorp AWS provider. See the [Terraform guide](docs/getting-started/terraform.md) for provider configuration, resource examples, and optional emulated S3 state.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -230,36 +141,13 @@ Choose from in-memory, persistent, hybrid, and write-ahead log storage depending
|
||||
|
||||
</details>
|
||||
|
||||
## Why Floci?
|
||||
|
||||
LocalStack's community edition [sunset in March 2026](https://blog.localstack.cloud/the-road-ahead-for-localstack/), requiring auth tokens and freezing security updates. Floci is the no-strings-attached alternative.
|
||||
|
||||
| Capability | Floci | LocalStack Community |
|
||||
|---|:---:|:---:|
|
||||
| Auth token required | No | Yes |
|
||||
| Security updates | Yes | Frozen |
|
||||
| Startup time | ~24 ms | ~3.3 s |
|
||||
| Idle memory | ~13 MiB | ~143 MiB |
|
||||
| Docker image size | ~90 MB | ~1.0 GB |
|
||||
| License | MIT | Restricted |
|
||||
| API Gateway v2 / HTTP API | Yes | No |
|
||||
| Cognito | Yes | No |
|
||||
| RDS, ElastiCache, MSK, MSK Connect | Real Docker | No |
|
||||
| Neptune (graph DB + Gremlin WebSocket) | Real Docker | No |
|
||||
| DocumentDB (MongoDB-compatible) | Real Docker | No |
|
||||
| ECS, EC2, EKS | Real Docker | No |
|
||||
| CodeBuild | Real Docker execution | No |
|
||||
| Native binary | ~40 MB | No |
|
||||
|
||||
**Broad AWS coverage. Free forever.** See the [Services Overview](https://floci.io/floci/services/) for the full list of emulated services.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["AWS SDK / CLI"]
|
||||
|
||||
subgraph Floci ["Floci, port 4566"]
|
||||
subgraph Runtime ["TheCloud AWS runtime, port 4566"]
|
||||
Router["HTTP Router\nJAX-RS / Vert.x"]
|
||||
|
||||
subgraph Stateless ["Stateless Services"]
|
||||
@@ -288,7 +176,7 @@ flowchart LR
|
||||
|
||||
## Supported Services
|
||||
|
||||
Floci supports local emulation for application services, data services, eventing, identity, infrastructure, billing, and container-backed workloads.
|
||||
The runtime emulates application services, data services, eventing, identity, infrastructure, billing, and container-backed workloads.
|
||||
|
||||
| Category | Services |
|
||||
|---|---|
|
||||
@@ -303,7 +191,7 @@ Floci supports local emulation for application services, data services, eventing
|
||||
| Cost and billing | AWS Budgets, Pricing, Cost Explorer, Cost and Usage Reports, BCM Pricing Calculator, BCM Data Exports |
|
||||
| Resilience, backup, and config | AWS FIS, AWS Backup, AWS Config, AppConfig, AppConfigData, CloudFormation, Cloud Control API |
|
||||
|
||||
For operation-level compatibility, see the [Services Overview](https://floci.io/floci/services/).
|
||||
For operation-level detail, see upstream Floci's [Services Overview](https://floci.io/floci/services/); it can list upstream work this fork has not picked up yet.
|
||||
|
||||
<details>
|
||||
<summary>Detailed service notes</summary>
|
||||
@@ -378,7 +266,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
|
||||
| AWS Backup | In-process | Vaults, backup plans, selections, simulated job lifecycle, recovery points |
|
||||
| AWS FIS | In-process | All 26 management APIs for templates, experiments, target accounts, action and target discovery, safety lever, tagging, and pagination; experiment execution is a safe control-plane simulation and does not inject faults into other services |
|
||||
| AWS Config | In-process | Config rules, evaluation-driven compliance (PutEvaluations, compliance details and summaries), configuration recorders, delivery channels, retention configuration, conformance packs, tagging |
|
||||
| Organizations | In-process | Organizations, roots, nested OUs, member accounts, all policy types with `FullAWSAccess` and effective-policy inheritance, trusted service access, delegated administrators, resource policy, and the full invitation/handshake flow; created accounts are usable Floci accounts and member accounts can read the organization they belong to |
|
||||
| Organizations | In-process | Organizations, roots, nested OUs, member accounts, all policy types with `FullAWSAccess` and effective-policy inheritance, trusted service access, delegated administrators, resource policy, and the full invitation/handshake flow; created accounts are usable accounts in the runtime and member accounts can read the organization they belong to |
|
||||
| CloudTrail | In-process | Trails, event selectors (S3 data events with bucket/prefix matching), `StartLogging`/`StopLogging`, scheduled gzipped log file emission to the destination bucket at AWS-shaped key paths, IAM-deny path emits `AccessDenied` records |
|
||||
| CloudFront | In-process | Distributions, origins, cache behaviors, invalidations, tagging |
|
||||
| WAF v2 | In-process | Web ACLs, IP sets, regex pattern sets, rule groups, logging configs, resource associations, tagging (REGIONAL and CLOUDFRONT scopes) |
|
||||
@@ -389,7 +277,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
|
||||
| Textract | In-process stub | API-compatible stubs, dummy block data, async job simulation |
|
||||
| Transcribe | In-process stub | Transcription jobs and custom vocabularies; jobs complete immediately, no real audio processing |
|
||||
| Pricing | In-process with static snapshot | Product discovery, attributes, price list files, pagination |
|
||||
| Cost Explorer | In-process | Cost synthesized from Floci resource state and pricing snapshots |
|
||||
| Cost Explorer | In-process | Cost synthesized from the runtime's resource state and pricing snapshots |
|
||||
| Cost and Usage Reports | In-process with floci-duck sidecar | CUR 2.0 and FOCUS 1.2 columns, account-scoped storage, Parquet emission |
|
||||
| BCM Pricing Calculator | In-process | Workload estimate create, usage pricing, read, and delete lifecycle |
|
||||
| BCM Data Exports | In-process | Export lifecycle, executions, update and delete operations |
|
||||
@@ -398,7 +286,7 @@ For operation-level compatibility, see the [Services Overview](https://floci.io/
|
||||
|
||||
## Real Docker Integration
|
||||
|
||||
Floci uses real Docker containers when in-process emulation would reduce fidelity. This applies to stateful databases, connection-heavy protocols, runtimes, and build systems.
|
||||
The runtime uses real Docker containers when in-process emulation would reduce fidelity. This applies to stateful databases, connection-heavy protocols, runtimes, and build systems.
|
||||
|
||||
| Service | Default image | What is real |
|
||||
|---|---|---|
|
||||
@@ -416,12 +304,12 @@ Floci uses real Docker containers when in-process emulation would reduce fidelit
|
||||
| EC2 | AMI-mapped Linux images | Linux containers, SSH key injection, UserData, IMDS, IAM credentials |
|
||||
| ECS | User-specified task image | Container lifecycle, start, stop, health checks |
|
||||
| EKS | `rancher/k3s:latest` | Kubernetes API server via k3s |
|
||||
| MWAA | `apache/airflow:<version>-python3.12` + `postgres:16-alpine` | Real Apache Airflow (LocalExecutor: scheduler + webserver) with its own Postgres metadata DB; webserver/CLI reachable via Floci's proxy |
|
||||
| MWAA | `apache/airflow:<version>-python3.12` + `postgres:16-alpine` | Real Apache Airflow (LocalExecutor: scheduler + webserver) with its own Postgres metadata DB; webserver/CLI reachable via the runtime's proxy |
|
||||
| CodeBuild | User-specified environment image | Buildspec execution, log streaming, S3 artifact upload |
|
||||
| OpenSearch | `opensearchproject/opensearch:2` | Full OpenSearch engine with REST API |
|
||||
| ECR | `registry:2` | OCI-compatible registry for docker push and docker pull |
|
||||
| Verified Permissions | `floci/floci-sidecar-cedar:1.1.0` | Cedar 4 policy parsing, schema validation and authorization decisions, via a [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
|
||||
| AppSync | `floci/floci-sidecar-graphql:0.2.0` | graphql-java schema validation, query planning and execution, via a [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
|
||||
| Verified Permissions | `floci/floci-sidecar-cedar:1.1.0` | Cedar 4 policy parsing, schema validation and authorization decisions, via an upstream [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
|
||||
| AppSync | `floci/floci-sidecar-graphql:0.2.0` | graphql-java schema validation, query planning and execution, via an upstream [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
|
||||
|
||||
Docker-backed services require the Docker socket:
|
||||
|
||||
@@ -430,7 +318,7 @@ docker run -d --name floci \
|
||||
-p 4566:4566 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-u root \
|
||||
floci/floci:latest
|
||||
floci-floci:latest
|
||||
```
|
||||
|
||||
### Overriding default images
|
||||
@@ -454,7 +342,7 @@ docker run -d --name floci \
|
||||
|
||||
## Persistence and Storage Modes
|
||||
|
||||
Floci can trade speed for durability depending on the workflow. Configure the default mode with `FLOCI_STORAGE_MODE`, or override storage per service.
|
||||
The runtime can trade speed for durability depending on the workflow. Configure the default mode with `FLOCI_STORAGE_MODE`, or override storage per service.
|
||||
|
||||
| Mode | Behavior | Best for | Durability |
|
||||
|---|---|---|:---:|
|
||||
@@ -465,22 +353,22 @@ Floci can trade speed for durability depending on the workflow. Configure the de
|
||||
|
||||
Use `memory` for fast test runs. Use `hybrid` when you want state preserved across container restarts without much overhead.
|
||||
|
||||
For more detail, see the [Storage Configuration documentation](https://floci.io/floci/configuration/storage/).
|
||||
For more detail, see upstream Floci's [Storage Configuration documentation](https://floci.io/floci/configuration/storage/).
|
||||
|
||||
## Multi-Account Isolation
|
||||
|
||||
Floci supports per-account resource isolation with no extra setup. If `AWS_ACCESS_KEY_ID` is exactly 12 digits, Floci uses it as the account ID. Resources created by one account are invisible to another.
|
||||
The runtime supports per-account resource isolation with no extra setup. If `AWS_ACCESS_KEY_ID` is exactly 12 digits, it is used as the account ID. Resources created by one account are invisible to another.
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=111111111111 aws sqs create-queue --queue-name orders
|
||||
AWS_ACCESS_KEY_ID=222222222222 aws sqs create-queue --queue-name orders
|
||||
```
|
||||
|
||||
Any other key format, such as `test` or `AKIA...`, causes Floci to fall back to `FLOCI_DEFAULT_ACCOUNT_ID`, which defaults to `000000000000`.
|
||||
Any other key format, such as `test` or `AKIA...`, falls back to `FLOCI_DEFAULT_ACCOUNT_ID`, which defaults to `000000000000`.
|
||||
|
||||
STS temporary credentials are routed too: credentials from `AssumeRole` resolve to the assumed role's account, so the cross-account assume-role-then-provision pattern works locally. Resolution precedence is 12-digit AKID → temporary-session lookup → `FLOCI_DEFAULT_ACCOUNT_ID`.
|
||||
|
||||
See the [Multi-Account Isolation docs](https://floci.io/floci/configuration/multi-account/).
|
||||
See upstream Floci's [Multi-Account Isolation docs](https://floci.io/floci/configuration/multi-account/).
|
||||
|
||||
## SDK Integration
|
||||
|
||||
@@ -526,7 +414,7 @@ client = boto3.client(
|
||||
|
||||
client.put_parameter(
|
||||
Name="/demo/app/message",
|
||||
Value="hello from floci",
|
||||
Value="hello from TheCloud",
|
||||
Type="String",
|
||||
Overwrite=True,
|
||||
)
|
||||
@@ -552,7 +440,7 @@ const client = new SQSClient({
|
||||
await client.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: "http://localhost:4566/000000000000/demo-queue",
|
||||
MessageBody: "hello from floci",
|
||||
MessageBody: "hello from TheCloud",
|
||||
}),
|
||||
);
|
||||
```
|
||||
@@ -613,7 +501,7 @@ use aws_sdk_secretsmanager::Client;
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.region(Region::new("us-east-1"))
|
||||
.credentials_provider(Credentials::new("test", "test", None, None, "floci"))
|
||||
.credentials_provider(Credentials::new("test", "test", None, None, "thecloud"))
|
||||
.endpoint_url("http://localhost:4566")
|
||||
.load()
|
||||
.await;
|
||||
@@ -623,7 +511,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
client
|
||||
.create_secret()
|
||||
.name("demo/secret")
|
||||
.secret_string("hello from floci")
|
||||
.secret_string("hello from TheCloud")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -649,7 +537,7 @@ aws --endpoint-url http://localhost:4566 s3 ls
|
||||
|
||||
## Testcontainers
|
||||
|
||||
Floci has Testcontainers modules for starting isolated Floci instances directly from tests. This avoids shared state, manual daemon setup, and port conflicts.
|
||||
Upstream Floci publishes Testcontainers modules for starting isolated instances directly from tests. This avoids shared state, manual daemon setup, and port conflicts. They start upstream's `floci/floci` image by default, so they test against upstream Floci rather than this fork unless you give them your own image.
|
||||
|
||||
For Testcontainers 1.x, use the versions as indicated in the table below.
|
||||
|
||||
@@ -767,7 +655,7 @@ def test_s3_create_bucket():
|
||||
|
||||
## Compatibility Testing
|
||||
|
||||
The [`compatibility-tests`](./compatibility-tests/) directory validates Floci across SDKs and tooling workflows.
|
||||
The [`compatibility-tests`](./compatibility-tests/) directory validates the runtime across SDKs and tooling workflows.
|
||||
|
||||
| Module | Language / Tool | SDK / Client | Tests |
|
||||
|---|---|---|---:|
|
||||
@@ -784,22 +672,19 @@ The [`compatibility-tests`](./compatibility-tests/) directory validates Floci ac
|
||||
|
||||
## Migrating from LocalStack
|
||||
|
||||
Floci is a drop-in replacement for LocalStack Community. The port, credentials, SDK configuration, and CLI endpoint pattern work the same way. Swap the image and keep going.
|
||||
The runtime is a drop-in replacement for LocalStack Community. The port, credentials, SDK configuration, and CLI endpoint pattern work the same way. Swap the image and keep going.
|
||||
|
||||
```yaml
|
||||
# Before
|
||||
image: localstack/localstack
|
||||
|
||||
# After, standard image
|
||||
image: floci/floci:latest
|
||||
|
||||
# After, if init scripts need AWS CLI or boto3
|
||||
image: floci/floci:latest-compat
|
||||
# After, your local build of this repository
|
||||
image: floci-floci:latest
|
||||
```
|
||||
|
||||
LocalStack environment variables are translated automatically:
|
||||
|
||||
| LocalStack | Floci equivalent |
|
||||
| LocalStack | Equivalent |
|
||||
|---|---|
|
||||
| `LOCALSTACK_HOST` | `FLOCI_HOSTNAME` |
|
||||
| `PERSISTENCE=1` | `FLOCI_STORAGE_MODE=persistent` |
|
||||
@@ -809,43 +694,23 @@ LocalStack environment variables are translated automatically:
|
||||
|
||||
Init scripts mounted under `/etc/localstack/init/` run unchanged. The `/_localstack/init` and `/_localstack/health` endpoints are still served. Once the emulator is up, the log also ends with a LocalStack-style `Ready.` line, so tooling that watches the log for it, such as the default wait strategy of Testcontainers' `LocalStackContainer`, works unchanged. Set `LOCALSTACK_PARITY=false` to opt out of automatic translation.
|
||||
|
||||
See the [full migration guide](https://floci.io/floci/getting-started/migrate-from-localstack/).
|
||||
See upstream Floci's [full migration guide](https://floci.io/floci/getting-started/migrate-from-localstack/).
|
||||
|
||||
## Image Tags
|
||||
## Building and Updating
|
||||
|
||||
Every tag combines a variant and a channel.
|
||||
The image is built from this repository; nothing is published to Docker Hub:
|
||||
|
||||
| Channel | Standard | Baseline (ARM64 only) | Compat with AWS CLI and boto3 |
|
||||
|---|---|---|---|
|
||||
| Release, floating | `latest` | `latest-baseline` | `latest-compat` |
|
||||
| Release, pinned | `x.y.z` | `x.y.z-baseline` | `x.y.z-compat` |
|
||||
| Nightly, floating | `nightly` | — | `nightly-compat` |
|
||||
| Nightly, dated | `nightly-mmddyyyy` | — | `nightly-mmddyyyy-compat` |
|
||||
|
||||
Use `latest` for stable releases, a pinned version for reproducible builds, and `nightly` to track `main`.
|
||||
|
||||
```yaml
|
||||
# Recommended
|
||||
image: floci/floci:latest
|
||||
|
||||
# Includes AWS CLI and boto3
|
||||
image: floci/floci:latest-compat
|
||||
|
||||
# ARM64 baseline for Raspberry Pi 4 / pre-LSE cores
|
||||
image: floci/floci:latest-baseline
|
||||
|
||||
# Pinned release
|
||||
image: floci/floci:x.y.z
|
||||
|
||||
# Track main
|
||||
image: floci/floci:nightly
|
||||
```bash
|
||||
docker build -f docker/Dockerfile -t floci-floci:latest .
|
||||
```
|
||||
|
||||
### Release train
|
||||
Tests run with Maven and JDK 25:
|
||||
|
||||
Stable releases ship on the **1st and 3rd Tuesday of each month**. Between trains, `floci/floci:nightly` tracks `main`. Every merged fix is available the next day, and dated `nightly-mmddyyyy` tags let you pin a specific night's build.
|
||||
```bash
|
||||
./mvnw test
|
||||
```
|
||||
|
||||
Versions are derived from Conventional Commits by [semantic-release](https://github.com/semantic-release/semantic-release); `CHANGELOG.md` is generated, never hand-edited. Releases are cut from `main` only: there are no maintenance branches.
|
||||
Upstream fixes come in by cherry-picking commits from Floci's `main` onto a branch, which then goes through review like any other change.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -853,26 +718,26 @@ All settings are overridable through environment variables with the `FLOCI_` pre
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `FLOCI_PORT` | `4566` | Port exposed by the Floci API |
|
||||
| `FLOCI_PORT` | `4566` | Port the runtime's API listens on |
|
||||
| `FLOCI_DEFAULT_REGION` | `us-east-1` | Default AWS region |
|
||||
| `FLOCI_DEFAULT_ACCOUNT_ID` | `000000000000` | Default AWS account ID |
|
||||
| `FLOCI_BASE_URL` | `http://localhost:4566` | Base URL used when Floci returns service URLs |
|
||||
| `FLOCI_HOSTNAME` | Unset | Hostname used in returned URLs when Floci runs inside Docker Compose |
|
||||
| `FLOCI_BASE_URL` | `http://localhost:4566` | Base URL used in service URLs the runtime returns |
|
||||
| `FLOCI_HOSTNAME` | Unset | Hostname used in returned URLs when the runtime runs inside Docker Compose |
|
||||
| `FLOCI_STORAGE_MODE` | `memory` | Storage mode: `memory`, `persistent`, `hybrid`, or `wal` |
|
||||
| `FLOCI_STORAGE_PERSISTENT_PATH` | `./data` | Directory used for persisted state |
|
||||
| `FLOCI_SERVICES_LAMBDA_ECR_BASE_URI` | `public.ecr.aws` | ECR base URI used when pulling Lambda runtime images (legacy name `FLOCI_ECR_BASE_URI` still works) |
|
||||
| `FLOCI_SERVICES_S3_ENFORCE_AUTH` | `false` | Enforce S3 public/private read access and reject unknown signed S3 access keys |
|
||||
|
||||
Full reference: [configuration docs](https://floci.io/floci/configuration/advanced/application-yml)
|
||||
Full reference: [`src/main/resources/application.yml`](src/main/resources/application.yml), and upstream Floci's [configuration docs](https://floci.io/floci/configuration/advanced/application-yml).
|
||||
|
||||
### Multi-container Docker Compose
|
||||
|
||||
When your application runs in a different container, set `FLOCI_HOSTNAME` to the Floci service name so returned URLs, such as SQS `QueueUrl` values, resolve correctly.
|
||||
When your application runs in a different container, set `FLOCI_HOSTNAME` to the runtime's service name so returned URLs, such as SQS `QueueUrl` values, resolve correctly.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
floci:
|
||||
image: floci/floci:latest
|
||||
image: floci-floci:latest
|
||||
ports:
|
||||
- "4566:4566"
|
||||
environment:
|
||||
@@ -887,59 +752,6 @@ services:
|
||||
|
||||
Without this, services may return URLs using `localhost`, which points to the wrong container from the application container.
|
||||
|
||||
## Community
|
||||
|
||||
Join the Floci community on [Slack](https://join.slack.com/t/floci/shared_invite/zt-3tjn02s3q-A00kEjJ1cZxsg_imTfy6Cw) or [GitHub Discussions](https://github.com/orgs/floci-io/discussions). Feature ideas, compatibility questions, design tradeoffs, and rough proposals are welcome.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Floci is independent open source, funded by the people and companies who use it.
|
||||
Sponsorship buys gratitude and nothing else: every emulated service is free for
|
||||
everyone, forever, and no sponsor gets features, priority, or roadmap influence
|
||||
that the rest of the Flock does not.
|
||||
|
||||
### 🥇 Gold
|
||||
|
||||
Large logo with top placement in the emulator READMEs and on floci.io, plus a
|
||||
mention in release notes.
|
||||
|
||||
[IceGuard](https://github.com/iceguard) · [Softmax](https://softmax.com/)
|
||||
|
||||
### 🥈 Silver
|
||||
|
||||
Logo in the emulator READMEs and on floci.io, plus a mention in release notes.
|
||||
|
||||
*Your logo here. [Become a sponsor](https://github.com/sponsors/floci-io).*
|
||||
|
||||
### 🥉 Community
|
||||
|
||||
Name in the emulator READMEs, a sponsor badge on GitHub, and our sincere thanks.
|
||||
|
||||
[AutoScout24](https://www.autoscout24.com) · [Nexxion AI](https://nexxion.ai/)
|
||||
|
||||
Every sponsor, including the Friends of the Flock who support Floci outside these
|
||||
tiers, is listed in [THANKS.md](https://github.com/floci-io/.github/blob/main/THANKS.md).
|
||||
|
||||
**[Sponsor Floci](https://github.com/sponsors/floci-io)**
|
||||
|
||||
## Star History
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/?repos=floci-io%2Ffloci&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=floci-io/floci&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=floci-io/floci&type=date&legend=top-left" />
|
||||
<img width="600" alt="Star History Chart" src="https://api.star-history.com/chart?repos=floci-io/floci&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/floci-io/floci/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=floci-io/floci&max=100&columns=20" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
MIT. Use it however you want.
|
||||
MIT. TheCloud's AWS runtime is a fork of [Floci](https://github.com/floci-io/floci); thanks to the Floci contributors and sponsors whose work it builds on.
|
||||
|
||||
+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"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -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
|
||||
|
||||
+33
-10
@@ -75,9 +75,9 @@ AWS-shaped `/oauth2/authorize` and `/oauth2/idpresponse` endpoints to the config
|
||||
OIDC provider, exchanges the returned code, provisions or reconciles the federated user,
|
||||
and issues a Cognito authorization code for the registered callback.
|
||||
|
||||
Only `ProviderType=OIDC` is supported by this flow. Floci does not provide a built-in hosted
|
||||
UI and does not implement social providers such as Google, Facebook, Login with Amazon or
|
||||
SignInWithApple.
|
||||
Only `ProviderType=OIDC` is supported by this flow. Floci does not implement social providers
|
||||
such as Google, Facebook, Login with Amazon or SignInWithApple. The pool's own users sign in on
|
||||
the hosted login page instead (see Well-Known And OAuth Endpoints below).
|
||||
|
||||
Two deliberate divergences from AWS, both consequences of not calling out to a third
|
||||
party:
|
||||
@@ -280,8 +280,8 @@ up, so an oversized request naming something that does not exist reports the req
|
||||
problem rather than `ResourceNotFoundException`, and an update violating both reports them
|
||||
in one message with the asset list first.
|
||||
|
||||
Branding is presentation for the hosted UI, which Floci does not serve, so it is stored
|
||||
and returned rather than rendered. Two divergences follow from that:
|
||||
Branding is presentation for the hosted UI. Floci's hosted login page does not apply it, so it
|
||||
is stored and returned rather than rendered. Two divergences follow from that:
|
||||
|
||||
- **`Settings` is stored opaquely.** AWS validates it against a deep schema, rejecting
|
||||
unknown properties with `Invalid settings provided. Validation errors: [{property:
|
||||
@@ -304,17 +304,40 @@ and returned rather than rendered. Two divergences follow from that:
|
||||
| `GET /{userPoolId}/.well-known/jwks.json` | JSON Web Key Set for JWT validation |
|
||||
| `GET /cognito-idp/oauth2/authorize` | OIDC authorization-code start endpoint |
|
||||
| `GET /cognito-idp/oauth2/idpresponse` | OIDC provider callback endpoint |
|
||||
| `POST /cognito-idp/oauth2/token` | OAuth authorization-code and client-credentials token endpoint |
|
||||
| `GET, POST /cognito-idp/login` | Hosted login page for the pool's own users |
|
||||
| `GET /cognito-idp/logout` | Ends the hosted login session |
|
||||
| `POST /cognito-idp/oauth2/token` | OAuth authorization-code, refresh-token and client-credentials token endpoint |
|
||||
|
||||
The OAuth endpoints support browser-style OIDC authorization-code sign-in as well as the
|
||||
emulator-friendly client-credentials flow:
|
||||
|
||||
- `GET /cognito-idp/oauth2/authorize` validates the app client and callback, then redirects
|
||||
to the configured provider with an opaque state and nonce.
|
||||
- `GET /cognito-idp/oauth2/authorize` validates the app client and callback. With
|
||||
`identity_provider` naming an OIDC provider it redirects there with an opaque state and nonce.
|
||||
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 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
|
||||
unconfirmed users get the same answer until the password is right. Users with a temporary
|
||||
password, MFA or a pending reset are told so; those steps are not served on the page yet.
|
||||
- `GET /cognito-idp/logout?client_id=...&logout_uri=...` ends the session and redirects to
|
||||
`logout_uri`, which must be one of the client's `LogoutURLs`. With `redirect_uri` and
|
||||
`response_type=code` instead, it ends the session and shows the login page again.
|
||||
- PKCE: send `code_challenge` with `code_challenge_method=S256` to authorize, and the matching
|
||||
`code_verifier` when redeeming the code. `nonce` is copied into the ID token.
|
||||
- Codes from the hosted login and from federated sign-in fire the Pre Token Generation trigger
|
||||
with `triggerSource=TokenGeneration_HostedAuth`, and the hosted login fires Pre and Post
|
||||
Authentication.
|
||||
- `GET /cognito-idp/oauth2/idpresponse` consumes the provider state, exchanges the provider
|
||||
code and redirects to the registered callback with a one-time Cognito authorization code.
|
||||
- `POST /cognito-idp/oauth2/token` redeems that authorization code once, or issues a machine
|
||||
token for `grant_type=client_credentials`.
|
||||
- `POST /cognito-idp/oauth2/token` redeems that authorization code once, exchanges a
|
||||
`refresh_token` for new access and ID tokens, or issues a machine token for
|
||||
`grant_type=client_credentials`.
|
||||
|
||||
`POST /cognito-idp/oauth2/token` is intentionally emulator-friendly rather than full Cognito parity:
|
||||
|
||||
|
||||
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
@@ -563,8 +563,10 @@ public class AcmJsonHandler {
|
||||
|
||||
private CertificateOptions parseOptions(JsonNode optionsNode) {
|
||||
if (optionsNode.isMissingNode()) return null;
|
||||
String ctPref = optionsNode.path("CertificateTransparencyLoggingPreference").asText("ENABLED");
|
||||
return new CertificateOptions(ctPref, "DISABLED");
|
||||
String ctPref = parseOptionValue(optionsNode, "CertificateTransparencyLoggingPreference");
|
||||
String export = parseOptionValue(optionsNode, "Export");
|
||||
// An exportable public certificate is requested with Export ENABLED, and keeps it for life.
|
||||
return new CertificateOptions(ctPref != null ? ctPref : "ENABLED", export != null ? export : "DISABLED");
|
||||
}
|
||||
|
||||
private List<CertificateStatus> parseCertificateStatuses(JsonNode node) {
|
||||
|
||||
+26
@@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||
import io.github.hectorvent.floci.core.common.SsrfProtection;
|
||||
import io.github.hectorvent.floci.services.cognito.CognitoService;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.inject.Inject;
|
||||
import org.apache.hc.client5.http.DnsResolver;
|
||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||
@@ -37,6 +39,7 @@ import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Verifies the RS256 signature of a token presented to an HTTP API v2 JWT authorizer, the same
|
||||
@@ -66,8 +69,27 @@ public class JwtSignatureVerifier implements AutoCloseable {
|
||||
private final CloseableHttpClient httpClient;
|
||||
private final boolean allowPrivateNetworkTargets;
|
||||
private final Map<String, CachedJwks> jwksCache = new ConcurrentHashMap<>();
|
||||
private Function<String, Optional<Map<String, RSAPublicKey>>> localIssuerKeys = issuer -> Optional.empty();
|
||||
|
||||
/**
|
||||
* An issuer that is one of this emulator's own Cognito pools is served at Floci's base URL, which
|
||||
* is plain HTTP on a hostname such as {@code localhost} or a Compose service name, so the HTTPS
|
||||
* and address rules below would refuse to fetch it. Its keys are read from the pool instead:
|
||||
* they are the same keys the pool publishes at its JWKS URL.
|
||||
*/
|
||||
@Inject
|
||||
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config, Instance<CognitoService> cognito) {
|
||||
this(objectMapper, config);
|
||||
withLocalIssuerKeys(issuer -> cognito.isResolvable()
|
||||
? cognito.get().localIssuerKeys(issuer) : Optional.empty());
|
||||
}
|
||||
|
||||
/** Where the keys of in-process issuers come from; package-private so tests can stand one in. */
|
||||
JwtSignatureVerifier withLocalIssuerKeys(Function<String, Optional<Map<String, RSAPublicKey>>> localIssuerKeys) {
|
||||
this.localIssuerKeys = localIssuerKeys;
|
||||
return this;
|
||||
}
|
||||
|
||||
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config) {
|
||||
this(objectMapper, SystemDefaultDnsResolver.INSTANCE,
|
||||
config.security().allowPrivateJwtTargets());
|
||||
@@ -162,6 +184,10 @@ public class JwtSignatureVerifier implements AutoCloseable {
|
||||
}
|
||||
|
||||
private RSAPublicKey resolveKey(String issuer, String kid) throws JwtVerificationException {
|
||||
Optional<Map<String, RSAPublicKey>> local = localIssuerKeys.apply(issuer);
|
||||
if (local.isPresent()) {
|
||||
return local.get().get(kid);
|
||||
}
|
||||
CachedJwks cached = jwksCache.get(issuer);
|
||||
if (cached != null && !cached.isExpired()) {
|
||||
RSAPublicKey key = cached.keysByKid().get(kid);
|
||||
|
||||
+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) {
|
||||
|
||||
+60
-1
@@ -291,7 +291,10 @@ final class CognitoAuthFlowHandler {
|
||||
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
|
||||
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
|
||||
"UserMigration_Authentication");
|
||||
if (user == null) throw ae;
|
||||
if (user == null) {
|
||||
if (!CognitoService.hidesUserExistence(client)) throw ae;
|
||||
throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
|
||||
}
|
||||
}
|
||||
|
||||
firePreAuthentication(pool, client, user, null, clientMetadata, false);
|
||||
@@ -1363,6 +1366,62 @@ final class CognitoAuthFlowHandler {
|
||||
return service.generateAuthResult(user, pool, client, override);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a password typed into the hosted login page. Same rules and triggers as
|
||||
* USER_PASSWORD_AUTH, minus SECRET_HASH: the page is Cognito's own, not the app client.
|
||||
* Throws {@code NewPasswordRequiredException} when the user still has a temporary password.
|
||||
*/
|
||||
CognitoUser authenticateHostedLogin(UserPool pool, UserPoolClient client, String username, String password) {
|
||||
CognitoUser user;
|
||||
try {
|
||||
user = service.adminGetUser(pool.getId(), username);
|
||||
} catch (AwsException ae) {
|
||||
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
|
||||
user = tryUserMigration(pool, client, username, password, null, Map.of(),
|
||||
"UserMigration_Authentication");
|
||||
if (user == null) throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
|
||||
}
|
||||
|
||||
firePreAuthentication(pool, client, user, null, Map.of(), false);
|
||||
|
||||
if (!user.isEnabled()) throw new AwsException("NotAuthorizedException", "User is disabled", 400);
|
||||
if ("RESET_REQUIRED".equals(user.getUserStatus())) {
|
||||
throw new AwsException("PasswordResetRequiredException", "Password reset required", 400);
|
||||
}
|
||||
if (user.getPasswordHash() == null || !user.getPasswordHash().equals(service.hashPassword(password))) {
|
||||
throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
|
||||
}
|
||||
if ("UNCONFIRMED".equals(user.getUserStatus())) {
|
||||
throw new AwsException("UserNotConfirmedException", "User is not confirmed", 400);
|
||||
}
|
||||
if (user.isTemporaryPassword() || "FORCE_CHANGE_PASSWORD".equals(user.getUserStatus())) {
|
||||
throw new AwsException("NewPasswordRequiredException", "Password change required", 400);
|
||||
}
|
||||
firePostAuthentication(pool, client, user, Map.of(), false);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Tokens for an authorization code from the hosted login or a federated sign-in. */
|
||||
Map<String, Object> issueHostedTokens(UserPool pool, UserPoolClient client, CognitoUser user, String nonce) {
|
||||
CognitoService.ClaimsOverride override = withIdClaim(
|
||||
firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_HostedAuth"), "nonce", nonce);
|
||||
return service.generateAuthResult(user, pool, client, override);
|
||||
}
|
||||
|
||||
private static CognitoService.ClaimsOverride withIdClaim(CognitoService.ClaimsOverride override,
|
||||
String name, String value) {
|
||||
if (value == null || value.isBlank()) return override;
|
||||
CognitoService.ClaimsOverride base = override != null ? override
|
||||
: new CognitoService.ClaimsOverride(null, null, null, null, null, null, null, null, null);
|
||||
Map<String, Object> idClaims = new HashMap<>();
|
||||
if (base.idClaimsToAddOrOverride() != null) idClaims.putAll(base.idClaimsToAddOrOverride());
|
||||
idClaims.put(name, value);
|
||||
return new CognitoService.ClaimsOverride(idClaims, base.idClaimsToSuppress(),
|
||||
base.accessClaimsToAddOrOverride(), base.accessClaimsToSuppress(),
|
||||
base.scopesToAdd(), base.scopesToSuppress(),
|
||||
base.groupsToOverride(), base.iamRolesToOverride(), base.preferredRole());
|
||||
}
|
||||
|
||||
CognitoService.ClaimsOverride preTokenGenerationForRefresh(UserPool pool, UserPoolClient client, CognitoUser user) {
|
||||
return firePreTokenGeneration(pool, client, user, Map.of(), "TokenGeneration_RefreshTokens");
|
||||
}
|
||||
|
||||
+13
-5
@@ -14,12 +14,13 @@ import org.jboss.logging.Logger;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Routes Cognito custom-domain requests by Host. On AWS a custom domain serves
|
||||
* {@code https://<domain>/oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize}, and
|
||||
* {@code /oauth2/idpresponse}; Floci maps those onto
|
||||
* the {@code /cognito-idp/oauth2/...} handlers and pins the pool and the account that own the
|
||||
* {@code https://<domain>/oauth2/token}, {@code /oauth2/userInfo}, {@code /oauth2/authorize},
|
||||
* {@code /oauth2/idpresponse} and the hosted {@code /login} and {@code /logout} pages; Floci maps
|
||||
* those onto the {@code /cognito-idp/...} handlers and pins the pool and the account that own the
|
||||
* domain, since the request itself carries no AWS credential.
|
||||
*/
|
||||
@Provider
|
||||
@@ -37,6 +38,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
|
||||
private static final Logger LOG = Logger.getLogger(CognitoCustomDomainFilter.class);
|
||||
private static final String OAUTH_PREFIX = "/oauth2/";
|
||||
private static final String TARGET_PREFIX = "/cognito-idp/oauth2/";
|
||||
private static final Set<String> HOSTED_PAGES = Set.of("/login", "/logout");
|
||||
private static final String HOSTED_TARGET_PREFIX = "/cognito-idp/";
|
||||
|
||||
private final CognitoService cognitoService;
|
||||
|
||||
@@ -49,7 +52,11 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
|
||||
public void filter(ContainerRequestContext requestContext) {
|
||||
URI originalUri = requestContext.getUriInfo().getRequestUri();
|
||||
String path = originalUri.getRawPath();
|
||||
if (path == null || !path.startsWith(OAUTH_PREFIX)) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
boolean hostedPage = HOSTED_PAGES.contains(path);
|
||||
if (!path.startsWith(OAUTH_PREFIX) && !hostedPage) {
|
||||
return;
|
||||
}
|
||||
String host = RequestHost.of(requestContext);
|
||||
@@ -62,7 +69,8 @@ public class CognitoCustomDomainFilter implements ContainerRequestFilter {
|
||||
}
|
||||
|
||||
URI newUri = UriBuilder.fromUri(originalUri)
|
||||
.replacePath(TARGET_PREFIX + path.substring(OAUTH_PREFIX.length()))
|
||||
.replacePath(hostedPage ? HOSTED_TARGET_PREFIX + path.substring(1)
|
||||
: TARGET_PREFIX + path.substring(OAUTH_PREFIX.length()))
|
||||
.build();
|
||||
LOG.debugv("Cognito custom domain routing: {0}{1} -> {2}", host, path, newUri.getPath());
|
||||
requestContext.setProperty(POOL_PROPERTY, domain.get().getUserPoolId());
|
||||
|
||||
+10
-2
@@ -47,6 +47,12 @@ public class CognitoFederationService {
|
||||
|
||||
public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List<String> scopes,
|
||||
String nonce, String providerName, String relyingPartyState) {
|
||||
return beginAuthorization(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, null);
|
||||
}
|
||||
|
||||
public String beginAuthorization(String userPoolId, String clientId, String redirectUri, List<String> scopes,
|
||||
String nonce, String providerName, String relyingPartyState,
|
||||
String codeChallenge) {
|
||||
IdentityProvider provider = cognitoService.describeIdentityProvider(userPoolId, providerName);
|
||||
requireOidcProvider(provider);
|
||||
String authorizeEndpoint = requiredProviderDetail(provider, "authorize_url", "authorize endpoint");
|
||||
@@ -54,7 +60,8 @@ public class CognitoFederationService {
|
||||
validateAuthorizeEndpoint(authorizeEndpoint, provider.getProviderName());
|
||||
Instant expiresAt = clock.instant().plus(TRANSACTION_LIFETIME);
|
||||
CognitoAuthorizationTransaction transaction = new CognitoAuthorizationTransaction(
|
||||
userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt);
|
||||
userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt,
|
||||
codeChallenge);
|
||||
String state = stateStore.putTransaction(transaction);
|
||||
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
@@ -89,7 +96,8 @@ public class CognitoFederationService {
|
||||
subject, issuer, mapAttributes(provider, claims));
|
||||
CognitoAuthorizationCode authorizationCode = new CognitoAuthorizationCode(
|
||||
transaction.userPoolId(), transaction.clientId(), user.getUsername(), transaction.redirectUri(),
|
||||
transaction.scopes(), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME));
|
||||
transaction.scopes(), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME), transaction.nonce(),
|
||||
transaction.codeChallenge());
|
||||
return stateStore.putAuthorizationCode(authorizationCode);
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -2,6 +2,7 @@ package io.github.hectorvent.floci.services.cognito;
|
||||
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
@@ -20,6 +21,7 @@ public class CognitoFederationStateStore {
|
||||
|
||||
private final ConcurrentHashMap<String, CognitoAuthorizationTransaction> transactions = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, CognitoAuthorizationCode> authorizationCodes = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, CognitoHostedSession> hostedSessions = new ConcurrentHashMap<>();
|
||||
private final Clock clock;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@@ -55,6 +57,31 @@ public class CognitoFederationStateStore {
|
||||
return Optional.of(authorizationCode);
|
||||
}
|
||||
|
||||
public String putHostedSession(CognitoHostedSession session) {
|
||||
return put(hostedSessions, session);
|
||||
}
|
||||
|
||||
public Optional<CognitoHostedSession> findHostedSession(String id) {
|
||||
if (id == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
CognitoHostedSession session = hostedSessions.get(id);
|
||||
if (session == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (isExpired(session.expiresAt())) {
|
||||
hostedSessions.remove(id, session);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(session);
|
||||
}
|
||||
|
||||
public void removeHostedSession(String id) {
|
||||
if (id != null) {
|
||||
hostedSessions.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> String put(ConcurrentHashMap<String, T> store, T value) {
|
||||
String key;
|
||||
do {
|
||||
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
package io.github.hectorvent.floci.services.cognito;
|
||||
|
||||
import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoHostedSession;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoUser;
|
||||
import io.github.hectorvent.floci.services.cognito.model.UserPool;
|
||||
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.CookieParam;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.NewCookie;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Cognito's hosted (managed) login pages: {@code /login} and {@code /logout}, reached from
|
||||
* {@code /oauth2/authorize} when no external identity provider is named. A successful sign-in
|
||||
* starts a browser session and redirects to the client's callback URL with an authorization
|
||||
* code, which {@code /oauth2/token} redeems.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
@Path("/")
|
||||
public class CognitoHostedLoginController {
|
||||
|
||||
static final String SESSION_COOKIE = "cognito";
|
||||
static final String CSRF_COOKIE = "XSRF-TOKEN";
|
||||
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;
|
||||
private final Clock clock;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@Inject
|
||||
public CognitoHostedLoginController(CognitoService cognitoService, CognitoFederationStateStore stateStore,
|
||||
Clock clock) {
|
||||
this.cognitoService = cognitoService;
|
||||
this.stateStore = stateStore;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** The parameters of an authorize request, carried through the login form unchanged. */
|
||||
record AuthorizeRequest(String clientId, String redirectUri, String scope, String state, String nonce,
|
||||
String codeChallenge, String codeChallengeMethod) {
|
||||
|
||||
Map<String, String> asQuery() {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("client_id", clientId);
|
||||
query.put("redirect_uri", redirectUri);
|
||||
query.put("response_type", "code");
|
||||
putIfPresent(query, "scope", scope);
|
||||
putIfPresent(query, "state", state);
|
||||
putIfPresent(query, "nonce", nonce);
|
||||
putIfPresent(query, "code_challenge", codeChallenge);
|
||||
putIfPresent(query, "code_challenge_method", codeChallengeMethod);
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!offersCognito(client) && externalProviders(client).isEmpty()) {
|
||||
return errorPage(NO_PROVIDERS);
|
||||
}
|
||||
if (offersCognito(client) && !"login".equals(prompt)) {
|
||||
Optional<CognitoUser> signedIn = sessionUser(client, sessionId);
|
||||
if (signedIn.isPresent()) {
|
||||
return redirectWithCode(client, request, signedIn.get(), null);
|
||||
}
|
||||
}
|
||||
return Response.status(Response.Status.FOUND)
|
||||
.location(URI.create(pagePath(requestContext, "login") + "?" + query(request.asQuery())))
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/cognito-idp/login")
|
||||
public Response loginPage(@Context ContainerRequestContext requestContext,
|
||||
@QueryParam("client_id") String clientId,
|
||||
@QueryParam("redirect_uri") String redirectUri,
|
||||
@QueryParam("response_type") String responseType,
|
||||
@QueryParam("scope") String scope,
|
||||
@QueryParam("state") String state,
|
||||
@QueryParam("nonce") String nonce,
|
||||
@QueryParam("code_challenge") String codeChallenge,
|
||||
@QueryParam("code_challenge_method") String codeChallengeMethod) {
|
||||
AuthorizeRequest request = new AuthorizeRequest(trimToNull(clientId), trimToNull(redirectUri),
|
||||
trimToNull(scope), state, trimToNull(nonce), trimToNull(codeChallenge), trimToNull(codeChallengeMethod));
|
||||
Object validated = validate(requestContext, request, responseType);
|
||||
if (validated instanceof Response error) {
|
||||
return error;
|
||||
}
|
||||
String csrf = randomToken();
|
||||
return loginForm(requestContext, (UserPoolClient) validated, request, csrf, null, null, 200)
|
||||
.cookie(cookie(requestContext, CSRF_COOKIE, csrf, -1))
|
||||
.build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/cognito-idp/login")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
public Response submitLogin(@Context ContainerRequestContext requestContext,
|
||||
@CookieParam(CSRF_COOKIE) String csrfCookie,
|
||||
MultivaluedMap<String, String> form) {
|
||||
AuthorizeRequest request = new AuthorizeRequest(trimToNull(form.getFirst("client_id")),
|
||||
trimToNull(form.getFirst("redirect_uri")), trimToNull(form.getFirst("scope")),
|
||||
form.getFirst("state"), trimToNull(form.getFirst("nonce")),
|
||||
trimToNull(form.getFirst("code_challenge")), trimToNull(form.getFirst("code_challenge_method")));
|
||||
Object validated = validate(requestContext, request, form.getFirst("response_type"));
|
||||
if (validated instanceof Response error) {
|
||||
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.");
|
||||
}
|
||||
|
||||
String username = trimToNull(form.getFirst("username"));
|
||||
String password = form.getFirst("password");
|
||||
if (username == null || password == null || password.isEmpty()) {
|
||||
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(requestContext, client, request, csrf, username, describe(e), 400).build();
|
||||
}
|
||||
|
||||
String sessionId = stateStore.putHostedSession(new CognitoHostedSession(
|
||||
client.getUserPoolId(), user.getUsername(), clock.instant().plus(SESSION_LIFETIME)));
|
||||
return redirectWithCode(client, request, user,
|
||||
cookie(requestContext, SESSION_COOKIE, sessionId, (int) SESSION_LIFETIME.toSeconds()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/cognito-idp/logout")
|
||||
public Response logout(@Context ContainerRequestContext requestContext,
|
||||
@CookieParam(SESSION_COOKIE) String sessionId,
|
||||
@QueryParam("client_id") String clientId,
|
||||
@QueryParam("logout_uri") String logoutUri,
|
||||
@QueryParam("redirect_uri") String redirectUri,
|
||||
@QueryParam("response_type") String responseType,
|
||||
@QueryParam("scope") String scope,
|
||||
@QueryParam("state") String state) {
|
||||
UserPoolClient client = findClient(requestContext, trimToNull(clientId));
|
||||
if (client == null) {
|
||||
return errorPage("The app client was not found.");
|
||||
}
|
||||
|
||||
String location;
|
||||
if (trimToNull(logoutUri) != null) {
|
||||
if (!client.getLogoutURLs().contains(logoutUri.trim())) {
|
||||
return errorPage("This sign-out URL is not registered for the app client.");
|
||||
}
|
||||
location = logoutUri.trim();
|
||||
} else {
|
||||
AuthorizeRequest request = new AuthorizeRequest(client.getClientId(), trimToNull(redirectUri),
|
||||
trimToNull(scope), state, null, null, null);
|
||||
Object validated = validate(requestContext, request, responseType);
|
||||
if (validated instanceof Response error) {
|
||||
return error;
|
||||
}
|
||||
location = pagePath(requestContext, "login") + "?" + query(request.asQuery());
|
||||
}
|
||||
|
||||
stateStore.removeHostedSession(sessionId);
|
||||
return Response.status(Response.Status.FOUND)
|
||||
.location(URI.create(location))
|
||||
.cookie(cookie(requestContext, SESSION_COOKIE, "", 0))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Optional<CognitoUser> sessionUser(UserPoolClient client, String sessionId) {
|
||||
Optional<CognitoHostedSession> session = stateStore.findHostedSession(sessionId)
|
||||
.filter(s -> s.userPoolId().equals(client.getUserPoolId()));
|
||||
if (session.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
CognitoUser user = cognitoService.adminGetUser(client.getUserPoolId(), session.get().username());
|
||||
return user.isEnabled() ? Optional.of(user) : Optional.empty();
|
||||
} catch (AwsException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Response redirectWithCode(UserPoolClient client, AuthorizeRequest request, CognitoUser user,
|
||||
NewCookie sessionCookie) {
|
||||
String code = stateStore.putAuthorizationCode(new CognitoAuthorizationCode(
|
||||
client.getUserPoolId(), client.getClientId(), user.getUsername(), request.redirectUri(),
|
||||
splitScopes(request.scope()), clock.instant().plus(AUTHORIZATION_CODE_LIFETIME),
|
||||
request.nonce(), request.codeChallenge()));
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
parameters.put("code", code);
|
||||
putIfPresent(parameters, "state", request.state());
|
||||
String redirectUri = request.redirectUri();
|
||||
Response.ResponseBuilder response = Response.status(Response.Status.FOUND)
|
||||
.location(URI.create(redirectUri + (redirectUri.contains("?") ? "&" : "?") + query(parameters)));
|
||||
if (sessionCookie != null) {
|
||||
response.cookie(sessionCookie);
|
||||
}
|
||||
return response.build();
|
||||
}
|
||||
|
||||
/** Returns the client when the request is one Cognito would accept, otherwise an error page. */
|
||||
private Object validate(ContainerRequestContext requestContext, AuthorizeRequest request, String responseType) {
|
||||
if (!"code".equals(trimToNull(responseType))) {
|
||||
return errorPage("Only response_type=code is supported.");
|
||||
}
|
||||
if (request.clientId() == null || request.redirectUri() == null) {
|
||||
return errorPage("client_id and redirect_uri are required.");
|
||||
}
|
||||
UserPoolClient client = findClient(requestContext, request.clientId());
|
||||
if (client == null) {
|
||||
return errorPage("The app client was not found.");
|
||||
}
|
||||
if (!client.isAllowedOAuthFlowsUserPoolClient() || !client.getAllowedOAuthFlows().contains("code")) {
|
||||
return errorPage("This app client is not allowed to use the authorization code flow.");
|
||||
}
|
||||
if (!client.getCallbackURLs().contains(request.redirectUri())) {
|
||||
return errorPage("redirect_uri is not registered for this app client.");
|
||||
}
|
||||
if (!offersCognito(client) && externalProviders(client).isEmpty()) {
|
||||
return errorPage(NO_PROVIDERS);
|
||||
}
|
||||
String pkceError = pkceError(request.codeChallenge(), request.codeChallengeMethod());
|
||||
if (pkceError != null) {
|
||||
return errorPage(pkceError);
|
||||
}
|
||||
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.";
|
||||
}
|
||||
if (!"S256".equals(codeChallengeMethod)) {
|
||||
return "code_challenge_method must be S256.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private UserPoolClient findClient(ContainerRequestContext requestContext, String clientId) {
|
||||
if (clientId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
UserPoolClient client = cognitoService.findClientById(clientId);
|
||||
String domainPoolId = (String) requestContext.getProperty(CognitoCustomDomainFilter.POOL_PROPERTY);
|
||||
return domainPoolId == null || domainPoolId.equals(client.getUserPoolId()) ? client : null;
|
||||
} catch (AwsException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(AwsException e) {
|
||||
return switch (e.getErrorCode()) {
|
||||
case "NotAuthorizedException" -> "User is disabled".equals(e.getMessage())
|
||||
? "This account is disabled." : "Incorrect user name or password.";
|
||||
case "UserNotConfirmedException" -> "This account is not confirmed yet. Confirm it from the app first.";
|
||||
case "PasswordResetRequiredException" -> "This account needs a password reset before it can sign in.";
|
||||
case "NewPasswordRequiredException" -> "This account still has a temporary password. "
|
||||
+ "Set a permanent one before signing in here.";
|
||||
case "UserLambdaValidationException" -> e.getMessage();
|
||||
default -> "Sign-in failed: " + e.getMessage();
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
for (Map.Entry<String, String> field : request.asQuery().entrySet()) {
|
||||
hidden.append("<input type=\"hidden\" name=\"").append(escape(field.getKey()))
|
||||
.append("\" value=\"").append(escape(field.getValue())).append("\">");
|
||||
}
|
||||
hidden.append("<input type=\"hidden\" name=\"_csrf\" value=\"").append(escape(csrf)).append("\">");
|
||||
String appName = client.getClientName() == null || client.getClientName().isBlank()
|
||||
? pool.getName() : client.getClientName();
|
||||
// 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>",
|
||||
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")
|
||||
.header("X-Frame-Options", "DENY")
|
||||
.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' "
|
||||
+ cspSource(request.redirectUri()) + "; frame-ancestors 'none'");
|
||||
}
|
||||
|
||||
private static String cspSource(String redirectUri) {
|
||||
URI uri = URI.create(redirectUri);
|
||||
return uri.getScheme() + "://" + uri.getRawAuthority();
|
||||
}
|
||||
|
||||
private Response errorPage(String message) {
|
||||
return Response.status(400).type(MediaType.TEXT_HTML_TYPE.withCharset("UTF-8"))
|
||||
.entity(page("Sign-in error", "<h1>Something went wrong</h1><p class=\"error\" role=\"alert\">"
|
||||
+ escape(message) + "</p>"))
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("X-Frame-Options", "DENY")
|
||||
.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'")
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String page(String title, String body) {
|
||||
return """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
:root { --bg:#f4f5f7; --card:#fff; --text:#17191c; --muted:#5d6470; --border:#d9dde3; --accent:#2f5fd0; --on-accent:#fff; --error:#b42318; --error-bg:#fdeceb; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg:#101215; --card:#181b20; --text:#e8eaed; --muted:#9aa1ac; --border:#2c3139; --accent:#7c9ef0; --on-accent:#0b1020; --error:#f08b82; --error-bg:#3a1714; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px 16px; background:var(--bg); color:var(--text); font:15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
main { width:100%%; max-width:380px; background:var(--card); border:1px solid var(--border); border-radius:10px; padding:28px; }
|
||||
h1 { margin:0 0 4px; font-size:22px; }
|
||||
.muted { color:var(--muted); margin:0 0 20px; overflow-wrap:anywhere; }
|
||||
form { display:grid; gap:14px; }
|
||||
label { display:grid; gap:6px; font-weight:600; font-size:14px; }
|
||||
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>
|
||||
</head>
|
||||
<body><main>%s<footer>Cognito-compatible sign-in · local emulator</footer></main></body>
|
||||
</html>
|
||||
""".formatted(escape(title), body);
|
||||
}
|
||||
|
||||
private NewCookie cookie(ContainerRequestContext requestContext, String name, String value, int maxAge) {
|
||||
return new NewCookie.Builder(name).value(value).path("/").maxAge(maxAge)
|
||||
.httpOnly(true).secure(isHttps(requestContext)).sameSite(NewCookie.SameSite.LAX).build();
|
||||
}
|
||||
|
||||
private static boolean isHttps(ContainerRequestContext requestContext) {
|
||||
String forwarded = requestContext.getHeaderString("X-Forwarded-Proto");
|
||||
if (forwarded != null) {
|
||||
return "https".equalsIgnoreCase(forwarded.split(",")[0].trim());
|
||||
}
|
||||
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;
|
||||
return (customDomain ? "/" : "/cognito-idp/") + page;
|
||||
}
|
||||
|
||||
private String randomToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static String escape(String value) {
|
||||
StringBuilder escaped = new StringBuilder(value.length());
|
||||
for (char c : value.toCharArray()) {
|
||||
switch (c) {
|
||||
case '&' -> escaped.append("&");
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(c);
|
||||
}
|
||||
}
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
private static String query(Map<String, String> parameters) {
|
||||
List<String> pairs = new ArrayList<>();
|
||||
for (Map.Entry<String, String> parameter : parameters.entrySet()) {
|
||||
pairs.add(encode(parameter.getKey()) + "=" + encode(parameter.getValue()));
|
||||
}
|
||||
return String.join("&", pairs);
|
||||
}
|
||||
|
||||
/** Query-string encoding with spaces as %20, which every URL parser reads back as a space. */
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
|
||||
private static void putIfPresent(Map<String, String> map, String key, String value) {
|
||||
if (value != null) {
|
||||
map.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> splitScopes(String scope) {
|
||||
if (scope == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> scopes = new ArrayList<>();
|
||||
for (String value : scope.trim().split("\\s+")) {
|
||||
if (!value.isBlank()) {
|
||||
scopes.add(value);
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
+116
-9
@@ -6,11 +6,11 @@ import io.github.hectorvent.floci.core.common.AwsException;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationCode;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoAuthorizationTransaction;
|
||||
import io.github.hectorvent.floci.services.cognito.model.CognitoUser;
|
||||
import io.github.hectorvent.floci.services.cognito.model.UserPool;
|
||||
import io.github.hectorvent.floci.services.cognito.model.UserPoolClient;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.CookieParam;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.POST;
|
||||
@@ -46,17 +46,37 @@ public class CognitoOAuthController {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final CognitoFederationService federationService;
|
||||
private final CognitoFederationStateStore stateStore;
|
||||
private final CognitoHostedLoginController hostedLogin;
|
||||
|
||||
@Inject
|
||||
public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper,
|
||||
CognitoFederationService federationService,
|
||||
CognitoFederationStateStore stateStore) {
|
||||
CognitoFederationStateStore stateStore,
|
||||
CognitoHostedLoginController hostedLogin) {
|
||||
this.cognitoService = cognitoService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.federationService = federationService;
|
||||
this.stateStore = stateStore;
|
||||
this.hostedLogin = hostedLogin;
|
||||
}
|
||||
|
||||
public CognitoOAuthController(CognitoService cognitoService, ObjectMapper objectMapper,
|
||||
CognitoFederationService federationService,
|
||||
CognitoFederationStateStore stateStore) {
|
||||
this(cognitoService, objectMapper, federationService, stateStore, null);
|
||||
}
|
||||
|
||||
public Response authorize(ContainerRequestContext requestContext, String clientId, String redirectUri,
|
||||
String responseType, String scope, String nonce, String providerName,
|
||||
String relyingPartyState) {
|
||||
return authorize(requestContext, clientId, redirectUri, responseType, scope, nonce, providerName,
|
||||
relyingPartyState, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Without {@code identity_provider} (or with {@code COGNITO}) the pool's own users sign in on
|
||||
* the hosted login page; any other provider name starts a federated sign-in.
|
||||
*/
|
||||
@GET
|
||||
@Path("/cognito-idp/oauth2/authorize")
|
||||
public Response authorize(@Context ContainerRequestContext requestContext,
|
||||
@@ -66,13 +86,22 @@ public class CognitoOAuthController {
|
||||
@QueryParam("scope") String scope,
|
||||
@QueryParam("nonce") String nonce,
|
||||
@QueryParam("identity_provider") String providerName,
|
||||
@QueryParam("state") String relyingPartyState) {
|
||||
@QueryParam("state") String relyingPartyState,
|
||||
@QueryParam("code_challenge") String codeChallenge,
|
||||
@QueryParam("code_challenge_method") String codeChallengeMethod,
|
||||
@QueryParam("prompt") String prompt,
|
||||
@CookieParam(CognitoHostedLoginController.SESSION_COOKIE) String sessionId) {
|
||||
if (!"code".equals(trimToNull(responseType))) {
|
||||
return oauthError("unsupported_response_type", "Only response_type=code is supported");
|
||||
}
|
||||
if (trimToNull(clientId) == null || trimToNull(redirectUri) == null || trimToNull(providerName) == null) {
|
||||
boolean hosted = trimToNull(providerName) == null || "COGNITO".equals(providerName.trim());
|
||||
if (trimToNull(clientId) == null || trimToNull(redirectUri) == null || (hosted && hostedLogin == null)) {
|
||||
return oauthError("invalid_request", "client_id, redirect_uri, and identity_provider are required");
|
||||
}
|
||||
String pkceError = CognitoHostedLoginController.pkceError(trimToNull(codeChallenge), trimToNull(codeChallengeMethod));
|
||||
if (pkceError != null) {
|
||||
return oauthError("invalid_request", pkceError);
|
||||
}
|
||||
|
||||
UserPoolClient client;
|
||||
try {
|
||||
@@ -91,9 +120,20 @@ public class CognitoOAuthController {
|
||||
return oauthError("invalid_request", "redirect_uri is not registered for this client");
|
||||
}
|
||||
|
||||
if (hosted) {
|
||||
return hostedLogin.authorizeWithCognito(requestContext, client,
|
||||
new CognitoHostedLoginController.AuthorizeRequest(clientId.trim(), redirectUri, trimToNull(scope),
|
||||
relyingPartyState, trimToNull(nonce), trimToNull(codeChallenge),
|
||||
trimToNull(codeChallengeMethod)),
|
||||
trimToNull(prompt), sessionId);
|
||||
}
|
||||
|
||||
try {
|
||||
String location = federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
|
||||
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState);
|
||||
String location = trimToNull(codeChallenge) == null
|
||||
? federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
|
||||
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState)
|
||||
: federationService.beginAuthorization(client.getUserPoolId(), clientId, redirectUri,
|
||||
splitScopes(scope), trimToNull(nonce), providerName, relyingPartyState, codeChallenge.trim());
|
||||
return Response.status(Response.Status.FOUND).location(URI.create(location)).build();
|
||||
} catch (AwsException e) {
|
||||
return oauthError("invalid_request", e.getMessage());
|
||||
@@ -185,8 +225,12 @@ public class CognitoOAuthController {
|
||||
if ("authorization_code".equals(grantType)) {
|
||||
return redeemAuthorizationCode(clientId, clientSecret, formParams, domainPoolId);
|
||||
}
|
||||
if ("refresh_token".equals(grantType)) {
|
||||
return refreshTokens(clientId, clientSecret, formParams, domainPoolId);
|
||||
}
|
||||
if (!"client_credentials".equals(grantType)) {
|
||||
return oauthError("unsupported_grant_type", "Only client_credentials and authorization_code are supported");
|
||||
return oauthError("unsupported_grant_type",
|
||||
"Only client_credentials, authorization_code and refresh_token are supported");
|
||||
}
|
||||
String scope = trimToNull(formParams.getFirst("scope"));
|
||||
|
||||
@@ -249,10 +293,12 @@ public class CognitoOAuthController {
|
||||
return oauthError("invalid_grant", "Authorization code is invalid or has expired");
|
||||
}
|
||||
CognitoAuthorizationCode consumedCode = consumed.get();
|
||||
if (!pkceVerified(consumedCode.codeChallenge(), trimToNull(formParams.getFirst("code_verifier")))) {
|
||||
return oauthError("invalid_grant", "code_verifier does not match the code_challenge");
|
||||
}
|
||||
try {
|
||||
UserPool pool = cognitoService.describeUserPool(consumedCode.userPoolId());
|
||||
CognitoUser user = cognitoService.adminGetUser(consumedCode.userPoolId(), consumedCode.userId());
|
||||
Map<String, Object> authentication = cognitoService.generateAuthResult(user, pool, client, null);
|
||||
Map<String, Object> authentication = cognitoService.issueHostedTokens(client, user, consumedCode.nonce());
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("access_token", (String) authentication.get("AccessToken"));
|
||||
body.put("id_token", (String) authentication.get("IdToken"));
|
||||
@@ -267,6 +313,67 @@ public class CognitoOAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
private Response refreshTokens(String clientId, String clientSecret, MultivaluedMap<String, String> formParams,
|
||||
String domainPoolId) {
|
||||
String refreshToken = trimToNull(formParams.getFirst("refresh_token"));
|
||||
if (refreshToken == null) {
|
||||
return oauthError("invalid_request", "refresh_token is required");
|
||||
}
|
||||
UserPoolClient client;
|
||||
try {
|
||||
client = cognitoService.findClientById(clientId);
|
||||
} catch (AwsException e) {
|
||||
return oauthError("invalid_client", "Client not found");
|
||||
}
|
||||
if (domainPoolId != null && !domainPoolId.equals(client.getUserPoolId())) {
|
||||
return oauthError("invalid_client", "Client not found");
|
||||
}
|
||||
if (client.getClientSecret() != null && !client.getClientSecret().isBlank()
|
||||
&& !secretsEqual(client.getClientSecret(), clientSecret)) {
|
||||
return oauthError("invalid_client", "Client secret is invalid");
|
||||
}
|
||||
try {
|
||||
Map<String, Object> result = cognitoService.initiateAuth(clientId, "REFRESH_TOKEN_AUTH",
|
||||
Map.of("REFRESH_TOKEN", refreshToken));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> authentication = (Map<String, Object>) result.get("AuthenticationResult");
|
||||
ObjectNode body = objectMapper.createObjectNode();
|
||||
body.put("access_token", (String) authentication.get("AccessToken"));
|
||||
body.put("id_token", (String) authentication.get("IdToken"));
|
||||
body.put("expires_in", ((Number) authentication.get("ExpiresIn")).longValue());
|
||||
body.put("token_type", (String) authentication.get("TokenType"));
|
||||
return Response.ok(body).type(MediaType.APPLICATION_JSON)
|
||||
.header("Cache-Control", "no-store").header("Pragma", "no-cache").build();
|
||||
} catch (AwsException e) {
|
||||
if ("NotAuthorizedException".equals(e.getErrorCode())) {
|
||||
return oauthError("invalid_grant", e.getMessage());
|
||||
}
|
||||
if ("InvalidParameterException".equals(e.getErrorCode())) {
|
||||
return oauthError("unauthorized_client", e.getMessage());
|
||||
}
|
||||
LOG.error("Failed to refresh Cognito tokens", e);
|
||||
return oauthError("invalid_request", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** RFC 7636 S256: the challenge is BASE64URL(SHA-256(verifier)). No challenge, nothing to check. */
|
||||
static boolean pkceVerified(String codeChallenge, String codeVerifier) {
|
||||
if (codeChallenge == null) {
|
||||
return true;
|
||||
}
|
||||
if (codeVerifier == null || codeVerifier.length() < 43 || codeVerifier.length() > 128) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
|
||||
String expected = Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
|
||||
codeChallenge.getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Response redirect(String redirectUri, Map<String, String> parameters) {
|
||||
StringBuilder location = new StringBuilder(redirectUri);
|
||||
location.append(redirectUri.contains("?") ? '&' : '?');
|
||||
|
||||
@@ -2592,7 +2592,8 @@ public class CognitoService implements ResourceProvider {
|
||||
String confirmationCode) {
|
||||
UserPool pool = poolStore.get(userPoolId)
|
||||
.orElseThrow(() -> userPoolNotFound(userPoolId));
|
||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
||||
CognitoUser user = userForClientOrNull(client, username);
|
||||
if (user == null) throw codeMismatch();
|
||||
if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
|
||||
try {
|
||||
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
||||
@@ -2636,7 +2637,8 @@ public class CognitoService implements ResourceProvider {
|
||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
|
||||
400));
|
||||
UserPool pool = describeUserPool(client.getUserPoolId());
|
||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
||||
CognitoUser user = userForClientOrNull(client, username);
|
||||
if (user == null) return simulatedCodeDelivery(username);
|
||||
if (!"UNCONFIRMED".equals(user.getUserStatus())) {
|
||||
throw new AwsException("NotAuthorizedException",
|
||||
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
|
||||
@@ -2695,6 +2697,14 @@ public class CognitoService implements ResourceProvider {
|
||||
|
||||
// ──────────────────────────── Auth ────────────────────────────
|
||||
|
||||
public CognitoUser authenticateHostedLogin(UserPoolClient client, String username, String password) {
|
||||
return authFlowHandler.authenticateHostedLogin(describeUserPool(client.getUserPoolId()), client, username, password);
|
||||
}
|
||||
|
||||
public Map<String, Object> issueHostedTokens(UserPoolClient client, CognitoUser user, String nonce) {
|
||||
return authFlowHandler.issueHostedTokens(describeUserPool(client.getUserPoolId()), client, user, nonce);
|
||||
}
|
||||
|
||||
public Map<String, Object> initiateAuth(String clientId, String authFlow, Map<String, String> authParameters) {
|
||||
return authFlowHandler.initiateAuth(clientId, authFlow, authParameters, Map.of());
|
||||
}
|
||||
@@ -2766,7 +2776,8 @@ public class CognitoService implements ResourceProvider {
|
||||
public Map<String, Object> forgotPassword(String clientId, String username) {
|
||||
UserPoolClient client = clientStore.get(clientId)
|
||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
||||
CognitoUser user = userForClientOrNull(client, username);
|
||||
if (user == null) return new LinkedHashMap<>(simulatedCodeDelivery(username));
|
||||
UserPool pool = describeUserPool(client.getUserPoolId());
|
||||
ensureVerificationWiring();
|
||||
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
|
||||
@@ -2792,7 +2803,8 @@ public class CognitoService implements ResourceProvider {
|
||||
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
|
||||
UserPoolClient client = clientStore.get(clientId)
|
||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
||||
CognitoUser user = userForClientOrNull(client, username);
|
||||
if (user == null) throw codeMismatch();
|
||||
ensureVerificationWiring();
|
||||
try {
|
||||
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
||||
@@ -3067,6 +3079,18 @@ public class CognitoService implements ResourceProvider {
|
||||
return overrideId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signing keys, by kid, of this emulator's own pool that {@code issuer} names exactly, so a
|
||||
* JWT authorizer can verify the pool's tokens in-process; empty for every other issuer.
|
||||
*/
|
||||
public Optional<Map<String, RSAPublicKey>> localIssuerKeys(String issuer) {
|
||||
if (issuer == null) return Optional.empty();
|
||||
String trimmed = issuer.replaceAll("/+$", "");
|
||||
String poolId = trimmed.substring(trimmed.lastIndexOf('/') + 1);
|
||||
if (poolId.isEmpty() || !getIssuer(poolId).equals(trimmed)) return Optional.empty();
|
||||
return poolStore.get(poolId).map(pool -> Map.of(getSigningKeyId(pool), getSigningPublicKey(pool)));
|
||||
}
|
||||
|
||||
public String getJwksUri(String poolId) {
|
||||
return getIssuer(poolId) + "/.well-known/jwks.json";
|
||||
}
|
||||
@@ -4438,10 +4462,38 @@ public class CognitoService implements ResourceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app client hides which usernames exist (PreventUserExistenceErrors=ENABLED). Real Cognito then
|
||||
* answers for an unknown user exactly as for a real one: a failed sign-in is "Incorrect username or password",
|
||||
* a code request reports a simulated delivery and a code check reports a wrong code. Unset means LEGACY.
|
||||
*/
|
||||
static boolean hidesUserExistence(UserPoolClient client) {
|
||||
return "ENABLED".equals(client.getPreventUserExistenceErrors());
|
||||
}
|
||||
|
||||
/** The user, or null when it does not exist and the client hides that; otherwise UserNotFoundException. */
|
||||
private CognitoUser userForClientOrNull(UserPoolClient client, String username) {
|
||||
try {
|
||||
return adminGetUser(client.getUserPoolId(), username);
|
||||
} catch (AwsException e) {
|
||||
if ("UserNotFoundException".equals(e.getErrorCode()) && hidesUserExistence(client)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> simulatedCodeDelivery(String username) {
|
||||
return username != null && username.contains("@")
|
||||
? Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", maskEmail(username))
|
||||
: Map.of("AttributeName", "phone_number", "DeliveryMedium", "SMS", "Destination", "+*******0000");
|
||||
}
|
||||
|
||||
private static AwsException codeMismatch() {
|
||||
return new AwsException("CodeMismatchException", "Invalid verification code provided, please try again.", 400);
|
||||
}
|
||||
|
||||
private AwsException mapVerificationCodeException(VerificationCodeException e) {
|
||||
return switch (e.getKind()) {
|
||||
case MISMATCH, NOT_FOUND -> new AwsException("CodeMismatchException",
|
||||
"Invalid verification code provided, please try again.", 400);
|
||||
case MISMATCH, NOT_FOUND -> codeMismatch();
|
||||
case EXPIRED -> new AwsException("ExpiredCodeException",
|
||||
"Invalid code provided, please request a code again.", 400);
|
||||
case RATE_LIMIT -> new AwsException("LimitExceededException",
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ public class CognitoWellKnownController {
|
||||
String authorizationEndpoint = tokenEndpoint.replace("/token", "/authorize");
|
||||
|
||||
String body = """
|
||||
{"issuer":"%s","jwks_uri":"%s","authorization_endpoint":"%s","token_endpoint":"%s","userinfo_endpoint":"%s","subject_types_supported":["public"],"response_types_supported":["code"],"grant_types_supported":["client_credentials","authorization_code"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"]}
|
||||
{"issuer":"%s","jwks_uri":"%s","authorization_endpoint":"%s","token_endpoint":"%s","userinfo_endpoint":"%s","subject_types_supported":["public"],"response_types_supported":["code"],"grant_types_supported":["client_credentials","authorization_code","refresh_token"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"]}
|
||||
""".formatted(issuer, jwksUri, authorizationEndpoint, tokenEndpoint, userInfoEndpoint).strip();
|
||||
return Response.ok(body).build();
|
||||
}
|
||||
|
||||
+13
-1
@@ -3,15 +3,27 @@ package io.github.hectorvent.floci.services.cognito.model;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A one-time code handed to the client's redirect URI. {@code codeChallenge} is the PKCE S256
|
||||
* challenge from the authorize request, and {@code nonce} is copied into the ID token; both are
|
||||
* null when the client did not send them.
|
||||
*/
|
||||
public record CognitoAuthorizationCode(
|
||||
String userPoolId,
|
||||
String clientId,
|
||||
String userId,
|
||||
String redirectUri,
|
||||
List<String> scopes,
|
||||
Instant expiresAt) {
|
||||
Instant expiresAt,
|
||||
String nonce,
|
||||
String codeChallenge) {
|
||||
|
||||
public CognitoAuthorizationCode {
|
||||
scopes = List.copyOf(scopes);
|
||||
}
|
||||
|
||||
public CognitoAuthorizationCode(String userPoolId, String clientId, String userId, String redirectUri,
|
||||
List<String> scopes, Instant expiresAt) {
|
||||
this(userPoolId, clientId, userId, redirectUri, scopes, expiresAt, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -11,9 +11,16 @@ public record CognitoAuthorizationTransaction(
|
||||
String nonce,
|
||||
String providerName,
|
||||
String relyingPartyState,
|
||||
Instant expiresAt) {
|
||||
Instant expiresAt,
|
||||
String codeChallenge) {
|
||||
|
||||
public CognitoAuthorizationTransaction {
|
||||
scopes = List.copyOf(scopes);
|
||||
}
|
||||
|
||||
public CognitoAuthorizationTransaction(String userPoolId, String clientId, String redirectUri, List<String> scopes,
|
||||
String nonce, String providerName, String relyingPartyState,
|
||||
Instant expiresAt) {
|
||||
this(userPoolId, clientId, redirectUri, scopes, nonce, providerName, relyingPartyState, expiresAt, null);
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package io.github.hectorvent.floci.services.cognito.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A signed-in browser on the hosted login pages, so the next authorize request skips the form. */
|
||||
public record CognitoHostedSession(String userPoolId, String username, Instant expiresAt) {
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -57,6 +57,8 @@ public class SmtpRelay {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(SmtpRelay.class);
|
||||
private static final long RELAY_TIMEOUT_SECONDS = 30;
|
||||
/** Every SASL method the mail client supports except XOAUTH2, which expects a token. */
|
||||
static final String PASSWORD_AUTH_METHODS = "CRAM-SHA256 CRAM-SHA1 CRAM-MD5 DIGEST-MD5 LOGIN PLAIN";
|
||||
|
||||
/** SES control header naming the configuration set to apply to a raw send. */
|
||||
public static final String HEADER_CONFIGURATION_SET = "X-SES-CONFIGURATION-SET";
|
||||
@@ -118,6 +120,9 @@ public class SmtpRelay {
|
||||
.filter(u -> !u.isBlank())
|
||||
.ifPresent(u -> {
|
||||
mailConfig.setUsername(u);
|
||||
// smtp-pass is a password, never an OAuth token: without this the client prefers
|
||||
// XOAUTH2 whenever the server offers it (Gmail does) and the login is refused.
|
||||
mailConfig.setAuthMethods(PASSWORD_AUTH_METHODS);
|
||||
config.services().ses().smtpPass()
|
||||
.filter(p -> !p.isBlank())
|
||||
.ifPresent(mailConfig::setPassword);
|
||||
|
||||
+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")
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package io.github.hectorvent.floci.services.acm;
|
||||
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
|
||||
@QuarkusTest
|
||||
class AcmExportableRequestTest {
|
||||
|
||||
private static final String ACM_CONTENT_TYPE = "application/x-amz-json-1.1";
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestCertificateKeepsTheExportOption() {
|
||||
String exportable = request("{\"DomainName\":\"exportable.example.com\",\"Options\":{\"Export\":\"ENABLED\"}}");
|
||||
|
||||
call("DescribeCertificate", "{\"CertificateArn\":\"" + exportable + "\"}")
|
||||
.statusCode(200)
|
||||
.body("Certificate.Options.Export", equalTo("ENABLED"))
|
||||
.body("Certificate.Options.CertificateTransparencyLoggingPreference", equalTo("ENABLED"));
|
||||
|
||||
call("ExportCertificate", "{\"CertificateArn\":\"" + exportable + "\",\"Passphrase\":\"c2VjcmV0\"}")
|
||||
.statusCode(200)
|
||||
.body("PrivateKey", notNullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestCertificateWithoutExportIsNotExportable() {
|
||||
String plain = request("{\"DomainName\":\"plain.example.com\",\"Options\":{\"CertificateTransparencyLoggingPreference\":\"DISABLED\"}}");
|
||||
|
||||
call("DescribeCertificate", "{\"CertificateArn\":\"" + plain + "\"}")
|
||||
.statusCode(200)
|
||||
.body("Certificate.Options.Export", equalTo("DISABLED"))
|
||||
.body("Certificate.Options.CertificateTransparencyLoggingPreference", equalTo("DISABLED"));
|
||||
|
||||
call("ExportCertificate", "{\"CertificateArn\":\"" + plain + "\",\"Passphrase\":\"c2VjcmV0\"}")
|
||||
.statusCode(400)
|
||||
.body("__type", equalTo("ValidationException"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestCertificateRejectsAnUnknownExportValue() {
|
||||
call("RequestCertificate", "{\"DomainName\":\"bad.example.com\",\"Options\":{\"Export\":\"MAYBE\"}}")
|
||||
.statusCode(400)
|
||||
.body("__type", equalTo("ValidationException"));
|
||||
}
|
||||
|
||||
private String request(String body) {
|
||||
return call("RequestCertificate", body).statusCode(200).extract().jsonPath().getString("CertificateArn");
|
||||
}
|
||||
|
||||
private io.restassured.response.ValidatableResponse call(String action, String body) {
|
||||
return given()
|
||||
.header("X-Amz-Target", "CertificateManager." + action)
|
||||
.contentType(ACM_CONTENT_TYPE)
|
||||
.body(body)
|
||||
.when()
|
||||
.post("/")
|
||||
.then();
|
||||
}
|
||||
}
|
||||
+20
@@ -98,6 +98,26 @@ class JwtSignatureVerifierTest {
|
||||
verifier.verify(token, issuer); // does not throw
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiesAnInProcessIssuerWithoutFetchingIt() throws Exception {
|
||||
// Floci's own Cognito issuer: plain HTTP on a hostname, which the strict fetch rules refuse.
|
||||
String localIssuer = "http://floci:4566/us-east-1_local";
|
||||
strictVerifier.withLocalIssuerKeys(candidate -> candidate.equals(localIssuer)
|
||||
? java.util.Optional.of(Map.of("test-key-1", publicKey)) : java.util.Optional.empty());
|
||||
|
||||
assertDoesNotThrow(() -> strictVerifier.verify(signToken("test-key-1", privateKey), localIssuer));
|
||||
assertThrows(JwtSignatureVerifier.JwtVerificationException.class,
|
||||
() -> strictVerifier.verify(signToken("other-key", privateKey), localIssuer));
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
gen.initialize(2048);
|
||||
String forged = signToken("test-key-1", (RSAPrivateKey) gen.generateKeyPair().getPrivate());
|
||||
assertThrows(JwtSignatureVerifier.JwtVerificationException.class,
|
||||
() -> strictVerifier.verify(forged, localIssuer));
|
||||
// Any other plain-HTTP issuer still goes through the strict fetch, and is refused.
|
||||
assertThrows(JwtSignatureVerifier.JwtVerificationException.class,
|
||||
() -> strictVerifier.verify(signToken("test-key-1", privateKey), "http://floci:4566/us-east-1_other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsATokenSignedWithADifferentKey() throws Exception {
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -36,6 +36,20 @@ class CognitoCustomDomainFilterTest {
|
||||
verify(request).setRequestUri(URI.create("http://" + DOMAIN + ":4566/cognito-idp/oauth2/token?x=1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheHostedLoginAndLogoutPagesOnACustomDomain() {
|
||||
when(cognitoService.findCustomDomain(DOMAIN)).thenReturn(Optional.of(domain("us-east-1_abc")));
|
||||
ContainerRequestContext login = request("https://" + DOMAIN + "/login?client_id=c", DOMAIN);
|
||||
ContainerRequestContext logout = request("https://" + DOMAIN + "/logout?client_id=c", DOMAIN);
|
||||
|
||||
filter.filter(login);
|
||||
filter.filter(logout);
|
||||
|
||||
verify(login).setRequestUri(URI.create("https://" + DOMAIN + "/cognito-idp/login?client_id=c"));
|
||||
verify(logout).setRequestUri(URI.create("https://" + DOMAIN + "/cognito-idp/logout?client_id=c"));
|
||||
verify(login).setProperty(CognitoCustomDomainFilter.POOL_PROPERTY, "us-east-1_abc");
|
||||
}
|
||||
|
||||
/** HTTP/2 carries no Host header; the authority is on the request URI. */
|
||||
@Test
|
||||
void fallsBackToTheRequestAuthorityWithoutAHostHeader() {
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
package io.github.hectorvent.floci.services.cognito;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.response.Response;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static io.github.hectorvent.floci.services.cognito.CognitoRestAssuredUtils.cognitoJson;
|
||||
import static io.restassured.RestAssured.given;
|
||||
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;
|
||||
|
||||
@QuarkusTest
|
||||
class CognitoHostedLoginIntegrationTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final String CALLBACK = "https://app.example.test/callback";
|
||||
private static final String SIGNED_OUT = "https://app.example.test/signed-out";
|
||||
private static final String PASSWORD = "Passw0rd!";
|
||||
private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUWU1p1r_wW1gFWFOEjXk-local-verifier";
|
||||
|
||||
private String poolId;
|
||||
private String clientId;
|
||||
private String email;
|
||||
|
||||
@BeforeAll
|
||||
static void configureRestAssured() {
|
||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createPoolClientAndUser() throws Exception {
|
||||
poolId = cognitoJson("CreateUserPool", """
|
||||
{"PoolName":"HostedLogin","UsernameAttributes":["email"],"AutoVerifiedAttributes":["email"]}
|
||||
""").path("UserPool").path("Id").asText();
|
||||
clientId = cognitoJson("CreateUserPoolClient", """
|
||||
{"UserPoolId":"%s","ClientName":"hosted-web","AllowedOAuthFlowsUserPoolClient":true,
|
||||
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid","email"],
|
||||
"CallbackURLs":["%s"],"LogoutURLs":["%s"],"SupportedIdentityProviders":["COGNITO"],
|
||||
"ExplicitAuthFlows":["ALLOW_REFRESH_TOKEN_AUTH"]}
|
||||
""".formatted(poolId, CALLBACK, SIGNED_OUT)).path("UserPoolClient").path("ClientId").asText();
|
||||
email = "hosted-" + System.nanoTime() + "@example.test";
|
||||
cognitoJson("AdminCreateUser", """
|
||||
{"UserPoolId":"%s","Username":"%s","MessageAction":"SUPPRESS",
|
||||
"UserAttributes":[{"Name":"email","Value":"%s"},{"Name":"email_verified","Value":"true"}]}
|
||||
""".formatted(poolId, email, email));
|
||||
cognitoJson("AdminSetUserPassword", """
|
||||
{"UserPoolId":"%s","Username":"%s","Password":"%s","Permanent":true}
|
||||
""".formatted(poolId, email, PASSWORD));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signsInOnTheHostedPageAndRedeemsThePkceCodeForTokens() throws Exception {
|
||||
String loginLocation = authorize(null, "openid email", "nonce-123")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
assertTrue(URI.create(loginLocation).getPath().endsWith("/cognito-idp/login"), loginLocation);
|
||||
assertEquals("app-state", queryParameter(loginLocation, "state"));
|
||||
|
||||
Response page = given().redirects().follow(false).urlEncodingEnabled(false).get(loginLocation)
|
||||
.then().statusCode(200)
|
||||
.header("Content-Type", containsString("text/html"))
|
||||
.body(containsString("to continue to <strong>hosted-web</strong>"))
|
||||
.body(containsString("type=\"email\""))
|
||||
.extract().response();
|
||||
String csrf = page.getCookie(CognitoHostedLoginController.CSRF_COOKIE);
|
||||
assertNotNull(csrf);
|
||||
assertEquals(csrf, hiddenValue(page.asString(), "_csrf"));
|
||||
|
||||
Response signedIn = submit(page, email, PASSWORD).then().statusCode(302).extract().response();
|
||||
String callback = signedIn.getHeader("Location");
|
||||
assertTrue(callback.startsWith(CALLBACK + "?code="), callback);
|
||||
assertEquals("app-state", queryParameter(callback, "state"));
|
||||
assertTrue(signedIn.getDetailedCookie(CognitoHostedLoginController.SESSION_COOKIE).isHttpOnly());
|
||||
|
||||
given().formParam("grant_type", "authorization_code").formParam("client_id", clientId)
|
||||
.formParam("code", queryParameter(callback, "code")).formParam("redirect_uri", CALLBACK)
|
||||
.formParam("code_verifier", VERIFIER + "-wrong")
|
||||
.when().post("/cognito-idp/oauth2/token")
|
||||
.then().statusCode(400).body("error", equalTo("invalid_grant"));
|
||||
|
||||
String sessionId = signedIn.getCookie(CognitoHostedLoginController.SESSION_COOKIE);
|
||||
String secondCallback = authorize(sessionId, "openid email", "nonce-456")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
assertTrue(secondCallback.startsWith(CALLBACK + "?code="), "an open session skips the form: " + secondCallback);
|
||||
|
||||
Response tokens = given().formParam("grant_type", "authorization_code").formParam("client_id", clientId)
|
||||
.formParam("code", queryParameter(secondCallback, "code")).formParam("redirect_uri", CALLBACK)
|
||||
.formParam("code_verifier", VERIFIER)
|
||||
.when().post("/cognito-idp/oauth2/token")
|
||||
.then().statusCode(200).body("token_type", equalTo("Bearer")).extract().response();
|
||||
JsonNode idToken = claims(tokens.path("id_token"));
|
||||
assertEquals("nonce-456", idToken.path("nonce").asText());
|
||||
assertEquals(email, idToken.path("email").asText());
|
||||
assertEquals("id", idToken.path("token_use").asText());
|
||||
|
||||
given().formParam("grant_type", "refresh_token").formParam("client_id", clientId)
|
||||
.formParam("refresh_token", tokens.path("refresh_token").toString())
|
||||
.when().post("/cognito-idp/oauth2/token")
|
||||
.then().statusCode(200).body("access_token", not(equalTo(tokens.path("access_token").toString())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongPasswordUnknownUserAndMissingCsrfAreRefused() {
|
||||
Response page = loginPage();
|
||||
submit(page, email, "Wrong-passw0rd").then().statusCode(400)
|
||||
.body(containsString("Incorrect user name or password."))
|
||||
.body(containsString("value=\"" + email + "\""));
|
||||
submit(page, "nobody@example.test", PASSWORD).then().statusCode(400)
|
||||
.body(containsString("Incorrect user name or password."));
|
||||
|
||||
given().redirects().follow(false)
|
||||
.formParam("client_id", clientId).formParam("redirect_uri", CALLBACK).formParam("response_type", "code")
|
||||
.formParam("username", email).formParam("password", PASSWORD)
|
||||
.when().post("/cognito-idp/login")
|
||||
.then().statusCode(400).body(containsString("Your sign-in form expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptLoginShowsTheFormAndLogoutEndsTheSession() {
|
||||
Response signedIn = submit(loginPage(), email, PASSWORD).then().statusCode(302).extract().response();
|
||||
String sessionId = signedIn.getCookie(CognitoHostedLoginController.SESSION_COOKIE);
|
||||
|
||||
String forced = given().redirects().follow(false).cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK)
|
||||
.queryParam("response_type", "code").queryParam("prompt", "login")
|
||||
.when().get("/cognito-idp/oauth2/authorize")
|
||||
.then().statusCode(302).extract().header("Location");
|
||||
assertTrue(URI.create(forced).getPath().endsWith("/cognito-idp/login"), forced);
|
||||
|
||||
given().redirects().follow(false).cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId)
|
||||
.queryParam("client_id", clientId).queryParam("logout_uri", "https://evil.example.test/")
|
||||
.when().get("/cognito-idp/logout")
|
||||
.then().statusCode(400).body(containsString("not registered"));
|
||||
|
||||
Response loggedOut = given().redirects().follow(false).cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId)
|
||||
.queryParam("client_id", clientId).queryParam("logout_uri", SIGNED_OUT)
|
||||
.when().get("/cognito-idp/logout")
|
||||
.then().statusCode(302).header("Location", equalTo(SIGNED_OUT)).extract().response();
|
||||
assertEquals(0, loggedOut.getDetailedCookie(CognitoHostedLoginController.SESSION_COOKIE).getMaxAge());
|
||||
|
||||
String afterLogout = authorize(sessionId, "openid", null).then().statusCode(302).extract().header("Location");
|
||||
assertTrue(URI.create(afterLogout).getPath().endsWith("/cognito-idp/login"), afterLogout);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientsWithoutCognitoAsAProviderAndBadPkceAreRejected() throws Exception {
|
||||
cognitoJson("CreateIdentityProvider", """
|
||||
{"UserPoolId":"%s","ProviderName":"CompanyIdp","ProviderType":"OIDC",
|
||||
"ProviderDetails":{"authorize_url":"https://idp.example.test/authorize","token_url":"https://idp.example.test/token",
|
||||
"attributes_url":"https://idp.example.test/userinfo","client_id":"idp-client","client_secret":"idp-secret"},
|
||||
"AttributeMapping":{"email":"email","username":"sub"}}
|
||||
""".formatted(poolId));
|
||||
String federatedOnly = cognitoJson("CreateUserPoolClient", """
|
||||
{"UserPoolId":"%s","ClientName":"federated-only","AllowedOAuthFlowsUserPoolClient":true,
|
||||
"AllowedOAuthFlows":["code"],"AllowedOAuthScopes":["openid"],"CallbackURLs":["%s"],
|
||||
"SupportedIdentityProviders":["CompanyIdp"]}
|
||||
""".formatted(poolId, CALLBACK)).path("UserPoolClient").path("ClientId").asText();
|
||||
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")
|
||||
.when().get("/cognito-idp/oauth2/authorize")
|
||||
.then().statusCode(400).body("error", equalTo("invalid_request"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unconfirmedUsersLearnWhyOnlyWithTheRightPassword() throws Exception {
|
||||
String pending = "pending-" + System.nanoTime() + "@example.test";
|
||||
cognitoJson("SignUp", """
|
||||
{"ClientId":"%s","Username":"%s","Password":"%s","UserAttributes":[{"Name":"email","Value":"%s"}]}
|
||||
""".formatted(clientId, pending, PASSWORD, pending));
|
||||
|
||||
Response page = loginPage();
|
||||
submit(page, pending, "Wrong-passw0rd").then().statusCode(400)
|
||||
.body(containsString("Incorrect user name or password."));
|
||||
submit(page, pending, PASSWORD).then().statusCode(400)
|
||||
.body(containsString("not confirmed yet"));
|
||||
}
|
||||
|
||||
private Response authorize(String sessionId, String scope, String nonce) {
|
||||
var request = given().redirects().follow(false)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK)
|
||||
.queryParam("response_type", "code").queryParam("scope", scope).queryParam("state", "app-state")
|
||||
.queryParam("code_challenge", challenge(VERIFIER)).queryParam("code_challenge_method", "S256");
|
||||
if (nonce != null) {
|
||||
request.queryParam("nonce", nonce);
|
||||
}
|
||||
if (sessionId != null) {
|
||||
request.cookie(CognitoHostedLoginController.SESSION_COOKIE, sessionId);
|
||||
}
|
||||
return request.when().get("/cognito-idp/oauth2/authorize");
|
||||
}
|
||||
|
||||
private Response loginPage() {
|
||||
return given().redirects().follow(false)
|
||||
.queryParam("client_id", clientId).queryParam("redirect_uri", CALLBACK).queryParam("response_type", "code")
|
||||
.queryParam("state", "app-state")
|
||||
.when().get("/cognito-idp/login")
|
||||
.then().statusCode(200).extract().response();
|
||||
}
|
||||
|
||||
private Response submit(Response page, String username, String password) {
|
||||
String html = page.asString();
|
||||
var request = given().redirects().follow(false)
|
||||
.cookie(CognitoHostedLoginController.CSRF_COOKIE, page.getCookie(CognitoHostedLoginController.CSRF_COOKIE))
|
||||
.formParam("username", username).formParam("password", password);
|
||||
Matcher hidden = Pattern.compile("<input type=\"hidden\" name=\"([^\"]+)\" value=\"([^\"]*)\">").matcher(html);
|
||||
while (hidden.find()) {
|
||||
request.formParam(hidden.group(1), unescape(hidden.group(2)));
|
||||
}
|
||||
return request.when().post("/cognito-idp/login");
|
||||
}
|
||||
|
||||
private static String hiddenValue(String html, String name) {
|
||||
Matcher matcher = Pattern.compile("name=\"" + Pattern.quote(name) + "\" value=\"([^\"]*)\"").matcher(html);
|
||||
return matcher.find() ? unescape(matcher.group(1)) : null;
|
||||
}
|
||||
|
||||
private static String unescape(String value) {
|
||||
return value.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">")
|
||||
.replace("&", "&");
|
||||
}
|
||||
|
||||
private static String challenge(String verifier) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode claims(String jwt) throws Exception {
|
||||
return OBJECT_MAPPER.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]));
|
||||
}
|
||||
|
||||
private static String queryParameter(String location, String name) {
|
||||
String query = URI.create(location).getRawQuery();
|
||||
if (query == null) {
|
||||
return null;
|
||||
}
|
||||
for (String parameter : query.split("&")) {
|
||||
String[] pair = parameter.split("=", 2);
|
||||
if (pair[0].equals(name)) {
|
||||
return URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -127,7 +127,7 @@ class CognitoOAuthControllerTest {
|
||||
POOL_ID, CLIENT_ID, "federated-user", CALLBACK_URI, List.of("openid"), CLOCK.instant().plusSeconds(60)));
|
||||
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
||||
when(cognitoService.adminGetUser(POOL_ID, "federated-user")).thenReturn(user());
|
||||
when(cognitoService.generateAuthResult(any(CognitoUser.class), any(UserPool.class), eq(client), eq(null)))
|
||||
when(cognitoService.issueHostedTokens(eq(client), any(CognitoUser.class), eq(null)))
|
||||
.thenReturn(Map.of("AccessToken", "access-token", "IdToken", "id-token", "RefreshToken", "refresh-token",
|
||||
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
||||
|
||||
@@ -226,7 +226,7 @@ class CognitoOAuthControllerTest {
|
||||
POOL_ID, CLIENT_ID, "federated-user", CALLBACK_URI, List.of("openid"), CLOCK.instant().plusSeconds(60)));
|
||||
when(cognitoService.describeUserPool(POOL_ID)).thenReturn(pool());
|
||||
when(cognitoService.adminGetUser(POOL_ID, "federated-user")).thenReturn(user());
|
||||
when(cognitoService.generateAuthResult(any(CognitoUser.class), any(UserPool.class), eq(client), eq(null)))
|
||||
when(cognitoService.issueHostedTokens(eq(client), any(CognitoUser.class), eq(null)))
|
||||
.thenReturn(Map.of("AccessToken", "access-token", "IdToken", "id-token", "RefreshToken", "refresh-token",
|
||||
"ExpiresIn", 3600, "TokenType", "Bearer"));
|
||||
return code;
|
||||
|
||||
+1
-1
@@ -373,7 +373,7 @@ class CognitoOAuthTokenIntegrationTest {
|
||||
@Order(16)
|
||||
void unsupportedGrantTypeReturnsUnsupportedGrantType() {
|
||||
given()
|
||||
.formParam("grant_type", "refresh_token")
|
||||
.formParam("grant_type", "password")
|
||||
.formParam("client_id", clientId)
|
||||
.when()
|
||||
.post("/cognito-idp/oauth2/token")
|
||||
|
||||
@@ -373,6 +373,63 @@ class CognitoServiceTest {
|
||||
null, null, List.of(), null, null);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class PreventUserExistenceErrors {
|
||||
|
||||
private UserPoolClient client(UserPool pool, String preventUserExistenceErrors) {
|
||||
return service.createUserPoolClient(pool.getId(), "existence-client", false, false, List.of(), List.of(),
|
||||
null, List.of(), null, List.of("ALLOW_USER_PASSWORD_AUTH"), null, null, List.of(),
|
||||
preventUserExistenceErrors, List.of(), null, null, null, List.of(), null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledAnswersAnUnknownUserLikeAWrongPassword() {
|
||||
UserPool pool = createPoolAndUser();
|
||||
UserPoolClient client = client(pool, "ENABLED");
|
||||
|
||||
AwsException unknown = assertThrows(AwsException.class, () -> service.initiateAuth(client.getClientId(),
|
||||
"USER_PASSWORD_AUTH", Map.of("USERNAME", "nobody", "PASSWORD", "Perm1234!")));
|
||||
AwsException wrongPassword = assertThrows(AwsException.class, () -> service.initiateAuth(client.getClientId(),
|
||||
"USER_PASSWORD_AUTH", Map.of("USERNAME", "alice", "PASSWORD", "Wrong1234!")));
|
||||
|
||||
assertEquals("NotAuthorizedException", unknown.getErrorCode());
|
||||
assertEquals(wrongPassword.getErrorCode(), unknown.getErrorCode());
|
||||
assertEquals(wrongPassword.getMessage(), unknown.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledSimulatesCodeDeliveryAndRejectsCodesForAnUnknownUser() {
|
||||
UserPool pool = createPoolAndUser();
|
||||
UserPoolClient client = client(pool, "ENABLED");
|
||||
|
||||
assertEquals(Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", "n***@e***"),
|
||||
service.forgotPassword(client.getClientId(), "nobody@example.com"));
|
||||
assertEquals("EMAIL", service.resendConfirmationCode(client.getClientId(), "nobody@example.com")
|
||||
.get("DeliveryMedium"));
|
||||
assertEquals("CodeMismatchException", assertThrows(AwsException.class, () ->
|
||||
service.confirmForgotPassword(client.getClientId(), "nobody@example.com", "123456", "Perm1234!"))
|
||||
.getErrorCode());
|
||||
assertEquals("CodeMismatchException", assertThrows(AwsException.class, () ->
|
||||
service.confirmSignUp(client.getClientId(), "nobody@example.com", "123456")).getErrorCode());
|
||||
assertThrows(AwsException.class, () -> service.adminGetUser(pool.getId(), "nobody@example.com"),
|
||||
"no user is created along the way");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyAndUnsetStillReportUserNotFound() {
|
||||
UserPool pool = createPoolAndUser();
|
||||
for (String setting : new String[] {"LEGACY", null}) {
|
||||
UserPoolClient client = client(pool, setting);
|
||||
|
||||
assertEquals("UserNotFoundException", assertThrows(AwsException.class, () -> service.initiateAuth(
|
||||
client.getClientId(), "USER_PASSWORD_AUTH", Map.of("USERNAME", "nobody", "PASSWORD", "x")))
|
||||
.getErrorCode());
|
||||
assertEquals("UserNotFoundException", assertThrows(AwsException.class, () ->
|
||||
service.forgotPassword(client.getClientId(), "nobody")).getErrorCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"Short1!a",
|
||||
@@ -1312,6 +1369,22 @@ class CognitoServiceTest {
|
||||
assertEquals("http://localhost:4566/custompool", service.getIssuer(pool.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localIssuerKeysServeOnlyThisEmulatorsOwnPoolIssuers() throws Exception {
|
||||
UserPool pool = createPoolAndUser();
|
||||
UserPoolClient client = clientWithFlows(pool, "ALLOW_USER_PASSWORD_AUTH");
|
||||
String token = (String) ((Map<?, ?>) service.initiateAuth(client.getClientId(), "USER_PASSWORD_AUTH",
|
||||
Map.of("USERNAME", "alice", "PASSWORD", "Perm1234!")).get("AuthenticationResult")).get("AccessToken");
|
||||
String kid = MAPPER.readTree(Base64.getUrlDecoder().decode(token.split("\\.")[0])).path("kid").asText();
|
||||
|
||||
Map<String, java.security.interfaces.RSAPublicKey> keys = service.localIssuerKeys(service.getIssuer(pool.getId())).orElseThrow();
|
||||
assertTrue(keys.containsKey(kid), "the key that signed the pool's tokens");
|
||||
assertTrue(service.localIssuerKeys(service.getIssuer(pool.getId()) + "/").isPresent());
|
||||
assertTrue(service.localIssuerKeys("https://cognito-idp.us-east-1.amazonaws.com/" + pool.getId()).isEmpty());
|
||||
assertTrue(service.localIssuerKeys(service.getIssuer("us-east-1_missing")).isEmpty());
|
||||
assertTrue(service.localIssuerKeys(null).isEmpty());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Groups
|
||||
// =========================================================================
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,8 @@ class SmtpRelayServerTest {
|
||||
String command = line.toUpperCase(Locale.ROOT);
|
||||
if (command.startsWith("EHLO")) {
|
||||
reply(out, "250-floci-stub");
|
||||
reply(out, "250 AUTH PLAIN");
|
||||
// Gmail also offers OAuth sign-in, which a password must never be sent as.
|
||||
reply(out, "250 AUTH PLAIN XOAUTH2 OAUTHBEARER");
|
||||
} else if (command.startsWith("HELO")) {
|
||||
reply(out, "250 floci-stub");
|
||||
} else if (command.startsWith("AUTH")) {
|
||||
|
||||
@@ -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