diff --git a/internal/awsmodel/request.go b/internal/awsmodel/request.go index 8acce1542..334b14f79 100644 --- a/internal/awsmodel/request.go +++ b/internal/awsmodel/request.go @@ -75,6 +75,11 @@ func GenerateRequests(service Service, operationName string, options RequestOpti return RequestPlan{Cases: []RequestCase{{Operation: operationName, Input: map[string]any{}}}, DeclaredErrors: declared}, nil } + // A create or delete names a resource of its own, so it neither collides + // with a fixture nor removes one. + if strings.HasPrefix(operationName, "Create") || strings.HasPrefix(operationName, "Delete") { + options.Fixtures = nil + } first := model.newRequestGenerator(0, options) full, ok := first.structure(operation.Input.Shape, nil, 0) plan := RequestPlan{Skipped: first.skipped, Unbroken: first.unbroken, DeclaredErrors: declared} diff --git a/internal/awsmodel/request_hints.go b/internal/awsmodel/request_hints.go index ea0ca3e92..8c3c4038a 100644 --- a/internal/awsmodel/request_hints.go +++ b/internal/awsmodel/request_hints.go @@ -18,10 +18,12 @@ import ( // RequestOptions places generated ARNs in the account and region the // requests are sent as. AccessKeyID is the key the requests are signed with. +// Fixtures maps a member to an existing resource it can name. type RequestOptions struct { AccountID string Region string AccessKeyID string + Fixtures map[string]string } // Models constrain most identifiers only by length, so "spxa" is a model-valid @@ -82,6 +84,11 @@ var conditionallyRequired = map[Service]map[string][]string{ } const ( + // imageManifest is well formed but names blobs no repository holds. + imageManifest = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":2,"digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"layers":[]}` + lifecyclePolicy = `{"rules":[{"rulePriority":1,"description":"expire untagged","selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":14},"action":{"type":"expire"}}]}` + repositoryPolicy = `{"Version":"2012-10-17","Statement":[{"Sid":"pull","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::%s:root"},"Action":"ecr:BatchGetImage"}]}` + policyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` trustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}` ) @@ -94,6 +101,9 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { return "", false } options := g.options + if fixture, ok := options.Fixtures[member]; ok { + return fixture, true + } suffix := fmt.Sprintf("%017x", g.saltNumber) name := "spx" + g.salt @@ -157,8 +167,15 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) { return name + ".example.com", true } case ECR: - if member == "resourceArn" { + switch member { + case "resourceArn": return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", options.Region, options.AccountID, name), true + case "imageManifest": + return imageManifest, true + case "lifecyclePolicyText": + return lifecyclePolicy, true + case "policyText": + return fmt.Sprintf(repositoryPolicy, options.AccountID), true } case EKS: switch member { diff --git a/internal/awsmodel/request_test.go b/internal/awsmodel/request_test.go index d9eeede18..07683544e 100644 --- a/internal/awsmodel/request_test.go +++ b/internal/awsmodel/request_test.go @@ -226,6 +226,21 @@ func TestGenerateRequestsSeedsConditionallyRequiredMembers(t *testing.T) { require.NotZero(t, nested) } +func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) { + options := testRequestOptions + options.Fixtures = map[string]string{"RoleName": "fixture"} + for operation, wantFixture := range map[string]bool{"TagRole": true, "CreateRole": false, "DeleteRole": false} { + plan, err := GenerateRequests(IAM, operation, options) + require.NoError(t, err) + for _, request := range plan.Cases { + if request.Path == "$.RoleName" { + continue + } + require.Equal(t, wantFixture, request.Input["RoleName"] == "fixture", "%s %s %s", operation, request.Constraint, request.Path) + } + } +} + func TestGenerateRequestsAttributesRejections(t *testing.T) { plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions) require.NoError(t, err) diff --git a/tests/integration/request_conformance_test.go b/tests/integration/request_conformance_test.go index 156ca9f08..c15e51f35 100644 --- a/tests/integration/request_conformance_test.go +++ b/tests/integration/request_conformance_test.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "strings" "testing" "time" @@ -50,8 +51,10 @@ func TestRequestConformance(t *testing.T) { startRequestConformanceBackends(t, gw) sender, err := newRequestSender(gw, service) require.NoError(t, err) + options := awsmodel.RequestOptions{AccountID: gw.AccountID, Region: testRegion, AccessKeyID: testAccessKeyID} + options.Fixtures = createRequestFixtures(t, sender, service, gw.AccountID) for _, operation := range coverage.Implemented { - plan, err := awsmodel.GenerateRequests(service, operation, awsmodel.RequestOptions{AccountID: gw.AccountID, Region: testRegion, AccessKeyID: testAccessKeyID}) + plan, err := awsmodel.GenerateRequests(service, operation, options) require.NoError(t, err, operation) suiteRequestConformance.recordOperation(service, operation, plan) judge := plan.NewJudge() @@ -82,6 +85,56 @@ func startRequestConformanceBackends(t *testing.T, gw *Gateway) { StartServiceDaemonLite(t, gw) } +// requestFixture is the name every fixture resource takes. +const requestFixture = "conformance-fixture" + +// createRequestFixtures creates one resource of each kind the service's +// generated requests name, so a case reaches its resource instead of ending +// in not-found. It returns the members that name them. +func createRequestFixtures(t *testing.T, sender *requestSender, service awsmodel.Service, accountID string) map[string]string { + t.Helper() + create := func(operation string, input map[string]any) { + t.Helper() + result, err := sender.send(awsmodel.RequestCase{Operation: operation, Input: input}) + require.NoError(t, err, operation) + require.True(t, result.Status >= 200 && result.Status < 300, "create fixture: %s: %d %s: %s", operation, result.Status, result.Code, result.Message) + } + switch service { + case awsmodel.IAM: + const oidcHost = "fixture.example.com" + create("CreateRole", map[string]any{"RoleName": requestFixture, "AssumeRolePolicyDocument": `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`}) + create("CreatePolicy", map[string]any{"PolicyName": requestFixture, "PolicyDocument": `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`}) + create("CreateOpenIDConnectProvider", map[string]any{"Url": "https://" + oidcHost, "ClientIDList": []any{"sts.amazonaws.com"}, "ThumbprintList": []any{strings.Repeat("a", 40)}}) + create("CreateInstanceProfile", map[string]any{"InstanceProfileName": requestFixture}) + create("CreateGroup", map[string]any{"GroupName": requestFixture}) + create("CreateUser", map[string]any{"UserName": requestFixture}) + return map[string]string{ + "RoleName": requestFixture, + "PolicyArn": "arn:aws:iam::" + accountID + ":policy/" + requestFixture, + "OpenIDConnectProviderArn": "arn:aws:iam::" + accountID + ":oidc-provider/" + oidcHost, + "InstanceProfileName": requestFixture, + "GroupName": requestFixture, + "UserName": requestFixture, + } + case awsmodel.ECS: + create("CreateCluster", map[string]any{"clusterName": requestFixture}) + return map[string]string{ + "cluster": requestFixture, + "clusters": requestFixture, + "resourceArn": "arn:aws:ecs:" + testRegion + ":" + accountID + ":cluster/" + requestFixture, + } + case awsmodel.ECR: + create("CreateRepository", map[string]any{"repositoryName": requestFixture}) + return map[string]string{ + "repositoryName": requestFixture, + "repositoryNames": requestFixture, + "resourceArn": "arn:aws:ecr:" + testRegion + ":" + accountID + ":repository/" + requestFixture, + } + default: + return nil + } +} + type requestSender struct { endpoint string service awsmodel.Service