Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,13 @@ All default images are configurable via environment variables, useful for pinnin
| **Route53** | In-process | Hosted zones with auto-created SOA + NS records, resource record sets (CREATE/UPSERT/DELETE with atomic validation), change tracking (always INSYNC), health checks, and per-resource tagging |
| **Transfer Family** | In-process | Server lifecycle (`CreateServer` / `DeleteServer` / `StartServer` / `StopServer` / `UpdateServer`), user management, SSH public key import, and tagging |
| **Textract** | In-process (stub) | API-compatible stubs for all operations; dummy block data with realistic shape and metadata; async job simulation with immediate SUCCEEDED status |
| **Pricing (Price List Service)** | In-process + bundled static snapshot | `DescribeServices`, `GetAttributeValues`, `GetProducts`, `ListPriceLists`, `GetPriceListFileUrl`; pagination; filesystem override via `FLOCI_SERVICES_PRICING_SNAPSHOT_PATH` |

> **Lambda, ElastiCache, RDS, MSK, ECS, EC2, EKS, OpenSearch, and CodeBuild** spin up real Docker containers and support IAM authentication and SigV4 request signing — the same auth flow as production AWS. **ECR** runs a shared `registry:2` container so the stock `docker` client can push and pull image bytes against repositories returned by the AWS-shaped control plane.
>
> For per-service operation counts and endpoint protocols, see the [Services Overview](https://floci.io/floci/services/) in the documentation site.

**46 AWS services supported.**
**47 AWS services supported.**

## Persistence & Storage Modes

Expand Down
115 changes: 115 additions & 0 deletions docs/services/pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Pricing (AWS Price List Service)

**Protocol:** JSON 1.1
**Header:** `X-Amz-Target: AWSPriceListService.<Action>`
**Endpoint prefix:** `api.pricing`

Floci emulates the AWS Price List Service backed by a bundled static snapshot.
Responses match the real AWS wire format so AWS SDK and CLI clients accept the
reply without modification. The bundled snapshot covers a minimal, representative
set of services and regions; for broader coverage, point Floci at your own
snapshot with `FLOCI_SERVICES_PRICING_SNAPSHOT_PATH`.

## Supported Operations

| Operation | Notes |
|-----------|-------|
| `DescribeServices` | Lists bundled services and their queryable attribute names |
| `GetAttributeValues` | Returns the set of values a given attribute can take |
| `GetProducts` | Returns `PriceList` as an array of JSON-encoded product-offer strings (matches AWS format) |
| `ListPriceLists` | Lists available price-list ARNs filtered by service, currency, and optional region |
| `GetPriceListFileUrl` | Returns a stub HTTPS URL; useful for code paths that validate URL presence |

Pagination is supported on all list operations via `NextToken` + `MaxResults`.

## Bundled Snapshot

The default snapshot on the classpath covers:

| ServiceCode | Regions | Notes |
|-------------|---------|-------|
| `AmazonEC2` | `us-east-1` (Linux/Shared tenancy, 3 instance types) | `t3.micro`, `m5.large`, `c5.large` |
| `AmazonS3` | `us-east-1` (Standard storage) | |
| `AWSLambda` | `us-east-1` (Requests) | |

The snapshot is intentionally minimal — enough to exercise SDK parsing and
filter logic — not a comprehensive price database.

## Configuration

| Variable | Default | Description |
|---|---|---|
| `FLOCI_SERVICES_PRICING_ENABLED` | `true` | Enable or disable the service |
| `FLOCI_SERVICES_PRICING_SNAPSHOT_PATH` | *(unset)* | Filesystem directory overriding the bundled snapshot |

### Snapshot directory layout

When `FLOCI_SERVICES_PRICING_SNAPSHOT_PATH` is set, Floci reads files in this
layout (falling back to the classpath entry for any file that does not exist):

```
<path>/
services.json # [ { "ServiceCode": "...", "AttributeNames": [...] } ]
attribute-values/<ServiceCode>/<Attr>.json # [ { "Value": "..." } ]
products/<ServiceCode>/<Region>.json # [ { "product": {...}, "terms": {...}, ... } ]
price-lists/<ServiceCode>.json # [ { "PriceListArn": "...", "RegionCode": "...", ... } ]
```

Each product entry is stored as a JSON object; Floci re-serializes it into the
array-of-JSON-strings shape AWS returns. Drop in a full snapshot generated from
the [AWS Price List Bulk API](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/price-changes.html)
when the bundled fixtures are insufficient.

## Examples

```bash
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test

aws pricing describe-services --service-code AmazonEC2

aws pricing get-attribute-values \
--service-code AmazonEC2 --attribute-name instanceType

aws pricing get-products \
--service-code AmazonEC2 \
--filters 'Type=TERM_MATCH,Field=instanceType,Value=t3.micro' \
'Type=TERM_MATCH,Field=regionCode,Value=us-east-1'

aws pricing list-price-lists \
--service-code AmazonEC2 \
--effective-date 2026-01-01T00:00:00Z \
--currency-code USD
```

```python
import boto3

client = boto3.client(
"pricing",
endpoint_url="http://localhost:4566",
region_name="us-east-1",
)

resp = client.get_products(
ServiceCode="AmazonEC2",
Filters=[
{"Type": "TERM_MATCH", "Field": "instanceType", "Value": "t3.micro"},
{"Type": "TERM_MATCH", "Field": "regionCode", "Value": "us-east-1"},
],
)
for item in resp["PriceList"]:
# AWS returns PriceList as an array of JSON strings; parse each separately.
import json
print(json.loads(item)["product"]["sku"])
```

## Out of Scope

- Bulk download of full regional price lists (`GetPriceListFileUrl` returns a
stub URL; the file is not served).
- Volume discounts, Savings Plans, or Reserved Instance pricing terms beyond
what the bundled snapshot declares.
- Automatic refresh of the snapshot from upstream AWS.
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ interface ServicesConfig {
Route53ServiceConfig route53();
TransferServiceConfig transfer();
TextractServiceConfig textract();
PricingServiceConfig pricing();
}

interface TransferServiceConfig {
Expand Down Expand Up @@ -640,6 +641,19 @@ interface TextractServiceConfig {
boolean enabled();
}

interface PricingServiceConfig {
@WithDefault("true")
boolean enabled();

/**
* Filesystem directory overriding the bundled pricing snapshot. When set, files at
* {@code <path>/services.json}, {@code <path>/products/<service>/<region>.json},
* {@code <path>/attribute-values/<service>/<attribute>.json}, and
* {@code <path>/price-lists/<service>.json} are read in preference to the classpath copy.
*/
Optional<String> snapshotPath();
}

interface EcrServiceConfig {
@WithDefault("true")
boolean enabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import io.github.hectorvent.floci.services.firehose.FirehoseJsonHandler;
import io.github.hectorvent.floci.services.glue.GlueJsonHandler;
import io.github.hectorvent.floci.services.resourcegroupstagging.ResourceGroupsTaggingJsonHandler;
import io.github.hectorvent.floci.services.pricing.PricingJsonHandler;
import io.github.hectorvent.floci.services.textract.TextractJsonHandler;
import io.github.hectorvent.floci.services.apigatewayv2.ApiGatewayV2JsonHandler;
import io.github.hectorvent.floci.services.cloudwatch.logs.CloudWatchLogsHandler;
Expand Down Expand Up @@ -66,6 +67,7 @@ public class AwsJson11Controller {
private final Ec2MessagesJsonHandler ec2MessagesJsonHandler;
private final TransferHandler transferHandler;
private final TextractJsonHandler textractJsonHandler;
private final PricingJsonHandler pricingJsonHandler;

@Inject
public AwsJson11Controller(ObjectMapper objectMapper, ResolvedServiceCatalog catalog,
Expand All @@ -85,7 +87,8 @@ public AwsJson11Controller(ObjectMapper objectMapper, ResolvedServiceCatalog cat
CodeDeployJsonHandler codeDeployJsonHandler,
Ec2MessagesJsonHandler ec2MessagesJsonHandler,
TransferHandler transferHandler,
TextractJsonHandler textractJsonHandler) {
TextractJsonHandler textractJsonHandler,
PricingJsonHandler pricingJsonHandler) {
this.objectMapper = objectMapper;
this.catalog = catalog;
this.regionResolver = regionResolver;
Expand All @@ -109,6 +112,7 @@ public AwsJson11Controller(ObjectMapper objectMapper, ResolvedServiceCatalog cat
this.ec2MessagesJsonHandler = ec2MessagesJsonHandler;
this.transferHandler = transferHandler;
this.textractJsonHandler = textractJsonHandler;
this.pricingJsonHandler = pricingJsonHandler;
}

@POST
Expand Down Expand Up @@ -157,6 +161,7 @@ public Response handle(
case "ec2messages" -> ec2MessagesJsonHandler.handle(action, request, region);
case "transfer" -> transferHandler.handle(action, request, region);
case "textract" -> textractJsonHandler.handle(action, request, region);
case "pricing" -> pricingJsonHandler.handle(action, request, region);
default -> null;
};
// catalog.matchTarget is protocol-agnostic: a JSON 1.0 target
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,11 @@ public ResolvedServiceCatalog(EmulatorConfig config) {
descriptor("textract", "textract", config.services().textract().enabled(), true,
null, null, 5000L, null, ServiceProtocol.JSON,
protocols(ServiceProtocol.JSON),
Set.of("Textract."), Set.of("textract"), Set.of(), Set.of())
Set.of("Textract."), Set.of("textract"), Set.of(), Set.of()),
descriptor("pricing", "pricing", config.services().pricing().enabled(), true,
null, null, 5000L, null, ServiceProtocol.JSON,
protocols(ServiceProtocol.JSON),
Set.of("AWSPriceListService."), Set.of("pricing", "api.pricing"), Set.of(), Set.of())
));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package io.github.hectorvent.floci.services.pricing;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.hectorvent.floci.core.common.AwsErrorResponse;
import io.github.hectorvent.floci.core.common.AwsException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;

/**
* JSON 1.1 handler for AWS Price List Service operations.
* Dispatches {@code X-Amz-Target: AWSPriceListService.*} actions to {@link PricingService}.
*
* @see <a href="https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_Operations_AWS_Price_List_Service.html">AWS Price List Service API</a>
*/
@ApplicationScoped
public class PricingJsonHandler {

private static final Logger LOG = Logger.getLogger(PricingJsonHandler.class);

private final PricingService service;

@Inject
public PricingJsonHandler(PricingService service) {
this.service = service;
}

public Response handle(String action, JsonNode request, String region) {
LOG.debugv("Pricing action: {0}", action);
return switch (action) {
case "DescribeServices" -> handleDescribeServices(request);
case "GetAttributeValues" -> handleGetAttributeValues(request);
case "GetProducts" -> handleGetProducts(request);
case "ListPriceLists" -> handleListPriceLists(request);
case "GetPriceListFileUrl" -> handleGetPriceListFileUrl(request);
default -> Response.status(400)
.entity(new AwsErrorResponse("UnknownOperationException",
"Unknown operation: AWSPriceListService." + action))
.build();
};
}

private Response handleDescribeServices(JsonNode request) {
ObjectNode response = service.describeServices(
stringOrNull(request, "ServiceCode"),
stringOrNull(request, "FormatVersion"),
stringOrNull(request, "NextToken"),
integerOrNull(request, "MaxResults"));
return Response.ok(response).build();
}

private Response handleGetAttributeValues(JsonNode request) {
ObjectNode response = service.getAttributeValues(
stringOrNull(request, "ServiceCode"),
stringOrNull(request, "AttributeName"),
stringOrNull(request, "NextToken"),
integerOrNull(request, "MaxResults"));
return Response.ok(response).build();
}

private Response handleGetProducts(JsonNode request) {
ObjectNode response = service.getProducts(
stringOrNull(request, "ServiceCode"),
parseFilters(request.path("Filters")),
stringOrNull(request, "FormatVersion"),
stringOrNull(request, "NextToken"),
integerOrNull(request, "MaxResults"));
return Response.ok(response).build();
}

private Response handleListPriceLists(JsonNode request) {
ObjectNode response = service.listPriceLists(
stringOrNull(request, "ServiceCode"),
timestampOrNull(request, "EffectiveDate"),
stringOrNull(request, "RegionCode"),
stringOrNull(request, "CurrencyCode"),
stringOrNull(request, "NextToken"),
integerOrNull(request, "MaxResults"));
return Response.ok(response).build();
}

private Response handleGetPriceListFileUrl(JsonNode request) {
ObjectNode response = service.getPriceListFileUrl(
stringOrNull(request, "PriceListArn"),
stringOrNull(request, "FileFormat"));
return Response.ok(response).build();
}

private static List<PricingService.FilterSpec> parseFilters(JsonNode node) {
List<PricingService.FilterSpec> out = new ArrayList<>();
if (node == null || !node.isArray()) {
return out;
}
for (JsonNode entry : node) {
String field = nonBlankOrNull(entry, "Field");
String value = nonBlankOrNull(entry, "Value");
if (field == null) {
throw new AwsException("InvalidParameterException",
"Filter entries must include a non-empty 'Field'.", 400);
}
if (value == null) {
throw new AwsException("InvalidParameterException",
"Filter entries must include a non-empty 'Value'.", 400);
}
out.add(new PricingService.FilterSpec(
entry.path("Type").asText("TERM_MATCH"),
field,
value));
}
return out;
}

private static String stringOrNull(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return (value != null && !value.isNull()) ? value.asText() : null;
}

private static String nonBlankOrNull(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText();
return (text == null || text.isEmpty()) ? null : text;
}

private static Integer integerOrNull(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return (value != null && value.isNumber()) ? value.asInt() : null;
}

/**
* Reads an AWS-style timestamp field. The JSON 1.1 wire format for AWS
* Pricing encodes timestamps as Unix epoch seconds (JSON number); SDK and
* CLI clients also sometimes send ISO-8601 strings. Both are accepted.
*/
private static Instant timestampOrNull(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
// AWS encodes epoch seconds (may include fractional part).
double seconds = value.asDouble();
long wholeSeconds = (long) seconds;
long nanos = (long) Math.round((seconds - wholeSeconds) * 1_000_000_000L);
return Instant.ofEpochSecond(wholeSeconds, nanos);
}
String text = value.asText();
if (text == null || text.isEmpty()) {
return null;
}
try {
return OffsetDateTime.parse(text).toInstant();
} catch (DateTimeParseException e1) {
try {
return Instant.parse(text);
} catch (DateTimeParseException e2) {
throw new AwsException("ValidationException",
field + " must be an ISO-8601 timestamp or Unix epoch seconds.", 400);
}
}
}
}
Loading
Loading