Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 299a7704c2 | |||
| a52602186d | |||
| 1b00488b7c | |||
| dfbfa4d2a7 | |||
| 5dd9a1e346 | |||
| 00b3af3813 | |||
| d8d7dd021b | |||
| eaf5ca6ade | |||
| cc64729599 | |||
| 9f17f72cc3 | |||
| d8d94a773d | |||
| 957579c8b7 | |||
| d9a1a36dc8 | |||
| 5d4b010710 | |||
| 2486869b6b | |||
| 327173c8f6 |
@@ -1,21 +1,17 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/assets/floci-black.svg#gh-light-mode-only" alt="Floci" width="500" />
|
<img src="docs/assets/thecloud-logo.png" alt="TheCloud" width="160" />
|
||||||
<img src="docs/assets/floci-white.svg#gh-dark-mode-only" alt="Floci" width="500" />
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">TheCloud AWS runtime</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Any Cloud. Locally.</strong><br />
|
<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>.
|
No account. No auth token. No feature gates. Just <code>docker compose up</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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://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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -24,41 +20,43 @@
|
|||||||
<a href="#supported-services">Services</a> ·
|
<a href="#supported-services">Services</a> ·
|
||||||
<a href="#sdk-integration">SDKs</a> ·
|
<a href="#sdk-integration">SDKs</a> ·
|
||||||
<a href="#testcontainers">Testcontainers</a> ·
|
<a href="#testcontainers">Testcontainers</a> ·
|
||||||
<a href="#migrating-from-localstack">Migration</a> ·
|
<a href="#migrating-from-localstack">Migration</a>
|
||||||
<a href="https://floci.io/floci/">Docs</a>
|
|
||||||
</p>
|
</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 |
|
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.
|
||||||
|---|---|:---:|
|
|
||||||
| **[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 |
|
|
||||||
|
|
||||||
## Quick Start
|
## 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
|
```bash
|
||||||
floci start
|
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git
|
||||||
|
cd FlociClone
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Export the AWS environment variables:
|
Export the AWS environment variables:
|
||||||
|
|
||||||
```bash
|
```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:
|
Use your existing AWS tools normally:
|
||||||
@@ -75,121 +73,41 @@ aws dynamodb create-table \
|
|||||||
aws dynamodb list-tables
|
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.
|
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>
|
<details>
|
||||||
<summary>Prefer Docker Compose?</summary>
|
<summary>Prefer <code>docker run</code>, or dev mode?</summary>
|
||||||
|
|
||||||
Create a `compose.yaml` file:
|
Build the image, then run it with the Docker socket so container-backed services work:
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
floci:
|
|
||||||
image: floci/floci:latest
|
|
||||||
ports:
|
|
||||||
- "4566:4566"
|
|
||||||
```
|
|
||||||
|
|
||||||
Start Floci:
|
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
export AWS_ENDPOINT_URL=http://localhost:4566
|
./mvnw quarkus:dev
|
||||||
export AWS_DEFAULT_REGION=us-east-1
|
|
||||||
export AWS_ACCESS_KEY_ID=test
|
|
||||||
export AWS_SECRET_ACCESS_KEY=test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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
|
## 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`
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FLOCI_SERVICES_UI_ENABLED` | `true` | Enable the console sidecar |
|
| `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_CONTAINER_NAME` | `floci-ui` | Name of the sidecar container |
|
||||||
| `FLOCI_SERVICES_UI_PORT` | `4500` | Host port the console is published on |
|
| `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 |
|
| `FLOCI_SERVICES_UI_KEEP_RUNNING_ON_SHUTDOWN` | `false` | Leave the sidecar running when the runtime 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/).
|
|
||||||
|
|
||||||
## Features
|
## 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.
|
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.
|
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>
|
|
||||||
|
|
||||||
<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.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -230,36 +141,13 @@ Choose from in-memory, persistent, hybrid, and write-ahead log storage depending
|
|||||||
|
|
||||||
</details>
|
</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
|
## Architecture Overview
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
Client["AWS SDK / CLI"]
|
Client["AWS SDK / CLI"]
|
||||||
|
|
||||||
subgraph Floci ["Floci, port 4566"]
|
subgraph Runtime ["TheCloud AWS runtime, port 4566"]
|
||||||
Router["HTTP Router\nJAX-RS / Vert.x"]
|
Router["HTTP Router\nJAX-RS / Vert.x"]
|
||||||
|
|
||||||
subgraph Stateless ["Stateless Services"]
|
subgraph Stateless ["Stateless Services"]
|
||||||
@@ -288,7 +176,7 @@ flowchart LR
|
|||||||
|
|
||||||
## Supported Services
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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>
|
<details>
|
||||||
<summary>Detailed service notes</summary>
|
<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 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 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 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 |
|
| 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
|
## 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 |
|
| 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 |
|
| 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 |
|
| ECS | User-specified task image | Container lifecycle, start, stop, health checks |
|
||||||
| EKS | `rancher/k3s:latest` | Kubernetes API server via k3s |
|
| 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 |
|
| CodeBuild | User-specified environment image | Buildspec execution, log streaming, S3 artifact upload |
|
||||||
| OpenSearch | `opensearchproject/opensearch:2` | Full OpenSearch engine with REST API |
|
| OpenSearch | `opensearchproject/opensearch:2` | Full OpenSearch engine with REST API |
|
||||||
| ECR | `registry:2` | OCI-compatible registry for docker push and docker pull |
|
| 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) |
|
| 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 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 an upstream [Floci sidecar](https://github.com/floci-io/floci-sidecars) |
|
||||||
|
|
||||||
Docker-backed services require the Docker socket:
|
Docker-backed services require the Docker socket:
|
||||||
|
|
||||||
@@ -430,7 +318,7 @@ docker run -d --name floci \
|
|||||||
-p 4566:4566 \
|
-p 4566:4566 \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-u root \
|
-u root \
|
||||||
floci/floci:latest
|
floci-floci:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Overriding default images
|
### Overriding default images
|
||||||
@@ -454,7 +342,7 @@ docker run -d --name floci \
|
|||||||
|
|
||||||
## Persistence and Storage Modes
|
## 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 |
|
| 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.
|
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
|
## 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
|
```bash
|
||||||
AWS_ACCESS_KEY_ID=111111111111 aws sqs create-queue --queue-name orders
|
AWS_ACCESS_KEY_ID=111111111111 aws sqs create-queue --queue-name orders
|
||||||
AWS_ACCESS_KEY_ID=222222222222 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`.
|
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
|
## SDK Integration
|
||||||
|
|
||||||
@@ -526,7 +414,7 @@ client = boto3.client(
|
|||||||
|
|
||||||
client.put_parameter(
|
client.put_parameter(
|
||||||
Name="/demo/app/message",
|
Name="/demo/app/message",
|
||||||
Value="hello from floci",
|
Value="hello from TheCloud",
|
||||||
Type="String",
|
Type="String",
|
||||||
Overwrite=True,
|
Overwrite=True,
|
||||||
)
|
)
|
||||||
@@ -552,7 +440,7 @@ const client = new SQSClient({
|
|||||||
await client.send(
|
await client.send(
|
||||||
new SendMessageCommand({
|
new SendMessageCommand({
|
||||||
QueueUrl: "http://localhost:4566/000000000000/demo-queue",
|
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>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||||
.region(Region::new("us-east-1"))
|
.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")
|
.endpoint_url("http://localhost:4566")
|
||||||
.load()
|
.load()
|
||||||
.await;
|
.await;
|
||||||
@@ -623,7 +511,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
client
|
client
|
||||||
.create_secret()
|
.create_secret()
|
||||||
.name("demo/secret")
|
.name("demo/secret")
|
||||||
.secret_string("hello from floci")
|
.secret_string("hello from TheCloud")
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -649,7 +537,7 @@ aws --endpoint-url http://localhost:4566 s3 ls
|
|||||||
|
|
||||||
## Testcontainers
|
## 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.
|
For Testcontainers 1.x, use the versions as indicated in the table below.
|
||||||
|
|
||||||
@@ -767,7 +655,7 @@ def test_s3_create_bucket():
|
|||||||
|
|
||||||
## Compatibility Testing
|
## 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 |
|
| Module | Language / Tool | SDK / Client | Tests |
|
||||||
|---|---|---|---:|
|
|---|---|---|---:|
|
||||||
@@ -784,22 +672,19 @@ The [`compatibility-tests`](./compatibility-tests/) directory validates Floci ac
|
|||||||
|
|
||||||
## Migrating from LocalStack
|
## 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
|
```yaml
|
||||||
# Before
|
# Before
|
||||||
image: localstack/localstack
|
image: localstack/localstack
|
||||||
|
|
||||||
# After, standard image
|
# After, your local build of this repository
|
||||||
image: floci/floci:latest
|
image: floci-floci:latest
|
||||||
|
|
||||||
# After, if init scripts need AWS CLI or boto3
|
|
||||||
image: floci/floci:latest-compat
|
|
||||||
```
|
```
|
||||||
|
|
||||||
LocalStack environment variables are translated automatically:
|
LocalStack environment variables are translated automatically:
|
||||||
|
|
||||||
| LocalStack | Floci equivalent |
|
| LocalStack | Equivalent |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `LOCALSTACK_HOST` | `FLOCI_HOSTNAME` |
|
| `LOCALSTACK_HOST` | `FLOCI_HOSTNAME` |
|
||||||
| `PERSISTENCE=1` | `FLOCI_STORAGE_MODE=persistent` |
|
| `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.
|
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 |
|
```bash
|
||||||
|---|---|---|---|
|
docker build -f docker/Dockerfile -t floci-floci:latest .
|
||||||
| 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
## Configuration
|
||||||
|
|
||||||
@@ -853,26 +718,26 @@ All settings are overridable through environment variables with the `FLOCI_` pre
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| 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_REGION` | `us-east-1` | Default AWS region |
|
||||||
| `FLOCI_DEFAULT_ACCOUNT_ID` | `000000000000` | Default AWS account ID |
|
| `FLOCI_DEFAULT_ACCOUNT_ID` | `000000000000` | Default AWS account ID |
|
||||||
| `FLOCI_BASE_URL` | `http://localhost:4566` | Base URL used when Floci returns service URLs |
|
| `FLOCI_BASE_URL` | `http://localhost:4566` | Base URL used in service URLs the runtime returns |
|
||||||
| `FLOCI_HOSTNAME` | Unset | Hostname used in returned URLs when Floci runs inside Docker Compose |
|
| `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_MODE` | `memory` | Storage mode: `memory`, `persistent`, `hybrid`, or `wal` |
|
||||||
| `FLOCI_STORAGE_PERSISTENT_PATH` | `./data` | Directory used for persisted state |
|
| `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_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 |
|
| `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
|
### 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
|
```yaml
|
||||||
services:
|
services:
|
||||||
floci:
|
floci:
|
||||||
image: floci/floci:latest
|
image: floci-floci:latest
|
||||||
ports:
|
ports:
|
||||||
- "4566:4566"
|
- "4566:4566"
|
||||||
environment:
|
environment:
|
||||||
@@ -887,59 +752,6 @@ services:
|
|||||||
|
|
||||||
Without this, services may return URLs using `localhost`, which points to the wrong container from the application container.
|
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
|
## 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.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -563,8 +563,10 @@ public class AcmJsonHandler {
|
|||||||
|
|
||||||
private CertificateOptions parseOptions(JsonNode optionsNode) {
|
private CertificateOptions parseOptions(JsonNode optionsNode) {
|
||||||
if (optionsNode.isMissingNode()) return null;
|
if (optionsNode.isMissingNode()) return null;
|
||||||
String ctPref = optionsNode.path("CertificateTransparencyLoggingPreference").asText("ENABLED");
|
String ctPref = parseOptionValue(optionsNode, "CertificateTransparencyLoggingPreference");
|
||||||
return new CertificateOptions(ctPref, "DISABLED");
|
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) {
|
private List<CertificateStatus> parseCertificateStatuses(JsonNode node) {
|
||||||
|
|||||||
+26
@@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.github.hectorvent.floci.config.EmulatorConfig;
|
import io.github.hectorvent.floci.config.EmulatorConfig;
|
||||||
import io.github.hectorvent.floci.core.common.SsrfProtection;
|
import io.github.hectorvent.floci.core.common.SsrfProtection;
|
||||||
|
import io.github.hectorvent.floci.services.cognito.CognitoService;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.inject.Instance;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import org.apache.hc.client5.http.DnsResolver;
|
import org.apache.hc.client5.http.DnsResolver;
|
||||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||||
@@ -37,6 +39,7 @@ import java.util.Base64;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
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
|
* 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 CloseableHttpClient httpClient;
|
||||||
private final boolean allowPrivateNetworkTargets;
|
private final boolean allowPrivateNetworkTargets;
|
||||||
private final Map<String, CachedJwks> jwksCache = new ConcurrentHashMap<>();
|
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
|
@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) {
|
public JwtSignatureVerifier(ObjectMapper objectMapper, EmulatorConfig config) {
|
||||||
this(objectMapper, SystemDefaultDnsResolver.INSTANCE,
|
this(objectMapper, SystemDefaultDnsResolver.INSTANCE,
|
||||||
config.security().allowPrivateJwtTargets());
|
config.security().allowPrivateJwtTargets());
|
||||||
@@ -162,6 +184,10 @@ public class JwtSignatureVerifier implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private RSAPublicKey resolveKey(String issuer, String kid) throws JwtVerificationException {
|
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);
|
CachedJwks cached = jwksCache.get(issuer);
|
||||||
if (cached != null && !cached.isExpired()) {
|
if (cached != null && !cached.isExpired()) {
|
||||||
RSAPublicKey key = cached.keysByKid().get(kid);
|
RSAPublicKey key = cached.keysByKid().get(kid);
|
||||||
|
|||||||
+4
-1
@@ -291,7 +291,10 @@ final class CognitoAuthFlowHandler {
|
|||||||
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
|
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
|
||||||
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
|
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
|
||||||
"UserMigration_Authentication");
|
"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);
|
firePreAuthentication(pool, client, user, null, clientMetadata, false);
|
||||||
|
|||||||
@@ -2592,7 +2592,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
String confirmationCode) {
|
String confirmationCode) {
|
||||||
UserPool pool = poolStore.get(userPoolId)
|
UserPool pool = poolStore.get(userPoolId)
|
||||||
.orElseThrow(() -> userPoolNotFound(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)) {
|
if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
|
||||||
try {
|
try {
|
||||||
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
||||||
@@ -2636,7 +2637,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
|
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
|
||||||
400));
|
400));
|
||||||
UserPool pool = describeUserPool(client.getUserPoolId());
|
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())) {
|
if (!"UNCONFIRMED".equals(user.getUserStatus())) {
|
||||||
throw new AwsException("NotAuthorizedException",
|
throw new AwsException("NotAuthorizedException",
|
||||||
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
|
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
|
||||||
@@ -2774,7 +2776,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
public Map<String, Object> forgotPassword(String clientId, String username) {
|
public Map<String, Object> forgotPassword(String clientId, String username) {
|
||||||
UserPoolClient client = clientStore.get(clientId)
|
UserPoolClient client = clientStore.get(clientId)
|
||||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
.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());
|
UserPool pool = describeUserPool(client.getUserPoolId());
|
||||||
ensureVerificationWiring();
|
ensureVerificationWiring();
|
||||||
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
|
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
|
||||||
@@ -2800,7 +2803,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
|
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
|
||||||
UserPoolClient client = clientStore.get(clientId)
|
UserPoolClient client = clientStore.get(clientId)
|
||||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
.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();
|
ensureVerificationWiring();
|
||||||
try {
|
try {
|
||||||
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
||||||
@@ -3075,6 +3079,18 @@ public class CognitoService implements ResourceProvider {
|
|||||||
return overrideId;
|
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) {
|
public String getJwksUri(String poolId) {
|
||||||
return getIssuer(poolId) + "/.well-known/jwks.json";
|
return getIssuer(poolId) + "/.well-known/jwks.json";
|
||||||
}
|
}
|
||||||
@@ -4446,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) {
|
private AwsException mapVerificationCodeException(VerificationCodeException e) {
|
||||||
return switch (e.getKind()) {
|
return switch (e.getKind()) {
|
||||||
case MISMATCH, NOT_FOUND -> new AwsException("CodeMismatchException",
|
case MISMATCH, NOT_FOUND -> codeMismatch();
|
||||||
"Invalid verification code provided, please try again.", 400);
|
|
||||||
case EXPIRED -> new AwsException("ExpiredCodeException",
|
case EXPIRED -> new AwsException("ExpiredCodeException",
|
||||||
"Invalid code provided, please request a code again.", 400);
|
"Invalid code provided, please request a code again.", 400);
|
||||||
case RATE_LIMIT -> new AwsException("LimitExceededException",
|
case RATE_LIMIT -> new AwsException("LimitExceededException",
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ public class EfsException extends AwsException {
|
|||||||
return new EfsException(Status.NOT_FOUND, "MountTargetNotFound", "Mount target " + mountTargetId + " does not exist.");
|
return new EfsException(Status.NOT_FOUND, "MountTargetNotFound", "Mount target " + mountTargetId + " does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static EfsException subnetNotFound(String subnetId) {
|
||||||
|
return new EfsException(Status.BAD_REQUEST, "SubnetNotFound", "The subnet ID '" + subnetId + "' does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
public static EfsException mountTargetConflict(String message) {
|
public static EfsException mountTargetConflict(String message) {
|
||||||
return new EfsException(Status.CONFLICT, "MountTargetConflict", message);
|
return new EfsException(Status.CONFLICT, "MountTargetConflict", message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package io.github.hectorvent.floci.services.efs;
|
|||||||
|
|
||||||
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
import io.github.hectorvent.floci.core.storage.StorageBackend;
|
||||||
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
import io.github.hectorvent.floci.core.storage.StorageFactory;
|
||||||
|
import io.github.hectorvent.floci.core.common.AwsException;
|
||||||
import io.github.hectorvent.floci.core.common.Resettable;
|
import io.github.hectorvent.floci.core.common.Resettable;
|
||||||
|
import io.github.hectorvent.floci.services.ec2.Ec2Service;
|
||||||
|
import io.github.hectorvent.floci.services.ec2.model.Subnet;
|
||||||
import io.github.hectorvent.floci.services.efs.model.*;
|
import io.github.hectorvent.floci.services.efs.model.*;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@@ -33,13 +36,16 @@ public class EfsService implements Resettable {
|
|||||||
private final ConcurrentHashMap<String, Object> syncLocks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Object> syncLocks = new ConcurrentHashMap<>();
|
||||||
private final RegionResolver regionResolver;
|
private final RegionResolver regionResolver;
|
||||||
|
|
||||||
|
private final Ec2Service ec2Service;
|
||||||
|
|
||||||
private Object lockFor(String key) {
|
private Object lockFor(String key) {
|
||||||
return syncLocks.computeIfAbsent(key, k -> new Object());
|
return syncLocks.computeIfAbsent(key, k -> new Object());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public EfsService(StorageFactory storageFactory, RegionResolver regionResolver) {
|
public EfsService(StorageFactory storageFactory, RegionResolver regionResolver, Ec2Service ec2Service) {
|
||||||
this.regionResolver = regionResolver;
|
this.regionResolver = regionResolver;
|
||||||
|
this.ec2Service = ec2Service;
|
||||||
this.fileSystemStore = storageFactory.create("efs", "efs-filesystems.json",
|
this.fileSystemStore = storageFactory.create("efs", "efs-filesystems.json",
|
||||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, FileSystem>>() {});
|
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, FileSystem>>() {});
|
||||||
this.mountTargetStore = storageFactory.create("efs", "efs-mounttargets.json",
|
this.mountTargetStore = storageFactory.create("efs", "efs-mounttargets.json",
|
||||||
@@ -361,14 +367,24 @@ public class EfsService implements Resettable {
|
|||||||
public MountTarget createMountTarget(CreateMountTargetRequest request, String region) {
|
public MountTarget createMountTarget(CreateMountTargetRequest request, String region) {
|
||||||
synchronized (lockFor(regionKey(region, request.getFileSystemId()))) {
|
synchronized (lockFor(regionKey(region, request.getFileSystemId()))) {
|
||||||
FileSystem fs = getFileSystem(region, request.getFileSystemId());
|
FileSystem fs = getFileSystem(region, request.getFileSystemId());
|
||||||
|
Subnet subnet = requireSubnet(region, request.getSubnetId());
|
||||||
for (MountTarget existing : mountTargetStore.scan(k -> k.startsWith(region + "::"))) {
|
|
||||||
|
List<MountTarget> regionMountTargets = mountTargetStore.scan(k -> k.startsWith(region + "::"));
|
||||||
|
for (MountTarget existing : regionMountTargets) {
|
||||||
if (existing.getFileSystemId().equals(request.getFileSystemId())) {
|
if (existing.getFileSystemId().equals(request.getFileSystemId())) {
|
||||||
if (existing.getSubnetId().equals(request.getSubnetId())) {
|
if (existing.getSubnetId().equals(request.getSubnetId())) {
|
||||||
throw EfsException.mountTargetConflict("Mount target already exists in this subnet");
|
throw EfsException.mountTargetConflict("Mount target already exists in this subnet");
|
||||||
}
|
}
|
||||||
|
// AWS allows one mount target per Availability Zone, all in one VPC.
|
||||||
|
if (subnet.getAvailabilityZone().equals(existing.getAvailabilityZoneName())) {
|
||||||
|
throw EfsException.mountTargetConflict("Mount target already exists in this AZ");
|
||||||
|
}
|
||||||
|
if (!subnet.getVpcId().equals(existing.getVpcId())) {
|
||||||
|
throw EfsException.mountTargetConflict("Mount targets of a file system must be in the same VPC");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
long inSubnet = regionMountTargets.stream().filter(existing -> request.getSubnetId().equals(existing.getSubnetId())).count();
|
||||||
|
|
||||||
String mtId = "fsmt-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17);
|
String mtId = "fsmt-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17);
|
||||||
|
|
||||||
@@ -376,11 +392,11 @@ public class EfsService implements Resettable {
|
|||||||
mt.setMountTargetId(mtId);
|
mt.setMountTargetId(mtId);
|
||||||
mt.setFileSystemId(request.getFileSystemId());
|
mt.setFileSystemId(request.getFileSystemId());
|
||||||
mt.setSubnetId(request.getSubnetId());
|
mt.setSubnetId(request.getSubnetId());
|
||||||
mt.setIpAddress(request.getIpAddress() != null ? request.getIpAddress() : "10.0.0.10");
|
mt.setIpAddress(request.getIpAddress() != null ? request.getIpAddress() : hostAddress(subnet.getCidrBlock(), inSubnet));
|
||||||
mt.setLifeCycleState(LifeCycleState.available);
|
mt.setLifeCycleState(LifeCycleState.available);
|
||||||
mt.setVpcId("vpc-12345678");
|
mt.setVpcId(subnet.getVpcId());
|
||||||
mt.setAvailabilityZoneName(region + "a");
|
mt.setAvailabilityZoneName(subnet.getAvailabilityZone());
|
||||||
mt.setAvailabilityZoneId(generateAzId(region + "a"));
|
mt.setAvailabilityZoneId(generateAzId(subnet.getAvailabilityZone()));
|
||||||
mt.setNetworkInterfaceId("eni-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17));
|
mt.setNetworkInterfaceId("eni-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17));
|
||||||
if (request.getSecurityGroups() != null) {
|
if (request.getSecurityGroups() != null) {
|
||||||
mt.setSecurityGroups(new ArrayList<>(request.getSecurityGroups()));
|
mt.setSecurityGroups(new ArrayList<>(request.getSecurityGroups()));
|
||||||
@@ -613,6 +629,32 @@ public class EfsService implements Resettable {
|
|||||||
return region + "::" + id;
|
return region + "::" + id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Subnet requireSubnet(String region, String subnetId) {
|
||||||
|
if (subnetId == null || subnetId.isBlank()) {
|
||||||
|
throw EfsException.badRequest("SubnetId is required.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ec2Service.requireSubnet(region, subnetId);
|
||||||
|
} catch (AwsException e) {
|
||||||
|
throw EfsException.subnetNotFound(subnetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An address inside the subnet for its n-th mount target, skipping the first four
|
||||||
|
* addresses AWS reserves in every subnet.
|
||||||
|
*/
|
||||||
|
static String hostAddress(String cidrBlock, long index) {
|
||||||
|
String[] parts = cidrBlock.split("/");
|
||||||
|
String[] octets = parts[0].split("\\.");
|
||||||
|
long base = 0;
|
||||||
|
for (String octet : octets) {
|
||||||
|
base = (base << 8) | Long.parseLong(octet);
|
||||||
|
}
|
||||||
|
long address = base + 4 + index;
|
||||||
|
return ((address >> 24) & 0xff) + "." + ((address >> 16) & 0xff) + "." + ((address >> 8) & 0xff) + "." + (address & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
private String generateAzId(String azName) {
|
private String generateAzId(String azName) {
|
||||||
if (azName == null || azName.length() < 2) return azName + "-id";
|
if (azName == null || azName.length() < 2) return azName + "-id";
|
||||||
String region = azName.substring(0, azName.length() - 1);
|
String region = azName.substring(0, azName.length() - 1);
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ public class GlueService {
|
|||||||
}
|
}
|
||||||
database.setName(databaseName);
|
database.setName(databaseName);
|
||||||
database.setCatalogId(regionResolver.getAccountId());
|
database.setCatalogId(regionResolver.getAccountId());
|
||||||
|
if (database.getCreateTime() == null) {
|
||||||
|
database.setCreateTime(Instant.now());
|
||||||
|
}
|
||||||
databaseStore.put(databaseName, database);
|
databaseStore.put(databaseName, database);
|
||||||
if (tags != null && !tags.isEmpty()) {
|
if (tags != null && !tags.isEmpty()) {
|
||||||
resourceGroupsTaggingService.tagResources(List.of(databaseArn(region, databaseName)), tags, region);
|
resourceGroupsTaggingService.tagResources(List.of(databaseArn(region, databaseName)), tags, region);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.github.hectorvent.floci.services.glue.model;
|
|||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -47,10 +48,12 @@ public class Connection {
|
|||||||
|
|
||||||
@JsonProperty("CreationTime")
|
@JsonProperty("CreationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant creationTime;
|
private Instant creationTime;
|
||||||
|
|
||||||
@JsonProperty("LastUpdatedTime")
|
@JsonProperty("LastUpdatedTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant lastUpdatedTime;
|
private Instant lastUpdatedTime;
|
||||||
|
|
||||||
@JsonProperty("LastUpdatedBy")
|
@JsonProperty("LastUpdatedBy")
|
||||||
@@ -64,6 +67,7 @@ public class Connection {
|
|||||||
|
|
||||||
@JsonProperty("LastConnectionValidationTime")
|
@JsonProperty("LastConnectionValidationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant lastConnectionValidationTime;
|
private Instant lastConnectionValidationTime;
|
||||||
|
|
||||||
@JsonProperty("AuthenticationConfiguration")
|
@JsonProperty("AuthenticationConfiguration")
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class Crawler {
|
public class Crawler {
|
||||||
@JsonProperty("Classifiers")
|
@JsonProperty("Classifiers")
|
||||||
private List<String> classifiers;
|
private List<String> classifiers;
|
||||||
@@ -22,6 +25,7 @@ public class Crawler {
|
|||||||
|
|
||||||
@JsonProperty("CreationTime")
|
@JsonProperty("CreationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant creationTime;
|
private Instant creationTime;
|
||||||
|
|
||||||
@JsonProperty("DatabaseName")
|
@JsonProperty("DatabaseName")
|
||||||
@@ -38,6 +42,7 @@ public class Crawler {
|
|||||||
|
|
||||||
@JsonProperty("LastUpdated")
|
@JsonProperty("LastUpdated")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant lastUpdated;
|
private Instant lastUpdated;
|
||||||
|
|
||||||
@JsonProperty("LineageConfiguration")
|
@JsonProperty("LineageConfiguration")
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class Database {
|
public class Database {
|
||||||
@JsonProperty("Name")
|
@JsonProperty("Name")
|
||||||
private String name;
|
private String name;
|
||||||
@@ -18,6 +21,7 @@ public class Database {
|
|||||||
private Map<String, String> parameters;
|
private Map<String, String> parameters;
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
@JsonProperty("CatalogId")
|
@JsonProperty("CatalogId")
|
||||||
private String catalogId;
|
private String catalogId;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.github.hectorvent.floci.services.glue.model;
|
|||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -22,10 +23,12 @@ public class GluePolicy {
|
|||||||
|
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
|
|
||||||
@JsonProperty("UpdateTime")
|
@JsonProperty("UpdateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant updateTime;
|
private Instant updateTime;
|
||||||
|
|
||||||
public String getPolicyInJson() { return policyInJson; }
|
public String getPolicyInJson() { return policyInJson; }
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||||
|
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||||
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a Glue timestamp as AWS does: epoch seconds to the millisecond, in the shortest
|
||||||
|
* form ({@code 1792964720.27}, not {@code 1792964720.270000000}). The AWS SDK for
|
||||||
|
* JavaScript v3 keeps any number whose text differs from its shortest form as a
|
||||||
|
* BigDecimal, and then refuses it as a timestamp.
|
||||||
|
*/
|
||||||
|
@RegisterForReflection
|
||||||
|
public class GlueTimestampSerializer extends JsonSerializer<Instant> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||||
|
gen.writeNumber(epochSeconds(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String epochSeconds(Instant value) {
|
||||||
|
return BigDecimal.valueOf(value.toEpochMilli(), 3).stripTrailingZeros().toPlainString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ import java.time.Instant;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class Job {
|
public class Job {
|
||||||
@JsonProperty("AllocatedCapacity")
|
@JsonProperty("AllocatedCapacity")
|
||||||
private Integer allocatedCapacity;
|
private Integer allocatedCapacity;
|
||||||
@@ -24,6 +27,7 @@ public class Job {
|
|||||||
|
|
||||||
@JsonProperty("CreatedOn")
|
@JsonProperty("CreatedOn")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant createdOn;
|
private Instant createdOn;
|
||||||
|
|
||||||
@JsonProperty("DefaultArguments")
|
@JsonProperty("DefaultArguments")
|
||||||
@@ -49,6 +53,7 @@ public class Job {
|
|||||||
|
|
||||||
@JsonProperty("LastModifiedOn")
|
@JsonProperty("LastModifiedOn")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant lastModifiedOn;
|
private Instant lastModifiedOn;
|
||||||
|
|
||||||
@JsonProperty("LogUri")
|
@JsonProperty("LogUri")
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class LastCrawlInfo {
|
public class LastCrawlInfo {
|
||||||
@JsonProperty("ErrorMessage")
|
@JsonProperty("ErrorMessage")
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
@@ -21,6 +24,7 @@ public class LastCrawlInfo {
|
|||||||
|
|
||||||
@JsonProperty("StartTime")
|
@JsonProperty("StartTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant startTime;
|
private Instant startTime;
|
||||||
|
|
||||||
@JsonProperty("Status")
|
@JsonProperty("Status")
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class Partition {
|
public class Partition {
|
||||||
@JsonProperty("Values")
|
@JsonProperty("Values")
|
||||||
private List<String> values;
|
private List<String> values;
|
||||||
@@ -17,9 +20,11 @@ public class Partition {
|
|||||||
private String tableName;
|
private String tableName;
|
||||||
@JsonProperty("CreationTime")
|
@JsonProperty("CreationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant creationTime;
|
private Instant creationTime;
|
||||||
@JsonProperty("LastAccessTime")
|
@JsonProperty("LastAccessTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant lastAccessTime;
|
private Instant lastAccessTime;
|
||||||
@JsonProperty("StorageDescriptor")
|
@JsonProperty("StorageDescriptor")
|
||||||
private StorageDescriptor storageDescriptor;
|
private StorageDescriptor storageDescriptor;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class Table {
|
public class Table {
|
||||||
@JsonProperty("Name")
|
@JsonProperty("Name")
|
||||||
private String name;
|
private String name;
|
||||||
@@ -19,12 +22,15 @@ public class Table {
|
|||||||
private String owner;
|
private String owner;
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
@JsonProperty("UpdateTime")
|
@JsonProperty("UpdateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant updateTime;
|
private Instant updateTime;
|
||||||
@JsonProperty("LastAccessTime")
|
@JsonProperty("LastAccessTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant lastAccessTime;
|
private Instant lastAccessTime;
|
||||||
@JsonProperty("PartitionKeys")
|
@JsonProperty("PartitionKeys")
|
||||||
private List<Column> partitionKeys;
|
private List<Column> partitionKeys;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
public class UserDefinedFunction {
|
public class UserDefinedFunction {
|
||||||
@JsonProperty("FunctionName")
|
@JsonProperty("FunctionName")
|
||||||
private String functionName;
|
private String functionName;
|
||||||
@@ -23,6 +26,7 @@ public class UserDefinedFunction {
|
|||||||
private String ownerType;
|
private String ownerType;
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
|
@JsonSerialize(using = GlueTimestampSerializer.class)
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
@JsonProperty("ResourceUris")
|
@JsonProperty("ResourceUris")
|
||||||
private List<ResourceUri> resourceUris;
|
private List<ResourceUri> resourceUris;
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public class SmtpRelay {
|
|||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(SmtpRelay.class);
|
private static final Logger LOG = Logger.getLogger(SmtpRelay.class);
|
||||||
private static final long RELAY_TIMEOUT_SECONDS = 30;
|
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. */
|
/** SES control header naming the configuration set to apply to a raw send. */
|
||||||
public static final String HEADER_CONFIGURATION_SET = "X-SES-CONFIGURATION-SET";
|
public static final String HEADER_CONFIGURATION_SET = "X-SES-CONFIGURATION-SET";
|
||||||
@@ -118,6 +120,9 @@ public class SmtpRelay {
|
|||||||
.filter(u -> !u.isBlank())
|
.filter(u -> !u.isBlank())
|
||||||
.ifPresent(u -> {
|
.ifPresent(u -> {
|
||||||
mailConfig.setUsername(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()
|
config.services().ses().smtpPass()
|
||||||
.filter(p -> !p.isBlank())
|
.filter(p -> !p.isBlank())
|
||||||
.ifPresent(mailConfig::setPassword);
|
.ifPresent(mailConfig::setPassword);
|
||||||
|
|||||||
@@ -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
|
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
|
@Test
|
||||||
void rejectsATokenSignedWithADifferentKey() throws Exception {
|
void rejectsATokenSignedWithADifferentKey() throws Exception {
|
||||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
|||||||
@@ -373,6 +373,63 @@ class CognitoServiceTest {
|
|||||||
null, null, List.of(), null, null);
|
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
|
@ParameterizedTest
|
||||||
@CsvSource({
|
@CsvSource({
|
||||||
"Short1!a",
|
"Short1!a",
|
||||||
@@ -1312,6 +1369,22 @@ class CognitoServiceTest {
|
|||||||
assertEquals("http://localhost:4566/custompool", service.getIssuer(pool.getId()));
|
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
|
// Groups
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class EfsIntegrationTest {
|
|||||||
.body("""
|
.body("""
|
||||||
{
|
{
|
||||||
"FileSystemId": "%s",
|
"FileSystemId": "%s",
|
||||||
"SubnetId": "subnet-12345",
|
"SubnetId": "subnet-default-us-east-1-a",
|
||||||
"SecurityGroups": ["sg-11111"]
|
"SecurityGroups": ["sg-11111"]
|
||||||
}
|
}
|
||||||
""".formatted(fileSystemId))
|
""".formatted(fileSystemId))
|
||||||
@@ -100,7 +100,10 @@ class EfsIntegrationTest {
|
|||||||
.statusCode(200)
|
.statusCode(200)
|
||||||
.body("MountTargetId", startsWith("fsmt-"))
|
.body("MountTargetId", startsWith("fsmt-"))
|
||||||
.body("FileSystemId", equalTo(fileSystemId))
|
.body("FileSystemId", equalTo(fileSystemId))
|
||||||
.body("SubnetId", equalTo("subnet-12345"))
|
.body("SubnetId", equalTo("subnet-default-us-east-1-a"))
|
||||||
|
.body("VpcId", equalTo("vpc-default-us-east-1"))
|
||||||
|
.body("AvailabilityZoneName", equalTo("us-east-1a"))
|
||||||
|
.body("IpAddress", equalTo("172.31.0.4"))
|
||||||
.extract().jsonPath().getString("MountTargetId");
|
.extract().jsonPath().getString("MountTargetId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package io.github.hectorvent.floci.services.efs;
|
||||||
|
|
||||||
|
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.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/** Mount targets take their VPC, zone and address from the EC2 subnet they are created in. */
|
||||||
|
@QuarkusTest
|
||||||
|
class EfsMountTargetSubnetTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void configureRestAssured() {
|
||||||
|
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createFileSystem(String token) {
|
||||||
|
return given()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{\"CreationToken\": \"" + token + "\"}")
|
||||||
|
.when()
|
||||||
|
.post("/2015-02-01/file-systems")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract().jsonPath().getString("FileSystemId");
|
||||||
|
}
|
||||||
|
|
||||||
|
private io.restassured.response.ValidatableResponse createMountTarget(String fileSystemId, String subnetId) {
|
||||||
|
return given()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body("{\"FileSystemId\": \"" + fileSystemId + "\", \"SubnetId\": \"" + subnetId + "\"}")
|
||||||
|
.when()
|
||||||
|
.post("/2015-02-01/mount-targets")
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesTheZoneAndVpcOfEachSubnet() {
|
||||||
|
String fileSystemId = createFileSystem("subnet-zones");
|
||||||
|
|
||||||
|
createMountTarget(fileSystemId, "subnet-default-us-east-1-b")
|
||||||
|
.statusCode(200)
|
||||||
|
.body("VpcId", equalTo("vpc-default-us-east-1"))
|
||||||
|
.body("AvailabilityZoneName", equalTo("us-east-1b"))
|
||||||
|
.body("IpAddress", equalTo("172.31.16.4"));
|
||||||
|
createMountTarget(fileSystemId, "subnet-default-us-east-1-c")
|
||||||
|
.statusCode(200)
|
||||||
|
.body("AvailabilityZoneName", equalTo("us-east-1c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesASubnetThatDoesNotExist() {
|
||||||
|
String fileSystemId = createFileSystem("subnet-missing");
|
||||||
|
|
||||||
|
createMountTarget(fileSystemId, "subnet-nope")
|
||||||
|
.statusCode(400)
|
||||||
|
.body("__type", equalTo("SubnetNotFound"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsOneMountTargetPerZone() {
|
||||||
|
String fileSystemId = createFileSystem("subnet-one-per-zone");
|
||||||
|
String vpcId = "vpc-default-us-east-1";
|
||||||
|
String secondSubnet = given()
|
||||||
|
.formParam("Action", "CreateSubnet")
|
||||||
|
.formParam("VpcId", vpcId)
|
||||||
|
.formParam("CidrBlock", "172.31.200.0/24")
|
||||||
|
.formParam("AvailabilityZone", "us-east-1a")
|
||||||
|
.header("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/ec2/aws4_request")
|
||||||
|
.when()
|
||||||
|
.post("/")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract().path("CreateSubnetResponse.subnet.subnetId");
|
||||||
|
|
||||||
|
createMountTarget(fileSystemId, "subnet-default-us-east-1-a").statusCode(200);
|
||||||
|
createMountTarget(fileSystemId, secondSubnet)
|
||||||
|
.statusCode(409)
|
||||||
|
.body("__type", equalTo("MountTargetConflict"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picksAddressesAfterTheFourAwsReserves() {
|
||||||
|
assertEquals("10.0.1.4", EfsService.hostAddress("10.0.1.0/24", 0));
|
||||||
|
assertEquals("10.0.2.1", EfsService.hostAddress("10.0.1.0/24", 253));
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package io.github.hectorvent.floci.services.glue;
|
||||||
|
|
||||||
|
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 java.util.UUID;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.hamcrest.Matchers.hasKey;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
|
import static org.hamcrest.Matchers.notNullValue;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS leaves unset fields out of Glue responses and reports times in milliseconds.
|
||||||
|
* A null or nanosecond timestamp instead breaks the AWS SDK for JavaScript v3,
|
||||||
|
* which refuses to read either.
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
class GlueOmitsNullFieldsIntegrationTest {
|
||||||
|
|
||||||
|
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
|
||||||
|
private static final String DATABASE = "nulls_" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void configureRestAssured() {
|
||||||
|
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static io.restassured.response.ValidatableResponse glue(String action, String body) {
|
||||||
|
return given().contentType(CONTENT_TYPE)
|
||||||
|
.header("X-Amz-Target", "AWSGlue." + action)
|
||||||
|
.body(body)
|
||||||
|
.when().post("/")
|
||||||
|
.then().statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNewTableHasOnlyAMillisecondCreateTime() {
|
||||||
|
glue("CreateDatabase", "{\"DatabaseInput\": {\"Name\": \"" + DATABASE + "\"}}");
|
||||||
|
glue("CreateTable", """
|
||||||
|
{
|
||||||
|
"DatabaseName": "%s",
|
||||||
|
"TableInput": {
|
||||||
|
"Name": "orders",
|
||||||
|
"StorageDescriptor": {"Columns": [{"Name": "id", "Type": "int"}], "Location": "s3://data/orders/"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(DATABASE));
|
||||||
|
|
||||||
|
String tables = glue("GetTables", "{\"DatabaseName\": \"" + DATABASE + "\"}")
|
||||||
|
.body("TableList[0].Name", equalTo("orders"))
|
||||||
|
.body("TableList[0].CreateTime", notNullValue())
|
||||||
|
.body("TableList[0]", not(hasKey("UpdateTime")))
|
||||||
|
.body("TableList[0]", not(hasKey("LastAccessTime")))
|
||||||
|
.extract().asString();
|
||||||
|
// Milliseconds in their shortest form, as AWS sends them: no trailing zeros.
|
||||||
|
assertTrue(tables.matches("(?s).*\"CreateTime\":\\d+(\\.\\d{0,2}[1-9])?[,}].*"), tables);
|
||||||
|
|
||||||
|
glue("GetDatabase", "{\"Name\": \"" + DATABASE + "\"}")
|
||||||
|
.body("Database", not(hasKey("Description")))
|
||||||
|
.body("Database.CreateTime", notNullValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class GlueTimestampSerializerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesMillisecondsInTheirShortestForm() {
|
||||||
|
assertEquals("1792964720.273", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720, 273_456_789)));
|
||||||
|
assertEquals("1792964720.27", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720, 270_000_000)));
|
||||||
|
assertEquals("1792964720", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -287,7 +287,8 @@ class SmtpRelayServerTest {
|
|||||||
String command = line.toUpperCase(Locale.ROOT);
|
String command = line.toUpperCase(Locale.ROOT);
|
||||||
if (command.startsWith("EHLO")) {
|
if (command.startsWith("EHLO")) {
|
||||||
reply(out, "250-floci-stub");
|
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")) {
|
} else if (command.startsWith("HELO")) {
|
||||||
reply(out, "250 floci-stub");
|
reply(out, "250 floci-stub");
|
||||||
} else if (command.startsWith("AUTH")) {
|
} else if (command.startsWith("AUTH")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user