diff --git a/compatibility-tests/sdk-test-java/pom.xml b/compatibility-tests/sdk-test-java/pom.xml
index a52548491d..91f4cc9635 100644
--- a/compatibility-tests/sdk-test-java/pom.xml
+++ b/compatibility-tests/sdk-test-java/pom.xml
@@ -287,6 +287,11 @@
software.amazon.awssdk
marketplacecatalog
+
+
+ software.amazon.awssdk
+ marketplaceagreement
+
software.amazon.awssdk
diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
index 1683c82860..e0093e701c 100644
--- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
+++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java
@@ -39,6 +39,7 @@
import software.amazon.awssdk.services.macie2.Macie2Client;
import software.amazon.awssdk.services.controlcatalog.ControlCatalogClient;
import software.amazon.awssdk.services.marketplacecatalog.MarketplaceCatalogClient;
+import software.amazon.awssdk.services.marketplaceagreement.MarketplaceAgreementClient;
import software.amazon.awssdk.services.inspector2.Inspector2Client;
import software.amazon.awssdk.services.securityhub.SecurityHubClient;
import software.amazon.awssdk.services.detective.DetectiveClient;
@@ -368,6 +369,14 @@ public static MarketplaceCatalogClient marketplaceCatalogClient() {
.build();
}
+ public static MarketplaceAgreementClient marketplaceAgreementClient() {
+ return MarketplaceAgreementClient.builder()
+ .endpointOverride(ENDPOINT)
+ .region(REGION)
+ .credentialsProvider(CREDENTIALS)
+ .build();
+ }
+
public static DetectiveClient detectiveClient(String accountId) {
return DetectiveClient.builder()
.endpointOverride(ENDPOINT)
diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceAgreementTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceAgreementTest.java
new file mode 100644
index 0000000000..3f3cf8cf51
--- /dev/null
+++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/MarketplaceAgreementTest.java
@@ -0,0 +1,18 @@
+package com.floci.test;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.marketplaceagreement.MarketplaceAgreementClient;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class MarketplaceAgreementTest {
+
+ @Test
+ void usesAwsSdkWireContract() {
+ try (MarketplaceAgreementClient client = TestFixtures.marketplaceAgreementClient()) {
+ var response = client.searchAgreements(r -> r.filters(
+ f -> f.name("AgreementType").values("PurchaseAgreement")));
+ assertNotNull(response.agreementViewSummaries());
+ }
+ }
+}
diff --git a/docs/services/marketplace.md b/docs/services/marketplace.md
index 87000f5605..b866e9a95a 100644
--- a/docs/services/marketplace.md
+++ b/docs/services/marketplace.md
@@ -2,7 +2,7 @@
Floci emulates AWS Marketplace APIs under the shared `aws-marketplace` SigV4 signing scope.
-## Marketplace Catalog
+## Supported operations
| Action | Description |
@@ -22,10 +22,46 @@ Floci emulates AWS Marketplace APIs under the shared `aws-marketplace` SigV4 sig
| `StartChangeSet` | Starts an idempotent Marketplace Catalog change set |
| `TagResource` | Adds or replaces tags on a Marketplace Catalog resource |
| `UntagResource` | Removes tag keys from a Marketplace Catalog resource |
+| `AcceptAgreementCancellationRequest` | Accepts an agreement cancellation request |
+| `AcceptAgreementPaymentRequest` | Accepts an agreement payment request |
+| `AcceptAgreementRequest` | Accepts an agreement request |
+| `BatchCreateBillingAdjustmentRequest` | Creates billing adjustment requests in a batch |
+| `CancelAgreement` | Cancels an agreement |
+| `CancelAgreementCancellationRequest` | Cancels an agreement cancellation request |
+| `CancelAgreementPaymentRequest` | Cancels an agreement payment request |
+| `CreateAgreementRequest` | Creates an agreement request |
+| `DescribeAgreement` | Describes an agreement |
+| `GetAgreementCancellationRequest` | Gets an agreement cancellation request |
+| `GetAgreementEntitlements` | Gets entitlements for an agreement |
+| `GetAgreementPaymentRequest` | Gets an agreement payment request |
+| `GetAgreementTerms` | Gets accepted agreement terms |
+| `GetBillingAdjustmentRequest` | Gets a billing adjustment request |
+| `ListAgreementCancellationRequests` | Lists agreement cancellation requests |
+| `ListAgreementCharges` | Lists agreement charges |
+| `ListAgreementInvoiceLineItems` | Lists agreement invoice line items |
+| `ListAgreementPaymentRequests` | Lists agreement payment requests |
+| `ListBillingAdjustmentRequests` | Lists billing adjustment requests |
+| `RejectAgreementCancellationRequest` | Rejects an agreement cancellation request |
+| `RejectAgreementPaymentRequest` | Rejects an agreement payment request |
+| `SearchAgreements` | Searches agreements |
+| `SendAgreementCancellationRequest` | Sends an agreement cancellation request |
+| `SendAgreementPaymentRequest` | Sends an agreement payment request |
+| `UpdatePurchaseOrders` | Updates purchase orders for an agreement |
+## Marketplace Catalog
+
Catalog change sets use AWS states (`PREPARING`, `APPLYING`, `SUCCEEDED`, and `CANCELLED`). Floci applies supported entity mutations locally when a change set is observed and persists entities, change sets, tags, resource policies, and assessments through `StorageFactory`, isolated by AWS account.
+## Marketplace Agreement
+
+Agreement request acceptance persists the resulting agreement and exposes it through subsequent read and search operations. State is isolated by AWS account through `StorageFactory`.
+
+### Known deviations
+
+- Accepted agreements use a local 365-day duration instead of deriving the end time from requested terms.
+- `PartyType` is validated for AWS-compatible values but does not otherwise change which locally stored agreements match.
+
## Configuration
| Variable | Default | Description |
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java
index 42b06b3757..afa7aa2eb2 100644
--- a/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java
+++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsJsonController.java
@@ -11,6 +11,7 @@
import io.github.hectorvent.floci.services.dynamodb.DynamoDbResponses;
import io.github.hectorvent.floci.services.dynamodb.DynamoDbStreamsJsonHandler;
import io.github.hectorvent.floci.services.networkfirewall.NetworkFirewallJsonHandler;
+import io.github.hectorvent.floci.services.marketplace.MarketplaceJsonHandler;
import io.github.hectorvent.floci.services.sns.SnsJsonHandler;
import io.github.hectorvent.floci.services.sqs.SqsJsonHandler;
import io.github.hectorvent.floci.services.stepfunctions.StepFunctionsJsonHandler;
@@ -49,6 +50,7 @@ public class AwsJsonController {
private final CloudControlJsonHandler cloudControlJsonHandler;
private final SwfJsonHandler swfJsonHandler;
private final NetworkFirewallJsonHandler networkFirewallJsonHandler;
+ private final MarketplaceJsonHandler marketplaceJsonHandler;
@Inject
public AwsJsonController(ObjectMapper objectMapper, ResolvedServiceCatalog catalog,
@@ -60,7 +62,8 @@ public AwsJsonController(ObjectMapper objectMapper, ResolvedServiceCatalog catal
CloudWatchMetricsJsonHandler cloudWatchMetricsJsonHandler,
CloudControlJsonHandler cloudControlJsonHandler,
SwfJsonHandler swfJsonHandler,
- NetworkFirewallJsonHandler networkFirewallJsonHandler) {
+ NetworkFirewallJsonHandler networkFirewallJsonHandler,
+ MarketplaceJsonHandler marketplaceJsonHandler) {
this.objectMapper = objectMapper;
this.strictBodyReader = objectMapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
this.catalog = catalog;
@@ -74,6 +77,7 @@ public AwsJsonController(ObjectMapper objectMapper, ResolvedServiceCatalog catal
this.cloudControlJsonHandler = cloudControlJsonHandler;
this.swfJsonHandler = swfJsonHandler;
this.networkFirewallJsonHandler = networkFirewallJsonHandler;
+ this.marketplaceJsonHandler = marketplaceJsonHandler;
}
@POST
@@ -124,6 +128,7 @@ public Response handleJsonRequest(
action, request, region, regionResolver.getAccountId());
case "network-firewall" -> networkFirewallJsonHandler.handle(
action, request, region, regionResolver.getAccountId());
+ case "marketplace" -> marketplaceJsonHandler.handle(action, request, region);
default -> null;
};
// catalog.matchTarget is protocol-agnostic: a JSON 1.1 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 4b1e9e1153..521899d049 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
@@ -644,8 +644,8 @@ public ResolvedServiceCatalog(EmulatorConfig config) {
Set.of(io.github.hectorvent.floci.services.codegurureviewer.CodeGuruReviewerController.class)),
descriptor("marketplace", "marketplace", config.services().marketplace().enabled(), true,
"marketplace", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON,
- protocols(ServiceProtocol.REST_JSON),
- Set.of(), Set.of("aws-marketplace"), Set.of(),
+ protocols(ServiceProtocol.REST_JSON, ServiceProtocol.JSON),
+ Set.of("AWSMPCommerceService_v20200301."), Set.of("aws-marketplace"), Set.of(),
Set.of(MarketplaceCatalogController.class))
));
}
diff --git a/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementController.java b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementController.java
new file mode 100644
index 0000000000..6dbdfcb1b0
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementController.java
@@ -0,0 +1,20 @@
+package io.github.hectorvent.floci.services.marketplace;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+/** Service-specific controller invoked by the shared AWS JSON 1.0 protocol handler. */
+@ApplicationScoped
+public class MarketplaceAgreementController {
+ private final MarketplaceAgreementService service;
+
+ @Inject
+ public MarketplaceAgreementController(MarketplaceAgreementService service) {
+ this.service = service;
+ }
+
+ public JsonNode handle(String action, JsonNode request, String region) {
+ return service.handle(action, request, region);
+ }
+}
diff --git a/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementService.java b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementService.java
new file mode 100644
index 0000000000..4e46cd8cbb
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/services/marketplace/MarketplaceAgreementService.java
@@ -0,0 +1,813 @@
+package io.github.hectorvent.floci.services.marketplace;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.github.hectorvent.floci.core.common.AwsException;
+import io.github.hectorvent.floci.core.common.RequestContext;
+import io.github.hectorvent.floci.core.common.Resettable;
+import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
+import io.github.hectorvent.floci.core.storage.StorageFactory;
+import io.github.hectorvent.floci.services.marketplace.model.AgreementProposalContext;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+@ApplicationScoped
+public class MarketplaceAgreementService implements Resettable {
+ private static final String REGION = "us-east-1";
+ private static final String CATALOG = "AWSMarketplace";
+ private static final Pattern PROPOSAL_ID = Pattern.compile("(at-|ap-)[A-Za-z0-9]+");
+ private static final Pattern CLIENT_TOKEN = Pattern.compile("[a-zA-Z0-9-]{1,64}");
+ private static final Set SEARCH_SORTS = Set.of("EndTime", "StartTime", "LastUpdateTime");
+
+ private final ObjectMapper mapper;
+ private final AccountAwareStorageBackend agreements;
+ private final AccountAwareStorageBackend agreementRequests;
+ private final AccountAwareStorageBackend cancellationRequests;
+ private final AccountAwareStorageBackend paymentRequests;
+ private final AccountAwareStorageBackend billingAdjustments;
+ private final AccountAwareStorageBackend catalogEntities;
+ private final RequestContext requestContext;
+
+ @Inject
+ public MarketplaceAgreementService(StorageFactory factory, ObjectMapper mapper, RequestContext requestContext) {
+ this(mapper,
+ factory.create("marketplace", "marketplace-agreements.json", type()),
+ factory.create("marketplace", "marketplace-agreement-requests.json", type()),
+ factory.create("marketplace", "marketplace-cancellation-requests.json", type()),
+ factory.create("marketplace", "marketplace-payment-requests.json", type()),
+ factory.create("marketplace", "marketplace-billing-adjustments.json", type()),
+ factory.create("marketplace", "marketplace-entities.json", type()),
+ requestContext);
+ }
+
+ MarketplaceAgreementService(ObjectMapper mapper,
+ AccountAwareStorageBackend agreements,
+ AccountAwareStorageBackend agreementRequests,
+ AccountAwareStorageBackend cancellationRequests,
+ AccountAwareStorageBackend paymentRequests,
+ AccountAwareStorageBackend billingAdjustments,
+ AccountAwareStorageBackend catalogEntities,
+ RequestContext requestContext) {
+ this.mapper = mapper;
+ this.agreements = agreements;
+ this.agreementRequests = agreementRequests;
+ this.cancellationRequests = cancellationRequests;
+ this.paymentRequests = paymentRequests;
+ this.billingAdjustments = billingAdjustments;
+ this.catalogEntities = catalogEntities;
+ this.requestContext = requestContext;
+ }
+
+ private static TypeReference