feat: add AWS model conformance coverage (#736)

This commit is contained in:
Josh Siviour
2026-08-05 18:46:37 +10:00
committed by GitHub
parent 506a8126b7
commit 688ce5ccfd
32 changed files with 3539 additions and 57 deletions
+16 -3
View File
@@ -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 \
+43
View File
@@ -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
View File
@@ -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.
+500
View File
@@ -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>
+187
View File
@@ -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")
}
+51
View File
@@ -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)
}
}
}
+153
View File
@@ -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())
}
+93
View File
@@ -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>`)
}
+21
View File
@@ -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",
}
+142
View File
@@ -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 ""
}
+119
View File
@@ -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)
}
})
}
}
+296
View File
@@ -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
}
+138
View File
@@ -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")
}
}
}
+13
View File
@@ -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"
]
}
+251
View File
@@ -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"
}
+86
View File
@@ -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)
}
}
+140
View File
@@ -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
}
+128
View File
@@ -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
View File
@@ -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)
+25
View File
@@ -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) {
+15
View File
@@ -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
+86
View File
@@ -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)
}
+51
View File
@@ -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"
]
}
+409
View File
@@ -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")
}
+91
View File
@@ -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)
})
}
}
+162
View File
@@ -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")
}
+9 -1
View File
@@ -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{
+28
View File
@@ -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