mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: add AWS model conformance coverage (#736)
This commit is contained in:
@@ -49,7 +49,7 @@ else
|
||||
_SECQ = 2>&1 | tee
|
||||
endif
|
||||
|
||||
build: go_build build-installer build-lb-agent
|
||||
build: go_build build-installer build-lb-agent generate-aws-model-coverage
|
||||
|
||||
# Build spinifex-ui frontend (requires pnpm)
|
||||
build-ui:
|
||||
@@ -146,9 +146,22 @@ test-harness:
|
||||
|
||||
# In-process integration tier: the real gateway router against embedded NATS
|
||||
# JetStream, with only the daemon-side NATS subjects stubbed.
|
||||
AWS_MODEL_CONFORMANCE_REPORT ?= $(CURDIR)/.cache/aws-model-conformance-report.txt
|
||||
AWS_MODEL_CONFORMANCE_MODE ?= fail
|
||||
AWS_MODEL_OPERATION_COVERAGE_REPORT ?= $(CURDIR)/docs/aws-model-operation-coverage.md
|
||||
test-integration:
|
||||
@echo -e "\n....Running in-process integration tests...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
|
||||
@cat $(AWS_MODEL_CONFORMANCE_REPORT)
|
||||
@$(MAKE) --no-print-directory generate-aws-model-coverage
|
||||
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_REPORT)"
|
||||
|
||||
generate-aws-model-coverage:
|
||||
@mkdir -p $(dir $(AWS_MODEL_OPERATION_COVERAGE_REPORT))
|
||||
@go run ./cmd/aws-model-coverage > $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
|
||||
|
||||
aws-model-coverage: generate-aws-model-coverage
|
||||
@cat $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
|
||||
|
||||
# Segscan storage oracle: needs the mulga umbrella repo's scripts/segscan
|
||||
# checked out alongside spinifex (see spinifex/testutil/segscanoracle), which
|
||||
@@ -342,7 +355,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-images test-harness test-integration test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-images test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dispatch := gateway.AWSOperationInventory()
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
inventory, ok := dispatch[string(service)]
|
||||
var modelInventory awsmodel.DispatchInventory
|
||||
switch {
|
||||
case ok:
|
||||
modelInventory = awsmodel.DispatchInventory{
|
||||
Registered: inventory.Registered,
|
||||
Stubbed: inventory.Stubbed,
|
||||
Unsupported: inventory.Unsupported,
|
||||
}
|
||||
case service == awsmodel.S3:
|
||||
modelInventory = awsmodel.DispatchInventory{
|
||||
Opaque: true,
|
||||
Note: "Spinifex delegates the S3 REST surface to Predastore, which has no operation-name dispatch table to compare mechanically.",
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: no gateway inventory for %s\n", service)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
coverage, err := awsmodel.CompareOperations(service, modelInventory)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "aws-model-coverage: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
coverages = append(coverages, coverage)
|
||||
}
|
||||
|
||||
fmt.Print(awsmodel.RenderCoverageMarkdown(coverages))
|
||||
}
|
||||
+5
-1
@@ -1,3 +1,7 @@
|
||||
# Spinifex Documentation
|
||||
|
||||
This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/)
|
||||
This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browsing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/)
|
||||
|
||||
The [AWS model operation coverage](aws-model-operation-coverage.md) report is
|
||||
generated from Spinifex's dispatch tables and the pinned AWS API models. Run
|
||||
`make aws-model-coverage` to regenerate and print it.
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
# AWS model operation coverage
|
||||
|
||||
Generated from the cached `aws-sdk-go v1.55.8` `api-2.json` models and Spinifex's authoritative gateway dispatch tables.
|
||||
|
||||
Implemented means a modelled operation is registered to a real handler. Stub and deliberately unsupported handlers are reported separately. Shape conformance does not imply behavioural conformance.
|
||||
|
||||
| Service | API version | Modelled | Registered | Implemented | Stubbed | Unsupported | Missing | Extra |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| acm | 2015-12-08 | 15 | 9 | 9 | 0 | 0 | 6 | 0 |
|
||||
| ec2 | 2016-11-15 | 625 | 119 | 119 | 0 | 0 | 506 | 0 |
|
||||
| ecr | 2015-09-21 | 47 | 39 | 19 | 13 | 7 | 11 | 3 |
|
||||
| ecs | 2014-11-13 | 56 | 39 | 31 | 5 | 0 | 20 | 3 |
|
||||
| elasticloadbalancingv2 | 2015-12-01 | 46 | 37 | 33 | 0 | 0 | 13 | 4 |
|
||||
| iam | 2010-05-08 | 159 | 75 | 75 | 0 | 0 | 84 | 0 |
|
||||
| s3 | 2006-03-01 | 99 | — | — | — | — | — | — |
|
||||
| sts | 2011-06-15 | 8 | 4 | 4 | 0 | 0 | 4 | 0 |
|
||||
|
||||
## acm
|
||||
|
||||
Implements **9 of 15** modelled operations (60.0%).
|
||||
|
||||
<details><summary>Implemented (9)</summary>
|
||||
|
||||
`AddTagsToCertificate`, `DeleteCertificate`, `DescribeCertificate`, `GetCertificate`, `ImportCertificate`, `ListCertificates`, `ListTagsForCertificate`, `RemoveTagsFromCertificate`
|
||||
|
||||
`RequestCertificate`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (6)</summary>
|
||||
|
||||
`ExportCertificate`, `GetAccountConfiguration`, `PutAccountConfiguration`, `RenewCertificate`, `ResendValidationEmail`, `UpdateCertificateOptions`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
## ec2
|
||||
|
||||
Implements **119 of 625** modelled operations (19.0%).
|
||||
|
||||
<details><summary>Implemented (119)</summary>
|
||||
|
||||
`AllocateAddress`, `AssociateAddress`, `AssociateIamInstanceProfile`, `AssociateRouteTable`, `AttachInternetGateway`, `AttachNetworkInterface`, `AttachVolume`, `AuthorizeSecurityGroupEgress`
|
||||
|
||||
`AuthorizeSecurityGroupIngress`, `CancelCapacityReservation`, `CancelSpotInstanceRequests`, `CopyImage`, `CopySnapshot`, `CreateCapacityReservation`, `CreateEgressOnlyInternetGateway`, `CreateImage`
|
||||
|
||||
`CreateInternetGateway`, `CreateKeyPair`, `CreateLaunchTemplate`, `CreateLaunchTemplateVersion`, `CreateNatGateway`, `CreateNetworkInterface`, `CreatePlacementGroup`, `CreateRoute`
|
||||
|
||||
`CreateRouteTable`, `CreateSecurityGroup`, `CreateSnapshot`, `CreateSubnet`, `CreateTags`, `CreateVolume`, `CreateVpc`, `DeleteEgressOnlyInternetGateway`
|
||||
|
||||
`DeleteInternetGateway`, `DeleteKeyPair`, `DeleteLaunchTemplate`, `DeleteLaunchTemplateVersions`, `DeleteNatGateway`, `DeleteNetworkInterface`, `DeletePlacementGroup`, `DeleteRoute`
|
||||
|
||||
`DeleteRouteTable`, `DeleteSecurityGroup`, `DeleteSnapshot`, `DeleteSubnet`, `DeleteTags`, `DeleteVolume`, `DeleteVpc`, `DeregisterImage`
|
||||
|
||||
`DescribeAccountAttributes`, `DescribeAddresses`, `DescribeAddressesAttribute`, `DescribeAvailabilityZones`, `DescribeCapacityReservations`, `DescribeEgressOnlyInternetGateways`, `DescribeIamInstanceProfileAssociations`, `DescribeImageAttribute`
|
||||
|
||||
`DescribeImages`, `DescribeInstanceAttribute`, `DescribeInstanceCreditSpecifications`, `DescribeInstanceStatus`, `DescribeInstanceTypes`, `DescribeInstances`, `DescribeInternetGateways`, `DescribeKeyPairs`
|
||||
|
||||
`DescribeLaunchTemplateVersions`, `DescribeLaunchTemplates`, `DescribeNatGateways`, `DescribeNetworkInterfaces`, `DescribePlacementGroups`, `DescribeRegions`, `DescribeRouteTables`, `DescribeSecurityGroupRules`
|
||||
|
||||
`DescribeSecurityGroups`, `DescribeSnapshots`, `DescribeSpotInstanceRequests`, `DescribeSubnets`, `DescribeTags`, `DescribeVolumeStatus`, `DescribeVolumes`, `DescribeVolumesModifications`
|
||||
|
||||
`DescribeVpcAttribute`, `DescribeVpcs`, `DetachInternetGateway`, `DetachNetworkInterface`, `DetachVolume`, `DisableEbsEncryptionByDefault`, `DisableSerialConsoleAccess`, `DisassociateAddress`
|
||||
|
||||
`DisassociateIamInstanceProfile`, `DisassociateRouteTable`, `EnableEbsEncryptionByDefault`, `EnableSerialConsoleAccess`, `GetConsoleOutput`, `GetEbsEncryptionByDefault`, `GetPasswordData`, `GetSerialConsoleAccessStatus`
|
||||
|
||||
`ImportKeyPair`, `ModifyImageAttribute`, `ModifyInstanceAttribute`, `ModifyInstanceMetadataOptions`, `ModifyLaunchTemplate`, `ModifyNetworkInterfaceAttribute`, `ModifySubnetAttribute`, `ModifyVolume`
|
||||
|
||||
`ModifyVpcAttribute`, `RebootInstances`, `RegisterImage`, `ReleaseAddress`, `ReplaceIamInstanceProfileAssociation`, `ReplaceRoute`, `ReplaceRouteTableAssociation`, `RequestSpotInstances`
|
||||
|
||||
`ResetImageAttribute`, `RevokeSecurityGroupEgress`, `RevokeSecurityGroupIngress`, `RunInstances`, `StartInstances`, `StopInstances`, `TerminateInstances`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (506)</summary>
|
||||
|
||||
`AcceptAddressTransfer`, `AcceptReservedInstancesExchangeQuote`, `AcceptTransitGatewayMulticastDomainAssociations`, `AcceptTransitGatewayPeeringAttachment`, `AcceptTransitGatewayVpcAttachment`, `AcceptVpcEndpointConnections`, `AcceptVpcPeeringConnection`, `AdvertiseByoipCidr`
|
||||
|
||||
`AllocateHosts`, `AllocateIpamPoolCidr`, `ApplySecurityGroupsToClientVpnTargetNetwork`, `AssignIpv6Addresses`, `AssignPrivateIpAddresses`, `AssignPrivateNatGatewayAddress`, `AssociateClientVpnTargetNetwork`, `AssociateDhcpOptions`
|
||||
|
||||
`AssociateEnclaveCertificateIamRole`, `AssociateInstanceEventWindow`, `AssociateIpamByoasn`, `AssociateIpamResourceDiscovery`, `AssociateNatGatewayAddress`, `AssociateSubnetCidrBlock`, `AssociateTransitGatewayMulticastDomain`, `AssociateTransitGatewayPolicyTable`
|
||||
|
||||
`AssociateTransitGatewayRouteTable`, `AssociateTrunkInterface`, `AssociateVpcCidrBlock`, `AttachClassicLinkVpc`, `AttachVerifiedAccessTrustProvider`, `AttachVpnGateway`, `AuthorizeClientVpnIngress`, `BundleInstance`
|
||||
|
||||
`CancelBundleTask`, `CancelCapacityReservationFleets`, `CancelConversionTask`, `CancelExportTask`, `CancelImageLaunchPermission`, `CancelImportTask`, `CancelReservedInstancesListing`, `CancelSpotFleetRequests`
|
||||
|
||||
`ConfirmProductInstance`, `CopyFpgaImage`, `CreateCapacityReservationFleet`, `CreateCarrierGateway`, `CreateClientVpnEndpoint`, `CreateClientVpnRoute`, `CreateCoipCidr`, `CreateCoipPool`
|
||||
|
||||
`CreateCustomerGateway`, `CreateDefaultSubnet`, `CreateDefaultVpc`, `CreateDhcpOptions`, `CreateFleet`, `CreateFlowLogs`, `CreateFpgaImage`, `CreateInstanceConnectEndpoint`
|
||||
|
||||
`CreateInstanceEventWindow`, `CreateInstanceExportTask`, `CreateIpam`, `CreateIpamExternalResourceVerificationToken`, `CreateIpamPool`, `CreateIpamResourceDiscovery`, `CreateIpamScope`, `CreateLocalGatewayRoute`
|
||||
|
||||
`CreateLocalGatewayRouteTable`, `CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociation`, `CreateLocalGatewayRouteTableVpcAssociation`, `CreateManagedPrefixList`, `CreateNetworkAcl`, `CreateNetworkAclEntry`, `CreateNetworkInsightsAccessScope`, `CreateNetworkInsightsPath`
|
||||
|
||||
`CreateNetworkInterfacePermission`, `CreatePublicIpv4Pool`, `CreateReplaceRootVolumeTask`, `CreateReservedInstancesListing`, `CreateRestoreImageTask`, `CreateSnapshots`, `CreateSpotDatafeedSubscription`, `CreateStoreImageTask`
|
||||
|
||||
`CreateSubnetCidrReservation`, `CreateTrafficMirrorFilter`, `CreateTrafficMirrorFilterRule`, `CreateTrafficMirrorSession`, `CreateTrafficMirrorTarget`, `CreateTransitGateway`, `CreateTransitGatewayConnect`, `CreateTransitGatewayConnectPeer`
|
||||
|
||||
`CreateTransitGatewayMulticastDomain`, `CreateTransitGatewayPeeringAttachment`, `CreateTransitGatewayPolicyTable`, `CreateTransitGatewayPrefixListReference`, `CreateTransitGatewayRoute`, `CreateTransitGatewayRouteTable`, `CreateTransitGatewayRouteTableAnnouncement`, `CreateTransitGatewayVpcAttachment`
|
||||
|
||||
`CreateVerifiedAccessEndpoint`, `CreateVerifiedAccessGroup`, `CreateVerifiedAccessInstance`, `CreateVerifiedAccessTrustProvider`, `CreateVpcEndpoint`, `CreateVpcEndpointConnectionNotification`, `CreateVpcEndpointServiceConfiguration`, `CreateVpcPeeringConnection`
|
||||
|
||||
`CreateVpnConnection`, `CreateVpnConnectionRoute`, `CreateVpnGateway`, `DeleteCarrierGateway`, `DeleteClientVpnEndpoint`, `DeleteClientVpnRoute`, `DeleteCoipCidr`, `DeleteCoipPool`
|
||||
|
||||
`DeleteCustomerGateway`, `DeleteDhcpOptions`, `DeleteFleets`, `DeleteFlowLogs`, `DeleteFpgaImage`, `DeleteInstanceConnectEndpoint`, `DeleteInstanceEventWindow`, `DeleteIpam`
|
||||
|
||||
`DeleteIpamExternalResourceVerificationToken`, `DeleteIpamPool`, `DeleteIpamResourceDiscovery`, `DeleteIpamScope`, `DeleteLocalGatewayRoute`, `DeleteLocalGatewayRouteTable`, `DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociation`, `DeleteLocalGatewayRouteTableVpcAssociation`
|
||||
|
||||
`DeleteManagedPrefixList`, `DeleteNetworkAcl`, `DeleteNetworkAclEntry`, `DeleteNetworkInsightsAccessScope`, `DeleteNetworkInsightsAccessScopeAnalysis`, `DeleteNetworkInsightsAnalysis`, `DeleteNetworkInsightsPath`, `DeleteNetworkInterfacePermission`
|
||||
|
||||
`DeletePublicIpv4Pool`, `DeleteQueuedReservedInstances`, `DeleteSpotDatafeedSubscription`, `DeleteSubnetCidrReservation`, `DeleteTrafficMirrorFilter`, `DeleteTrafficMirrorFilterRule`, `DeleteTrafficMirrorSession`, `DeleteTrafficMirrorTarget`
|
||||
|
||||
`DeleteTransitGateway`, `DeleteTransitGatewayConnect`, `DeleteTransitGatewayConnectPeer`, `DeleteTransitGatewayMulticastDomain`, `DeleteTransitGatewayPeeringAttachment`, `DeleteTransitGatewayPolicyTable`, `DeleteTransitGatewayPrefixListReference`, `DeleteTransitGatewayRoute`
|
||||
|
||||
`DeleteTransitGatewayRouteTable`, `DeleteTransitGatewayRouteTableAnnouncement`, `DeleteTransitGatewayVpcAttachment`, `DeleteVerifiedAccessEndpoint`, `DeleteVerifiedAccessGroup`, `DeleteVerifiedAccessInstance`, `DeleteVerifiedAccessTrustProvider`, `DeleteVpcEndpointConnectionNotifications`
|
||||
|
||||
`DeleteVpcEndpointServiceConfigurations`, `DeleteVpcEndpoints`, `DeleteVpcPeeringConnection`, `DeleteVpnConnection`, `DeleteVpnConnectionRoute`, `DeleteVpnGateway`, `DeprovisionByoipCidr`, `DeprovisionIpamByoasn`
|
||||
|
||||
`DeprovisionIpamPoolCidr`, `DeprovisionPublicIpv4PoolCidr`, `DeregisterInstanceEventNotificationAttributes`, `DeregisterTransitGatewayMulticastGroupMembers`, `DeregisterTransitGatewayMulticastGroupSources`, `DescribeAddressTransfers`, `DescribeAggregateIdFormat`, `DescribeAwsNetworkPerformanceMetricSubscriptions`
|
||||
|
||||
`DescribeBundleTasks`, `DescribeByoipCidrs`, `DescribeCapacityBlockOfferings`, `DescribeCapacityReservationFleets`, `DescribeCarrierGateways`, `DescribeClassicLinkInstances`, `DescribeClientVpnAuthorizationRules`, `DescribeClientVpnConnections`
|
||||
|
||||
`DescribeClientVpnEndpoints`, `DescribeClientVpnRoutes`, `DescribeClientVpnTargetNetworks`, `DescribeCoipPools`, `DescribeConversionTasks`, `DescribeCustomerGateways`, `DescribeDhcpOptions`, `DescribeElasticGpus`
|
||||
|
||||
`DescribeExportImageTasks`, `DescribeExportTasks`, `DescribeFastLaunchImages`, `DescribeFastSnapshotRestores`, `DescribeFleetHistory`, `DescribeFleetInstances`, `DescribeFleets`, `DescribeFlowLogs`
|
||||
|
||||
`DescribeFpgaImageAttribute`, `DescribeFpgaImages`, `DescribeHostReservationOfferings`, `DescribeHostReservations`, `DescribeHosts`, `DescribeIdFormat`, `DescribeIdentityIdFormat`, `DescribeImportImageTasks`
|
||||
|
||||
`DescribeImportSnapshotTasks`, `DescribeInstanceConnectEndpoints`, `DescribeInstanceEventNotificationAttributes`, `DescribeInstanceEventWindows`, `DescribeInstanceTopology`, `DescribeInstanceTypeOfferings`, `DescribeIpamByoasn`, `DescribeIpamExternalResourceVerificationTokens`
|
||||
|
||||
`DescribeIpamPools`, `DescribeIpamResourceDiscoveries`, `DescribeIpamResourceDiscoveryAssociations`, `DescribeIpamScopes`, `DescribeIpams`, `DescribeIpv6Pools`, `DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociations`, `DescribeLocalGatewayRouteTableVpcAssociations`
|
||||
|
||||
`DescribeLocalGatewayRouteTables`, `DescribeLocalGatewayVirtualInterfaceGroups`, `DescribeLocalGatewayVirtualInterfaces`, `DescribeLocalGateways`, `DescribeLockedSnapshots`, `DescribeMacHosts`, `DescribeManagedPrefixLists`, `DescribeMovingAddresses`
|
||||
|
||||
`DescribeNetworkAcls`, `DescribeNetworkInsightsAccessScopeAnalyses`, `DescribeNetworkInsightsAccessScopes`, `DescribeNetworkInsightsAnalyses`, `DescribeNetworkInsightsPaths`, `DescribeNetworkInterfaceAttribute`, `DescribeNetworkInterfacePermissions`, `DescribePrefixLists`
|
||||
|
||||
`DescribePrincipalIdFormat`, `DescribePublicIpv4Pools`, `DescribeReplaceRootVolumeTasks`, `DescribeReservedInstances`, `DescribeReservedInstancesListings`, `DescribeReservedInstancesModifications`, `DescribeReservedInstancesOfferings`, `DescribeScheduledInstanceAvailability`
|
||||
|
||||
`DescribeScheduledInstances`, `DescribeSecurityGroupReferences`, `DescribeSnapshotAttribute`, `DescribeSnapshotTierStatus`, `DescribeSpotDatafeedSubscription`, `DescribeSpotFleetInstances`, `DescribeSpotFleetRequestHistory`, `DescribeSpotFleetRequests`
|
||||
|
||||
`DescribeSpotPriceHistory`, `DescribeStaleSecurityGroups`, `DescribeStoreImageTasks`, `DescribeTrafficMirrorFilterRules`, `DescribeTrafficMirrorFilters`, `DescribeTrafficMirrorSessions`, `DescribeTrafficMirrorTargets`, `DescribeTransitGatewayAttachments`
|
||||
|
||||
`DescribeTransitGatewayConnectPeers`, `DescribeTransitGatewayConnects`, `DescribeTransitGatewayMulticastDomains`, `DescribeTransitGatewayPeeringAttachments`, `DescribeTransitGatewayPolicyTables`, `DescribeTransitGatewayRouteTableAnnouncements`, `DescribeTransitGatewayRouteTables`, `DescribeTransitGatewayVpcAttachments`
|
||||
|
||||
`DescribeTransitGateways`, `DescribeTrunkInterfaceAssociations`, `DescribeVerifiedAccessEndpoints`, `DescribeVerifiedAccessGroups`, `DescribeVerifiedAccessInstanceLoggingConfigurations`, `DescribeVerifiedAccessInstances`, `DescribeVerifiedAccessTrustProviders`, `DescribeVolumeAttribute`
|
||||
|
||||
`DescribeVpcClassicLink`, `DescribeVpcClassicLinkDnsSupport`, `DescribeVpcEndpointConnectionNotifications`, `DescribeVpcEndpointConnections`, `DescribeVpcEndpointServiceConfigurations`, `DescribeVpcEndpointServicePermissions`, `DescribeVpcEndpointServices`, `DescribeVpcEndpoints`
|
||||
|
||||
`DescribeVpcPeeringConnections`, `DescribeVpnConnections`, `DescribeVpnGateways`, `DetachClassicLinkVpc`, `DetachVerifiedAccessTrustProvider`, `DetachVpnGateway`, `DisableAddressTransfer`, `DisableAwsNetworkPerformanceMetricSubscription`
|
||||
|
||||
`DisableFastLaunch`, `DisableFastSnapshotRestores`, `DisableImage`, `DisableImageBlockPublicAccess`, `DisableImageDeprecation`, `DisableImageDeregistrationProtection`, `DisableIpamOrganizationAdminAccount`, `DisableSnapshotBlockPublicAccess`
|
||||
|
||||
`DisableTransitGatewayRouteTablePropagation`, `DisableVgwRoutePropagation`, `DisableVpcClassicLink`, `DisableVpcClassicLinkDnsSupport`, `DisassociateClientVpnTargetNetwork`, `DisassociateEnclaveCertificateIamRole`, `DisassociateInstanceEventWindow`, `DisassociateIpamByoasn`
|
||||
|
||||
`DisassociateIpamResourceDiscovery`, `DisassociateNatGatewayAddress`, `DisassociateSubnetCidrBlock`, `DisassociateTransitGatewayMulticastDomain`, `DisassociateTransitGatewayPolicyTable`, `DisassociateTransitGatewayRouteTable`, `DisassociateTrunkInterface`, `DisassociateVpcCidrBlock`
|
||||
|
||||
`EnableAddressTransfer`, `EnableAwsNetworkPerformanceMetricSubscription`, `EnableFastLaunch`, `EnableFastSnapshotRestores`, `EnableImage`, `EnableImageBlockPublicAccess`, `EnableImageDeprecation`, `EnableImageDeregistrationProtection`
|
||||
|
||||
`EnableIpamOrganizationAdminAccount`, `EnableReachabilityAnalyzerOrganizationSharing`, `EnableSnapshotBlockPublicAccess`, `EnableTransitGatewayRouteTablePropagation`, `EnableVgwRoutePropagation`, `EnableVolumeIO`, `EnableVpcClassicLink`, `EnableVpcClassicLinkDnsSupport`
|
||||
|
||||
`ExportClientVpnClientCertificateRevocationList`, `ExportClientVpnClientConfiguration`, `ExportImage`, `ExportTransitGatewayRoutes`, `GetAssociatedEnclaveCertificateIamRoles`, `GetAssociatedIpv6PoolCidrs`, `GetAwsNetworkPerformanceData`, `GetCapacityReservationUsage`
|
||||
|
||||
`GetCoipPoolUsage`, `GetConsoleScreenshot`, `GetDefaultCreditSpecification`, `GetEbsDefaultKmsKeyId`, `GetFlowLogsIntegrationTemplate`, `GetGroupsForCapacityReservation`, `GetHostReservationPurchasePreview`, `GetImageBlockPublicAccessState`
|
||||
|
||||
`GetInstanceMetadataDefaults`, `GetInstanceTpmEkPub`, `GetInstanceTypesFromInstanceRequirements`, `GetInstanceUefiData`, `GetIpamAddressHistory`, `GetIpamDiscoveredAccounts`, `GetIpamDiscoveredPublicAddresses`, `GetIpamDiscoveredResourceCidrs`
|
||||
|
||||
`GetIpamPoolAllocations`, `GetIpamPoolCidrs`, `GetIpamResourceCidrs`, `GetLaunchTemplateData`, `GetManagedPrefixListAssociations`, `GetManagedPrefixListEntries`, `GetNetworkInsightsAccessScopeAnalysisFindings`, `GetNetworkInsightsAccessScopeContent`
|
||||
|
||||
`GetReservedInstancesExchangeQuote`, `GetSecurityGroupsForVpc`, `GetSnapshotBlockPublicAccessState`, `GetSpotPlacementScores`, `GetSubnetCidrReservations`, `GetTransitGatewayAttachmentPropagations`, `GetTransitGatewayMulticastDomainAssociations`, `GetTransitGatewayPolicyTableAssociations`
|
||||
|
||||
`GetTransitGatewayPolicyTableEntries`, `GetTransitGatewayPrefixListReferences`, `GetTransitGatewayRouteTableAssociations`, `GetTransitGatewayRouteTablePropagations`, `GetVerifiedAccessEndpointPolicy`, `GetVerifiedAccessGroupPolicy`, `GetVpnConnectionDeviceSampleConfiguration`, `GetVpnConnectionDeviceTypes`
|
||||
|
||||
`GetVpnTunnelReplacementStatus`, `ImportClientVpnClientCertificateRevocationList`, `ImportImage`, `ImportInstance`, `ImportSnapshot`, `ImportVolume`, `ListImagesInRecycleBin`, `ListSnapshotsInRecycleBin`
|
||||
|
||||
`LockSnapshot`, `ModifyAddressAttribute`, `ModifyAvailabilityZoneGroup`, `ModifyCapacityReservation`, `ModifyCapacityReservationFleet`, `ModifyClientVpnEndpoint`, `ModifyDefaultCreditSpecification`, `ModifyEbsDefaultKmsKeyId`
|
||||
|
||||
`ModifyFleet`, `ModifyFpgaImageAttribute`, `ModifyHosts`, `ModifyIdFormat`, `ModifyIdentityIdFormat`, `ModifyInstanceCapacityReservationAttributes`, `ModifyInstanceCreditSpecification`, `ModifyInstanceEventStartTime`
|
||||
|
||||
`ModifyInstanceEventWindow`, `ModifyInstanceMaintenanceOptions`, `ModifyInstanceMetadataDefaults`, `ModifyInstancePlacement`, `ModifyIpam`, `ModifyIpamPool`, `ModifyIpamResourceCidr`, `ModifyIpamResourceDiscovery`
|
||||
|
||||
`ModifyIpamScope`, `ModifyLocalGatewayRoute`, `ModifyManagedPrefixList`, `ModifyPrivateDnsNameOptions`, `ModifyReservedInstances`, `ModifySecurityGroupRules`, `ModifySnapshotAttribute`, `ModifySnapshotTier`
|
||||
|
||||
`ModifySpotFleetRequest`, `ModifyTrafficMirrorFilterNetworkServices`, `ModifyTrafficMirrorFilterRule`, `ModifyTrafficMirrorSession`, `ModifyTransitGateway`, `ModifyTransitGatewayPrefixListReference`, `ModifyTransitGatewayVpcAttachment`, `ModifyVerifiedAccessEndpoint`
|
||||
|
||||
`ModifyVerifiedAccessEndpointPolicy`, `ModifyVerifiedAccessGroup`, `ModifyVerifiedAccessGroupPolicy`, `ModifyVerifiedAccessInstance`, `ModifyVerifiedAccessInstanceLoggingConfiguration`, `ModifyVerifiedAccessTrustProvider`, `ModifyVolumeAttribute`, `ModifyVpcEndpoint`
|
||||
|
||||
`ModifyVpcEndpointConnectionNotification`, `ModifyVpcEndpointServiceConfiguration`, `ModifyVpcEndpointServicePayerResponsibility`, `ModifyVpcEndpointServicePermissions`, `ModifyVpcPeeringConnectionOptions`, `ModifyVpcTenancy`, `ModifyVpnConnection`, `ModifyVpnConnectionOptions`
|
||||
|
||||
`ModifyVpnTunnelCertificate`, `ModifyVpnTunnelOptions`, `MonitorInstances`, `MoveAddressToVpc`, `MoveByoipCidrToIpam`, `ProvisionByoipCidr`, `ProvisionIpamByoasn`, `ProvisionIpamPoolCidr`
|
||||
|
||||
`ProvisionPublicIpv4PoolCidr`, `PurchaseCapacityBlock`, `PurchaseHostReservation`, `PurchaseReservedInstancesOffering`, `PurchaseScheduledInstances`, `RegisterInstanceEventNotificationAttributes`, `RegisterTransitGatewayMulticastGroupMembers`, `RegisterTransitGatewayMulticastGroupSources`
|
||||
|
||||
`RejectTransitGatewayMulticastDomainAssociations`, `RejectTransitGatewayPeeringAttachment`, `RejectTransitGatewayVpcAttachment`, `RejectVpcEndpointConnections`, `RejectVpcPeeringConnection`, `ReleaseHosts`, `ReleaseIpamPoolAllocation`, `ReplaceNetworkAclAssociation`
|
||||
|
||||
`ReplaceNetworkAclEntry`, `ReplaceTransitGatewayRoute`, `ReplaceVpnTunnel`, `ReportInstanceStatus`, `RequestSpotFleet`, `ResetAddressAttribute`, `ResetEbsDefaultKmsKeyId`, `ResetFpgaImageAttribute`
|
||||
|
||||
`ResetInstanceAttribute`, `ResetNetworkInterfaceAttribute`, `ResetSnapshotAttribute`, `RestoreAddressToClassic`, `RestoreImageFromRecycleBin`, `RestoreManagedPrefixListVersion`, `RestoreSnapshotFromRecycleBin`, `RestoreSnapshotTier`
|
||||
|
||||
`RevokeClientVpnIngress`, `RunScheduledInstances`, `SearchLocalGatewayRoutes`, `SearchTransitGatewayMulticastGroups`, `SearchTransitGatewayRoutes`, `SendDiagnosticInterrupt`, `StartNetworkInsightsAccessScopeAnalysis`, `StartNetworkInsightsAnalysis`
|
||||
|
||||
`StartVpcEndpointServicePrivateDnsVerification`, `TerminateClientVpnConnections`, `UnassignIpv6Addresses`, `UnassignPrivateIpAddresses`, `UnassignPrivateNatGatewayAddress`, `UnlockSnapshot`, `UnmonitorInstances`, `UpdateSecurityGroupRuleDescriptionsEgress`
|
||||
|
||||
`UpdateSecurityGroupRuleDescriptionsIngress`, `WithdrawByoipCidr`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
## ecr
|
||||
|
||||
Implements **19 of 47** modelled operations (40.4%).
|
||||
|
||||
<details><summary>Implemented (19)</summary>
|
||||
|
||||
`BatchDeleteImage`, `BatchGetImage`, `CreateRepository`, `DeleteLifecyclePolicy`, `DeleteRepository`, `DeleteRepositoryPolicy`, `DescribeImages`, `DescribeRepositories`
|
||||
|
||||
`GetAuthorizationToken`, `GetLifecyclePolicy`, `GetLifecyclePolicyPreview`, `GetRepositoryPolicy`, `ListImages`, `ListTagsForResource`, `PutImage`, `PutImageTagMutability`
|
||||
|
||||
`PutLifecyclePolicy`, `SetRepositoryPolicy`, `StartLifecyclePolicyPreview`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (11)</summary>
|
||||
|
||||
`CreatePullThroughCacheRule`, `CreateRepositoryCreationTemplate`, `DeletePullThroughCacheRule`, `DeleteRegistryPolicy`, `DeleteRepositoryCreationTemplate`, `DescribeImageReplicationStatus`, `DescribePullThroughCacheRules`, `DescribeRepositoryCreationTemplates`
|
||||
|
||||
`UpdatePullThroughCacheRule`, `UpdateRepositoryCreationTemplate`, `ValidatePullThroughCacheRule`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (13)</summary>
|
||||
|
||||
`BatchCheckLayerAvailability`, `CompleteLayerUpload`, `DescribeRegistry`, `GetDownloadUrlForLayer`, `GetRegistryPolicy`, `InitiateLayerUpload`, `ListRepositories`, `PutRegistryPolicy`
|
||||
|
||||
`PutReplicationConfiguration`, `ReplicateImage`, `TagResource`, `UntagResource`, `UploadLayerPart`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (7)</summary>
|
||||
|
||||
`BatchGetRepositoryScanningConfiguration`, `DescribeImageScanFindings`, `GetImageScanningConfiguration`, `GetRegistryScanningConfiguration`, `PutImageScanningConfiguration`, `PutRegistryScanningConfiguration`, `StartImageScan`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (3)</summary>
|
||||
|
||||
`GetImageScanningConfiguration`, `ListRepositories`, `ReplicateImage`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## ecs
|
||||
|
||||
Implements **31 of 56** modelled operations (55.4%).
|
||||
|
||||
<details><summary>Implemented (31)</summary>
|
||||
|
||||
`CreateCapacityProvider`, `CreateCluster`, `CreateService`, `DeleteCapacityProvider`, `DeleteCluster`, `DeleteService`, `DeregisterContainerInstance`, `DeregisterTaskDefinition`
|
||||
|
||||
`DescribeCapacityProviders`, `DescribeClusters`, `DescribeContainerInstances`, `DescribeServices`, `DescribeTaskDefinition`, `DescribeTasks`, `ListClusters`, `ListContainerInstances`
|
||||
|
||||
`ListServices`, `ListTagsForResource`, `ListTaskDefinitions`, `ListTasks`, `PutClusterCapacityProviders`, `RegisterContainerInstance`, `RegisterTaskDefinition`, `RunTask`
|
||||
|
||||
`StartTask`, `StopTask`, `SubmitTaskStateChange`, `TagResource`, `UntagResource`, `UpdateContainerInstancesState`, `UpdateService`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (20)</summary>
|
||||
|
||||
`CreateTaskSet`, `DeleteAccountSetting`, `DeleteAttributes`, `DeleteTaskDefinitions`, `DeleteTaskSet`, `DescribeTaskSets`, `DiscoverPollEndpoint`, `ExecuteCommand`
|
||||
|
||||
`GetTaskProtection`, `ListAttributes`, `PutAccountSettingDefault`, `PutAttributes`, `SubmitAttachmentStateChanges`, `SubmitContainerStateChange`, `UpdateCapacityProvider`, `UpdateClusterSettings`
|
||||
|
||||
`UpdateContainerAgent`, `UpdateServicePrimaryTaskSet`, `UpdateTaskProtection`, `UpdateTaskSet`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (5)</summary>
|
||||
|
||||
`ListAccountSettings`, `ListServicesByNamespace`, `ListTaskDefinitionFamilies`, `PutAccountSetting`, `UpdateCluster`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (3)</summary>
|
||||
|
||||
`PollAssignments`, `ProvisionCapacity`, `ReportTaskGPU`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## elasticloadbalancingv2
|
||||
|
||||
Implements **33 of 46** modelled operations (71.7%).
|
||||
|
||||
<details><summary>Implemented (33)</summary>
|
||||
|
||||
`AddListenerCertificates`, `AddTags`, `CreateListener`, `CreateLoadBalancer`, `CreateRule`, `CreateTargetGroup`, `DeleteListener`, `DeleteLoadBalancer`
|
||||
|
||||
`DeleteRule`, `DeleteTargetGroup`, `DeregisterTargets`, `DescribeListenerCertificates`, `DescribeListeners`, `DescribeLoadBalancerAttributes`, `DescribeLoadBalancers`, `DescribeRules`
|
||||
|
||||
`DescribeSSLPolicies`, `DescribeTags`, `DescribeTargetGroupAttributes`, `DescribeTargetGroups`, `DescribeTargetHealth`, `ModifyListener`, `ModifyLoadBalancerAttributes`, `ModifyRule`
|
||||
|
||||
`ModifyTargetGroup`, `ModifyTargetGroupAttributes`, `RegisterTargets`, `RemoveListenerCertificates`, `RemoveTags`, `SetIpAddressType`, `SetRulePriorities`, `SetSecurityGroups`
|
||||
|
||||
`SetSubnets`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (13)</summary>
|
||||
|
||||
`AddTrustStoreRevocations`, `CreateTrustStore`, `DeleteSharedTrustStoreAssociation`, `DeleteTrustStore`, `DescribeAccountLimits`, `DescribeTrustStoreAssociations`, `DescribeTrustStoreRevocations`, `DescribeTrustStores`
|
||||
|
||||
`GetResourcePolicy`, `GetTrustStoreCaCertificatesBundle`, `GetTrustStoreRevocationContent`, `ModifyTrustStore`, `RemoveTrustStoreRevocations`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (4)</summary>
|
||||
|
||||
`DescribeListenerAttributes`, `GetLBConfig`, `LBAgentHeartbeat`, `ModifyListenerAttributes`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## iam
|
||||
|
||||
Implements **75 of 159** modelled operations (47.2%).
|
||||
|
||||
<details><summary>Implemented (75)</summary>
|
||||
|
||||
`AddRoleToInstanceProfile`, `AddUserToGroup`, `AttachGroupPolicy`, `AttachRolePolicy`, `AttachUserPolicy`, `CreateAccessKey`, `CreateGroup`, `CreateInstanceProfile`
|
||||
|
||||
`CreateOpenIDConnectProvider`, `CreatePolicy`, `CreateRole`, `CreateUser`, `DeleteAccessKey`, `DeleteGroup`, `DeleteGroupPolicy`, `DeleteInstanceProfile`
|
||||
|
||||
`DeleteOpenIDConnectProvider`, `DeletePolicy`, `DeleteRole`, `DeleteRolePolicy`, `DeleteUser`, `DeleteUserPolicy`, `DetachGroupPolicy`, `DetachRolePolicy`
|
||||
|
||||
`DetachUserPolicy`, `GetAccountSummary`, `GetGroup`, `GetGroupPolicy`, `GetInstanceProfile`, `GetOpenIDConnectProvider`, `GetPolicy`, `GetPolicyVersion`
|
||||
|
||||
`GetRole`, `GetRolePolicy`, `GetUser`, `GetUserPolicy`, `ListAccessKeys`, `ListAttachedGroupPolicies`, `ListAttachedRolePolicies`, `ListAttachedUserPolicies`
|
||||
|
||||
`ListGroupPolicies`, `ListGroups`, `ListGroupsForUser`, `ListInstanceProfileTags`, `ListInstanceProfiles`, `ListInstanceProfilesForRole`, `ListOpenIDConnectProviderTags`, `ListOpenIDConnectProviders`
|
||||
|
||||
`ListPolicies`, `ListPolicyTags`, `ListPolicyVersions`, `ListRolePolicies`, `ListRoleTags`, `ListRoles`, `ListUserPolicies`, `ListUserTags`
|
||||
|
||||
`ListUsers`, `PutGroupPolicy`, `PutRolePolicy`, `PutUserPolicy`, `RemoveRoleFromInstanceProfile`, `RemoveUserFromGroup`, `TagInstanceProfile`, `TagOpenIDConnectProvider`
|
||||
|
||||
`TagPolicy`, `TagRole`, `TagUser`, `UntagInstanceProfile`, `UntagOpenIDConnectProvider`, `UntagPolicy`, `UntagRole`, `UntagUser`
|
||||
|
||||
`UpdateAccessKey`, `UpdateAssumeRolePolicy`, `UpdateRole`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (84)</summary>
|
||||
|
||||
`AddClientIDToOpenIDConnectProvider`, `ChangePassword`, `CreateAccountAlias`, `CreateLoginProfile`, `CreatePolicyVersion`, `CreateSAMLProvider`, `CreateServiceLinkedRole`, `CreateServiceSpecificCredential`
|
||||
|
||||
`CreateVirtualMFADevice`, `DeactivateMFADevice`, `DeleteAccountAlias`, `DeleteAccountPasswordPolicy`, `DeleteLoginProfile`, `DeletePolicyVersion`, `DeleteRolePermissionsBoundary`, `DeleteSAMLProvider`
|
||||
|
||||
`DeleteSSHPublicKey`, `DeleteServerCertificate`, `DeleteServiceLinkedRole`, `DeleteServiceSpecificCredential`, `DeleteSigningCertificate`, `DeleteUserPermissionsBoundary`, `DeleteVirtualMFADevice`, `EnableMFADevice`
|
||||
|
||||
`GenerateCredentialReport`, `GenerateOrganizationsAccessReport`, `GenerateServiceLastAccessedDetails`, `GetAccessKeyLastUsed`, `GetAccountAuthorizationDetails`, `GetAccountPasswordPolicy`, `GetContextKeysForCustomPolicy`, `GetContextKeysForPrincipalPolicy`
|
||||
|
||||
`GetCredentialReport`, `GetLoginProfile`, `GetMFADevice`, `GetOrganizationsAccessReport`, `GetSAMLProvider`, `GetSSHPublicKey`, `GetServerCertificate`, `GetServiceLastAccessedDetails`
|
||||
|
||||
`GetServiceLastAccessedDetailsWithEntities`, `GetServiceLinkedRoleDeletionStatus`, `ListAccountAliases`, `ListEntitiesForPolicy`, `ListMFADeviceTags`, `ListMFADevices`, `ListPoliciesGrantingServiceAccess`, `ListSAMLProviderTags`
|
||||
|
||||
`ListSAMLProviders`, `ListSSHPublicKeys`, `ListServerCertificateTags`, `ListServerCertificates`, `ListServiceSpecificCredentials`, `ListSigningCertificates`, `ListVirtualMFADevices`, `PutRolePermissionsBoundary`
|
||||
|
||||
`PutUserPermissionsBoundary`, `RemoveClientIDFromOpenIDConnectProvider`, `ResetServiceSpecificCredential`, `ResyncMFADevice`, `SetDefaultPolicyVersion`, `SetSecurityTokenServicePreferences`, `SimulateCustomPolicy`, `SimulatePrincipalPolicy`
|
||||
|
||||
`TagMFADevice`, `TagSAMLProvider`, `TagServerCertificate`, `UntagMFADevice`, `UntagSAMLProvider`, `UntagServerCertificate`, `UpdateAccountPasswordPolicy`, `UpdateGroup`
|
||||
|
||||
`UpdateLoginProfile`, `UpdateOpenIDConnectProviderThumbprint`, `UpdateRoleDescription`, `UpdateSAMLProvider`, `UpdateSSHPublicKey`, `UpdateServerCertificate`, `UpdateServiceSpecificCredential`, `UpdateSigningCertificate`
|
||||
|
||||
`UpdateUser`, `UploadSSHPublicKey`, `UploadServerCertificate`, `UploadSigningCertificate`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
## s3
|
||||
|
||||
Operation coverage is not enumerable: Spinifex delegates the S3 REST surface to Predastore, which has no operation-name dispatch table to compare mechanically.
|
||||
|
||||
## sts
|
||||
|
||||
Implements **4 of 8** modelled operations (50.0%).
|
||||
|
||||
<details><summary>Implemented (4)</summary>
|
||||
|
||||
`AssumeRole`, `AssumeRoleWithWebIdentity`, `GetCallerIdentity`, `GetSessionToken`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Missing from dispatch (4)</summary>
|
||||
|
||||
`AssumeRoleWithSAML`, `DecodeAuthorizationMessage`, `GetAccessKeyInfo`, `GetFederationToken`
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered stubs (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Deliberately unsupported (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Registered outside the pinned model (0)</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,187 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DispatchInventory is the gateway-side operation registration state supplied
|
||||
// to CompareOperations.
|
||||
type DispatchInventory struct {
|
||||
Registered []string
|
||||
Stubbed []string
|
||||
Unsupported []string
|
||||
Opaque bool
|
||||
Note string
|
||||
}
|
||||
|
||||
// OperationCoverage is a deterministic model-versus-dispatch comparison.
|
||||
// Implemented contains only modelled operations with a real handler; Extra
|
||||
// contains registered operations absent from the pinned model.
|
||||
type OperationCoverage struct {
|
||||
Service Service
|
||||
APIVersion string
|
||||
Modelled []string
|
||||
Registered []string
|
||||
Implemented []string
|
||||
Stubbed []string
|
||||
Unsupported []string
|
||||
Missing []string
|
||||
Extra []string
|
||||
Opaque bool
|
||||
Note string
|
||||
}
|
||||
|
||||
// CompareOperations compares one embedded service model with an authoritative
|
||||
// gateway dispatch inventory.
|
||||
func CompareOperations(service Service, dispatch DispatchInventory) (OperationCoverage, error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
return OperationCoverage{}, err
|
||||
}
|
||||
coverage := OperationCoverage{
|
||||
Service: service,
|
||||
APIVersion: model.Metadata().APIVersion,
|
||||
Modelled: model.Operations(),
|
||||
Opaque: dispatch.Opaque,
|
||||
Note: dispatch.Note,
|
||||
}
|
||||
if dispatch.Opaque {
|
||||
return coverage, nil
|
||||
}
|
||||
|
||||
registered, err := uniqueSorted("registered", dispatch.Registered)
|
||||
if err != nil {
|
||||
return OperationCoverage{}, fmt.Errorf("awsmodel: %s dispatch: %w", service, err)
|
||||
}
|
||||
stubbed, err := uniqueSorted("stubbed", dispatch.Stubbed)
|
||||
if err != nil {
|
||||
return OperationCoverage{}, fmt.Errorf("awsmodel: %s dispatch: %w", service, err)
|
||||
}
|
||||
unsupported, err := uniqueSorted("unsupported", dispatch.Unsupported)
|
||||
if err != nil {
|
||||
return OperationCoverage{}, fmt.Errorf("awsmodel: %s dispatch: %w", service, err)
|
||||
}
|
||||
registeredSet := toSet(registered)
|
||||
stubbedSet := toSet(stubbed)
|
||||
unsupportedSet := toSet(unsupported)
|
||||
for operation := range stubbedSet {
|
||||
if !registeredSet[operation] {
|
||||
return OperationCoverage{}, fmt.Errorf("awsmodel: %s stubbed operation %q is not registered", service, operation)
|
||||
}
|
||||
if unsupportedSet[operation] {
|
||||
return OperationCoverage{}, fmt.Errorf("awsmodel: %s operation %q is both stubbed and unsupported", service, operation)
|
||||
}
|
||||
}
|
||||
for operation := range unsupportedSet {
|
||||
if !registeredSet[operation] {
|
||||
return OperationCoverage{}, fmt.Errorf("awsmodel: %s unsupported operation %q is not registered", service, operation)
|
||||
}
|
||||
}
|
||||
|
||||
modelledSet := toSet(coverage.Modelled)
|
||||
for _, operation := range coverage.Modelled {
|
||||
if !registeredSet[operation] {
|
||||
coverage.Missing = append(coverage.Missing, operation)
|
||||
continue
|
||||
}
|
||||
if !stubbedSet[operation] && !unsupportedSet[operation] {
|
||||
coverage.Implemented = append(coverage.Implemented, operation)
|
||||
}
|
||||
}
|
||||
for _, operation := range registered {
|
||||
if !modelledSet[operation] {
|
||||
coverage.Extra = append(coverage.Extra, operation)
|
||||
}
|
||||
}
|
||||
coverage.Registered = registered
|
||||
coverage.Stubbed = stubbed
|
||||
coverage.Unsupported = unsupported
|
||||
return coverage, nil
|
||||
}
|
||||
|
||||
func uniqueSorted(label string, values []string) ([]string, error) {
|
||||
result := append([]string(nil), values...)
|
||||
sort.Strings(result)
|
||||
for index, value := range result {
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("%s operation is empty", label)
|
||||
}
|
||||
if index > 0 && result[index-1] == value {
|
||||
return nil, fmt.Errorf("%s operation %q is duplicated", label, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toSet(values []string) map[string]bool {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// RenderCoverageMarkdown renders a reproducible divergence report suitable for
|
||||
// publishing directly or checking as a generated artefact.
|
||||
func RenderCoverageMarkdown(coverages []OperationCoverage) string {
|
||||
ordered := append([]OperationCoverage(nil), coverages...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Service < ordered[j].Service })
|
||||
|
||||
var report strings.Builder
|
||||
fmt.Fprintf(&report, "# AWS model operation coverage\n\n")
|
||||
fmt.Fprintf(&report, "Generated from the cached `aws-sdk-go %s` `api-2.json` models and Spinifex's authoritative gateway dispatch tables.\n\n", SourceSDKVersion)
|
||||
report.WriteString("Implemented means a modelled operation is registered to a real handler. Stub and deliberately unsupported handlers are reported separately. Shape conformance does not imply behavioural conformance.\n\n")
|
||||
report.WriteString("| Service | API version | Modelled | Registered | Implemented | Stubbed | Unsupported | Missing | Extra |\n")
|
||||
report.WriteString("|---|---:|---:|---:|---:|---:|---:|---:|---:|\n")
|
||||
for _, coverage := range ordered {
|
||||
if coverage.Opaque {
|
||||
fmt.Fprintf(&report, "| %s | %s | %d | — | — | — | — | — | — |\n",
|
||||
coverage.Service, coverage.APIVersion, len(coverage.Modelled))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&report, "| %s | %s | %d | %d | %d | %d | %d | %d | %d |\n",
|
||||
coverage.Service, coverage.APIVersion, len(coverage.Modelled), len(coverage.Registered),
|
||||
len(coverage.Implemented), len(coverage.Stubbed), len(coverage.Unsupported), len(coverage.Missing), len(coverage.Extra))
|
||||
}
|
||||
|
||||
for _, coverage := range ordered {
|
||||
fmt.Fprintf(&report, "\n## %s\n\n", coverage.Service)
|
||||
if coverage.Opaque {
|
||||
fmt.Fprintf(&report, "Operation coverage is not enumerable: %s\n", coverage.Note)
|
||||
continue
|
||||
}
|
||||
percentage := float64(0)
|
||||
if len(coverage.Modelled) != 0 {
|
||||
percentage = 100 * float64(len(coverage.Implemented)) / float64(len(coverage.Modelled))
|
||||
}
|
||||
fmt.Fprintf(&report, "Implements **%d of %d** modelled operations (%.1f%%).\n", len(coverage.Implemented), len(coverage.Modelled), percentage)
|
||||
writeOperationList(&report, "Implemented", coverage.Implemented)
|
||||
writeOperationList(&report, "Missing from dispatch", coverage.Missing)
|
||||
writeOperationList(&report, "Registered stubs", coverage.Stubbed)
|
||||
writeOperationList(&report, "Deliberately unsupported", coverage.Unsupported)
|
||||
writeOperationList(&report, "Registered outside the pinned model", coverage.Extra)
|
||||
if coverage.Note != "" {
|
||||
fmt.Fprintf(&report, "\nNote: %s\n", coverage.Note)
|
||||
}
|
||||
}
|
||||
return report.String()
|
||||
}
|
||||
|
||||
func writeOperationList(report *strings.Builder, title string, operations []string) {
|
||||
fmt.Fprintf(report, "\n<details><summary>%s (%d)</summary>\n\n", title, len(operations))
|
||||
if len(operations) == 0 {
|
||||
report.WriteString("None.\n")
|
||||
} else {
|
||||
for start := 0; start < len(operations); start += 8 {
|
||||
end := min(start+8, len(operations))
|
||||
quoted := make([]string, 0, end-start)
|
||||
for _, operation := range operations[start:end] {
|
||||
quoted = append(quoted, "`"+operation+"`")
|
||||
}
|
||||
report.WriteString(strings.Join(quoted, ", ") + "\n\n")
|
||||
}
|
||||
}
|
||||
report.WriteString("\n</details>\n")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompareOperations(t *testing.T) {
|
||||
coverage, err := CompareOperations(STS, DispatchInventory{
|
||||
Registered: []string{"AssumeRole", "ExtraOperation", "GetCallerIdentity", "GetSessionToken"},
|
||||
Stubbed: []string{"GetSessionToken"},
|
||||
Unsupported: []string{"GetCallerIdentity"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(coverage.Implemented) != 1 || coverage.Implemented[0] != "AssumeRole" {
|
||||
t.Fatalf("implemented = %v, want [AssumeRole]", coverage.Implemented)
|
||||
}
|
||||
if len(coverage.Missing) != 5 {
|
||||
t.Fatalf("missing count = %d, want 5: %v", len(coverage.Missing), coverage.Missing)
|
||||
}
|
||||
if len(coverage.Extra) != 1 || coverage.Extra[0] != "ExtraOperation" {
|
||||
t.Fatalf("extra = %v, want [ExtraOperation]", coverage.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareOperationsRejectsInvalidInventory(t *testing.T) {
|
||||
_, err := CompareOperations(STS, DispatchInventory{Registered: []string{"AssumeRole"}, Stubbed: []string{"Missing"}})
|
||||
if err == nil || !strings.Contains(err.Error(), `stubbed operation "Missing" is not registered`) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCoverageMarkdown(t *testing.T) {
|
||||
coverage, err := CompareOperations(STS, DispatchInventory{Registered: []string{"AssumeRole"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := RenderCoverageMarkdown([]OperationCoverage{coverage})
|
||||
for _, want := range []string{
|
||||
"# AWS model operation coverage",
|
||||
"| sts | 2011-06-15 | 8 | 1 | 1 | 0 | 0 | 7 | 0 |",
|
||||
"Implements **1 of 8** modelled operations (12.5%).",
|
||||
"<summary>Missing from dispatch (7)</summary>",
|
||||
} {
|
||||
if !strings.Contains(report, want) {
|
||||
t.Errorf("report does not contain %q:\n%s", want, report)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const ec2ErrorCatalogPath = "models/ec2/error-codes.json"
|
||||
|
||||
type ec2ErrorClass string
|
||||
|
||||
const (
|
||||
ec2ErrorClassClient ec2ErrorClass = "client"
|
||||
ec2ErrorClassServer ec2ErrorClass = "server"
|
||||
)
|
||||
|
||||
type ec2ErrorCatalogFile struct {
|
||||
Source string `json:"source"`
|
||||
VerifiedOn string `json:"verifiedOn"`
|
||||
CommonClient []string `json:"commonClient"`
|
||||
SpecificClient []string `json:"specificClient"`
|
||||
Server []string `json:"server"`
|
||||
}
|
||||
|
||||
type ec2ErrorCatalog struct {
|
||||
metadata ec2ErrorCatalogFile
|
||||
codes map[string]ec2ErrorClass
|
||||
}
|
||||
|
||||
var loadEC2ErrorCatalog = sync.OnceValues(func() (*ec2ErrorCatalog, error) {
|
||||
contents, err := modelFS.ReadFile(ec2ErrorCatalogPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("awsmodel: read EC2 error catalog: %w", err)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.DisallowUnknownFields()
|
||||
var file ec2ErrorCatalogFile
|
||||
if err := decoder.Decode(&file); err != nil {
|
||||
return nil, fmt.Errorf("awsmodel: parse EC2 error catalog: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
err = errors.New("multiple JSON values")
|
||||
}
|
||||
return nil, fmt.Errorf("awsmodel: parse EC2 error catalog: %w", err)
|
||||
}
|
||||
if file.Source == "" || file.VerifiedOn == "" {
|
||||
return nil, errors.New("awsmodel: EC2 error catalog requires source and verifiedOn")
|
||||
}
|
||||
|
||||
catalog := &ec2ErrorCatalog{metadata: file, codes: make(map[string]ec2ErrorClass)}
|
||||
add := func(label string, values []string, class ec2ErrorClass) error {
|
||||
if !slices.IsSorted(values) {
|
||||
return fmt.Errorf("awsmodel: EC2 error catalog %s codes are not sorted", label)
|
||||
}
|
||||
for _, code := range values {
|
||||
if code == "" {
|
||||
return fmt.Errorf("awsmodel: EC2 error catalog %s contains an empty code", label)
|
||||
}
|
||||
if previous, exists := catalog.codes[code]; exists {
|
||||
return fmt.Errorf("awsmodel: EC2 error catalog code %q is duplicated (%s and %s)", code, previous, label)
|
||||
}
|
||||
catalog.codes[code] = class
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := add("commonClient", file.CommonClient, ec2ErrorClassClient); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("specificClient", file.SpecificClient, ec2ErrorClassClient); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("server", file.Server, ec2ErrorClassServer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catalog, nil
|
||||
})
|
||||
|
||||
// ValidateEC2ErrorResponse validates an EC2 Query API error envelope against
|
||||
// the checked-in catalog curated from AWS's EC2 error-code reference. The AWS
|
||||
// api-2.json model declares no operation errors, so this is intentionally a
|
||||
// separate oracle.
|
||||
func ValidateEC2ErrorResponse(status int, body []byte) ([]Violation, error) {
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("awsmodel: decode EC2 error response: %w", err)
|
||||
}
|
||||
if root.name != "Response" {
|
||||
return nil, fmt.Errorf("awsmodel: decode EC2 error response: root element is %q, want %q", root.name, "Response")
|
||||
}
|
||||
errorsNode := directXMLChild(root, "Errors")
|
||||
if errorsNode == nil {
|
||||
return nil, errors.New("awsmodel: decode EC2 error response: Errors element is missing")
|
||||
}
|
||||
errorNode := directXMLChild(errorsNode, "Error")
|
||||
if errorNode == nil {
|
||||
return nil, errors.New("awsmodel: decode EC2 error response: Error element is missing")
|
||||
}
|
||||
code := directXMLText(errorNode, "Code")
|
||||
if code == "" {
|
||||
return nil, errors.New("awsmodel: decode EC2 error response: Code element is missing")
|
||||
}
|
||||
|
||||
catalog, err := loadEC2ErrorCatalog()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
class, known := catalog.codes[code]
|
||||
if !known {
|
||||
return []Violation{{
|
||||
Rule: RuleErrorCode,
|
||||
Path: "$error.Code",
|
||||
Message: fmt.Sprintf("error code %q is not in the curated EC2 catalog", code),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
wantStatusClass := 4
|
||||
if class == ec2ErrorClassServer {
|
||||
wantStatusClass = 5
|
||||
}
|
||||
if status/100 != wantStatusClass {
|
||||
return []Violation{{
|
||||
Rule: RuleHTTPStatus,
|
||||
Path: "$response.status",
|
||||
Message: fmt.Sprintf("status %d is not in the %dxx class required for EC2 %s error %q", status, wantStatusClass, class, code),
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func directXMLChild(node *xmlNode, name string) *xmlNode {
|
||||
for _, child := range node.children {
|
||||
if child.name == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func directXMLText(node *xmlNode, name string) string {
|
||||
child := directXMLChild(node, name)
|
||||
if child == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(child.text.String())
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateEC2ErrorResponseAllowsCuratedCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{name: "common client", status: 403, code: "UnauthorizedOperation"},
|
||||
{name: "specific client", status: 404, code: "InvalidInstanceID.NotFound"},
|
||||
{name: "server", status: 500, code: "InternalError"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
violations, err := ValidateEC2ErrorResponse(test.status, ec2ErrorBody(test.code))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("violations = %#v, want none", violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEC2ErrorResponseRejectsUnverifiedCode(t *testing.T) {
|
||||
violations, err := ValidateEC2ErrorResponse(403, ec2ErrorBody("ExpiredToken"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{{
|
||||
Rule: RuleErrorCode,
|
||||
Path: "$error.Code",
|
||||
Message: `error code "ExpiredToken" is not in the curated EC2 catalog`,
|
||||
}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEC2ErrorResponseRejectsWrongStatusClass(t *testing.T) {
|
||||
violations, err := ValidateEC2ErrorResponse(400, ec2ErrorBody("InsufficientInstanceCapacity"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{{
|
||||
Rule: RuleHTTPStatus,
|
||||
Path: "$response.status",
|
||||
Message: `status 400 is not in the 5xx class required for EC2 server error "InsufficientInstanceCapacity"`,
|
||||
}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEC2ErrorResponseRejectsMalformedEnvelope(t *testing.T) {
|
||||
_, err := ValidateEC2ErrorResponse(400, []byte(`<ErrorResponse><Error><Code>InvalidAction</Code></Error></ErrorResponse>`))
|
||||
if err == nil || !strings.Contains(err.Error(), `root element is "ErrorResponse", want "Response"`) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEC2ErrorCatalogMetadataAndClasses(t *testing.T) {
|
||||
catalog, err := loadEC2ErrorCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if catalog.metadata.Source != "https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html" {
|
||||
t.Fatalf("source = %q", catalog.metadata.Source)
|
||||
}
|
||||
if catalog.metadata.VerifiedOn == "" {
|
||||
t.Fatal("verifiedOn is empty")
|
||||
}
|
||||
if len(catalog.codes) != 112 {
|
||||
t.Fatalf("catalog code count = %d, want 112", len(catalog.codes))
|
||||
}
|
||||
if catalog.codes["InvalidParameterValue"] != ec2ErrorClassClient {
|
||||
t.Fatal("InvalidParameterValue is not classified as client")
|
||||
}
|
||||
if catalog.codes["ServerInternal"] != ec2ErrorClassServer {
|
||||
t.Fatal("ServerInternal is not classified as server")
|
||||
}
|
||||
}
|
||||
|
||||
func ec2ErrorBody(code string) []byte {
|
||||
return []byte(`<Response><Errors><Error><Code>` + code + `</Code><Message>test</Message></Error></Errors><RequestID>request-1</RequestID></Response>`)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package awsmodel
|
||||
|
||||
import "embed"
|
||||
|
||||
// modelFS contains only the curated EC2 error catalog. The large upstream
|
||||
// api-2.json files are resolved from the pinned aws-sdk-go module cache at
|
||||
// runtime; they are intentionally not source-controlled here.
|
||||
//
|
||||
//go:embed models/ec2/error-codes.json
|
||||
var modelFS embed.FS
|
||||
|
||||
var modelFiles = map[Service]string{
|
||||
ACM: "models/apis/acm/2015-12-08/api-2.json",
|
||||
EC2: "models/apis/ec2/2016-11-15/api-2.json",
|
||||
ECR: "models/apis/ecr/2015-09-21/api-2.json",
|
||||
ECS: "models/apis/ecs/2014-11-13/api-2.json",
|
||||
ElasticLoadBalancingV2: "models/apis/elasticloadbalancingv2/2015-12-01/api-2.json",
|
||||
IAM: "models/apis/iam/2010-05-08/api-2.json",
|
||||
S3: "models/apis/s3/2006-03-01/api-2.json",
|
||||
STS: "models/apis/sts/2011-06-15/api-2.json",
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateErrorResponse decodes an AWS error envelope and checks its code
|
||||
// against the errors declared for operation. Modelled is false when AWS's
|
||||
// operation definition declares no errors, as is the case for some STS calls.
|
||||
func ValidateErrorResponse(service Service, operationName string, body []byte) (violations []Violation, modelled bool, err error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
operation, ok := model.Operation(operationName)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("awsmodel: %s operation %q is not modelled", service, operationName)
|
||||
}
|
||||
|
||||
code, err := model.decodeErrorCode(body)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("awsmodel: decode %s %s error response: %w", service, operationName, err)
|
||||
}
|
||||
if isUnmodelledCommonError(service, code) {
|
||||
return nil, false, nil
|
||||
}
|
||||
codes := model.operationErrorCodes(operation)
|
||||
if len(codes) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
if slices.Contains(codes, code) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return []Violation{{
|
||||
Rule: RuleErrorCode,
|
||||
Path: "$error.Code",
|
||||
Message: fmt.Sprintf("error code %q is not declared; allowed [%s]", code, strings.Join(codes, ", ")),
|
||||
}}, true, nil
|
||||
}
|
||||
|
||||
// isUnmodelledCommonError identifies service/protocol errors AWS does not put
|
||||
// in operation error lists. They remain counted as unmodelled by the harness;
|
||||
// they are not treated as proof that an operation-specific code conforms.
|
||||
func isUnmodelledCommonError(service Service, code string) bool {
|
||||
switch service {
|
||||
case IAM, ElasticLoadBalancingV2:
|
||||
return isUnmodelledQueryError(code)
|
||||
case STS:
|
||||
// STS also uses these two runtime input errors even though api-2.json
|
||||
// does not declare them on AssumeRole/GetSessionToken.
|
||||
return isUnmodelledQueryError(code) || code == "ValidationError" || code == "InvalidParameterValue"
|
||||
case ECS:
|
||||
switch code {
|
||||
case "AccessDeniedException", "IncompleteSignatureException", "InvalidClientTokenIdException",
|
||||
"MissingAuthenticationTokenException", "RequestExpiredException", "SignatureDoesNotMatchException",
|
||||
"ThrottlingException":
|
||||
return true
|
||||
}
|
||||
default:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isUnmodelledQueryError(code string) bool {
|
||||
switch code {
|
||||
case "AccessDenied", "IncompleteSignature", "InvalidClientTokenId", "MissingAuthenticationToken",
|
||||
"RequestExpired", "SignatureDoesNotMatch", "Throttling":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) operationErrorCodes(operation *Operation) []string {
|
||||
codes := make([]string, 0, len(operation.Errors))
|
||||
for _, ref := range operation.Errors {
|
||||
code := ref.Shape
|
||||
if shape := m.shapes[ref.Shape]; shape != nil && shape.Error != nil && shape.Error.Code != "" {
|
||||
code = shape.Error.Code
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
sort.Strings(codes)
|
||||
return codes
|
||||
}
|
||||
|
||||
func (m *Model) decodeErrorCode(body []byte) (string, error) {
|
||||
switch m.metadata.Protocol {
|
||||
case "json", "rest-json":
|
||||
var envelope struct {
|
||||
Type string `json:"__type"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return "", err
|
||||
}
|
||||
code := envelope.Type
|
||||
if code == "" {
|
||||
code = envelope.Code
|
||||
}
|
||||
if separator := strings.LastIndex(code, "#"); separator >= 0 {
|
||||
code = code[separator+1:]
|
||||
}
|
||||
if separator := strings.Index(code, ":"); separator >= 0 {
|
||||
code = code[:separator]
|
||||
}
|
||||
if code == "" {
|
||||
return "", fmt.Errorf("JSON error envelope has no code")
|
||||
}
|
||||
return code, nil
|
||||
case "ec2", "query":
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
code := findXMLText(root, "Code")
|
||||
if code == "" {
|
||||
return "", fmt.Errorf("XML error envelope has no Code element")
|
||||
}
|
||||
return code, nil
|
||||
default:
|
||||
return "", fmt.Errorf("error decoding for protocol %q is not implemented", m.metadata.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func findXMLText(node *xmlNode, name string) string {
|
||||
if node.name == name {
|
||||
return strings.TrimSpace(node.text.String())
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if value := findXMLText(child, name); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateErrorResponseQueryAllowsDeclaredCode(t *testing.T) {
|
||||
body := []byte(`<ErrorResponse><Error><Code>NoSuchEntity</Code><Message>missing</Message></Error></ErrorResponse>`)
|
||||
violations, modelled, err := ValidateErrorResponse(IAM, "GetUser", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !modelled {
|
||||
t.Fatal("GetUser errors should be modelled")
|
||||
}
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("violations = %#v, want none", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorResponseRejectsUndeclaredCode(t *testing.T) {
|
||||
body := []byte(`<ErrorResponse><Error><Code>InventedFailure</Code></Error></ErrorResponse>`)
|
||||
violations, modelled, err := ValidateErrorResponse(IAM, "GetUser", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !modelled {
|
||||
t.Fatal("GetUser errors should be modelled")
|
||||
}
|
||||
want := []Violation{{
|
||||
Rule: RuleErrorCode,
|
||||
Path: "$error.Code",
|
||||
Message: `error code "InventedFailure" is not declared; allowed [NoSuchEntity, ServiceFailure]`,
|
||||
}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorResponseJSONAllowsDeclaredShapeName(t *testing.T) {
|
||||
body := []byte(`{"__type":"com.amazonaws.ecs#ClusterNotFoundException","message":"missing"}`)
|
||||
violations, modelled, err := ValidateErrorResponse(ECS, "DescribeTasks", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !modelled || len(violations) != 0 {
|
||||
t.Fatalf("modelled = %t, violations = %#v, want modelled and conforming", modelled, violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorResponseELBAllowsDeclaredCode(t *testing.T) {
|
||||
body := []byte(`<ErrorResponse><Error><Code>LoadBalancerNotFound</Code></Error></ErrorResponse>`)
|
||||
violations, modelled, err := ValidateErrorResponse(ElasticLoadBalancingV2, "DescribeLoadBalancers", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !modelled || len(violations) != 0 {
|
||||
t.Fatalf("modelled = %t, violations = %#v, want modelled and conforming", modelled, violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorResponseRejectsMalformedEnvelope(t *testing.T) {
|
||||
_, _, err := ValidateErrorResponse(ECS, "DescribeTasks", []byte(`{"message":"missing code"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected missing error code to fail decoding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorResponseOperationWithoutDeclaredErrors(t *testing.T) {
|
||||
body := []byte(`<ErrorResponse><Error><Code>AccessDenied</Code></Error></ErrorResponse>`)
|
||||
violations, modelled, err := ValidateErrorResponse(STS, "GetCallerIdentity", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modelled || len(violations) != 0 {
|
||||
t.Fatalf("modelled = %t, violations = %#v, want unmodelled and no violations", modelled, violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorResponseClassifiesCommonErrorsAsUnmodelled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
service Service
|
||||
operation string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "query authorization",
|
||||
service: IAM,
|
||||
operation: "CreateUser",
|
||||
body: `<ErrorResponse><Error><Code>AccessDenied</Code></Error></ErrorResponse>`,
|
||||
},
|
||||
{
|
||||
name: "STS runtime validation",
|
||||
service: STS,
|
||||
operation: "AssumeRole",
|
||||
body: `<ErrorResponse><Error><Code>ValidationError</Code></Error></ErrorResponse>`,
|
||||
},
|
||||
{
|
||||
name: "JSON authorization",
|
||||
service: ECS,
|
||||
operation: "DescribeTasks",
|
||||
body: `{"__type":"AccessDeniedException"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
violations, modelled, err := ValidateErrorResponse(test.service, test.operation, []byte(test.body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modelled || len(violations) != 0 {
|
||||
t.Fatalf("modelled = %t, violations = %#v, want unmodelled and no violations", modelled, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// Package awsmodel loads the AWS api-2.json service definitions used by the
|
||||
// conformance suite.
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SourceSDKVersion is the aws-sdk-go release whose module-cache models are
|
||||
// used by the conformance suite.
|
||||
const SourceSDKVersion = "v1.55.8"
|
||||
|
||||
const sourceSDKModule = "github.com/aws/aws-sdk-go"
|
||||
|
||||
// Service identifies an AWS service model loaded from the SDK module cache.
|
||||
type Service string
|
||||
|
||||
const (
|
||||
ACM Service = "acm"
|
||||
EC2 Service = "ec2"
|
||||
ECR Service = "ecr"
|
||||
ECS Service = "ecs"
|
||||
ElasticLoadBalancingV2 Service = "elasticloadbalancingv2"
|
||||
IAM Service = "iam"
|
||||
S3 Service = "s3"
|
||||
STS Service = "sts"
|
||||
)
|
||||
|
||||
// Metadata describes the service and wire protocol represented by a model.
|
||||
type Metadata struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
EndpointPrefix string `json:"endpointPrefix"`
|
||||
Protocol string `json:"protocol"`
|
||||
Protocols []string `json:"protocols"`
|
||||
ServiceAbbreviation string `json:"serviceAbbreviation"`
|
||||
ServiceFullName string `json:"serviceFullName"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
SignatureVersion string `json:"signatureVersion"`
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
// Operation describes an AWS API operation and the shapes used by its input,
|
||||
// output and declared errors.
|
||||
type Operation struct {
|
||||
Name string `json:"name"`
|
||||
HTTP HTTP `json:"http"`
|
||||
Input *ShapeRef `json:"input"`
|
||||
Output *ShapeRef `json:"output"`
|
||||
Errors []ShapeRef `json:"errors"`
|
||||
}
|
||||
|
||||
// HTTP describes an operation's HTTP binding.
|
||||
type HTTP struct {
|
||||
Method string `json:"method"`
|
||||
RequestURI string `json:"requestUri"`
|
||||
ResponseCode int `json:"responseCode"`
|
||||
}
|
||||
|
||||
// Shape describes a value in an AWS service model. Depending on Type, it can
|
||||
// refer to structure members, a list member, or map keys and values.
|
||||
type Shape struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Members map[string]ShapeRef `json:"members"`
|
||||
Member *ShapeRef `json:"member"`
|
||||
Key *ShapeRef `json:"key"`
|
||||
Value *ShapeRef `json:"value"`
|
||||
Enum []string `json:"enum"`
|
||||
Min *float64 `json:"min"`
|
||||
Max *float64 `json:"max"`
|
||||
Pattern string `json:"pattern"`
|
||||
LocationName string `json:"locationName"`
|
||||
TimestampFormat string `json:"timestampFormat"`
|
||||
Payload string `json:"payload"`
|
||||
Flattened bool `json:"flattened"`
|
||||
Sensitive bool `json:"sensitive"`
|
||||
Exception bool `json:"exception"`
|
||||
Fault bool `json:"fault"`
|
||||
Error *ErrorInfo `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorInfo describes an error shape's code and HTTP classification.
|
||||
type ErrorInfo struct {
|
||||
Code string `json:"code"`
|
||||
HTTPStatusCode int `json:"httpStatusCode"`
|
||||
SenderFault bool `json:"senderFault"`
|
||||
}
|
||||
|
||||
// ShapeRef names another shape and carries any wire binding specific to the
|
||||
// place where it is referenced.
|
||||
type ShapeRef struct {
|
||||
Shape string `json:"shape"`
|
||||
ResultWrapper string `json:"resultWrapper"`
|
||||
Location string `json:"location"`
|
||||
LocationName string `json:"locationName"`
|
||||
QueryName string `json:"queryName"`
|
||||
TimestampFormat string `json:"timestampFormat"`
|
||||
Flattened bool `json:"flattened"`
|
||||
Streaming bool `json:"streaming"`
|
||||
XMLAttribute bool `json:"xmlAttribute"`
|
||||
}
|
||||
|
||||
// Model is an indexed AWS service definition.
|
||||
type Model struct {
|
||||
service Service
|
||||
metadata Metadata
|
||||
operations map[string]*Operation
|
||||
shapes map[string]*Shape
|
||||
}
|
||||
|
||||
type modelDocument struct {
|
||||
Version string `json:"version"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Operations map[string]*Operation `json:"operations"`
|
||||
Shapes map[string]*Shape `json:"shapes"`
|
||||
}
|
||||
|
||||
type loadResult struct {
|
||||
model *Model
|
||||
err error
|
||||
}
|
||||
|
||||
var modelCache sync.Map
|
||||
|
||||
var modelRoot = sync.OnceValues(resolveModelRoot)
|
||||
|
||||
func resolveModelRoot() (string, error) {
|
||||
if root := os.Getenv("GOMODCACHE"); root != "" {
|
||||
return filepath.Join(root, sourceSDKModule+"@"+SourceSDKVersion), nil
|
||||
}
|
||||
if output, err := exec.Command("go", "env", "GOMODCACHE").Output(); err == nil {
|
||||
if root := string(output); root != "" {
|
||||
return filepath.Join(stringTrimSpace(root), sourceSDKModule+"@"+SourceSDKVersion), nil
|
||||
}
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("awsmodel: resolve Go module cache: %w", err)
|
||||
}
|
||||
return filepath.Join(currentUser.HomeDir, "go", "pkg", "mod", sourceSDKModule+"@"+SourceSDKVersion), nil
|
||||
}
|
||||
|
||||
func stringTrimSpace(value string) string {
|
||||
for len(value) > 0 && (value[len(value)-1] == '\n' || value[len(value)-1] == '\r' || value[len(value)-1] == ' ' || value[len(value)-1] == '\t') {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Services returns the supported service identifiers in stable order.
|
||||
func Services() []Service {
|
||||
services := make([]Service, 0, len(modelFiles))
|
||||
for service := range modelFiles {
|
||||
services = append(services, service)
|
||||
}
|
||||
slices.Sort(services)
|
||||
return services
|
||||
}
|
||||
|
||||
// Load parses and indexes the cached SDK model for service. Each service is
|
||||
// parsed at most once and the resulting Model is safe for concurrent reads.
|
||||
func Load(service Service) (*Model, error) {
|
||||
path, ok := modelFiles[service]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("awsmodel: unsupported service %q", service)
|
||||
}
|
||||
|
||||
loader, _ := modelCache.LoadOrStore(service, sync.OnceValue(func() loadResult {
|
||||
root, err := modelRoot()
|
||||
if err != nil {
|
||||
return loadResult{err: err}
|
||||
}
|
||||
contents, err := os.ReadFile(filepath.Join(root, path))
|
||||
if err != nil {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: read cached %s model at %s: %w", service, filepath.Join(root, path), err)}
|
||||
}
|
||||
|
||||
var document modelDocument
|
||||
if err := json.Unmarshal(contents, &document); err != nil {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: parse %s model: %w", service, err)}
|
||||
}
|
||||
if document.Version != "2.0" {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: %s model version is %q, want %q", service, document.Version, "2.0")}
|
||||
}
|
||||
if len(document.Operations) == 0 || len(document.Shapes) == 0 {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: %s model has no operations or shapes", service)}
|
||||
}
|
||||
|
||||
model := &Model{
|
||||
service: service,
|
||||
metadata: document.Metadata,
|
||||
operations: document.Operations,
|
||||
shapes: document.Shapes,
|
||||
}
|
||||
if err := model.validateReferences(); err != nil {
|
||||
return loadResult{err: err}
|
||||
}
|
||||
return loadResult{model: model}
|
||||
}))
|
||||
load, ok := loader.(func() loadResult)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("awsmodel: invalid cache entry for service %q", service)
|
||||
}
|
||||
result := load()
|
||||
return result.model, result.err
|
||||
}
|
||||
|
||||
// Service returns the identifier used to load the model.
|
||||
func (m *Model) Service() Service { return m.service }
|
||||
|
||||
// Metadata returns the service metadata from api-2.json.
|
||||
func (m *Model) Metadata() Metadata { return m.metadata }
|
||||
|
||||
// Operation resolves an operation by its model key, such as DescribeInstances.
|
||||
func (m *Model) Operation(name string) (*Operation, bool) {
|
||||
operation, ok := m.operations[name]
|
||||
return operation, ok
|
||||
}
|
||||
|
||||
// Shape resolves a shape by name.
|
||||
func (m *Model) Shape(name string) (*Shape, bool) {
|
||||
shape, ok := m.shapes[name]
|
||||
return shape, ok
|
||||
}
|
||||
|
||||
// Operations returns all operation keys in stable order.
|
||||
func (m *Model) Operations() []string { return sortedKeys(m.operations) }
|
||||
|
||||
// Shapes returns all shape names in stable order.
|
||||
func (m *Model) Shapes() []string { return sortedKeys(m.shapes) }
|
||||
|
||||
func sortedKeys[T any](values map[string]T) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (m *Model) validateReferences() error {
|
||||
validate := func(owner string, ref *ShapeRef) error {
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := m.shapes[ref.Shape]; !ok {
|
||||
return fmt.Errorf("awsmodel: %s model %s references unknown shape %q", m.service, owner, ref.Shape)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for name, operation := range m.operations {
|
||||
if operation == nil {
|
||||
return fmt.Errorf("awsmodel: %s model operation %q is null", m.service, name)
|
||||
}
|
||||
if err := validate("operation "+name+" input", operation.Input); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate("operation "+name+" output", operation.Output); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range operation.Errors {
|
||||
if err := validate(fmt.Sprintf("operation %s error %d", name, i), &operation.Errors[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, shape := range m.shapes {
|
||||
if shape == nil {
|
||||
return fmt.Errorf("awsmodel: %s model shape %q is null", m.service, name)
|
||||
}
|
||||
for memberName, member := range shape.Members {
|
||||
if err := validate("shape "+name+" member "+memberName, &member); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for label, ref := range map[string]*ShapeRef{
|
||||
"member": shape.Member,
|
||||
"key": shape.Key,
|
||||
"value": shape.Value,
|
||||
} {
|
||||
if err := validate("shape "+name+" "+label, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServices(t *testing.T) {
|
||||
want := []Service{ACM, EC2, ECR, ECS, ElasticLoadBalancingV2, IAM, S3, STS}
|
||||
if got := Services(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Services() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllModels(t *testing.T) {
|
||||
wantSizes := map[Service]struct {
|
||||
apiVersion string
|
||||
operations int
|
||||
shapes int
|
||||
}{
|
||||
ACM: {"2015-12-08", 15, 102},
|
||||
EC2: {"2016-11-15", 625, 3198},
|
||||
ECR: {"2015-09-21", 47, 330},
|
||||
ECS: {"2014-11-13", 56, 400},
|
||||
ElasticLoadBalancingV2: {"2015-12-01", 46, 357},
|
||||
IAM: {"2010-05-08", 159, 497},
|
||||
S3: {"2006-03-01", 99, 604},
|
||||
STS: {"2011-06-15", 8, 76},
|
||||
}
|
||||
|
||||
for service, want := range wantSizes {
|
||||
t.Run(string(service), func(t *testing.T) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%q): %v", service, err)
|
||||
}
|
||||
if model.Service() != service {
|
||||
t.Errorf("Service() = %q, want %q", model.Service(), service)
|
||||
}
|
||||
if model.Metadata().APIVersion != want.apiVersion || model.Metadata().Protocol == "" {
|
||||
t.Errorf("metadata is incomplete: %+v", model.Metadata())
|
||||
}
|
||||
if got := len(model.Operations()); got != want.operations {
|
||||
t.Errorf("operation count = %d, want %d", got, want.operations)
|
||||
}
|
||||
if got := len(model.Shapes()); got != want.shapes {
|
||||
t.Errorf("shape count = %d, want %d", got, want.shapes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEC2IndexesOperationsAndShapes(t *testing.T) {
|
||||
model, err := Load(EC2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
operation, ok := model.Operation("DescribeInstances")
|
||||
if !ok {
|
||||
t.Fatal("DescribeInstances operation was not indexed")
|
||||
}
|
||||
if operation.Output == nil || operation.Output.Shape != "DescribeInstancesResult" {
|
||||
t.Fatalf("DescribeInstances output = %+v, want DescribeInstancesResult", operation.Output)
|
||||
}
|
||||
|
||||
shape, ok := model.Shape(operation.Output.Shape)
|
||||
if !ok {
|
||||
t.Fatalf("output shape %q was not indexed", operation.Output.Shape)
|
||||
}
|
||||
if reservationSet, ok := shape.Members["Reservations"]; !ok || reservationSet.Shape != "ReservationList" {
|
||||
t.Errorf("Reservations member = %+v, want ReservationList", reservationSet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPreservesWrappersAndErrorMetadata(t *testing.T) {
|
||||
model, err := Load(IAM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
getUser, ok := model.Operation("GetUser")
|
||||
if !ok || getUser.Output == nil || getUser.Output.ResultWrapper != "GetUserResult" {
|
||||
t.Fatalf("GetUser output = %+v, want GetUserResult wrapper", getUser)
|
||||
}
|
||||
|
||||
createUser, ok := model.Operation("CreateUser")
|
||||
if !ok || len(createUser.Errors) == 0 {
|
||||
t.Fatalf("CreateUser operation has no modelled errors: %+v", createUser)
|
||||
}
|
||||
errorShape, ok := model.Shape(createUser.Errors[0].Shape)
|
||||
if !ok || errorShape.Error == nil || errorShape.Error.Code == "" || errorShape.Error.HTTPStatusCode == 0 {
|
||||
t.Fatalf("CreateUser error metadata was not loaded: %+v", errorShape)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownService(t *testing.T) {
|
||||
_, err := Load("not-a-service")
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported service "not-a-service"`) {
|
||||
t.Fatalf("Load(unknown) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCachesModelAcrossConcurrentCalls(t *testing.T) {
|
||||
const callers = 16
|
||||
models := make(chan *Model, callers)
|
||||
errors := make(chan error, callers)
|
||||
|
||||
var wait sync.WaitGroup
|
||||
for range callers {
|
||||
wait.Go(func() {
|
||||
model, err := Load(STS)
|
||||
models <- model
|
||||
errors <- err
|
||||
})
|
||||
}
|
||||
wait.Wait()
|
||||
close(models)
|
||||
close(errors)
|
||||
|
||||
for err := range errors {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var first *Model
|
||||
for model := range models {
|
||||
if first == nil {
|
||||
first = model
|
||||
continue
|
||||
}
|
||||
if model != first {
|
||||
t.Fatal("Load returned different cached model pointers")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# AWS model cache
|
||||
|
||||
The conformance suite reads `api-2.json` from the Go module cache for the
|
||||
pinned `github.com/aws/aws-sdk-go` version `v1.55.8`. Running Go tests or
|
||||
`go mod download` populates that cache; the large upstream model files are
|
||||
intentionally not tracked in this repository.
|
||||
|
||||
`ec2/error-codes.json` is separate from the SDK models because EC2's
|
||||
`api-2.json` declares no operation errors. It is a curated subset of the
|
||||
official [EC2 error-code reference](https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html):
|
||||
all documented common and server codes, plus the action-specific codes that
|
||||
Spinifex currently emits. The catalog records its verification date and must be
|
||||
reviewed against that source when it changes.
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"source": "https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html",
|
||||
"verifiedOn": "2026-08-05",
|
||||
"commonClient": [
|
||||
"AuthFailure",
|
||||
"Blocked",
|
||||
"DryRunOperation",
|
||||
"IdempotentParameterMismatch",
|
||||
"IncompleteSignature",
|
||||
"InvalidAction",
|
||||
"InvalidCharacter",
|
||||
"InvalidClientTokenId",
|
||||
"InvalidPaginationToken",
|
||||
"InvalidParameter",
|
||||
"InvalidParameterCombination",
|
||||
"InvalidParameterDependency",
|
||||
"InvalidParameterValue",
|
||||
"InvalidQueryParameter",
|
||||
"MalformedQueryString",
|
||||
"MissingAction",
|
||||
"MissingAuthenticationToken",
|
||||
"MissingParameter",
|
||||
"OptInRequired",
|
||||
"PendingVerification",
|
||||
"RequestExpired",
|
||||
"TagPolicyViolation",
|
||||
"UnauthorizedOperation",
|
||||
"UnknownParameter",
|
||||
"UnsupportedInstanceAttribute",
|
||||
"UnsupportedOperation",
|
||||
"UnsupportedProtocol",
|
||||
"ValidationError"
|
||||
],
|
||||
"specificClient": [
|
||||
"CannotDelete",
|
||||
"DependencyViolation",
|
||||
"Gateway.NotAttached",
|
||||
"IncorrectInstanceState",
|
||||
"IncorrectState",
|
||||
"InvalidAMIID.Malformed",
|
||||
"InvalidAMIID.NotFound",
|
||||
"InvalidAMIID.Unavailable",
|
||||
"InvalidAMIName.Duplicate",
|
||||
"InvalidAMIName.Malformed",
|
||||
"InvalidAddress.Locked",
|
||||
"InvalidAllocationID.NotFound",
|
||||
"InvalidAssociationID.NotFound",
|
||||
"InvalidAttachmentID.NotFound",
|
||||
"InvalidAvailabilityZone",
|
||||
"InvalidCapacityReservationId.Malformed",
|
||||
"InvalidCapacityReservationId.NotFound",
|
||||
"InvalidEgressOnlyInternetGatewayId.NotFound",
|
||||
"InvalidGroup.Duplicate",
|
||||
"InvalidGroup.NotFound",
|
||||
"InvalidGroup.Reserved",
|
||||
"InvalidInstanceAttributeValue",
|
||||
"InvalidInstanceID.Malformed",
|
||||
"InvalidInstanceID.NotFound",
|
||||
"InvalidInstanceType",
|
||||
"InvalidInternetGatewayID.NotFound",
|
||||
"InvalidKey.Format",
|
||||
"InvalidKeyPair.Duplicate",
|
||||
"InvalidKeyPair.Format",
|
||||
"InvalidKeyPair.NotFound",
|
||||
"InvalidLaunchTemplateId.Malformed",
|
||||
"InvalidLaunchTemplateId.NotFound",
|
||||
"InvalidLaunchTemplateId.VersionNotFound",
|
||||
"InvalidLaunchTemplateName.AlreadyExistsException",
|
||||
"InvalidLaunchTemplateName.MalformedException",
|
||||
"InvalidLaunchTemplateName.NotFoundException",
|
||||
"InvalidNatGatewayID.NotFound",
|
||||
"InvalidNetworkInterface.InUse",
|
||||
"InvalidNetworkInterface.NotFound",
|
||||
"InvalidNetworkInterfaceID.NotFound",
|
||||
"InvalidPermission.Duplicate",
|
||||
"InvalidPermission.NotFound",
|
||||
"InvalidPlacementGroup.Duplicate",
|
||||
"InvalidPlacementGroup.InUse",
|
||||
"InvalidPlacementGroup.Unknown",
|
||||
"InvalidRoute.NotFound",
|
||||
"InvalidRouteTableID.NotFound",
|
||||
"InvalidSecurityGroupRuleId.Malformed",
|
||||
"InvalidSecurityGroupRuleId.NotFound",
|
||||
"InvalidSnapshot.InUse",
|
||||
"InvalidSnapshot.NotFound",
|
||||
"InvalidSnapshotID.Malformed",
|
||||
"InvalidSpotInstanceRequestID.NotFound",
|
||||
"InvalidSubnet.Conflict",
|
||||
"InvalidSubnet.Range",
|
||||
"InvalidSubnetID.NotFound",
|
||||
"InvalidVolume.NotFound",
|
||||
"InvalidVolumeID.Malformed",
|
||||
"InvalidVpcID.NotFound",
|
||||
"InvalidVpcRange",
|
||||
"OperationNotPermitted",
|
||||
"ReservationCapacityExceeded",
|
||||
"Resource.AlreadyAssociated",
|
||||
"ResourceLimitExceeded",
|
||||
"RouteAlreadyExists",
|
||||
"RulesPerSecurityGroupLimitExceeded",
|
||||
"SecurityGroupsPerInterfaceLimitExceeded",
|
||||
"SignatureDoesNotMatch",
|
||||
"UnknownVolumeType",
|
||||
"Unsupported",
|
||||
"VolumeInUse"
|
||||
],
|
||||
"server": [
|
||||
"BandwidthLimitExceeded",
|
||||
"InsufficientAddressCapacity",
|
||||
"InsufficientCapacity",
|
||||
"InsufficientHostCapacity",
|
||||
"InsufficientInstanceCapacity",
|
||||
"InsufficientReservedInstanceCapacity",
|
||||
"InsufficientVolumeCapacity",
|
||||
"InternalError",
|
||||
"InternalFailure",
|
||||
"RequestLimitExceeded",
|
||||
"ServerInternal",
|
||||
"ServiceUnavailable",
|
||||
"Unavailable"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateResponse decodes an AWS wire response and validates it against the
|
||||
// operation's output shape. JSON services are decoded directly; EC2 and Query
|
||||
// XML responses are normalized from their wire names and result wrappers to
|
||||
// api-2.json member names first.
|
||||
func ValidateResponse(service Service, operationName string, body []byte) ([]Violation, error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation, ok := model.Operation(operationName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("awsmodel: %s operation %q is not modelled", service, operationName)
|
||||
}
|
||||
var document any
|
||||
switch model.metadata.Protocol {
|
||||
case "json", "rest-json":
|
||||
document, err = decodeJSONResponse(body)
|
||||
case "ec2", "query":
|
||||
if operation.Output == nil {
|
||||
err = validateEmptyXMLResponse(operationName, body)
|
||||
} else {
|
||||
document, err = model.decodeXMLResponse(operationName, operation.Output, body)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("awsmodel: response decoding for %s protocol %q is not implemented", service, model.metadata.Protocol)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("awsmodel: decode %s %s response: %w", service, operationName, err)
|
||||
}
|
||||
if operation.Output == nil {
|
||||
// Some Query/EC2 operations deliberately have no modelled output. There
|
||||
// is no shape to inspect, but decoding above still verifies that the
|
||||
// response is syntactically valid and has the correct XML envelope.
|
||||
return nil, nil
|
||||
}
|
||||
return Validate(service, operationName, document)
|
||||
}
|
||||
|
||||
func decodeJSONResponse(body []byte) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
var document any
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return nil, errors.New("multiple JSON values")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
type xmlNode struct {
|
||||
name string
|
||||
text strings.Builder
|
||||
children []*xmlNode
|
||||
}
|
||||
|
||||
func parseXML(body []byte) (*xmlNode, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(body))
|
||||
var root *xmlNode
|
||||
var stack []*xmlNode
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch token := token.(type) {
|
||||
case xml.StartElement:
|
||||
node := &xmlNode{name: token.Name.Local}
|
||||
if len(stack) == 0 {
|
||||
if root != nil {
|
||||
return nil, errors.New("multiple XML roots")
|
||||
}
|
||||
root = node
|
||||
} else {
|
||||
parent := stack[len(stack)-1]
|
||||
parent.children = append(parent.children, node)
|
||||
}
|
||||
stack = append(stack, node)
|
||||
case xml.CharData:
|
||||
if len(stack) != 0 {
|
||||
stack[len(stack)-1].text.Write([]byte(token))
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) == 0 {
|
||||
return nil, errors.New("unexpected XML end element")
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
if root == nil {
|
||||
return nil, errors.New("empty XML response")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func validateEmptyXMLResponse(operationName string, body []byte) error {
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wantRoot := operationName + "Response"
|
||||
if root.name != wantRoot {
|
||||
return fmt.Errorf("root element is %q, want %q", root.name, wantRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) decodeXMLResponse(operationName string, output *ShapeRef, body []byte) (any, error) {
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wantRoot := operationName + "Response"
|
||||
if root.name != wantRoot {
|
||||
return nil, fmt.Errorf("root element is %q, want %q", root.name, wantRoot)
|
||||
}
|
||||
|
||||
outputNode := root
|
||||
if output.ResultWrapper != "" {
|
||||
outputNode = firstXMLChild(root, output.ResultWrapper)
|
||||
if outputNode == nil {
|
||||
return nil, fmt.Errorf("result wrapper %q is missing", output.ResultWrapper)
|
||||
}
|
||||
}
|
||||
return m.normalizeXMLShape(output.Shape, []*xmlNode{outputNode}, false), nil
|
||||
}
|
||||
|
||||
func (m *Model) normalizeXMLShape(shapeName string, nodes []*xmlNode, flattened bool) any {
|
||||
shape := m.shapes[shapeName]
|
||||
if shape == nil || len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch shape.Type {
|
||||
case "structure":
|
||||
return m.normalizeXMLStructure(shape, nodes[0])
|
||||
case "list":
|
||||
itemNodes := nodes
|
||||
if !flattened && !shape.Flattened {
|
||||
itemName := shape.Member.LocationName
|
||||
if itemName == "" {
|
||||
if m.metadata.Protocol == "ec2" {
|
||||
itemName = "item"
|
||||
} else {
|
||||
itemName = "member"
|
||||
}
|
||||
}
|
||||
itemNodes = xmlChildren(nodes[0], itemName)
|
||||
}
|
||||
items := make([]any, 0, len(itemNodes))
|
||||
for _, node := range itemNodes {
|
||||
items = append(items, m.normalizeXMLShape(shape.Member.Shape, []*xmlNode{node}, false))
|
||||
}
|
||||
return items
|
||||
case "map":
|
||||
entries := nodes
|
||||
if !flattened && !shape.Flattened {
|
||||
entries = xmlChildren(nodes[0], "entry")
|
||||
}
|
||||
result := make(map[string]any, len(entries))
|
||||
for _, entry := range entries {
|
||||
keyNode := firstXMLChild(entry, xmlRefName("key", *shape.Key))
|
||||
valueNode := firstXMLChild(entry, xmlRefName("value", *shape.Value))
|
||||
if keyNode != nil && valueNode != nil {
|
||||
key := strings.TrimSpace(keyNode.text.String())
|
||||
result[key] = m.normalizeXMLShape(shape.Value.Shape, []*xmlNode{valueNode}, false)
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return strings.TrimSpace(nodes[0].text.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) normalizeXMLStructure(shape *Shape, node *xmlNode) map[string]any {
|
||||
document := make(map[string]any)
|
||||
wireMembers := make(map[string]string, len(shape.Members))
|
||||
for name, ref := range shape.Members {
|
||||
wireMembers[xmlRefName(name, ref)] = name
|
||||
}
|
||||
|
||||
grouped := make(map[string][]*xmlNode)
|
||||
for _, child := range node.children {
|
||||
if isTransportMetadata(child.name) {
|
||||
continue
|
||||
}
|
||||
grouped[child.name] = append(grouped[child.name], child)
|
||||
}
|
||||
for wireName, children := range grouped {
|
||||
name, ok := wireMembers[wireName]
|
||||
if !ok {
|
||||
document[wireName] = strings.TrimSpace(children[0].text.String())
|
||||
continue
|
||||
}
|
||||
ref := shape.Members[name]
|
||||
target := m.shapes[ref.Shape]
|
||||
flattened := ref.Flattened || (target != nil && target.Flattened)
|
||||
document[name] = m.normalizeXMLShape(ref.Shape, children, flattened)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
func xmlRefName(memberName string, ref ShapeRef) string {
|
||||
if ref.LocationName != "" {
|
||||
return ref.LocationName
|
||||
}
|
||||
return memberName
|
||||
}
|
||||
|
||||
func firstXMLChild(node *xmlNode, name string) *xmlNode {
|
||||
for _, child := range node.children {
|
||||
if child.name == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func xmlChildren(node *xmlNode, name string) []*xmlNode {
|
||||
children := make([]*xmlNode, 0)
|
||||
for _, child := range node.children {
|
||||
if child.name == name {
|
||||
children = append(children, child)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func isTransportMetadata(name string) bool {
|
||||
normalized := strings.ToLower(name)
|
||||
return normalized == "requestid" || normalized == "responsemetadata"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateResponseEC2XML(t *testing.T) {
|
||||
body := []byte(`<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
|
||||
<requestId>req-1</requestId>
|
||||
<reservationSet><item><instancesSet><item>
|
||||
<instanceState><code>16</code><name>paused</name></instanceState>
|
||||
<inventedField>surprise</inventedField>
|
||||
</item></instancesSet></item></reservationSet>
|
||||
</DescribeInstancesResponse>`)
|
||||
|
||||
violations, err := ValidateResponse(EC2, "DescribeInstances", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{
|
||||
{Rule: RuleUnknownField, Path: "$.Reservations[0].Instances[0].inventedField", Message: "field is not present in the model shape"},
|
||||
{Rule: RuleEnum, Path: "$.Reservations[0].Instances[0].State.Name", Message: `value "paused" is not one of [pending, running, shutting-down, terminated, stopping, stopped]`},
|
||||
}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseQueryXML(t *testing.T) {
|
||||
body := []byte(`<GetUserResponse><GetUserResult><User>
|
||||
<Path>/</Path><UserId>AIDAEXAMPLE</UserId>
|
||||
<Arn>arn:aws:iam::123456789012:user/alice</Arn>
|
||||
<CreateDate>2026-08-05T00:00:00Z</CreateDate>
|
||||
</User></GetUserResult></GetUserResponse>`)
|
||||
|
||||
violations, err := ValidateResponse(IAM, "GetUser", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{{Rule: RuleRequiredMember, Path: "$.User.UserName", Message: "required member is missing"}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseJSON(t *testing.T) {
|
||||
body := []byte(`{"tasks":[{"launchType":"ON_PREMISES"}]}`)
|
||||
violations, err := ValidateResponse(ECS, "DescribeTasks", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(violations) != 1 || violations[0].Rule != RuleEnum || violations[0].Path != "$.tasks[0].launchType" {
|
||||
t.Fatalf("violations = %#v, want launchType enum violation", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseConformingEmptyEC2List(t *testing.T) {
|
||||
body := []byte(`<DescribeInstancesResponse><requestId>req-1</requestId><reservationSet></reservationSet></DescribeInstancesResponse>`)
|
||||
violations, err := ValidateResponse(EC2, "DescribeInstances", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("violations = %#v, want none", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseOperationWithoutOutputShape(t *testing.T) {
|
||||
body := []byte(`<AttachInternetGatewayResponse><requestId>req-1</requestId></AttachInternetGatewayResponse>`)
|
||||
violations, err := ValidateResponse(EC2, "AttachInternetGateway", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("violations = %#v, want none", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResponseRejectsWrongWrapper(t *testing.T) {
|
||||
_, err := ValidateResponse(IAM, "GetUser", []byte(`<GetUserResponse><WrongResult/></GetUserResponse>`))
|
||||
if err == nil || !strings.Contains(err.Error(), `result wrapper "GetUserResult" is missing`) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rule identifies a model constraint violated by a response document.
|
||||
type Rule string
|
||||
|
||||
const (
|
||||
RuleUnknownField Rule = "unknown_field"
|
||||
RuleRequiredMember Rule = "required_member"
|
||||
RuleEnum Rule = "enum"
|
||||
RuleErrorCode Rule = "error_code"
|
||||
RuleHTTPStatus Rule = "http_status"
|
||||
)
|
||||
|
||||
// Violation describes one response value that does not conform to its output
|
||||
// shape. Path uses a JSONPath-like notation rooted at $.
|
||||
type Violation struct {
|
||||
Rule Rule
|
||||
Path string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (v Violation) String() string {
|
||||
return fmt.Sprintf("%s: %s (%s)", v.Path, v.Message, v.Rule)
|
||||
}
|
||||
|
||||
// Validate checks a normalized response document against an operation's
|
||||
// output shape. Structure keys must use the member names from api-2.json;
|
||||
// protocol-specific response middleware is responsible for translating wire
|
||||
// names and wrappers to that representation.
|
||||
func Validate(service Service, operationName string, document any) ([]Violation, error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation, ok := model.Operation(operationName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("awsmodel: %s operation %q is not modelled", service, operationName)
|
||||
}
|
||||
if operation.Output == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var violations []Violation
|
||||
model.validateShape(operation.Output.Shape, "$", document, &violations)
|
||||
return violations, nil
|
||||
}
|
||||
|
||||
func (m *Model) validateShape(shapeName, path string, value any, violations *[]Violation) {
|
||||
shape := m.shapes[shapeName]
|
||||
if shape == nil {
|
||||
// Load verifies references, so this is unreachable for a loaded model.
|
||||
return
|
||||
}
|
||||
|
||||
switch shape.Type {
|
||||
case "structure":
|
||||
m.validateStructure(shape, path, value, violations)
|
||||
case "list":
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for index, item := range values {
|
||||
m.validateShape(shape.Member.Shape, fmt.Sprintf("%s[%d]", path, index), item, violations)
|
||||
}
|
||||
case "map":
|
||||
values, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keys := sortedKeys(values)
|
||||
for _, key := range keys {
|
||||
keyPath := path + "[" + strconv.Quote(key) + "]"
|
||||
m.validateShape(shape.Key.Shape, keyPath+".key", key, violations)
|
||||
m.validateShape(shape.Value.Shape, keyPath, values[key], violations)
|
||||
}
|
||||
case "string":
|
||||
stringValue, ok := value.(string)
|
||||
if !ok || len(shape.Enum) == 0 {
|
||||
return
|
||||
}
|
||||
if !slices.Contains(shape.Enum, stringValue) {
|
||||
*violations = append(*violations, Violation{
|
||||
Rule: RuleEnum,
|
||||
Path: path,
|
||||
Message: fmt.Sprintf("value %q is not one of [%s]", stringValue, strings.Join(shape.Enum, ", ")),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) validateStructure(shape *Shape, path string, value any, violations *[]Violation) {
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
fields = map[string]any{}
|
||||
}
|
||||
|
||||
required := append([]string(nil), shape.Required...)
|
||||
slices.Sort(required)
|
||||
for _, name := range required {
|
||||
if _, present := fields[name]; !present {
|
||||
*violations = append(*violations, Violation{
|
||||
Rule: RuleRequiredMember,
|
||||
Path: memberPath(path, name),
|
||||
Message: "required member is missing",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fieldNames := sortedKeys(fields)
|
||||
for _, name := range fieldNames {
|
||||
if _, modelled := shape.Members[name]; !modelled {
|
||||
*violations = append(*violations, Violation{
|
||||
Rule: RuleUnknownField,
|
||||
Path: memberPath(path, name),
|
||||
Message: "field is not present in the model shape",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
memberNames := sortedKeys(shape.Members)
|
||||
for _, name := range memberNames {
|
||||
field, present := fields[name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
m.validateShape(shape.Members[name].Shape, memberPath(path, name), field, violations)
|
||||
}
|
||||
}
|
||||
|
||||
func memberPath(parent, name string) string {
|
||||
return parent + "." + name
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateUnknownField(t *testing.T) {
|
||||
document := validIAMGetUserDocument()
|
||||
document["Invented"] = true
|
||||
|
||||
violations, err := Validate(IAM, "GetUser", document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{{
|
||||
Rule: RuleUnknownField,
|
||||
Path: "$.Invented",
|
||||
Message: "field is not present in the model shape",
|
||||
}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiredMembers(t *testing.T) {
|
||||
document := validIAMGetUserDocument()
|
||||
delete(document["User"].(map[string]any), "UserName")
|
||||
|
||||
violations, err := Validate(IAM, "GetUser", document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{{
|
||||
Rule: RuleRequiredMember,
|
||||
Path: "$.User.UserName",
|
||||
Message: "required member is missing",
|
||||
}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEnum(t *testing.T) {
|
||||
document := map[string]any{
|
||||
"tasks": []any{
|
||||
map[string]any{"launchType": "ON_PREMISES"},
|
||||
},
|
||||
}
|
||||
|
||||
violations, err := Validate(ECS, "DescribeTasks", document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Violation{{
|
||||
Rule: RuleEnum,
|
||||
Path: "$.tasks[0].launchType",
|
||||
Message: "value \"ON_PREMISES\" is not one of [EC2, FARGATE, EXTERNAL]",
|
||||
}}
|
||||
if !reflect.DeepEqual(violations, want) {
|
||||
t.Fatalf("violations = %#v, want %#v", violations, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConformingDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
service Service
|
||||
operation string
|
||||
document any
|
||||
}{
|
||||
{
|
||||
name: "IAM required members",
|
||||
service: IAM,
|
||||
operation: "GetUser",
|
||||
document: validIAMGetUserDocument(),
|
||||
},
|
||||
{
|
||||
name: "ECS enum",
|
||||
service: ECS,
|
||||
operation: "DescribeTasks",
|
||||
document: map[string]any{
|
||||
"tasks": []any{map[string]any{"launchType": "EC2"}},
|
||||
"failures": []any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
violations, err := Validate(test.service, test.operation, test.document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("violations = %#v, want none", violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUnknownOperation(t *testing.T) {
|
||||
_, err := Validate(STS, "InventedOperation", map[string]any{})
|
||||
if err == nil || !strings.Contains(err.Error(), `operation "InventedOperation" is not modelled`) {
|
||||
t.Fatalf("Validate unknown operation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViolationString(t *testing.T) {
|
||||
violation := Violation{Rule: RuleEnum, Path: "$.status", Message: `value "broken" is invalid`}
|
||||
want := `$.status: value "broken" is invalid (enum)`
|
||||
if got := violation.String(); got != want {
|
||||
t.Fatalf("String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func validIAMGetUserDocument() map[string]any {
|
||||
return map[string]any{
|
||||
"User": map[string]any{
|
||||
"Path": "/",
|
||||
"UserName": "alice",
|
||||
"UserId": "AIDAEXAMPLE",
|
||||
"Arn": "arn:aws:iam::123456789012:user/alice",
|
||||
"CreateDate": "2026-08-05T00:00:00Z",
|
||||
},
|
||||
}
|
||||
}
|
||||
+23
-52
@@ -11,6 +11,27 @@ import (
|
||||
gateway_ecrapi "github.com/mulgadc/spinifex/spinifex/gateway/ecrapi"
|
||||
)
|
||||
|
||||
type ecrInlineHandler func(*GatewayConfig, http.ResponseWriter, *http.Request) error
|
||||
|
||||
// ecrInlineActions is the authoritative inventory of ECR operations handled
|
||||
// directly by the gateway rather than relayed through gateway_ecrapi.Actions.
|
||||
// Keeping dispatch and coverage on the same map prevents the generated
|
||||
// operation report from classifying an inline implementation as a stub.
|
||||
var ecrInlineActions = map[string]ecrInlineHandler{
|
||||
"GetAuthorizationToken": (*GatewayConfig).handleGetAuthorizationToken,
|
||||
"DescribeRepositories": (*GatewayConfig).handleDescribeRepositories,
|
||||
"CreateRepository": (*GatewayConfig).handleCreateRepository,
|
||||
"DeleteRepository": (*GatewayConfig).handleDeleteRepository,
|
||||
"PutImageTagMutability": (*GatewayConfig).handlePutImageTagMutability,
|
||||
"ListImages": (*GatewayConfig).handleListImages,
|
||||
"DescribeImages": (*GatewayConfig).handleDescribeImages,
|
||||
"BatchGetImage": (*GatewayConfig).handleBatchGetImage,
|
||||
"PutImage": (*GatewayConfig).handlePutImage,
|
||||
"BatchDeleteImage": (*GatewayConfig).handleBatchDeleteImage,
|
||||
"StartLifecyclePolicyPreview": (*GatewayConfig).handleStartLifecyclePolicyPreview,
|
||||
"GetLifecyclePolicyPreview": (*GatewayConfig).handleGetLifecyclePolicyPreview,
|
||||
}
|
||||
|
||||
// ecrActionFromTarget extracts the action suffix from an X-Amz-Target header.
|
||||
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
|
||||
func ecrActionFromTarget(target string) string {
|
||||
@@ -39,58 +60,8 @@ func (gw *GatewayConfig) ECR_Request(w http.ResponseWriter, r *http.Request) err
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAuthorizationToken mints a registry token from the SigV4 auth context;
|
||||
// it is served inline rather than relayed onto a NATS subject.
|
||||
if action == "GetAuthorizationToken" {
|
||||
return gw.handleGetAuthorizationToken(w, r)
|
||||
}
|
||||
|
||||
// DescribeRepositories builds repository ARNs/URIs from the gateway region
|
||||
// and internal suffix, which the relayed Handler signature does not carry,
|
||||
// so it is served inline like GetAuthorizationToken.
|
||||
if action == "DescribeRepositories" {
|
||||
return gw.handleDescribeRepositories(w, r)
|
||||
}
|
||||
|
||||
// CreateRepository and DeleteRepository return the repository ARN/URI, built
|
||||
// from the gateway region and internal suffix, so they are served inline.
|
||||
if action == "CreateRepository" {
|
||||
return gw.handleCreateRepository(w, r)
|
||||
}
|
||||
if action == "DeleteRepository" {
|
||||
return gw.handleDeleteRepository(w, r)
|
||||
}
|
||||
|
||||
// PutImageTagMutability read-modify-writes the per-account RepoMeta record
|
||||
// over NATS, like CreateRepository, so it is served inline.
|
||||
if action == "PutImageTagMutability" {
|
||||
return gw.handlePutImageTagMutability(w, r)
|
||||
}
|
||||
|
||||
// Image-management actions need the gateway-side predastore Store (manifest
|
||||
// bytes) carried by the OCI Registry, which the relayed Handler signature
|
||||
// does not reach, so they are served inline.
|
||||
switch action {
|
||||
case "ListImages":
|
||||
return gw.handleListImages(w, r)
|
||||
case "DescribeImages":
|
||||
return gw.handleDescribeImages(w, r)
|
||||
case "BatchGetImage":
|
||||
return gw.handleBatchGetImage(w, r)
|
||||
case "PutImage":
|
||||
return gw.handlePutImage(w, r)
|
||||
case "BatchDeleteImage":
|
||||
return gw.handleBatchDeleteImage(w, r)
|
||||
}
|
||||
|
||||
// Lifecycle-policy preview evaluates the stored/override policy against the
|
||||
// repo's current images via the gateway-side OCI registry, which the relayed
|
||||
// Handler signature does not reach, so they are served inline.
|
||||
switch action {
|
||||
case "StartLifecyclePolicyPreview":
|
||||
return gw.handleStartLifecyclePolicyPreview(w, r)
|
||||
case "GetLifecyclePolicyPreview":
|
||||
return gw.handleGetLifecyclePolicyPreview(w, r)
|
||||
if inline, ok := ecrInlineActions[action]; ok {
|
||||
return inline(gw, w, r)
|
||||
}
|
||||
|
||||
accountID, _ := r.Context().Value(ctxAccountID).(string)
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
@@ -102,6 +104,29 @@ var Actions = map[string]Handler{
|
||||
"ListTagsForResource": ListTagsForResource,
|
||||
}
|
||||
|
||||
// StubbedActionNames returns the Actions entries still bound to NotImplemented.
|
||||
func StubbedActionNames() []string {
|
||||
return actionsWithHandler(NotImplemented)
|
||||
}
|
||||
|
||||
// UnsupportedActionNames returns actions that deliberately report the feature
|
||||
// as unsupported rather than behaving as implemented operations.
|
||||
func UnsupportedActionNames() []string {
|
||||
return actionsWithHandler(ScanningNotSupported)
|
||||
}
|
||||
|
||||
func actionsWithHandler(target Handler) []string {
|
||||
want := reflect.ValueOf(target).Pointer()
|
||||
actions := make([]string, 0)
|
||||
for name, handler := range Actions {
|
||||
if reflect.ValueOf(handler).Pointer() == want {
|
||||
actions = append(actions, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(actions)
|
||||
return actions
|
||||
}
|
||||
|
||||
// WriteJSONResponse serialises obj as a 200 AWS JSON 1.1 response using the
|
||||
// SDK's jsonutil marshaler (epoch-second time encoding), matching ACM/EKS.
|
||||
func WriteJSONResponse(w http.ResponseWriter, obj any) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
@@ -109,6 +111,19 @@ var Actions = map[string]Handler{
|
||||
"ListTagsForResource": ListTagsForResource,
|
||||
}
|
||||
|
||||
// StubbedActionNames returns the Actions entries still bound to NotImplemented.
|
||||
func StubbedActionNames() []string {
|
||||
stub := reflect.ValueOf(NotImplemented).Pointer()
|
||||
actions := make([]string, 0)
|
||||
for name, handler := range Actions {
|
||||
if reflect.ValueOf(handler).Pointer() == stub {
|
||||
actions = append(actions, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(actions)
|
||||
return actions
|
||||
}
|
||||
|
||||
// RawJSONActions encode their response with encoding/json instead of jsonutil.
|
||||
// These are internal agent↔gateway shapes (not AWS SDK types) whose payloads
|
||||
// carry RFC3339 time fields the on-VM agent decodes with encoding/json; the
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
gateway_ecrapi "github.com/mulgadc/spinifex/spinifex/gateway/ecrapi"
|
||||
gateway_ecs "github.com/mulgadc/spinifex/spinifex/gateway/ecs"
|
||||
)
|
||||
|
||||
// ServiceOperationInventory describes the authoritative dispatch state for an
|
||||
// AWS-compatible service. Registered operations minus Stubbed and Unsupported
|
||||
// are implemented handlers.
|
||||
type ServiceOperationInventory struct {
|
||||
Registered []string
|
||||
Stubbed []string
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
// AWSOperationInventory returns a fresh, stable snapshot of the gateway's
|
||||
// operation dispatch tables. S3 is intentionally absent: Spinifex does not
|
||||
// dispatch S3 operations and delegates that REST surface to Predastore.
|
||||
func AWSOperationInventory() map[string]ServiceOperationInventory {
|
||||
ecrInline := mapKeys(ecrInlineActions)
|
||||
ecrRegistered := union(mapKeys(gateway_ecrapi.Actions), ecrInline)
|
||||
ecrStubbed := without(gateway_ecrapi.StubbedActionNames(), ecrInline)
|
||||
return map[string]ServiceOperationInventory{
|
||||
"acm": {
|
||||
Registered: mapKeys(acmActions),
|
||||
},
|
||||
"ec2": {
|
||||
Registered: mapKeys(ec2Actions),
|
||||
},
|
||||
"ecr": {
|
||||
Registered: ecrRegistered,
|
||||
Stubbed: ecrStubbed,
|
||||
Unsupported: gateway_ecrapi.UnsupportedActionNames(),
|
||||
},
|
||||
"ecs": {
|
||||
Registered: mapKeys(gateway_ecs.Actions),
|
||||
Stubbed: gateway_ecs.StubbedActionNames(),
|
||||
},
|
||||
"elasticloadbalancingv2": {
|
||||
Registered: mapKeys(elbv2Actions),
|
||||
},
|
||||
"iam": {
|
||||
Registered: mapKeys(iamActions),
|
||||
},
|
||||
"sts": {
|
||||
Registered: mapKeys(stsActions),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys[K ~string, V any](values map[K]V) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, string(key))
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func without(values, excluded []string) []string {
|
||||
exclude := make(map[string]bool, len(excluded))
|
||||
for _, value := range excluded {
|
||||
exclude[value] = true
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if !exclude[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func union(left, right []string) []string {
|
||||
set := make(map[string]bool, len(left)+len(right))
|
||||
for _, value := range left {
|
||||
set[value] = true
|
||||
}
|
||||
for _, value := range right {
|
||||
set[value] = true
|
||||
}
|
||||
return mapKeys(set)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAWSOperationInventoryClassifiesDispatchers(t *testing.T) {
|
||||
inventory := AWSOperationInventory()
|
||||
|
||||
require.Contains(t, inventory["ec2"].Registered, "DescribeInstances")
|
||||
require.NotContains(t, inventory["ec2"].Stubbed, "DescribeInstances")
|
||||
|
||||
require.Contains(t, inventory["ecs"].Registered, "DescribeClusters")
|
||||
require.NotContains(t, inventory["ecs"].Stubbed, "DescribeClusters")
|
||||
require.Contains(t, inventory["ecs"].Stubbed, "UpdateCluster")
|
||||
|
||||
// These operations are NotImplemented in the relay table but intercepted
|
||||
// by ECR_Request's authoritative inline dispatch map.
|
||||
require.Contains(t, inventory["ecr"].Registered, "GetAuthorizationToken")
|
||||
require.NotContains(t, inventory["ecr"].Stubbed, "GetAuthorizationToken")
|
||||
require.NotContains(t, inventory["ecr"].Stubbed, "DescribeRepositories")
|
||||
require.Contains(t, inventory["ecr"].Unsupported, "StartImageScan")
|
||||
|
||||
_, hasS3 := inventory["s3"]
|
||||
require.False(t, hasS3, "S3 is delegated to Predastore, not dispatched here")
|
||||
}
|
||||
|
||||
func TestAWSOperationInventoryStatusesAreDisjointSubsets(t *testing.T) {
|
||||
for service, inventory := range AWSOperationInventory() {
|
||||
t.Run(service, func(t *testing.T) {
|
||||
registered := stringSet(inventory.Registered)
|
||||
for _, action := range inventory.Stubbed {
|
||||
require.True(t, registered[action], "stubbed action %q is not registered", action)
|
||||
require.NotContains(t, inventory.Unsupported, action, "action cannot be stubbed and unsupported")
|
||||
}
|
||||
for _, action := range inventory.Unsupported {
|
||||
require.True(t, registered[action], "unsupported action %q is not registered", action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]bool {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# AWS model conformance ratchet
|
||||
|
||||
The integration suite validates every successful modelled AWS response. Services
|
||||
listed in `conformance-promoted-services.json` are blocking in the default
|
||||
`fail` mode; findings from all other services remain warnings until triaged and
|
||||
fixed.
|
||||
|
||||
Run the ratchet with:
|
||||
|
||||
```sh
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Use `AWS_MODEL_CONFORMANCE_MODE=warn` only for investigation. The report is
|
||||
written to `.cache/aws-model-conformance-report.txt` by default.
|
||||
|
||||
## Promoted services
|
||||
|
||||
- `sts` — promoted on 2026-08-05 after the integration baseline completed with
|
||||
no STS violations or decoder errors.
|
||||
|
||||
## Triaged warnings
|
||||
|
||||
Baseline from 2026-08-05: 370 successful responses checked, three violations,
|
||||
zero decoder errors.
|
||||
|
||||
| Service / operation | Finding | Classification | Implementation cause |
|
||||
|---|---|---|---|
|
||||
| EC2 `DescribeImages` | `BootMode` is emitted as `""`, outside the model enum | Defect | Legacy AMI metadata is intentionally projected through `aws.String(amiMeta.BootMode)`, which emits an empty element instead of omitting the optional member. |
|
||||
| IAM `ListAccessKeys` | Required `AccessKeyMetadata` missing | Defect | The empty result starts as a nil slice, so Query XML serialization omits the required list container. |
|
||||
| IAM `ListGroups` | Required `Groups` missing | Defect | When the KV bucket has keys but none match the account, the result slice remains nil and Query XML serialization omits the required list container. |
|
||||
|
||||
These warnings are not suppressed by a finding allowlist. They remain visible
|
||||
on every run. Handler changes belong in focused follow-up work; once a service's
|
||||
warnings are resolved, add that service to the promoted-services file in the
|
||||
same change that proves its full integration baseline is clean.
|
||||
|
||||
## Error-code coverage
|
||||
|
||||
Phase 4 validates IAM, STS, ECS, and ELBv2 error envelopes against their service
|
||||
models. The 2026-08-05 full integration baseline checked 46 operation-declared
|
||||
errors (45 IAM, one STS) with no error-code violations. ECS and ELBv2
|
||||
decoding/model comparison have focused tests but their error paths are not
|
||||
exercised by the current integration suite, so neither service is ready for
|
||||
promotion on that evidence.
|
||||
|
||||
Twelve responses are reported separately as `errors_unmodelled`: four IAM and
|
||||
eight STS. These are protocol-level authentication/authorization/signature/
|
||||
throttling errors that AWS omits from operation error lists, plus STS's runtime
|
||||
`ValidationError` and `InvalidParameterValue` responses. “Unmodelled” is not a
|
||||
conformance pass; it means the operation model cannot judge the code. They stay
|
||||
visible in the report rather than being silently counted as conforming.
|
||||
|
||||
EC2's model declares no operation errors, so EC2 uses a separate checked-in
|
||||
catalog curated from AWS's official EC2 error-code reference. It contains every
|
||||
documented common and server error plus the documented action-specific codes
|
||||
Spinifex currently emits. The validator checks the EC2 XML envelope, catalog
|
||||
membership, and AWS's documented 4xx client / 5xx server classification.
|
||||
|
||||
The same baseline examined 42 EC2 error responses and exposed 16 violations:
|
||||
|
||||
| Finding | Count | Classification | Implementation cause |
|
||||
|---|---:|---|---|
|
||||
| EC2 authorization failures return `AccessDenied`, which is absent from the EC2 catalog; EC2 documents `UnauthorizedOperation` | 15 | Defect | The shared policy evaluator returns the Query/IAM-oriented `AccessDenied` code for EC2 requests. |
|
||||
| `RequestSpotInstances` returns `InsufficientInstanceCapacity` with HTTP 400 instead of the documented server-error 5xx class | 1 | Defect | The global error lookup currently assigns HTTP 400 to `InsufficientInstanceCapacity`. |
|
||||
|
||||
Six additional production-referenced values are deliberately not accepted by
|
||||
the catalog because the chosen EC2 reference does not list them: `ExpiredToken`,
|
||||
`IamInstanceProfileAlreadyAssociated`, `InvalidIamInstanceProfile.NotFound`,
|
||||
`NoSuchAssociation`, `NoSuchEntity`, and `RequestEntityTooLarge`. A runtime
|
||||
occurrence remains a visible violation until it is either backed by an
|
||||
authoritative EC2 source or replaced with a documented EC2 code.
|
||||
|
||||
## Operation coverage
|
||||
|
||||
Phase 5 generates the public model-versus-dispatch inventory at
|
||||
`docs/aws-model-operation-coverage.md`. Run `make aws-model-coverage` to
|
||||
regenerate and print it. A normal `make build` and the integration target both
|
||||
regenerate the document so handler-map changes cannot silently leave it stale.
|
||||
|
||||
The inventory counts modelled operations bound to real handlers separately
|
||||
from registered stubs and deliberately unsupported handlers. S3 is marked
|
||||
opaque because Spinifex delegates its REST surface to Predastore rather than
|
||||
using an operation-name dispatch table; no mechanical S3 coverage percentage
|
||||
is claimed.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"promotedServices": [
|
||||
"sts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
)
|
||||
|
||||
type conformanceFinding struct {
|
||||
service awsmodel.Service
|
||||
operation string
|
||||
violation awsmodel.Violation
|
||||
count int
|
||||
}
|
||||
|
||||
type conformanceDiagnostic struct {
|
||||
service awsmodel.Service
|
||||
operation string
|
||||
message string
|
||||
count int
|
||||
}
|
||||
|
||||
// conformanceCollector is shared across the integration suite. Findings for
|
||||
// services in the checked-in promotion policy become blocking in fail mode.
|
||||
type conformanceCollector struct {
|
||||
mu sync.Mutex
|
||||
checked int
|
||||
checkedByService map[awsmodel.Service]int
|
||||
errorChecked int
|
||||
errorCheckedByService map[awsmodel.Service]int
|
||||
errorUnmodelled int
|
||||
errorUnmodelledByService map[awsmodel.Service]int
|
||||
findings map[string]*conformanceFinding
|
||||
diagnostics map[string]*conformanceDiagnostic
|
||||
}
|
||||
|
||||
func newConformanceCollector() *conformanceCollector {
|
||||
return &conformanceCollector{
|
||||
checkedByService: make(map[awsmodel.Service]int),
|
||||
errorCheckedByService: make(map[awsmodel.Service]int),
|
||||
errorUnmodelledByService: make(map[awsmodel.Service]int),
|
||||
findings: make(map[string]*conformanceFinding),
|
||||
diagnostics: make(map[string]*conformanceDiagnostic),
|
||||
}
|
||||
}
|
||||
|
||||
var suiteConformance = newConformanceCollector()
|
||||
|
||||
func (c *conformanceCollector) record(service awsmodel.Service, operation string, body []byte) {
|
||||
violations, err := awsmodel.ValidateResponse(service, operation, body)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.checked++
|
||||
c.checkedByService[service]++
|
||||
if err != nil {
|
||||
key := string(service) + "\x00" + operation + "\x00" + err.Error()
|
||||
diagnostic := c.diagnostics[key]
|
||||
if diagnostic == nil {
|
||||
diagnostic = &conformanceDiagnostic{service: service, operation: operation, message: err.Error()}
|
||||
c.diagnostics[key] = diagnostic
|
||||
}
|
||||
diagnostic.count++
|
||||
return
|
||||
}
|
||||
c.recordViolationsLocked(service, operation, violations)
|
||||
}
|
||||
|
||||
func (c *conformanceCollector) recordError(service awsmodel.Service, operation string, status int, body []byte) {
|
||||
var (
|
||||
violations []awsmodel.Violation
|
||||
modelled bool
|
||||
err error
|
||||
)
|
||||
if service == awsmodel.EC2 {
|
||||
violations, err = awsmodel.ValidateEC2ErrorResponse(status, body)
|
||||
modelled = true
|
||||
} else {
|
||||
violations, modelled, err = awsmodel.ValidateErrorResponse(service, operation, body)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err != nil {
|
||||
key := string(service) + "\x00" + operation + "\x00error\x00" + err.Error()
|
||||
diagnostic := c.diagnostics[key]
|
||||
if diagnostic == nil {
|
||||
diagnostic = &conformanceDiagnostic{service: service, operation: operation, message: err.Error()}
|
||||
c.diagnostics[key] = diagnostic
|
||||
}
|
||||
diagnostic.count++
|
||||
return
|
||||
}
|
||||
if !modelled {
|
||||
c.errorUnmodelled++
|
||||
c.errorUnmodelledByService[service]++
|
||||
return
|
||||
}
|
||||
c.errorChecked++
|
||||
c.errorCheckedByService[service]++
|
||||
c.recordViolationsLocked(service, operation, violations)
|
||||
}
|
||||
|
||||
func (c *conformanceCollector) recordViolationsLocked(service awsmodel.Service, operation string, violations []awsmodel.Violation) {
|
||||
for _, violation := range violations {
|
||||
key := strings.Join([]string{string(service), operation, string(violation.Rule), violation.Path, violation.Message}, "\x00")
|
||||
finding := c.findings[key]
|
||||
if finding == nil {
|
||||
finding = &conformanceFinding{service: service, operation: operation, violation: violation}
|
||||
c.findings[key] = finding
|
||||
}
|
||||
finding.count++
|
||||
}
|
||||
}
|
||||
|
||||
func (c *conformanceCollector) counts() (checked, violations, diagnostics int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, finding := range c.findings {
|
||||
violations += finding.count
|
||||
}
|
||||
for _, diagnostic := range c.diagnostics {
|
||||
diagnostics += diagnostic.count
|
||||
}
|
||||
return c.checked, violations, diagnostics
|
||||
}
|
||||
|
||||
func (c *conformanceCollector) blocking(policy conformancePolicy, mode conformanceMode) int {
|
||||
if mode != conformanceModeFail {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.blockingLocked(policy)
|
||||
}
|
||||
|
||||
func (c *conformanceCollector) blockingLocked(policy conformancePolicy) int {
|
||||
blocking := 0
|
||||
for _, finding := range c.findings {
|
||||
if policy.isPromoted(finding.service) {
|
||||
blocking += finding.count
|
||||
}
|
||||
}
|
||||
for _, diagnostic := range c.diagnostics {
|
||||
if policy.isPromoted(diagnostic.service) {
|
||||
blocking += diagnostic.count
|
||||
}
|
||||
}
|
||||
return blocking
|
||||
}
|
||||
|
||||
func (c *conformanceCollector) report(policy conformancePolicy, mode conformanceMode) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
violationCount := 0
|
||||
findings := make([]*conformanceFinding, 0, len(c.findings))
|
||||
for _, finding := range c.findings {
|
||||
violationCount += finding.count
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
diagnosticCount := 0
|
||||
diagnostics := make([]*conformanceDiagnostic, 0, len(c.diagnostics))
|
||||
for _, diagnostic := range c.diagnostics {
|
||||
diagnosticCount += diagnostic.count
|
||||
diagnostics = append(diagnostics, diagnostic)
|
||||
}
|
||||
sort.Slice(findings, func(i, j int) bool {
|
||||
left, right := findings[i], findings[j]
|
||||
return fmt.Sprint(left.service, "\x00", left.operation, "\x00", left.violation.Path, "\x00", left.violation.Rule) <
|
||||
fmt.Sprint(right.service, "\x00", right.operation, "\x00", right.violation.Path, "\x00", right.violation.Rule)
|
||||
})
|
||||
sort.Slice(diagnostics, func(i, j int) bool {
|
||||
left, right := diagnostics[i], diagnostics[j]
|
||||
return fmt.Sprint(left.service, "\x00", left.operation, "\x00", left.message) <
|
||||
fmt.Sprint(right.service, "\x00", right.operation, "\x00", right.message)
|
||||
})
|
||||
|
||||
var report strings.Builder
|
||||
blocking := 0
|
||||
if mode == conformanceModeFail {
|
||||
blocking = c.blockingLocked(policy)
|
||||
}
|
||||
promoted := make([]string, 0, len(policy.promoted))
|
||||
for _, service := range policy.services() {
|
||||
promoted = append(promoted, string(service))
|
||||
}
|
||||
fmt.Fprintf(&report, "AWS model conformance (%s): checked=%d errors_checked=%d errors_unmodelled=%d violations=%d decode_errors=%d blocking=%d promoted=%s\n",
|
||||
mode, c.checked, c.errorChecked, c.errorUnmodelled, violationCount, diagnosticCount, blocking, strings.Join(promoted, ","))
|
||||
|
||||
checkedServices := make(map[awsmodel.Service]bool, len(c.checkedByService)+len(c.errorCheckedByService)+len(c.errorUnmodelledByService)+len(policy.promoted))
|
||||
for service := range c.checkedByService {
|
||||
checkedServices[service] = true
|
||||
}
|
||||
for service := range c.errorCheckedByService {
|
||||
checkedServices[service] = true
|
||||
}
|
||||
for service := range c.errorUnmodelledByService {
|
||||
checkedServices[service] = true
|
||||
}
|
||||
for service := range policy.promoted {
|
||||
checkedServices[service] = true
|
||||
}
|
||||
services := make([]awsmodel.Service, 0, len(checkedServices))
|
||||
for service := range checkedServices {
|
||||
services = append(services, service)
|
||||
}
|
||||
sort.Slice(services, func(i, j int) bool { return services[i] < services[j] })
|
||||
for _, service := range services {
|
||||
fmt.Fprintf(&report, "CHECKED %s success=%d errors=%d unmodelled_errors=%d promoted=%t\n",
|
||||
service, c.checkedByService[service], c.errorCheckedByService[service], c.errorUnmodelledByService[service], policy.isPromoted(service))
|
||||
}
|
||||
|
||||
for _, finding := range findings {
|
||||
severity := "WARN"
|
||||
if mode == conformanceModeFail && policy.isPromoted(finding.service) {
|
||||
severity = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(&report, "%s %s %s %s %s count=%d: %s\n",
|
||||
severity,
|
||||
finding.service, finding.operation, finding.violation.Path, finding.violation.Rule, finding.count, finding.violation.Message)
|
||||
}
|
||||
for _, diagnostic := range diagnostics {
|
||||
severity := "WARN"
|
||||
if mode == conformanceModeFail && policy.isPromoted(diagnostic.service) {
|
||||
severity = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(&report, "%s %s %s decode_error count=%d: %s\n",
|
||||
severity,
|
||||
diagnostic.service, diagnostic.operation, diagnostic.count, diagnostic.message)
|
||||
}
|
||||
return strings.TrimSuffix(report.String(), "\n")
|
||||
}
|
||||
|
||||
func conformanceMiddleware(next http.Handler, collector *conformanceCollector) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
service, operation, ok := resolveConformanceRequest(r)
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
capture := &conformanceResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(capture, r)
|
||||
status := capture.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
if status >= 200 && status < 300 {
|
||||
collector.record(service, operation, capture.body.Bytes())
|
||||
} else if errorCodeConformanceService(service) {
|
||||
collector.recordError(service, operation, status, capture.body.Bytes())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func errorCodeConformanceService(service awsmodel.Service) bool {
|
||||
switch service {
|
||||
case awsmodel.EC2, awsmodel.IAM, awsmodel.STS, awsmodel.ECS, awsmodel.ElasticLoadBalancingV2:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type conformanceResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *conformanceResponseWriter) WriteHeader(status int) {
|
||||
if w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *conformanceResponseWriter) Write(body []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
w.body.Write(body)
|
||||
return w.ResponseWriter.Write(body)
|
||||
}
|
||||
|
||||
func (w *conformanceResponseWriter) Flush() {
|
||||
_ = http.NewResponseController(w.ResponseWriter).Flush()
|
||||
}
|
||||
|
||||
func (w *conformanceResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func resolveConformanceRequest(r *http.Request) (awsmodel.Service, string, bool) {
|
||||
service, ok := conformanceService(r)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var operation string
|
||||
if target := r.Header.Get("X-Amz-Target"); target != "" {
|
||||
if separator := strings.LastIndex(target, "."); separator >= 0 {
|
||||
operation = target[separator+1:]
|
||||
} else {
|
||||
operation = target
|
||||
}
|
||||
} else if service != awsmodel.S3 {
|
||||
operation = queryAction(r)
|
||||
}
|
||||
if operation == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
model, err := awsmodel.Load(service)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
if _, modelled := model.Operation(operation); !modelled {
|
||||
return "", "", false
|
||||
}
|
||||
return service, operation, true
|
||||
}
|
||||
|
||||
func conformanceService(r *http.Request) (awsmodel.Service, bool) {
|
||||
if target := r.Header.Get("X-Amz-Target"); target != "" {
|
||||
prefix := target
|
||||
if separator := strings.LastIndex(target, "."); separator >= 0 {
|
||||
prefix = target[:separator]
|
||||
}
|
||||
switch prefix {
|
||||
case "AmazonEC2ContainerServiceV20141113":
|
||||
return awsmodel.ECS, true
|
||||
case "AmazonEC2ContainerRegistry_V20150921":
|
||||
return awsmodel.ECR, true
|
||||
case "CertificateManager":
|
||||
return awsmodel.ACM, true
|
||||
}
|
||||
}
|
||||
|
||||
authorization := r.Header.Get("Authorization")
|
||||
credentialAt := strings.Index(authorization, "Credential=")
|
||||
if credentialAt >= 0 {
|
||||
credential := authorization[credentialAt+len("Credential="):]
|
||||
if end := strings.IndexAny(credential, " ,"); end >= 0 {
|
||||
credential = credential[:end]
|
||||
}
|
||||
parts := strings.Split(credential, "/")
|
||||
if len(parts) >= 5 && parts[len(parts)-1] == "aws4_request" {
|
||||
return modelService(parts[len(parts)-2])
|
||||
}
|
||||
}
|
||||
|
||||
// AssumeRoleWithWebIdentity is intentionally unsigned and is the only
|
||||
// anonymous modelled request in this harness.
|
||||
if queryAction(r) == "AssumeRoleWithWebIdentity" {
|
||||
return awsmodel.STS, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func modelService(signatureService string) (awsmodel.Service, bool) {
|
||||
switch signatureService {
|
||||
case "acm":
|
||||
return awsmodel.ACM, true
|
||||
case "ec2":
|
||||
return awsmodel.EC2, true
|
||||
case "ecr":
|
||||
return awsmodel.ECR, true
|
||||
case "ecs":
|
||||
return awsmodel.ECS, true
|
||||
case "elasticloadbalancing":
|
||||
return awsmodel.ElasticLoadBalancingV2, true
|
||||
case "iam":
|
||||
return awsmodel.IAM, true
|
||||
case "s3":
|
||||
return awsmodel.S3, true
|
||||
case "sts":
|
||||
return awsmodel.STS, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func queryAction(r *http.Request) string {
|
||||
if action := r.URL.Query().Get("Action"); action != "" {
|
||||
return action
|
||||
}
|
||||
if r.Body == nil {
|
||||
return ""
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return values.Get("Action")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
)
|
||||
|
||||
type conformanceMode string
|
||||
|
||||
const (
|
||||
conformanceModeWarn conformanceMode = "warn"
|
||||
conformanceModeFail conformanceMode = "fail"
|
||||
)
|
||||
|
||||
type conformancePolicy struct {
|
||||
promoted map[awsmodel.Service]bool
|
||||
}
|
||||
|
||||
type conformancePolicyFile struct {
|
||||
PromotedServices []awsmodel.Service `json:"promotedServices"`
|
||||
}
|
||||
|
||||
//go:embed conformance-promoted-services.json
|
||||
var conformancePolicyJSON []byte
|
||||
|
||||
func loadConformancePolicy() (conformancePolicy, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(conformancePolicyJSON)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var file conformancePolicyFile
|
||||
if err := decoder.Decode(&file); err != nil {
|
||||
return conformancePolicy{}, fmt.Errorf("parse promoted-services policy: %w", err)
|
||||
}
|
||||
|
||||
known := make(map[awsmodel.Service]bool)
|
||||
for _, service := range awsmodel.Services() {
|
||||
known[service] = true
|
||||
}
|
||||
policy := conformancePolicy{promoted: make(map[awsmodel.Service]bool, len(file.PromotedServices))}
|
||||
for _, service := range file.PromotedServices {
|
||||
if !known[service] {
|
||||
return conformancePolicy{}, fmt.Errorf("promoted-services policy contains unknown service %q", service)
|
||||
}
|
||||
if policy.promoted[service] {
|
||||
return conformancePolicy{}, fmt.Errorf("promoted-services policy contains duplicate service %q", service)
|
||||
}
|
||||
policy.promoted[service] = true
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func conformancePolicyFor(services ...awsmodel.Service) conformancePolicy {
|
||||
policy := conformancePolicy{promoted: make(map[awsmodel.Service]bool, len(services))}
|
||||
for _, service := range services {
|
||||
policy.promoted[service] = true
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (p conformancePolicy) isPromoted(service awsmodel.Service) bool {
|
||||
return p.promoted[service]
|
||||
}
|
||||
|
||||
func (p conformancePolicy) services() []awsmodel.Service {
|
||||
services := make([]awsmodel.Service, 0, len(p.promoted))
|
||||
for service := range p.promoted {
|
||||
services = append(services, service)
|
||||
}
|
||||
sort.Slice(services, func(i, j int) bool { return services[i] < services[j] })
|
||||
return services
|
||||
}
|
||||
|
||||
func conformanceModeFromEnvironment() (conformanceMode, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("AWS_MODEL_CONFORMANCE_MODE")))
|
||||
if value == "" {
|
||||
return conformanceModeFail, nil
|
||||
}
|
||||
switch conformanceMode(value) {
|
||||
case conformanceModeWarn, conformanceModeFail:
|
||||
return conformanceMode(value), nil
|
||||
default:
|
||||
return "", fmt.Errorf("AWS_MODEL_CONFORMANCE_MODE must be %q or %q, got %q", conformanceModeWarn, conformanceModeFail, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEmbeddedConformancePolicyPromotesSTS(t *testing.T) {
|
||||
policy, err := loadConformancePolicy()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []awsmodel.Service{awsmodel.STS}, policy.services())
|
||||
require.True(t, policy.isPromoted(awsmodel.STS))
|
||||
require.False(t, policy.isPromoted(awsmodel.EC2))
|
||||
}
|
||||
|
||||
func TestConformanceModeFromEnvironment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want conformanceMode
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default is fail", want: conformanceModeFail},
|
||||
{name: "explicit fail", value: "fail", want: conformanceModeFail},
|
||||
{name: "warn", value: "warn", want: conformanceModeWarn},
|
||||
{name: "case and whitespace", value: " WARN ", want: conformanceModeWarn},
|
||||
{name: "invalid", value: "off", wantErr: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("AWS_MODEL_CONFORMANCE_MODE", test.value)
|
||||
got, err := conformanceModeFromEnvironment()
|
||||
if test.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConformanceMiddlewareDetectsStubbedNATSMalformedResponse(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
gw := startGateway(t, collector)
|
||||
stubEmptyInstanceBuckets(t, gw)
|
||||
gw.StubSubject(t, "ec2.DescribeInstances", mustMarshal(t, &ec2.DescribeInstancesOutput{
|
||||
Reservations: []*ec2.Reservation{{
|
||||
Instances: []*ec2.Instance{{
|
||||
State: &ec2.InstanceState{Name: aws.String("paused")},
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
|
||||
_, err := gw.EC2Client(t).DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
checked, violations, diagnostics := collector.counts()
|
||||
require.Equal(t, 1, checked)
|
||||
require.Equal(t, 1, violations)
|
||||
require.Zero(t, diagnostics)
|
||||
require.Contains(t, collector.report(conformancePolicy{}, conformanceModeWarn), "$.Reservations[0].Instances[0].State.Name enum")
|
||||
}
|
||||
|
||||
func TestConformanceRatchetBlocksPromotedService(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
collector.record(awsmodel.STS, "GetCallerIdentity", []byte(`<GetCallerIdentityResponse><GetCallerIdentityResult>
|
||||
<UserId>user-1</UserId><Account>123456789012</Account>
|
||||
<Arn>arn:aws:iam::123456789012:user/alice</Arn><Invented>true</Invented>
|
||||
</GetCallerIdentityResult></GetCallerIdentityResponse>`))
|
||||
policy := conformancePolicyFor(awsmodel.STS)
|
||||
|
||||
require.Equal(t, 1, collector.blocking(policy, conformanceModeFail))
|
||||
report := collector.report(policy, conformanceModeFail)
|
||||
require.Contains(t, report, "blocking=1 promoted=sts")
|
||||
require.Contains(t, report, "CHECKED sts success=1 errors=0 unmodelled_errors=0 promoted=true")
|
||||
require.Contains(t, report, "FAIL sts GetCallerIdentity $.Invented unknown_field")
|
||||
}
|
||||
|
||||
func TestConformanceRatchetWarnModeDoesNotBlock(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
collector.record(awsmodel.STS, "GetCallerIdentity", []byte(`<GetCallerIdentityResponse><GetCallerIdentityResult>
|
||||
<Invented>true</Invented>
|
||||
</GetCallerIdentityResult></GetCallerIdentityResponse>`))
|
||||
policy := conformancePolicyFor(awsmodel.STS)
|
||||
|
||||
require.Zero(t, collector.blocking(policy, conformanceModeWarn))
|
||||
report := collector.report(policy, conformanceModeWarn)
|
||||
require.Contains(t, report, "blocking=0 promoted=sts")
|
||||
require.Contains(t, report, "WARN sts GetCallerIdentity $.Invented unknown_field")
|
||||
require.NotContains(t, report, "FAIL sts")
|
||||
}
|
||||
|
||||
func TestConformanceRatchetDoesNotBlockUnpromotedService(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
collector.record(awsmodel.ECS, "DescribeTasks", []byte(`{"tasks":[{"launchType":"INVALID"}]}`))
|
||||
policy := conformancePolicyFor(awsmodel.STS)
|
||||
|
||||
require.Zero(t, collector.blocking(policy, conformanceModeFail))
|
||||
require.Contains(t, collector.report(policy, conformanceModeFail), "WARN ecs DescribeTasks")
|
||||
}
|
||||
|
||||
func TestConformanceMiddlewareAcceptsStubbedNATSConformingResponse(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
gw := startGateway(t, collector)
|
||||
stubEmptyInstanceBuckets(t, gw)
|
||||
gw.StubSubject(t, "ec2.DescribeInstances", mustMarshal(t, &ec2.DescribeInstancesOutput{
|
||||
Reservations: []*ec2.Reservation{{
|
||||
Instances: []*ec2.Instance{{
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
|
||||
_, err := gw.EC2Client(t).DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
checked, violations, diagnostics := collector.counts()
|
||||
require.Equal(t, 1, checked)
|
||||
require.Zero(t, violations)
|
||||
require.Zero(t, diagnostics)
|
||||
}
|
||||
|
||||
func TestResolveConformanceRequestPreservesQueryBody(t *testing.T) {
|
||||
body := "Action=GetCallerIdentity&Version=2011-06-15"
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIATEST/20260805/us-east-1/sts/aws4_request, SignedHeaders=host, Signature=abc")
|
||||
|
||||
service, operation, ok := resolveConformanceRequest(req)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, awsmodel.STS, service)
|
||||
require.Equal(t, "GetCallerIdentity", operation)
|
||||
|
||||
preserved, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal([]byte(body), preserved))
|
||||
}
|
||||
|
||||
func TestConformanceMiddlewareChecksDeclaredErrorResponses(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"__type":"InvalidParameterException"}`))
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
|
||||
req.Header.Set("X-Amz-Target", "AmazonEC2ContainerServiceV20141113.DescribeTasks")
|
||||
|
||||
conformanceMiddleware(next, collector).ServeHTTP(httptest.NewRecorder(), req)
|
||||
checked, violations, diagnostics := collector.counts()
|
||||
require.Zero(t, checked)
|
||||
require.Zero(t, violations)
|
||||
require.Zero(t, diagnostics)
|
||||
require.Contains(t, collector.report(conformancePolicy{}, conformanceModeWarn),
|
||||
"CHECKED ecs success=0 errors=1 unmodelled_errors=0 promoted=false")
|
||||
}
|
||||
|
||||
func TestConformanceMiddlewareChecksCuratedEC2ErrorResponses(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`<Response><Errors><Error><Code>AccessDenied</Code><Message>denied</Message></Error></Errors><RequestID>request-1</RequestID></Response>`))
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`Action=DescribeInstances&Version=2016-11-15`))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIATEST/20260805/us-east-1/ec2/aws4_request, SignedHeaders=host, Signature=abc")
|
||||
|
||||
conformanceMiddleware(next, collector).ServeHTTP(httptest.NewRecorder(), req)
|
||||
checked, violations, diagnostics := collector.counts()
|
||||
require.Zero(t, checked)
|
||||
require.Equal(t, 1, violations)
|
||||
require.Zero(t, diagnostics)
|
||||
require.Contains(t, collector.report(conformancePolicy{}, conformanceModeWarn),
|
||||
`WARN ec2 DescribeInstances $error.Code error_code count=1: error code "AccessDenied" is not in the curated EC2 catalog`)
|
||||
}
|
||||
|
||||
func TestConformanceRatchetBlocksUndeclaredErrorForPromotedService(t *testing.T) {
|
||||
collector := newConformanceCollector()
|
||||
collector.recordError(awsmodel.STS, "AssumeRole", http.StatusBadRequest, []byte(`<ErrorResponse><Error>
|
||||
<Code>InventedFailure</Code><Message>broken</Message>
|
||||
</Error></ErrorResponse>`))
|
||||
policy := conformancePolicyFor(awsmodel.STS)
|
||||
|
||||
require.Equal(t, 1, collector.blocking(policy, conformanceModeFail))
|
||||
report := collector.report(policy, conformanceModeFail)
|
||||
require.Contains(t, report, "CHECKED sts success=0 errors=1 unmodelled_errors=0 promoted=true")
|
||||
require.Contains(t, report, "FAIL sts AssumeRole $error.Code error_code")
|
||||
}
|
||||
@@ -119,6 +119,10 @@ type Option func(*gateway.GatewayConfig)
|
||||
// every other control-plane path (auth, routing, XML/JSON marshaling, IAM
|
||||
// policy evaluation) runs for real.
|
||||
func StartGateway(t *testing.T, opts ...Option) *Gateway {
|
||||
return startGateway(t, suiteConformance, opts...)
|
||||
}
|
||||
|
||||
func startGateway(t *testing.T, collector *conformanceCollector, opts ...Option) *Gateway {
|
||||
t.Helper()
|
||||
|
||||
// Every test connects into its own dynamically provisioned NATS account
|
||||
@@ -174,7 +178,11 @@ func StartGateway(t *testing.T, opts ...Option) *Gateway {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(cfg.SetupRoutes())
|
||||
handler := cfg.SetupRoutes()
|
||||
if collector != nil {
|
||||
handler = conformanceMiddleware(handler, collector)
|
||||
}
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
gw := &Gateway{
|
||||
|
||||
@@ -5,6 +5,7 @@ package integration
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,17 @@ import (
|
||||
// Per-test state isolation is preserved despite the shared server — see the
|
||||
// accountAuthenticator doc comment in shared_nats.go for how.
|
||||
func TestMain(m *testing.M) {
|
||||
policy, err := loadConformancePolicy()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tests/integration: load AWS conformance policy:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
mode, err := conformanceModeFromEnvironment()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tests/integration:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
h, err := startSharedNATS()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tests/integration: failed to start shared NATS server:", err)
|
||||
@@ -37,6 +49,22 @@ func TestMain(m *testing.M) {
|
||||
|
||||
code := m.Run()
|
||||
|
||||
report := suiteConformance.report(policy, mode)
|
||||
fmt.Fprintln(os.Stderr, report)
|
||||
if blocking := suiteConformance.blocking(policy, mode); blocking != 0 {
|
||||
fmt.Fprintf(os.Stderr, "tests/integration: %d blocking AWS model conformance violation(s)\n", blocking)
|
||||
code = 1
|
||||
}
|
||||
if reportPath := os.Getenv("AWS_MODEL_CONFORMANCE_REPORT"); reportPath != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tests/integration: create conformance report directory:", err)
|
||||
code = 1
|
||||
} else if err := os.WriteFile(reportPath, []byte(report+"\n"), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tests/integration: write conformance report:", err)
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Connections are closed here, together, rather than per-test: closing
|
||||
// one early can kill the connection the clienttoken singleton latched
|
||||
// onto (see above), so every connection's lifetime is pinned to the
|
||||
|
||||
Reference in New Issue
Block a user