diff --git a/README.md b/README.md index 433290b1e..817c65eaa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/services/pricing.md b/docs/services/pricing.md new file mode 100644 index 000000000..417c46ba1 --- /dev/null +++ b/docs/services/pricing.md @@ -0,0 +1,115 @@ +# Pricing (AWS Price List Service) + +**Protocol:** JSON 1.1 +**Header:** `X-Amz-Target: AWSPriceListService.` +**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): + +``` +/ + services.json # [ { "ServiceCode": "...", "AttributeNames": [...] } ] + attribute-values//.json # [ { "Value": "..." } ] + products//.json # [ { "product": {...}, "terms": {...}, ... } ] + price-lists/.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. diff --git a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java index c12839261..6d029fe10 100644 --- a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java +++ b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java @@ -300,6 +300,7 @@ interface ServicesConfig { Route53ServiceConfig route53(); TransferServiceConfig transfer(); TextractServiceConfig textract(); + PricingServiceConfig pricing(); } interface TransferServiceConfig { @@ -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 /services.json}, {@code /products//.json}, + * {@code /attribute-values//.json}, and + * {@code /price-lists/.json} are read in preference to the classpath copy. + */ + Optional snapshotPath(); + } + interface EcrServiceConfig { @WithDefault("true") boolean enabled(); diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java index 30b69be19..c2a8df4c6 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java @@ -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; @@ -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, @@ -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; @@ -109,6 +112,7 @@ public AwsJson11Controller(ObjectMapper objectMapper, ResolvedServiceCatalog cat this.ec2MessagesJsonHandler = ec2MessagesJsonHandler; this.transferHandler = transferHandler; this.textractJsonHandler = textractJsonHandler; + this.pricingJsonHandler = pricingJsonHandler; } @POST @@ -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 diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java index 3ea52c040..1b43765e2 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java @@ -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()) )); } diff --git a/src/main/java/io/github/hectorvent/floci/services/pricing/PricingJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/pricing/PricingJsonHandler.java new file mode 100644 index 000000000..38418d19f --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/pricing/PricingJsonHandler.java @@ -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 AWS Price List Service API + */ +@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 parseFilters(JsonNode node) { + List 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); + } + } + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/pricing/PricingService.java b/src/main/java/io/github/hectorvent/floci/services/pricing/PricingService.java new file mode 100644 index 000000000..26c9bd1f3 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/pricing/PricingService.java @@ -0,0 +1,507 @@ +package io.github.hectorvent.floci.services.pricing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.config.EmulatorConfig; +import io.github.hectorvent.floci.core.common.AwsException; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AWS Pricing API emulation backed by a bundled static snapshot. + *

+ * Snapshots ship on the classpath under {@code pricing-snapshots/} and can be + * overridden at runtime by setting {@code floci.services.pricing.snapshot-path} + * (env: {@code FLOCI_SERVICES_PRICING_SNAPSHOT_PATH}) to a filesystem directory + * with the same layout. + * + * @see AWS Price List Service API + */ +@ApplicationScoped +public class PricingService { + + private static final Logger LOG = Logger.getLogger(PricingService.class); + + static final String DEFAULT_FORMAT_VERSION = "aws_v1"; + private static final String SERVICES_FILE = "services.json"; + private static final String ATTRIBUTE_VALUES_DIR = "attribute-values"; + private static final String PRODUCTS_DIR = "products"; + private static final String PRICE_LISTS_DIR = "price-lists"; + + private final ObjectMapper objectMapper; + private final SnapshotLoader loader; + + private final Map servicesByCode = new HashMap<>(); + private final List servicesOrdered = new ArrayList<>(); + + @Inject + public PricingService(ObjectMapper objectMapper, EmulatorConfig config) { + this(objectMapper, new SnapshotLoader(config.services().pricing().snapshotPath().orElse(null))); + } + + PricingService(ObjectMapper objectMapper, SnapshotLoader loader) { + this.objectMapper = objectMapper; + this.loader = loader; + } + + @PostConstruct + void load() { + try { + JsonNode servicesNode = loader.readJson(SERVICES_FILE, objectMapper); + if (servicesNode == null || !servicesNode.isArray()) { + LOG.warnv("Pricing snapshot {0} not found or malformed; service will respond with empty results.", SERVICES_FILE); + return; + } + for (JsonNode entry : servicesNode) { + String code = entry.path("ServiceCode").asText(null); + if (code == null || code.isEmpty()) { + continue; + } + List attrs = new ArrayList<>(); + for (JsonNode a : entry.path("AttributeNames")) { + attrs.add(a.asText()); + } + ServiceEntry se = new ServiceEntry(code, attrs); + servicesByCode.put(code, se); + servicesOrdered.add(se); + } + LOG.infov("Pricing snapshot loaded: {0} services.", servicesOrdered.size()); + } catch (IOException e) { + LOG.errorv(e, "Failed to load pricing snapshot from {0}", loader.describe()); + } + } + + /** Returns response for {@code DescribeServices}. */ + public ObjectNode describeServices(String serviceCode, String formatVersion, String nextToken, Integer maxResults) { + List slice; + if (serviceCode != null && !serviceCode.isEmpty()) { + ServiceEntry entry = servicesByCode.get(serviceCode); + if (entry == null) { + throw new AwsException("InvalidParameterException", + "Invalid ServiceCode: " + serviceCode, 400); + } + slice = List.of(entry); + } else { + slice = servicesOrdered; + } + + Page page = paginate(slice, nextToken, maxResults); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("FormatVersion", resolveFormatVersion(formatVersion)); + ArrayNode services = response.putArray("Services"); + for (ServiceEntry entry : page.items()) { + ObjectNode node = services.addObject(); + node.put("ServiceCode", entry.serviceCode()); + ArrayNode attrs = node.putArray("AttributeNames"); + for (String attr : entry.attributeNames()) { + attrs.add(attr); + } + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return response; + } + + /** Returns response for {@code GetAttributeValues}. */ + public ObjectNode getAttributeValues(String serviceCode, String attributeName, String nextToken, Integer maxResults) { + requireNonEmpty(serviceCode, "ServiceCode"); + requireNonEmpty(attributeName, "AttributeName"); + requireSafePathSegment(serviceCode, "ServiceCode"); + requireSafePathSegment(attributeName, "AttributeName"); + + if (!servicesByCode.containsKey(serviceCode)) { + throw new AwsException("InvalidParameterException", + "Invalid ServiceCode: " + serviceCode, 400); + } + + String resource = ATTRIBUTE_VALUES_DIR + "/" + serviceCode + "/" + attributeName + ".json"; + JsonNode node; + try { + node = loader.readJson(resource, objectMapper); + } catch (IOException e) { + throw new AwsException("InternalFailure", "Failed to read snapshot: " + resource, 500); + } + + List values = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode v : node) { + JsonNode value = v.path("Value"); + if (!value.isMissingNode() && !value.isNull()) { + values.add(value.asText()); + } + } + } + + Page page = paginate(values, nextToken, maxResults); + + ObjectNode response = objectMapper.createObjectNode(); + ArrayNode arr = response.putArray("AttributeValues"); + for (String v : page.items()) { + arr.addObject().put("Value", v); + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return response; + } + + /** + * Returns response for {@code GetProducts}. + *

+ * The AWS wire format returns {@code PriceList} as an array of JSON strings + * (each string is a serialized product offer). Each element is also valid JSON on its own. + */ + public ObjectNode getProducts(String serviceCode, List filters, String formatVersion, + String nextToken, Integer maxResults) { + requireNonEmpty(serviceCode, "ServiceCode"); + requireSafePathSegment(serviceCode, "ServiceCode"); + if (!servicesByCode.containsKey(serviceCode)) { + throw new AwsException("InvalidParameterException", + "Invalid ServiceCode: " + serviceCode, 400); + } + + String region = resolveRegionFromFilters(filters); + if (region != null) { + requireSafePathSegment(region, "regionCode"); + } + List products = loadProducts(serviceCode, region); + List matched = applyFilters(products, filters); + + Page page = paginate(matched, nextToken, maxResults); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("FormatVersion", resolveFormatVersion(formatVersion)); + ArrayNode priceList = response.putArray("PriceList"); + for (JsonNode product : page.items()) { + try { + priceList.add(objectMapper.writeValueAsString(product)); + } catch (Exception e) { + throw new AwsException("InternalFailure", "Failed to serialize product", 500); + } + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return response; + } + + /** Returns response for {@code ListPriceLists}. */ + public ObjectNode listPriceLists(String serviceCode, Instant effectiveDate, String regionCode, + String currencyCode, String nextToken, Integer maxResults) { + requireNonEmpty(serviceCode, "ServiceCode"); + requireNonEmpty(currencyCode, "CurrencyCode"); + requireSafePathSegment(serviceCode, "ServiceCode"); + if (effectiveDate == null) { + throw new AwsException("ValidationException", + "1 validation error detected: Value at 'EffectiveDate' failed to satisfy constraint: Member must not be null.", 400); + } + + if (!servicesByCode.containsKey(serviceCode)) { + throw new AwsException("InvalidParameterException", + "Invalid ServiceCode: " + serviceCode, 400); + } + + Instant effectiveInstant = effectiveDate; + + String resource = PRICE_LISTS_DIR + "/" + serviceCode + ".json"; + JsonNode node; + try { + node = loader.readJson(resource, objectMapper); + } catch (IOException e) { + throw new AwsException("InternalFailure", "Failed to read snapshot: " + resource, 500); + } + + // Group entries per (region, currency) and keep the one whose + // effective window covers `effectiveDate`. AWS returns one price list + // per service/region/currency pair, matched by effective date. + Map winningByKey = new java.util.LinkedHashMap<>(); + Map winningEffective = new HashMap<>(); + if (node != null && node.isArray()) { + for (JsonNode entry : node) { + String entryRegion = entry.path("RegionCode").asText(""); + String entryCurrency = entry.path("CurrencyCode").asText(""); + if (regionCode != null && !regionCode.isEmpty() && !regionCode.equals(entryRegion)) { + continue; + } + if (!currencyCode.equals(entryCurrency)) { + continue; + } + Instant entryEffective = resolvePriceListEffective(entry); + if (entryEffective == null || entryEffective.isAfter(effectiveInstant)) { + // Entry's window has not started yet. + continue; + } + String key = entryRegion + "|" + entryCurrency; + Instant current = winningEffective.get(key); + if (current == null || entryEffective.isAfter(current)) { + winningByKey.put(key, entry); + winningEffective.put(key, entryEffective); + } + } + } + List entries = new ArrayList<>(winningByKey.values()); + + Page page = paginate(entries, nextToken, maxResults); + + ObjectNode response = objectMapper.createObjectNode(); + ArrayNode arr = response.putArray("PriceLists"); + for (JsonNode entry : page.items()) { + ObjectNode out = arr.addObject(); + out.put("PriceListArn", entry.path("PriceListArn").asText()); + out.put("RegionCode", entry.path("RegionCode").asText()); + out.put("CurrencyCode", entry.path("CurrencyCode").asText()); + ArrayNode formats = out.putArray("FileFormats"); + for (JsonNode f : entry.path("FileFormats")) { + formats.add(f.asText()); + } + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return response; + } + + /** + * Returns response for {@code GetPriceListFileUrl}. Returns a stub HTTPS URL that + * points back at the configured Pricing snapshot; sufficient for integration tests + * that assert a URL is returned but do not download it. + */ + public ObjectNode getPriceListFileUrl(String priceListArn, String fileFormat) { + requireNonEmpty(priceListArn, "PriceListArn"); + requireNonEmpty(fileFormat, "FileFormat"); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("Url", "https://pricing-snapshot.floci.local/" + + java.net.URLEncoder.encode(priceListArn, java.nio.charset.StandardCharsets.UTF_8) + + "/" + fileFormat.toLowerCase()); + return response; + } + + private List loadProducts(String serviceCode, String region) { + String resolvedRegion = (region == null || region.isEmpty()) ? "us-east-1" : region; + requireSafePathSegment(resolvedRegion, "regionCode"); + String resource = PRODUCTS_DIR + "/" + serviceCode + "/" + resolvedRegion + ".json"; + JsonNode node; + try { + node = loader.readJson(resource, objectMapper); + } catch (IOException e) { + throw new AwsException("InternalFailure", "Failed to read snapshot: " + resource, 500); + } + List products = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode p : node) { + products.add(p); + } + } + return products; + } + + private static List applyFilters(List products, List filters) { + if (filters == null || filters.isEmpty()) { + return products; + } + List out = new ArrayList<>(); + for (JsonNode product : products) { + JsonNode attrs = product.path("product").path("attributes"); + boolean match = true; + for (FilterSpec filter : filters) { + JsonNode attrValue = attrs.path(filter.field()); + if (attrValue.isMissingNode() || attrValue.isNull() + || !filter.value().equals(attrValue.asText())) { + match = false; + break; + } + } + if (match) { + out.add(product); + } + } + return out; + } + + private static String resolveRegionFromFilters(List filters) { + if (filters == null) { + return null; + } + for (FilterSpec f : filters) { + if ("regionCode".equals(f.field())) { + return f.value(); + } + } + return null; + } + + private static String resolveFormatVersion(String requested) { + return (requested == null || requested.isEmpty()) ? DEFAULT_FORMAT_VERSION : requested; + } + + private static Page paginate(List items, String nextToken, Integer maxResults) { + int start = decodeToken(nextToken); + if (start < 0 || start > items.size()) { + throw new AwsException("ExpiredNextTokenException", "Invalid NextToken.", 400); + } + int limit = (maxResults == null || maxResults <= 0) ? items.size() : Math.min(maxResults, items.size() - start); + int end = Math.min(items.size(), start + limit); + List sliced = items.subList(start, end); + String next = (end < items.size()) ? encodeToken(end) : null; + return new Page<>(sliced, next); + } + + private static String encodeToken(int offset) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(Integer.toString(offset).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private static int decodeToken(String token) { + if (token == null || token.isEmpty()) { + return 0; + } + try { + byte[] decoded = Base64.getUrlDecoder().decode(token); + return Integer.parseInt(new String(decoded, java.nio.charset.StandardCharsets.UTF_8)); + } catch (IllegalArgumentException e) { + return -1; + } + } + + private static void requireNonEmpty(String value, String field) { + if (value == null || value.isEmpty()) { + throw new AwsException("ValidationException", + "1 validation error detected: Value at '" + field + "' failed to satisfy constraint: Member must not be null.", 400); + } + } + + /** + * Rejects any input used to build a snapshot filesystem path that contains + * characters outside {@code [A-Za-z0-9._-]}, to prevent traversal of the + * override directory via {@code ..} or absolute-path segments. + * AWS service codes, attribute names, and region codes all satisfy this + * charset; values that don't cannot match anything in the catalog anyway. + */ + private static void requireSafePathSegment(String value, String field) { + if (value == null || value.isEmpty() || !SAFE_PATH_SEGMENT.matcher(value).matches()) { + throw new AwsException("InvalidParameterException", + "Invalid value for '" + field + "': contains characters not permitted in a path segment.", 400); + } + } + + /** + * Extracts the effective-date instant for a price-list snapshot entry. + * Prefers the {@code EffectiveDate} field when present; otherwise parses the + * 14-digit {@code yyyyMMddHHmmss} segment from the {@code PriceListArn}. + */ + private static Instant resolvePriceListEffective(JsonNode entry) { + JsonNode effective = entry.path("EffectiveDate"); + if (!effective.isMissingNode() && !effective.isNull() && !effective.asText().isEmpty()) { + try { + return java.time.OffsetDateTime.parse(effective.asText()).toInstant(); + } catch (java.time.format.DateTimeParseException ignored) { + try { + return Instant.parse(effective.asText()); + } catch (java.time.format.DateTimeParseException ignored2) { + // fall through to ARN parsing + } + } + } + String arn = entry.path("PriceListArn").asText(""); + java.util.regex.Matcher m = ARN_TIMESTAMP.matcher(arn); + if (m.find()) { + String ts = m.group(1); + try { + return java.time.LocalDateTime.parse(ts, ARN_TIMESTAMP_FMT) + .toInstant(java.time.ZoneOffset.UTC); + } catch (java.time.format.DateTimeParseException ignored) { + // fall through + } + } + return null; + } + + private static final java.util.regex.Pattern SAFE_PATH_SEGMENT = + java.util.regex.Pattern.compile("[A-Za-z0-9._-]+"); + private static final java.util.regex.Pattern ARN_TIMESTAMP = + java.util.regex.Pattern.compile("/(\\d{14})(?:/|$)"); + private static final java.time.format.DateTimeFormatter ARN_TIMESTAMP_FMT = + java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + record ServiceEntry(String serviceCode, List attributeNames) { + } + + /** Single filter clause from {@code GetProducts} input. */ + public record FilterSpec(String type, String field, String value) { + } + + private record Page(List items, String nextToken) { + } + + /** + * Loads snapshot JSON from either a filesystem override directory or the classpath. + * Package-private to permit substitution in tests. + */ + static final class SnapshotLoader { + private final Path overrideRoot; + + SnapshotLoader(String overridePath) { + if (overridePath == null || overridePath.isEmpty()) { + this.overrideRoot = null; + } else { + Path root = Path.of(overridePath).toAbsolutePath().normalize(); + try { + // Resolve symlinks when the directory exists so that the + // containment check below compares real paths. + if (Files.isDirectory(root)) { + root = root.toRealPath(); + } + } catch (IOException e) { + // Fall back to the normalized path; containment check will + // still apply string-level to the resolved candidate. + } + this.overrideRoot = root; + } + } + + JsonNode readJson(String relativePath, ObjectMapper mapper) throws IOException { + if (overrideRoot != null) { + Path candidate = overrideRoot.resolve(relativePath).normalize(); + if (!candidate.startsWith(overrideRoot)) { + // Path traversal attempt — refuse silently and fall through + // to the classpath fallback (which is read-only and safe). + candidate = null; + } + if (candidate != null && Files.isRegularFile(candidate)) { + try (InputStream in = Files.newInputStream(candidate)) { + return mapper.readTree(in); + } + } + } + String resource = "pricing-snapshots/" + relativePath; + try (InputStream in = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource)) { + if (in == null) { + return null; + } + return mapper.readTree(in); + } + } + + String describe() { + return overrideRoot != null ? overrideRoot.toString() : "classpath:pricing-snapshots/"; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2e3275f85..adc441521 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -258,3 +258,5 @@ floci: enabled: true textract: enabled: true + pricing: + enabled: true diff --git a/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/group.json b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/group.json new file mode 100644 index 000000000..1e6d07afc --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/group.json @@ -0,0 +1,5 @@ +[ + {"Value": "AWS-Lambda-Requests"}, + {"Value": "AWS-Lambda-Duration"}, + {"Value": "AWS-Lambda-Storage"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/groupDescription.json b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/groupDescription.json new file mode 100644 index 000000000..d7f759f29 --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/groupDescription.json @@ -0,0 +1,4 @@ +[ + {"Value": "Invocation call for a Lambda function"}, + {"Value": "Duration of a Lambda function"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/location.json b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/location.json new file mode 100644 index 000000000..32150edac --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/location.json @@ -0,0 +1,5 @@ +[ + {"Value": "US East (N. Virginia)"}, + {"Value": "US West (Oregon)"}, + {"Value": "EU (Ireland)"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/productFamily.json b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/productFamily.json new file mode 100644 index 000000000..bbe23485a --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AWSLambda/productFamily.json @@ -0,0 +1,3 @@ +[ + {"Value": "Serverless"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/instanceType.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/instanceType.json new file mode 100644 index 000000000..aacb78ded --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/instanceType.json @@ -0,0 +1,10 @@ +[ + {"Value": "t3.nano"}, + {"Value": "t3.micro"}, + {"Value": "t3.small"}, + {"Value": "t3.medium"}, + {"Value": "m5.large"}, + {"Value": "m5.xlarge"}, + {"Value": "c5.large"}, + {"Value": "r5.large"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/location.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/location.json new file mode 100644 index 000000000..42bd5833a --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/location.json @@ -0,0 +1,7 @@ +[ + {"Value": "US East (N. Virginia)"}, + {"Value": "US East (Ohio)"}, + {"Value": "US West (Oregon)"}, + {"Value": "EU (Ireland)"}, + {"Value": "EU (Frankfurt)"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/operatingSystem.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/operatingSystem.json new file mode 100644 index 000000000..cede53073 --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/operatingSystem.json @@ -0,0 +1,6 @@ +[ + {"Value": "Linux"}, + {"Value": "Windows"}, + {"Value": "RHEL"}, + {"Value": "SUSE"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/productFamily.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/productFamily.json new file mode 100644 index 000000000..c676b6f2f --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/productFamily.json @@ -0,0 +1,5 @@ +[ + {"Value": "Compute Instance"}, + {"Value": "Storage"}, + {"Value": "Data Transfer"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/tenancy.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/tenancy.json new file mode 100644 index 000000000..f302953a1 --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonEC2/tenancy.json @@ -0,0 +1,5 @@ +[ + {"Value": "Shared"}, + {"Value": "Dedicated"}, + {"Value": "Host"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/location.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/location.json new file mode 100644 index 000000000..32150edac --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/location.json @@ -0,0 +1,5 @@ +[ + {"Value": "US East (N. Virginia)"}, + {"Value": "US West (Oregon)"}, + {"Value": "EU (Ireland)"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/productFamily.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/productFamily.json new file mode 100644 index 000000000..5da9d92db --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/productFamily.json @@ -0,0 +1,5 @@ +[ + {"Value": "Storage"}, + {"Value": "API Request"}, + {"Value": "Data Transfer"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/storageClass.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/storageClass.json new file mode 100644 index 000000000..76c130eb7 --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/storageClass.json @@ -0,0 +1,6 @@ +[ + {"Value": "General Purpose"}, + {"Value": "Infrequent Access"}, + {"Value": "Archive"}, + {"Value": "Intelligent-Tiering"} +] diff --git a/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/volumeType.json b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/volumeType.json new file mode 100644 index 000000000..68b4dbbca --- /dev/null +++ b/src/main/resources/pricing-snapshots/attribute-values/AmazonS3/volumeType.json @@ -0,0 +1,7 @@ +[ + {"Value": "Standard"}, + {"Value": "Standard - Infrequent Access"}, + {"Value": "One Zone - Infrequent Access"}, + {"Value": "Glacier Flexible Retrieval"}, + {"Value": "Glacier Deep Archive"} +] diff --git a/src/main/resources/pricing-snapshots/price-lists/AWSLambda.json b/src/main/resources/pricing-snapshots/price-lists/AWSLambda.json new file mode 100644 index 000000000..b0ef990c4 --- /dev/null +++ b/src/main/resources/pricing-snapshots/price-lists/AWSLambda.json @@ -0,0 +1,9 @@ +[ + { + "PriceListArn": "arn:aws:pricing:::price-list/aws/AWSLambda/USD/20260101000000/us-east-1", + "EffectiveDate": "2026-01-01T00:00:00Z", + "RegionCode": "us-east-1", + "CurrencyCode": "USD", + "FileFormats": ["json", "csv"] + } +] diff --git a/src/main/resources/pricing-snapshots/price-lists/AmazonEC2.json b/src/main/resources/pricing-snapshots/price-lists/AmazonEC2.json new file mode 100644 index 000000000..48b3b8c45 --- /dev/null +++ b/src/main/resources/pricing-snapshots/price-lists/AmazonEC2.json @@ -0,0 +1,23 @@ +[ + { + "PriceListArn": "arn:aws:pricing:::price-list/aws/AmazonEC2/USD/20260101000000/us-east-1", + "EffectiveDate": "2026-01-01T00:00:00Z", + "RegionCode": "us-east-1", + "CurrencyCode": "USD", + "FileFormats": ["json", "csv"] + }, + { + "PriceListArn": "arn:aws:pricing:::price-list/aws/AmazonEC2/USD/20260401000000/us-east-1", + "EffectiveDate": "2026-04-01T00:00:00Z", + "RegionCode": "us-east-1", + "CurrencyCode": "USD", + "FileFormats": ["json", "csv"] + }, + { + "PriceListArn": "arn:aws:pricing:::price-list/aws/AmazonEC2/USD/20260101000000/us-west-2", + "EffectiveDate": "2026-01-01T00:00:00Z", + "RegionCode": "us-west-2", + "CurrencyCode": "USD", + "FileFormats": ["json", "csv"] + } +] diff --git a/src/main/resources/pricing-snapshots/price-lists/AmazonS3.json b/src/main/resources/pricing-snapshots/price-lists/AmazonS3.json new file mode 100644 index 000000000..b0ca3bbec --- /dev/null +++ b/src/main/resources/pricing-snapshots/price-lists/AmazonS3.json @@ -0,0 +1,9 @@ +[ + { + "PriceListArn": "arn:aws:pricing:::price-list/aws/AmazonS3/USD/20260101000000/us-east-1", + "EffectiveDate": "2026-01-01T00:00:00Z", + "RegionCode": "us-east-1", + "CurrencyCode": "USD", + "FileFormats": ["json", "csv"] + } +] diff --git a/src/main/resources/pricing-snapshots/products/AWSLambda/us-east-1.json b/src/main/resources/pricing-snapshots/products/AWSLambda/us-east-1.json new file mode 100644 index 000000000..85cdbf770 --- /dev/null +++ b/src/main/resources/pricing-snapshots/products/AWSLambda/us-east-1.json @@ -0,0 +1,40 @@ +[ + { + "product": { + "productFamily": "Serverless", + "attributes": { + "servicecode": "AWSLambda", + "location": "US East (N. Virginia)", + "locationType": "AWS Region", + "group": "AWS-Lambda-Requests", + "groupDescription": "Invocation call for a Lambda function", + "regionCode": "us-east-1" + }, + "sku": "FLOCI-LAMBDA-REQ-USE1" + }, + "publicationDate": "2026-01-01T00:00:00Z", + "serviceCode": "AWSLambda", + "terms": { + "OnDemand": { + "FLOCI-LAMBDA-REQ-USE1.ONDEMAND": { + "priceDimensions": { + "FLOCI-LAMBDA-REQ-USE1.ONDEMAND.DIM1": { + "unit": "Requests", + "endRange": "Inf", + "description": "$0.20 per 1M requests", + "appliesTo": [], + "rateCode": "FLOCI-LAMBDA-REQ-USE1.ONDEMAND.DIM1", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0000002000"} + } + }, + "sku": "FLOCI-LAMBDA-REQ-USE1", + "effectiveDate": "2026-01-01T00:00:00Z", + "offerTermCode": "ONDEMAND", + "termAttributes": {} + } + } + }, + "version": "20260101000000" + } +] diff --git a/src/main/resources/pricing-snapshots/products/AmazonEC2/us-east-1.json b/src/main/resources/pricing-snapshots/products/AmazonEC2/us-east-1.json new file mode 100644 index 000000000..6c2bfad1e --- /dev/null +++ b/src/main/resources/pricing-snapshots/products/AmazonEC2/us-east-1.json @@ -0,0 +1,125 @@ +[ + { + "product": { + "productFamily": "Compute Instance", + "attributes": { + "servicecode": "AmazonEC2", + "location": "US East (N. Virginia)", + "locationType": "AWS Region", + "instanceType": "t3.micro", + "tenancy": "Shared", + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + "regionCode": "us-east-1" + }, + "sku": "FLOCI-EC2-T3MICRO-USE1" + }, + "publicationDate": "2026-01-01T00:00:00Z", + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "FLOCI-EC2-T3MICRO-USE1.ONDEMAND": { + "priceDimensions": { + "FLOCI-EC2-T3MICRO-USE1.ONDEMAND.DIM1": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.0104 per On Demand Linux t3.micro Instance Hour", + "appliesTo": [], + "rateCode": "FLOCI-EC2-T3MICRO-USE1.ONDEMAND.DIM1", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0104000000"} + } + }, + "sku": "FLOCI-EC2-T3MICRO-USE1", + "effectiveDate": "2026-01-01T00:00:00Z", + "offerTermCode": "ONDEMAND", + "termAttributes": {} + } + } + }, + "version": "20260101000000" + }, + { + "product": { + "productFamily": "Compute Instance", + "attributes": { + "servicecode": "AmazonEC2", + "location": "US East (N. Virginia)", + "locationType": "AWS Region", + "instanceType": "m5.large", + "tenancy": "Shared", + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + "regionCode": "us-east-1" + }, + "sku": "FLOCI-EC2-M5LARGE-USE1" + }, + "publicationDate": "2026-01-01T00:00:00Z", + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "FLOCI-EC2-M5LARGE-USE1.ONDEMAND": { + "priceDimensions": { + "FLOCI-EC2-M5LARGE-USE1.ONDEMAND.DIM1": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.096 per On Demand Linux m5.large Instance Hour", + "appliesTo": [], + "rateCode": "FLOCI-EC2-M5LARGE-USE1.ONDEMAND.DIM1", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0960000000"} + } + }, + "sku": "FLOCI-EC2-M5LARGE-USE1", + "effectiveDate": "2026-01-01T00:00:00Z", + "offerTermCode": "ONDEMAND", + "termAttributes": {} + } + } + }, + "version": "20260101000000" + }, + { + "product": { + "productFamily": "Compute Instance", + "attributes": { + "servicecode": "AmazonEC2", + "location": "US East (N. Virginia)", + "locationType": "AWS Region", + "instanceType": "c5.large", + "tenancy": "Shared", + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + "regionCode": "us-east-1" + }, + "sku": "FLOCI-EC2-C5LARGE-USE1" + }, + "publicationDate": "2026-01-01T00:00:00Z", + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "FLOCI-EC2-C5LARGE-USE1.ONDEMAND": { + "priceDimensions": { + "FLOCI-EC2-C5LARGE-USE1.ONDEMAND.DIM1": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.085 per On Demand Linux c5.large Instance Hour", + "appliesTo": [], + "rateCode": "FLOCI-EC2-C5LARGE-USE1.ONDEMAND.DIM1", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0850000000"} + } + }, + "sku": "FLOCI-EC2-C5LARGE-USE1", + "effectiveDate": "2026-01-01T00:00:00Z", + "offerTermCode": "ONDEMAND", + "termAttributes": {} + } + } + }, + "version": "20260101000000" + } +] diff --git a/src/main/resources/pricing-snapshots/products/AmazonS3/us-east-1.json b/src/main/resources/pricing-snapshots/products/AmazonS3/us-east-1.json new file mode 100644 index 000000000..46a787fa2 --- /dev/null +++ b/src/main/resources/pricing-snapshots/products/AmazonS3/us-east-1.json @@ -0,0 +1,40 @@ +[ + { + "product": { + "productFamily": "Storage", + "attributes": { + "servicecode": "AmazonS3", + "location": "US East (N. Virginia)", + "locationType": "AWS Region", + "storageClass": "General Purpose", + "volumeType": "Standard", + "regionCode": "us-east-1" + }, + "sku": "FLOCI-S3-STANDARD-USE1" + }, + "publicationDate": "2026-01-01T00:00:00Z", + "serviceCode": "AmazonS3", + "terms": { + "OnDemand": { + "FLOCI-S3-STANDARD-USE1.ONDEMAND": { + "priceDimensions": { + "FLOCI-S3-STANDARD-USE1.ONDEMAND.DIM1": { + "unit": "GB-Mo", + "endRange": "51200", + "description": "$0.023 per GB - first 50 TB / month of storage used", + "appliesTo": [], + "rateCode": "FLOCI-S3-STANDARD-USE1.ONDEMAND.DIM1", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0230000000"} + } + }, + "sku": "FLOCI-S3-STANDARD-USE1", + "effectiveDate": "2026-01-01T00:00:00Z", + "offerTermCode": "ONDEMAND", + "termAttributes": {} + } + } + }, + "version": "20260101000000" + } +] diff --git a/src/main/resources/pricing-snapshots/services.json b/src/main/resources/pricing-snapshots/services.json new file mode 100644 index 000000000..582637cfe --- /dev/null +++ b/src/main/resources/pricing-snapshots/services.json @@ -0,0 +1,30 @@ +[ + { + "ServiceCode": "AmazonEC2", + "AttributeNames": [ + "instanceType", + "location", + "tenancy", + "operatingSystem", + "productFamily" + ] + }, + { + "ServiceCode": "AmazonS3", + "AttributeNames": [ + "storageClass", + "location", + "volumeType", + "productFamily" + ] + }, + { + "ServiceCode": "AWSLambda", + "AttributeNames": [ + "location", + "group", + "groupDescription", + "productFamily" + ] + } +] diff --git a/src/test/java/io/github/hectorvent/floci/services/pricing/PricingIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/pricing/PricingIntegrationTest.java new file mode 100644 index 000000000..98567f8e4 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/pricing/PricingIntegrationTest.java @@ -0,0 +1,450 @@ +package io.github.hectorvent.floci.services.pricing; + +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.*; + +/** + * Integration tests for the AWS Pricing (Price List Service) emulation. + * Validates AWS-compatible wire format using RestAssured. + * Protocol: JSON 1.1 — Content-Type: application/x-amz-json-1.1, + * X-Amz-Target: AWSPriceListService.<Action> + */ +@QuarkusTest +class PricingIntegrationTest { + + private static final String CONTENT_TYPE = "application/x-amz-json-1.1"; + private static final String AUTH_HEADER = + "AWS4-HMAC-SHA256 Credential=AKID/20260101/us-east-1/pricing/aws4_request"; + + @BeforeAll + static void configureRestAssured() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + @Test + void describeServices_noArgs_returnsBundledServices() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.DescribeServices") + .header("Authorization", AUTH_HEADER) + .body("{}") + .when() + .post("/") + .then() + .statusCode(200) + .body("FormatVersion", equalTo("aws_v1")) + .body("Services.ServiceCode", hasItems("AmazonEC2", "AmazonS3", "AWSLambda")) + .body("Services.find { it.ServiceCode == 'AmazonEC2' }.AttributeNames", + hasItems("instanceType", "location")); + } + + @Test + void describeServices_specificServiceCode_returnsSingleEntry() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.DescribeServices") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Services", hasSize(1)) + .body("Services[0].ServiceCode", equalTo("AmazonEC2")); + } + + @Test + void describeServices_unknownServiceCode_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.DescribeServices") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonBogus\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("InvalidParameterException")); + } + + @Test + void getAttributeValues_instanceType_returnsValues() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetAttributeValues") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"AttributeName\":\"instanceType\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("AttributeValues.Value", hasItems("t3.micro", "m5.large")); + } + + @Test + void getAttributeValues_missingServiceCode_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetAttributeValues") + .header("Authorization", AUTH_HEADER) + .body("{\"AttributeName\":\"instanceType\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("ValidationException")); + } + + @Test + void getAttributeValues_unknownAttribute_returnsEmpty() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetAttributeValues") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"AttributeName\":\"unknownAttr\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("AttributeValues", hasSize(0)); + } + + @Test + void getProducts_filtersByInstanceType() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"Filters\":[" + + "{\"Type\":\"TERM_MATCH\",\"Field\":\"instanceType\",\"Value\":\"t3.micro\"}," + + "{\"Type\":\"TERM_MATCH\",\"Field\":\"regionCode\",\"Value\":\"us-east-1\"}]}") + .when() + .post("/") + .then() + .statusCode(200) + .body("FormatVersion", equalTo("aws_v1")) + .body("PriceList", hasSize(1)) + .body("PriceList[0]", containsString("t3.micro")) + .body("PriceList[0]", containsString("AmazonEC2")); + } + + @Test + void getProducts_noFilters_returnsAllForDefaultRegion() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceList", hasSize(3)); + } + + @Test + void getProducts_missingServiceCode_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("ValidationException")); + } + + @Test + void getProducts_unknownServiceCode_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonBogus\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("InvalidParameterException")); + } + + @Test + void listPriceLists_returnsMatchingRegion() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":\"2026-01-01T00:00:00Z\"," + + "\"CurrencyCode\":\"USD\",\"RegionCode\":\"us-east-1\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(1)) + .body("PriceLists[0].RegionCode", equalTo("us-east-1")) + .body("PriceLists[0].CurrencyCode", equalTo("USD")) + .body("PriceLists[0].FileFormats", hasItems("json", "csv")); + } + + @Test + void listPriceLists_withoutRegion_returnsAll() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":\"2026-01-01T00:00:00Z\"," + + "\"CurrencyCode\":\"USD\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(2)); + } + + @Test + void listPriceLists_missingEffectiveDate_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"CurrencyCode\":\"USD\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("ValidationException")); + } + + @Test + void getPriceListFileUrl_returnsStubUrl() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetPriceListFileUrl") + .header("Authorization", AUTH_HEADER) + .body("{\"PriceListArn\":\"arn:aws:pricing:::price-list/aws/AmazonEC2/USD/20260101000000/us-east-1\"," + + "\"FileFormat\":\"json\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Url", startsWith("https://pricing-snapshot.floci.local/")) + .body("Url", endsWith("/json")); + } + + @Test + void getPriceListFileUrl_missingArn_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetPriceListFileUrl") + .header("Authorization", AUTH_HEADER) + .body("{\"FileFormat\":\"json\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("ValidationException")); + } + + @Test + void unknownAction_returnsUnknownOperationError() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetBogusAction") + .header("Authorization", AUTH_HEADER) + .body("{}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("UnknownOperationException")); + } + + @Test + void getAttributeValues_rejectsPathTraversalInAttributeName() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetAttributeValues") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"AttributeName\":\"../../../../etc/passwd\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("InvalidParameterException")); + } + + @Test + void getProducts_rejectsPathTraversalInRegionFilter() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"Filters\":[" + + "{\"Type\":\"TERM_MATCH\",\"Field\":\"regionCode\",\"Value\":\"../../etc\"}]}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("InvalidParameterException")); + } + + @Test + void listPriceLists_selectsNewestVersionAtOrBeforeEffectiveDate() { + // Before the 2026-04-01 version begins, expect the 2026-01-01 ARN. + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":\"2026-02-15T00:00:00Z\"," + + "\"CurrencyCode\":\"USD\",\"RegionCode\":\"us-east-1\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(1)) + .body("PriceLists[0].PriceListArn", endsWith("20260101000000/us-east-1")); + + // After the 2026-04-01 version begins, expect that newer ARN. + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":\"2026-06-01T00:00:00Z\"," + + "\"CurrencyCode\":\"USD\",\"RegionCode\":\"us-east-1\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(1)) + .body("PriceLists[0].PriceListArn", endsWith("20260401000000/us-east-1")); + } + + @Test + void listPriceLists_excludesEntriesNotYetEffective() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":\"2025-01-01T00:00:00Z\"," + + "\"CurrencyCode\":\"USD\",\"RegionCode\":\"us-east-1\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(0)); + } + + @Test + void listPriceLists_malformedEffectiveDate_returns400() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":\"not-a-date\"," + + "\"CurrencyCode\":\"USD\"}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("ValidationException")); + } + + @Test + void listPriceLists_acceptsNumericEpochEffectiveDate() { + // 1767225600 = 2026-01-01T00:00:00Z; before 2026-04-01, so expect the older ARN. + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":1767225600," + + "\"CurrencyCode\":\"USD\",\"RegionCode\":\"us-east-1\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(1)) + .body("PriceLists[0].PriceListArn", endsWith("20260101000000/us-east-1")); + } + + @Test + void listPriceLists_acceptsFractionalEpochEffectiveDate() { + // 1775001600 = 2026-04-01T00:00:00Z. 0.5s offset is still past that boundary. + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.ListPriceLists") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"EffectiveDate\":1775001600.5," + + "\"CurrencyCode\":\"USD\",\"RegionCode\":\"us-east-1\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceLists", hasSize(1)) + .body("PriceLists[0].PriceListArn", endsWith("20260401000000/us-east-1")); + } + + @Test + void getProducts_rejectsFilterWithMissingValue() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"Filters\":[" + + "{\"Type\":\"TERM_MATCH\",\"Field\":\"instanceType\"}]}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("InvalidParameterException")); + } + + @Test + void getProducts_rejectsFilterWithMissingField() { + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"Filters\":[" + + "{\"Type\":\"TERM_MATCH\",\"Value\":\"t3.micro\"}]}") + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", equalTo("InvalidParameterException")); + } + + @Test + void getProducts_paginationTokenSlicesResults() { + String next = given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"MaxResults\":2}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceList", hasSize(2)) + .body("NextToken", notNullValue()) + .extract().path("NextToken"); + + given() + .contentType(CONTENT_TYPE) + .header("X-Amz-Target", "AWSPriceListService.GetProducts") + .header("Authorization", AUTH_HEADER) + .body("{\"ServiceCode\":\"AmazonEC2\",\"MaxResults\":2,\"NextToken\":\"" + next + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("PriceList", hasSize(1)) + .body("NextToken", nullValue()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 2f37927c6..086b2d2ae 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -176,3 +176,5 @@ floci: enabled: true textract: enabled: true + pricing: + enabled: true