diff --git a/CHANGELOG.md b/CHANGELOG.md index 2712856c..17a65b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -274,6 +274,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 out now replays with **zero** differences and `StateValid` true. That last step is what makes the claim load-bearing rather than cosmetic: a re-minted ETag turns the recorded delete into a `PreconditionFailed` against a control nothing had changed. 28 draw sites remain on `crypto/rand`. +- **The compute and edge family — API Gateway, AppSync, Batch, EMR Serverless, ECR, ELB and + Route 53 — mints derived identifiers** (#856). Eleven generators across nine plugin files moved, + so a recorded stream that creates a REST API and a resource under its root, an AppSync API with a + key and a function, a hosted zone and a record-set change inside it, a load balancer with a target + group and a listener naming both ARNs, a Batch job, an EMR Serverless application and job run, and + an ECR repository with an image read back by the digest the push minted now replays with **zero** + differences and `StateValid` true. Every one of those later requests names what an earlier one + minted, which is what makes the stream an assertion rather than a smoke test. +- **The shared UUID-shaped generator is now `IDMint.HexUUID`** (#856). Batch job IDs and EMR + Serverless job-run IDs publish the same `8-4-4-4-12` rendering the six services in the previous + tier do, and the helper they would have called was declared in the Lambda plugin and named for a + Lambda revision. Promoting it to a method on the mint and rewriting its thirteen call sites is what + keeps a Batch job ID from being minted by `generateLambdaRevisionID`; the rendering is unchanged. +- **An API Gateway ID can now contain a digit** (#856). The `crypto/rand` version read five bytes and + mapped each *nibble* through the service's 36-character alphabet, so only `a` through `p` could + ever appear and a digit never did. Deriving it draws a byte per character, which reaches the whole + published set, so an API, resource, deployment, authorizer or usage-plan ID — and an API Gateway v2 + route, integration or mapping ID — now looks like one AWS would issue. This is the one identifier + whose *alphabet* changed when it was derived, and it widened rather than narrowed. 23 draw sites + remain on `crypto/rand`. - **A stream recorded under a seed replays under the same seed** (#1140). Every seedable outcome in substrate is written through a control-plane endpoint, and only the AWS path recorded anything — so a seed never entered the event stream. A replay opens by resetting the whole `StateManager`, and a diff --git a/docs/services.md b/docs/services.md index dd989ff4..256eb3bd 100644 --- a/docs/services.md +++ b/docs/services.md @@ -2207,19 +2207,33 @@ Three kinds of value stay random, and one more is still migrating: CloudFormation's [stack and change-set ARNs](#stack-and-change-set-arns-are-deterministic), which predate this rule and are what generalising it was modelled on. - EC2, IAM, STS, SQS, SNS, Lambda, EFS, FSx, Transfer, ECS, Step Functions, EventBridge, - CloudWatch Logs, CloudFront and Service Quotas identifiers are derived today. A CloudFront - distribution, invalidation and origin access control all draw from one generator, so the three - moved together with the origin access control family (#1277). The remaining services are - migrating one family at a time, tracked on #856; until a service moves, its identifiers are - still drawn from `crypto/rand` and a replay of a stream creating one of its resources still - diverges. - -Six of those services publish an identifier from one shared generator rather than declaring their + CloudWatch Logs, CloudFront, Service Quotas, API Gateway (v1 and v2), AppSync, Batch, EMR + Serverless, ECR, ELB and Route 53 identifiers are derived today. A CloudFront distribution, + invalidation and origin access control all draw from one generator, so the three moved together + with the origin access control family (#1277). The remaining services are migrating one family + at a time, tracked on #856; until a service moves, its identifiers are still drawn from + `crypto/rand` and a replay of a stream creating one of its resources still diverges. + +Nine of those services publish an identifier from one shared generator rather than declaring their own, so they moved together: an ECS task ID, a Step Functions execution name, an SQS message ID, -an EventBridge event ID, a CloudWatch Logs upload sequence token and a Service Quotas request ID -are all the same sixteen derived bytes rendered in UUID *shape* — `8-4-4-4-12` lowercase hex -without the RFC 4122 version and variant bits, which is the form substrate published before it -derived them and is unchanged by deriving them. +an EventBridge event ID, a CloudWatch Logs upload sequence token, a Service Quotas request ID, a +Lambda revision ID, a Batch job ID and an EMR Serverless job-run ID are all the same sixteen +derived bytes rendered in UUID *shape* — `8-4-4-4-12` lowercase hex without the RFC 4122 version +and variant bits, which is the form substrate published before it derived them and is unchanged by +deriving them. + +One identifier's **alphabet** changed when it was derived, and widened rather than narrowed. An API +Gateway ID is ten lowercase alphanumeric characters; the `crypto/rand` version read five bytes and +mapped each *nibble* through that 36-character alphabet, so only `a` through `p` could appear and a +digit never did. Drawing a byte per character reaches the whole published set, so an API ID, +resource ID, deployment ID, authorizer ID, usage-plan ID or API Gateway v2 route, integration and +mapping ID now looks like one AWS would issue. + +An ECR image digest is minted rather than computed from the manifest, so it is reproducible across +a replay but is not the SHA-256 of the image it names, and two pushes of identical manifest bytes +store two images where AWS stores one. [#1283](https://github.com/scttfrdmn/substrate/issues/1283) +tracks deriving it from the manifest, which changes what the digest *means* rather than where its +bytes come from. An SQS send mints a message's initial receipt handle and each receive replaces it, matching real SQS: two `ReceiveMessage` calls that return the same message hand back different handles and only diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 82ee1a73..4c01db6a 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -336,8 +336,9 @@ all; before #856 every such stream diverged on its first create, which is why th tests above are built on caller-chosen bucket and key names instead. Two caveats. **Not every service's identifiers are derived yet.** EC2, IAM, STS, SQS, SNS, -Lambda, EFS, FSx, Transfer, ECS, Step Functions, EventBridge, CloudWatch Logs, CloudFront and -Service Quotas are; the rest are migrating one family at a time, and until a service moves, a +Lambda, EFS, FSx, Transfer, ECS, Step Functions, EventBridge, CloudWatch Logs, CloudFront, +Service Quotas, API Gateway (v1 and v2), AppSync, Batch, EMR Serverless, ECR, ELB and Route 53 +are; the rest are migrating one family at a time, and until a service moves, a replay of a stream creating one of its resources still diverges. And a recording made against an **unfrozen** clock can still diverge on a `state_hash_after` even when every identifier matches, because a handler reading the live clock stamps its record a few hundred diff --git a/emulator/apigateway_plugin.go b/emulator/apigateway_plugin.go index 59aad80c..1ba8c3dd 100644 --- a/emulator/apigateway_plugin.go +++ b/emulator/apigateway_plugin.go @@ -2,7 +2,6 @@ package emulator import ( "context" - "crypto/rand" "encoding/base64" "encoding/json" "fmt" @@ -115,7 +114,7 @@ func (p *APIGatewayPlugin) HandleRequest(ctx *RequestContext, req *AWSRequest) ( case "DeleteUsagePlan": return p.deleteUsagePlan(ctx, params["planId"]) case "CreateUsagePlanKey": - return apigwJSONResponse(http.StatusCreated, map[string]string{"id": generateAPIGatewayID(), "type": "API_KEY"}) + return apigwJSONResponse(http.StatusCreated, map[string]string{"id": generateAPIGatewayID(ctx.IDs), "type": "API_KEY"}) case "CreateDomainName": return p.createDomainName(ctx, req) case "GetDomainName": @@ -391,8 +390,8 @@ func (p *APIGatewayPlugin) createRestAPI(ctx *RequestContext, req *AWSRequest) ( return nil, &AWSError{Code: "BadRequestException", Message: "name is required", HTTPStatus: http.StatusBadRequest} } - apiID := generateAPIGatewayID() - rootResID := generateAPIGatewayID() + apiID := generateAPIGatewayID(ctx.IDs) + rootResID := generateAPIGatewayID(ctx.IDs) now := p.tc.Now() api := RestAPIState{ @@ -567,7 +566,7 @@ func (p *APIGatewayPlugin) createResource(ctx *RequestContext, req *AWSRequest, } fullPath += body.PathPart - resID := generateAPIGatewayID() + resID := generateAPIGatewayID(ctx.IDs) res := ResourceState{ ID: resID, ParentID: parentID, @@ -837,7 +836,7 @@ func (p *APIGatewayPlugin) createDeployment(ctx *RequestContext, req *AWSRequest } dep := DeploymentState{ - ID: generateAPIGatewayID(), + ID: generateAPIGatewayID(ctx.IDs), Description: body.Description, CreatedDate: p.tc.Now(), APIId: apiID, @@ -1061,7 +1060,7 @@ func (p *APIGatewayPlugin) createAuthorizer(ctx *RequestContext, req *AWSRequest } auth := AuthorizerState{ - ID: generateAPIGatewayID(), + ID: generateAPIGatewayID(ctx.IDs), Name: body.Name, Type: body.Type, ProviderARNs: body.ProviderARNs, @@ -1282,7 +1281,7 @@ func (p *APIGatewayPlugin) createUsagePlan(ctx *RequestContext, req *AWSRequest) } plan := UsagePlanState{ - ID: generateAPIGatewayID(), + ID: generateAPIGatewayID(ctx.IDs), Name: body.Name, Description: body.Description, Tags: body.Tags, @@ -1498,18 +1497,21 @@ func (p *APIGatewayPlugin) getBasePathMappings(ctx *RequestContext, req *AWSRequ // --- ID generation ----------------------------------------------------------- -// generateAPIGatewayID generates a 10-character lowercase alphanumeric ID -// suitable for use as an API Gateway resource identifier. -func generateAPIGatewayID() string { - b := make([]byte, 5) - _, _ = rand.Read(b) - const chars = "abcdefghijklmnopqrstuvwxyz0123456789" - out := make([]byte, 10) - for i, by := range b { - out[i*2] = chars[by>>4%36] - out[i*2+1] = chars[by&0xf%36] - } - return string(out) +// apigwIDAlphabet is the alphabet API Gateway publishes its 10-character identifiers in — +// an API id, a resource id, a deployment id, an authorizer id and a usage-plan id are all +// drawn from it. +const apigwIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + +// generateAPIGatewayID mints a 10-character lowercase alphanumeric ID from m, suitable for +// use as an API Gateway (v1 or v2) resource identifier. +// +// The derived form draws from the whole alphabet, which the crypto/rand form it replaces did +// not: that one read five bytes and mapped each *nibble* through the 36-character alphabet, +// so only its first sixteen characters — `a` through `p` — could ever appear and a digit +// never did. Widening it moves the identifier toward what the service publishes rather than +// away from it, so the rendering change is a fix and not a cost of #856. +func generateAPIGatewayID(m *IDMint) string { + return m.Chars(10, apigwIDAlphabet) } // --- Response helper --------------------------------------------------------- diff --git a/emulator/apigatewayv2_plugin.go b/emulator/apigatewayv2_plugin.go index 630c7705..51956585 100644 --- a/emulator/apigatewayv2_plugin.go +++ b/emulator/apigatewayv2_plugin.go @@ -274,7 +274,7 @@ func (p *APIGatewayV2Plugin) createAPI(ctx *RequestContext, req *AWSRequest) (*A body.ProtocolType = "HTTP" } - apiID := generateAPIGatewayID() + apiID := generateAPIGatewayID(ctx.IDs) api := V2ApiState{ APIID: apiID, Name: body.Name, @@ -408,7 +408,7 @@ func (p *APIGatewayV2Plugin) createRoute(ctx *RequestContext, req *AWSRequest, a } route := V2RouteState{ - RouteID: generateAPIGatewayID(), + RouteID: generateAPIGatewayID(ctx.IDs), RouteKey: body.RouteKey, Target: body.Target, AuthorizationType: body.AuthorizationType, @@ -493,7 +493,7 @@ func (p *APIGatewayV2Plugin) createIntegration(ctx *RequestContext, req *AWSRequ } integ := V2IntegrationState{ - IntegrationID: generateAPIGatewayID(), + IntegrationID: generateAPIGatewayID(ctx.IDs), IntegrationType: body.IntegrationType, IntegrationURI: body.IntegrationURI, PayloadFormatVersion: body.PayloadFormatVersion, @@ -667,7 +667,7 @@ func (p *APIGatewayV2Plugin) createAuthorizerV2(ctx *RequestContext, req *AWSReq } auth := V2AuthorizerState{ - AuthorizerID: generateAPIGatewayID(), + AuthorizerID: generateAPIGatewayID(ctx.IDs), Name: body.Name, AuthorizerType: body.AuthorizerType, IdentitySource: body.IdentitySource, @@ -750,7 +750,7 @@ func (p *APIGatewayV2Plugin) createDeploymentV2(ctx *RequestContext, req *AWSReq } dep := V2DeploymentState{ - DeploymentID: generateAPIGatewayID(), + DeploymentID: generateAPIGatewayID(ctx.IDs), DeploymentStatus: "DEPLOYED", Description: body.Description, CreatedDate: p.tc.Now(), @@ -843,7 +843,7 @@ func (p *APIGatewayV2Plugin) createAPIMapping(ctx *RequestContext, req *AWSReque return nil, &AWSError{Code: "BadRequestException", Message: "invalid request body", HTTPStatus: http.StatusBadRequest} } - mappingID := generateAPIGatewayID() + mappingID := generateAPIGatewayID(ctx.IDs) mapping := v2APIMappingState{ APIMappingID: mappingID, APIID: body.APIID, diff --git a/emulator/appsync_plugin.go b/emulator/appsync_plugin.go index e1214262..d51034a0 100644 --- a/emulator/appsync_plugin.go +++ b/emulator/appsync_plugin.go @@ -140,7 +140,7 @@ func (p *AppSyncPlugin) createGraphqlAPI(reqCtx *RequestContext, req *AWSRequest input.AuthenticationType = "API_KEY" } - apiID := generateAppSyncAPIID() + apiID := generateAppSyncAPIID(reqCtx.IDs) acct := reqCtx.AccountID region := reqCtx.Region arn := fmt.Sprintf("arn:aws:appsync:%s:%s:apis/%s", region, acct, apiID) @@ -458,7 +458,7 @@ func (p *AppSyncPlugin) createFunction(reqCtx *RequestContext, req *AWSRequest, return nil, &AWSError{Code: "BadRequestException", Message: "name is required", HTTPStatus: http.StatusBadRequest} } acct, region := reqCtx.AccountID, reqCtx.Region - funcID := generateAppSyncFunctionID() + funcID := generateAppSyncFunctionID(reqCtx.IDs) fn := AppSyncFunction{ APIID: apiID, FunctionID: funcID, @@ -540,7 +540,7 @@ func (p *AppSyncPlugin) createAPIKey(reqCtx *RequestContext, req *AWSRequest, ap return nil, expiresErr } - keyID := generateAppSyncAPIKeyID() + keyID := generateAppSyncAPIKeyID(reqCtx.IDs) key := AppSyncAPIKey{ ID: keyID, Description: input.Description, diff --git a/emulator/appsync_types.go b/emulator/appsync_types.go index 62ca595f..18d128d8 100644 --- a/emulator/appsync_types.go +++ b/emulator/appsync_types.go @@ -109,19 +109,20 @@ func appSyncAPIKeyIDsKey(acct, region, apiID string) string { return fmt.Sprintf("apikey_ids:%s/%s/%s", acct, region, apiID) } -// generateAppSyncAPIID returns a new unique AppSync API ID (13 lowercase hex chars). -func generateAppSyncAPIID() string { - return randomHex(13) +// generateAppSyncAPIID mints a new unique AppSync API ID from m (26 lowercase hex chars, +// which is the width AppSync publishes an API ID in). +func generateAppSyncAPIID(m *IDMint) string { + return m.Hex(13) } -// generateAppSyncFunctionID returns a new unique AppSync function ID. -func generateAppSyncFunctionID() string { - return randomHex(26) +// generateAppSyncFunctionID mints a new unique AppSync function ID from m. +func generateAppSyncFunctionID(m *IDMint) string { + return m.Hex(26) } -// generateAppSyncAPIKeyID returns a new unique AppSync API key ID. -func generateAppSyncAPIKeyID() string { - return randomHex(26) +// generateAppSyncAPIKeyID mints a new unique AppSync API key ID from m. +func generateAppSyncAPIKeyID(m *IDMint) string { + return m.Hex(26) } // parseAppSyncOperation derives the AppSync operation name from the HTTP method diff --git a/emulator/batch_plugin.go b/emulator/batch_plugin.go index a9d57a42..9901ddc0 100644 --- a/emulator/batch_plugin.go +++ b/emulator/batch_plugin.go @@ -2,7 +2,6 @@ package emulator import ( "context" - "crypto/rand" "crypto/sha256" "encoding/json" "fmt" @@ -578,7 +577,7 @@ func (p *BatchPlugin) submitJob(ctx *RequestContext, req *AWSRequest) (*AWSRespo return nil, &AWSError{Code: "MissingParameter", Message: "jobName is required", HTTPStatus: http.StatusBadRequest} } - jobID := generateBatchJobID() + jobID := generateBatchJobID(ctx.IDs) job := BatchJob{ JobID: jobID, JobName: body.JobName, @@ -878,11 +877,9 @@ func (p *BatchPlugin) nextJobDefinitionRevision(goCtx context.Context, ctx *Requ return highest, nil } -// generateBatchJobID generates a random UUID-formatted job ID. -func generateBatchJobID() string { - b := make([]byte, 16) - _, _ = rand.Read(b) - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +// generateBatchJobID mints a UUID-shaped job ID from m. +func generateBatchJobID(m *IDMint) string { + return m.HexUUID() } // batchJSONResponse serializes v to JSON and returns an AWSResponse. diff --git a/emulator/cloudwatchlogs_plugin.go b/emulator/cloudwatchlogs_plugin.go index d76652cc..5b91276c 100644 --- a/emulator/cloudwatchlogs_plugin.go +++ b/emulator/cloudwatchlogs_plugin.go @@ -419,7 +419,7 @@ func (p *CloudWatchLogsPlugin) createLogStream(ctx *RequestContext, req *AWSRequ LogStreamName: body.LogStreamName, ARN: cwLogStreamARN(ctx.Region, ctx.AccountID, body.LogGroupName, body.LogStreamName), CreationTime: now, - UploadSequenceToken: generateLambdaRevisionID(ctx.IDs), + UploadSequenceToken: ctx.IDs.HexUUID(), } data, err := json.Marshal(ls) if err != nil { @@ -607,7 +607,7 @@ func (p *CloudWatchLogsPlugin) putLogEvents(ctx *RequestContext, req *AWSRequest var ls CWLogStream if json.Unmarshal(streamData, &ls) == nil { ls.LastIngestionTime = now - ls.UploadSequenceToken = generateLambdaRevisionID(ctx.IDs) + ls.UploadSequenceToken = ctx.IDs.HexUUID() if updated, marshalErr := json.Marshal(ls); marshalErr == nil { _ = p.state.Put(goCtx, cloudwatchLogsNamespace, streamKey, updated) } diff --git a/emulator/ec2_types.go b/emulator/ec2_types.go index bac6ecc9..d912b9cf 100644 --- a/emulator/ec2_types.go +++ b/emulator/ec2_types.go @@ -495,7 +495,7 @@ func generateAssociationID(m *IDMint) string { // flag day: a caller moves by taking a mint and calling [IDMint.Hex] with the same width. // EC2's own ids no longer come through here. // -// TODO(#856): 28 draw sites remain on crypto/rand, tiered by service family on the issue; +// TODO(#856): 23 draw sites remain on crypto/rand, tiered by service family on the issue; // delete this function when the last caller moves. func randomHex(n int) string { b := make([]byte, n) diff --git a/emulator/ecr_plugin.go b/emulator/ecr_plugin.go index 6da1ec7a..5acc5560 100644 --- a/emulator/ecr_plugin.go +++ b/emulator/ecr_plugin.go @@ -2,7 +2,6 @@ package emulator import ( "context" - "crypto/rand" "encoding/base64" "encoding/json" "fmt" @@ -401,7 +400,7 @@ func (p *ECRPlugin) putImage(ctx *RequestContext, req *AWSRequest) (*AWSResponse digest := body.ImageDigest if digest == "" { - digest = generateECRDigest() + digest = generateECRDigest(ctx.IDs) } img := ECRImage{ @@ -1213,11 +1212,16 @@ func (p *ECRPlugin) saveImageTagsMap(goCtx context.Context, tagsKey string, m ma _ = p.state.Put(goCtx, ecrNamespace, tagsKey, b) } -// generateECRDigest creates a random sha256 digest string. -func generateECRDigest() string { - b := make([]byte, 32) - _, _ = rand.Read(b) - return fmt.Sprintf("sha256:%x", b) +// generateECRDigest mints a sha256-shaped image digest from m, for a PutImage that supplied no +// `imageDigest` of its own. +// +// A real digest is the SHA-256 of the image manifest, so ECR computes the same one for two pushes +// of identical manifest bytes and treats the second as the same image. Substrate mints an +// unrelated value instead, which is why every image it stores is distinct even when the manifests +// are byte-identical; #1283 tracks deriving it from the manifest, which is a change to what the +// digest *means* rather than to where its bytes come from and so is not #856's to make. +func generateECRDigest(m *IDMint) string { + return "sha256:" + m.Hex(32) } // ecrJSONResponse marshals v as JSON and returns an AWSResponse with diff --git a/emulator/ecs_plugin.go b/emulator/ecs_plugin.go index 75659d8b..89c6d6fb 100644 --- a/emulator/ecs_plugin.go +++ b/emulator/ecs_plugin.go @@ -830,7 +830,7 @@ func (p *ECSPlugin) runTask(ctx *RequestContext, req *AWSRequest) (*AWSResponse, var tasks []ECSTask for i := 0; i < body.Count; i++ { - taskID := generateLambdaRevisionID(ctx.IDs)[:16] + taskID := ctx.IDs.HexUUID()[:16] task := ECSTask{ TaskArn: fmt.Sprintf("arn:aws:ecs:%s:%s:task/%s/%s", ctx.Region, ctx.AccountID, clusterName, taskID), TaskDefinitionArn: td.TaskDefinitionArn, diff --git a/emulator/elb_classic.go b/emulator/elb_classic.go index 388c9e35..f7f4e14e 100644 --- a/emulator/elb_classic.go +++ b/emulator/elb_classic.go @@ -517,7 +517,7 @@ func (p *ELBPlugin) createClassicLoadBalancer(reqCtx *RequestContext, req *AWSRe lb := ELBClassicLoadBalancer{ Name: name, ARN: elbClassicLoadBalancerARN(reqCtx.Region, reqCtx.AccountID, name), - DNSName: elbClassicDNSName(name, generateELBSuffix(), reqCtx.Region, scheme), + DNSName: elbClassicDNSName(name, generateELBSuffix(reqCtx.IDs), reqCtx.Region, scheme), Scheme: scheme, Listeners: listeners, AvailabilityZones: extractIndexedParams(req.Params, "AvailabilityZones.member"), diff --git a/emulator/elb_plugin.go b/emulator/elb_plugin.go index 0d8e22bb..aabba210 100644 --- a/emulator/elb_plugin.go +++ b/emulator/elb_plugin.go @@ -131,7 +131,7 @@ func (p *ELBPlugin) createLoadBalancer(reqCtx *RequestContext, req *AWSRequest) scheme = "internet-facing" } vpcID := req.Params["VpcId"] - suffix := generateELBSuffix() + suffix := generateELBSuffix(reqCtx.IDs) arn := elbLoadBalancerARN(reqCtx.Region, reqCtx.AccountID, lbType, name, suffix) dnsName := elbDNSName(name, suffix, reqCtx.Region) @@ -281,7 +281,7 @@ func (p *ELBPlugin) createTargetGroup(reqCtx *RequestContext, req *AWSRequest) ( if targetType == "" { targetType = "instance" } - suffix := generateELBSuffix() + suffix := generateELBSuffix(reqCtx.IDs) arn := elbTargetGroupARN(reqCtx.Region, reqCtx.AccountID, name, suffix) tags, tagErr := elbTagsForCreate(req, false, elbKindTargetGroup) @@ -562,7 +562,7 @@ func (p *ELBPlugin) createListener(reqCtx *RequestContext, req *AWSRequest) (*AW return nil, tagErr } - suffix := generateELBSuffix() + suffix := generateELBSuffix(reqCtx.IDs) arn, ok := elbListenerARN(lbARN, suffix) if !ok { // The listener's ARN carries the load balancer's name and id, so an unparseable @@ -741,7 +741,7 @@ func (p *ELBPlugin) createRule(reqCtx *RequestContext, req *AWSRequest) (*AWSRes return nil, tagErr } - suffix := generateELBSuffix() + suffix := generateELBSuffix(reqCtx.IDs) arn, ok := elbRuleARN(listenerARN, suffix) if !ok { // As in createListener: a rule's ARN is built from its listener's, so an unparseable diff --git a/emulator/elb_types.go b/emulator/elb_types.go index fbd85587..fae73768 100644 --- a/emulator/elb_types.go +++ b/emulator/elb_types.go @@ -218,9 +218,11 @@ type ELBRule struct { EverTagged bool `json:"ever_tagged,omitempty"` } -// generateELBSuffix generates a unique 17-character ELB resource suffix. -func generateELBSuffix() string { - return "0" + randomHex(8) +// generateELBSuffix mints a unique 17-character ELB resource suffix from m. It is the trailing +// segment of a load balancer, target group, listener or rule ARN, and of a classic load +// balancer's DNS name. +func generateELBSuffix(m *IDMint) string { + return "0" + m.Hex(8) } // elbARNSubtypes maps a `Type` a caller sends to `CreateLoadBalancer` onto the abbreviation AWS's diff --git a/emulator/emrserverless_plugin.go b/emulator/emrserverless_plugin.go index 95fd4c32..bfd22a5b 100644 --- a/emulator/emrserverless_plugin.go +++ b/emulator/emrserverless_plugin.go @@ -2,7 +2,6 @@ package emulator import ( "context" - "crypto/rand" "encoding/json" "fmt" "net/http" @@ -171,7 +170,7 @@ func (p *EMRServerlessPlugin) createApplication(ctx *RequestContext, req *AWSReq return nil, &AWSError{Code: "ValidationException", Message: "invalid request body", HTTPStatus: http.StatusBadRequest} } - appID := generateEMRServerlessAppID() + appID := generateEMRServerlessAppID(ctx.IDs) arn := fmt.Sprintf("arn:aws:emr-serverless:%s:%s:/applications/%s", ctx.Region, ctx.AccountID, appID) app := EMRServerlessApp{ ApplicationID: appID, @@ -236,7 +235,7 @@ func (p *EMRServerlessPlugin) startJobRun(ctx *RequestContext, req *AWSRequest, } } - runID := generateEMRServerlessRunID() + runID := generateEMRServerlessRunID(ctx.IDs) arn := fmt.Sprintf("arn:aws:emr-serverless:%s:%s:/applications/%s/jobruns/%s", ctx.Region, ctx.AccountID, appID, runID) run := EMRServerlessJobRun{ @@ -328,19 +327,15 @@ func (p *EMRServerlessPlugin) listJobRuns(ctx *RequestContext, _ *AWSRequest, ap return emrServerlessJSONResponse(http.StatusOK, map[string]interface{}{"jobRuns": summaries}) } -// generateEMRServerlessAppID generates a numeric-looking EMR Serverless application ID. -func generateEMRServerlessAppID() string { - b := make([]byte, 4) - _, _ = rand.Read(b) - n := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) - return fmt.Sprintf("00%08x", n) +// generateEMRServerlessAppID mints an EMR Serverless application ID from m, in the +// "00"-prefixed lowercase-hex form the service publishes one in. +func generateEMRServerlessAppID(m *IDMint) string { + return "00" + m.Hex(4) } -// generateEMRServerlessRunID generates a UUID-formatted job run ID. -func generateEMRServerlessRunID() string { - b := make([]byte, 16) - _, _ = rand.Read(b) - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +// generateEMRServerlessRunID mints a UUID-shaped job run ID from m. +func generateEMRServerlessRunID(m *IDMint) string { + return m.HexUUID() } // emrServerlessJSONResponse serializes v to JSON and returns an AWSResponse. diff --git a/emulator/eventbridge_plugin.go b/emulator/eventbridge_plugin.go index 2395c5e9..1221249d 100644 --- a/emulator/eventbridge_plugin.go +++ b/emulator/eventbridge_plugin.go @@ -465,7 +465,7 @@ func (p *EventBridgePlugin) putEvents(ctx *RequestContext, req *AWSRequest) (*AW Detail: entry.Detail, EventBusName: busName, Time: now, - EventID: generateLambdaRevisionID(ctx.IDs), + EventID: ctx.IDs.HexUUID(), } existing = append(existing, ev) results = append(results, resultEntry{EventID: ev.EventID}) diff --git a/emulator/ids.go b/emulator/ids.go index 37df6b73..894b4b7f 100644 --- a/emulator/ids.go +++ b/emulator/ids.go @@ -61,7 +61,7 @@ import ( // already derived from its inputs: a public IP from its instance id, a secret's ARN from its // name, CloudFormation's stack UUIDs from account and region. // -// TODO(#856): 28 draw sites remain on crypto/rand, tiered by service family on the issue. +// TODO(#856): 23 draw sites remain on crypto/rand, tiered by service family on the issue. // IDMint mints the identifiers one request publishes, derived from that request's own id so // that replaying the request mints the same ones. @@ -179,3 +179,22 @@ func (m *IDMint) UUID() string { func (m *IDMint) Base64(n int) string { return base64.StdEncoding.EncodeToString(m.bytes(n)) } + +// HexUUID returns sixteen bytes in UUID *shape* — 8-4-4-4-12 lowercase hex — without the RFC +// 4122 version and variant bits [IDMint.UUID] sets. +// +// It exists because a dozen call sites across nine services published exactly this rendering +// from crypto/rand, and #856 is about making an identifier reproducible across a replay rather +// than about changing which bytes a caller sees: setting the two nibbles UUID sets would change +// every one of them. A new mint site that wants a real version-4 shape should use UUID; this is +// for the identifiers substrate already publishes in the looser form. +// +// The callers are Lambda revision and code ids, ECS task ids, Step Functions execution names, +// SQS message ids, EventBridge event ids, CloudWatch Logs upload sequence tokens, Service Quotas +// request ids, Batch job ids and EMR Serverless job-run ids. Until this method existed the first +// seven of those reached a helper declared in lambda_plugin.go, which is how a Batch job id came +// to be minted by a function named for a Lambda revision. +func (m *IDMint) HexUUID() string { + h := m.Hex(16) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] +} diff --git a/emulator/ids_test.go b/emulator/ids_test.go index 9539047f..8abfadc4 100644 --- a/emulator/ids_test.go +++ b/emulator/ids_test.go @@ -654,3 +654,418 @@ func idsEFSCall(t *testing.T, ts *emulator.TestServer, method, path string, body require.Less(t, resp.StatusCode, 300, "%s %s: %s", method, path, out) return out } + +// Tier 3 of #856: the compute and edge family — API Gateway (v1 and v2), AppSync, Batch, EMR +// Serverless, ECR, ELB and Route 53. +// +// Same two layers as the tiers above. One property is asserted on the mint directly, because +// this tier is the first to change an identifier's *alphabet* rather than only its source, and +// one recorded stream is asserted over the wire, because interlocked creates are what a +// re-minted identifier actually breaks. + +// TestIDMint_TheAPIGatewayAlphabetIsTheWholePublishedSet pins the one rendering change tier 3 +// makes. +// +// The crypto/rand form of generateAPIGatewayID read five bytes and mapped each *nibble* +// through a 36-character alphabet, so only the first sixteen characters — `a` through `p` — +// could ever appear in an API Gateway identifier and a digit never did. [emulator.IDMint.Chars] +// draws one byte per character, which is both the correct use of the seam and the alphabet API +// Gateway publishes. The seed is fixed, so this is a deterministic statement about the mapping +// and not a sample of a random source. +func TestIDMint_TheAPIGatewayAlphabetIsTheWholePublishedSet(t *testing.T) { + t.Parallel() + + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + mint := emulator.NewIDMint("ids-tier3-alphabet") + + used := map[rune]bool{} + for range 50 { + id := mint.Chars(10, alphabet) + require.Len(t, id, 10) + for _, c := range id { + require.True(t, strings.ContainsRune(alphabet, c), + "%q is outside the published alphabet", c) + used[c] = true + } + } + + beyondNibbleRange := 0 + for c := range used { + if strings.IndexRune(alphabet, c) >= 16 { + beyondNibbleRange++ + } + } + assert.Positive(t, beyondNibbleRange, + "the nibble mapping this replaces could reach only a-p; %d of the alphabet's last 20 "+ + "characters appear", beyondNibbleRange) +} + +// TestIDs_OneCreateRestApiMintsDistinctIdentifiers is the ordinal's test for this tier: one +// request that mints two identifiers, the API's own and its root resource's. +// +// A mint that ignored its ordinal would answer the same value for both, and every later +// request addressing the root resource would address the API instead. +func TestIDs_OneCreateRestApiMintsDistinctIdentifiers(t *testing.T) { + t.Parallel() + ts := emulator.StartTestServer(t) + ts.FreezeTime() + + var api struct { + ID string `json:"id"` + RootResourceID string `json:"rootResourceId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsAPIGatewayHost, http.MethodPost, + "/restapis", map[string]any{"name": "ids-tier3"}), &api)) + + require.Len(t, api.ID, 10, "an API Gateway identifier is ten characters") + require.Len(t, api.RootResourceID, 10) + assert.NotEqual(t, api.ID, api.RootResourceID, + "one CreateRestApi mints the API's id and its root resource's id, not one value twice") +} + +// TestIDs_OneCreateHostedZoneMintsAZoneAndAChangeID is the same property on the other kind of +// two-draw request: Route 53 answers a zone identifier and the identifier of the change that +// created it, from one call. +func TestIDs_OneCreateHostedZoneMintsAZoneAndAChangeID(t *testing.T) { + t.Parallel() + ts := emulator.StartTestServer(t) + ts.FreezeTime() + + zoneID, changeID := idsCreateHostedZone(t, ts, "ids-tier3.example.") + assert.NotEqual(t, zoneID, changeID, + "a hosted zone and the change that created it are two identifiers: %q and %q", + zoneID, changeID) + assert.Contains(t, changeID, "/change/C", "a change id keeps its published prefix") +} + +// TestReplay_TheComputeAndEdgeFamiliesReplayWithTheIdentifiersTheyMinted is the wire-level +// assertion for tier 3, and the same claim the tiers above make for EC2/IAM and for the +// messaging/storage group: a stream whose later requests *name* what its earlier ones minted +// replays with no differences and reaches the recorded state. +// +// Each of the seven services contributes a create followed by a request that can only succeed +// against the identifier that create minted — a resource under a REST API's root, an API key +// on an AppSync API, a record set in a hosted zone, a listener on a load balancer and its +// target group, a DescribeJobs naming a job id, a GetJobRun naming an application and a run, +// and a BatchGetImage naming an image digest. A re-minted identifier turns one of those into a +// refusal rather than into a differently-worded success. +func TestReplay_TheComputeAndEdgeFamiliesReplayWithTheIdentifiersTheyMinted(t *testing.T) { + t.Parallel() + ts := emulator.StartTestServer(t, + emulator.WithRecordedBodies(), emulator.WithRecordedStateHashes()) + require.True(t, ts.Store().RecordsStateHashes(), "precondition: state_hash_after is compared") + + idsRecordComputeAndEdgeCreates(t, ts) + + results, err := replayEngineFor(ts, emulator.ReplayConfig{ValidateState: true}). + Replay(t.Context(), replayStreamID) + require.NoError(t, err) + + assert.Positive(t, results.TotalEvents, "the stream has to contain the creates") + assert.Equal(t, results.TotalEvents, results.SuccessEvents, + "every recorded request is re-executed and answers") + assert.Empty(t, results.Differences, + "a compute or edge identifier replays as the one recorded: %s", + replayDifferenceSummary(results)) + assert.True(t, results.StateValid, + "and the state it reaches is the recorded state: %v", results.StateErrors) +} + +// idsRecordComputeAndEdgeCreates records the tier-3 stream, one interlocked pair or triple per +// service. +func idsRecordComputeAndEdgeCreates(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + // Frozen for the reason [idsRecordInterlockedCreates] gives: a replay pins the clock to the + // recorded event's timestamp, so a handler stamping a record off a live clock diverges in + // the state hash for a reason that has nothing to do with an identifier. + ts.FreezeTime() + + idsRecordAPIGateway(t, ts) + idsRecordAppSync(t, ts) + idsRecordRoute53(t, ts) + idsRecordELB(t, ts) + idsRecordBatchAndEMRServerless(t, ts) + idsRecordECR(t, ts) +} + +// idsRecordAPIGateway records a REST API, a resource under the root resource it minted, and a +// deployment. +func idsRecordAPIGateway(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + var api struct { + ID string `json:"id"` + RootResourceID string `json:"rootResourceId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsAPIGatewayHost, http.MethodPost, + "/restapis", map[string]any{"name": "ids-tier3"}), &api)) + require.NotEmpty(t, api.ID) + require.NotEmpty(t, api.RootResourceID) + + idsRESTCall(t, ts, idsAPIGatewayHost, http.MethodPost, + "/restapis/"+api.ID+"/resources/"+api.RootResourceID, + map[string]any{"pathPart": "things"}) + idsRESTCall(t, ts, idsAPIGatewayHost, http.MethodPost, + "/restapis/"+api.ID+"/deployments", map[string]any{"stageName": "prod"}) + idsRESTCall(t, ts, idsAPIGatewayHost, http.MethodGet, + "/restapis/"+api.ID+"/resources", nil) +} + +// idsRecordAppSync records a GraphQL API, then an API key and a function on it. +func idsRecordAppSync(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + var created struct { + API struct { + APIID string `json:"apiId"` + } `json:"graphqlApi"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsAppSyncHost, http.MethodPost, + "/v1/apis", map[string]any{"name": "ids-tier3", "authenticationType": "API_KEY"}), + &created)) + apiID := created.API.APIID + require.NotEmpty(t, apiID) + + idsRESTCall(t, ts, idsAppSyncHost, http.MethodPost, + "/v1/apis/"+apiID+"/apikeys", map[string]any{"description": "ids-tier3"}) + + var fn struct { + Function struct { + FunctionID string `json:"functionId"` + } `json:"functionConfiguration"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsAppSyncHost, http.MethodPost, + "/v1/apis/"+apiID+"/functions", map[string]any{"name": "idsFn"}), &fn)) + require.NotEmpty(t, fn.Function.FunctionID) + + // Two reads that name what was minted: the function by its own id, and the key collection + // on the API's id. + idsRESTCall(t, ts, idsAppSyncHost, http.MethodGet, + "/v1/apis/"+apiID+"/functions/"+fn.Function.FunctionID, nil) + idsRESTCall(t, ts, idsAppSyncHost, http.MethodGet, "/v1/apis/"+apiID+"/apikeys", nil) +} + +// idsRecordRoute53 records a hosted zone, a record set inside it, and a read of the zone. +func idsRecordRoute53(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + zoneID, _ := idsCreateHostedZone(t, ts, "ids-tier3.example.") + idsXMLCall(t, ts, idsRoute53Host, http.MethodPost, "/2013-04-01/hostedzone/"+zoneID+"/rrset", + ``+ + `CREATEwww.ids-tier3.example.`+ + `A300`+ + `192.0.2.1`+ + ``+ + ``) + idsXMLCall(t, ts, idsRoute53Host, http.MethodGet, "/2013-04-01/hostedzone/"+zoneID, "") +} + +// idsCreateHostedZone creates one hosted zone and returns its id and the change id the create +// answered with. +func idsCreateHostedZone(t *testing.T, ts *emulator.TestServer, name string) (zoneID, changeID string) { + t.Helper() + + var created struct { + Zone struct { + ID string `xml:"Id"` + } `xml:"HostedZone"` + Change struct { + ID string `xml:"Id"` + } `xml:"ChangeInfo"` + } + body := idsXMLCall(t, ts, idsRoute53Host, http.MethodPost, "/2013-04-01/hostedzone", + ``+name+``+ + `ids-tier3`) + require.NoError(t, xml.Unmarshal(body, &created)) + require.NotEmpty(t, created.Zone.ID, "CreateHostedZone returned no zone: %s", body) + + // The zone id is reported as "/hostedzone/Z…" and addressed as either form. + return strings.TrimPrefix(created.Zone.ID, "/hostedzone/"), created.Change.ID +} + +// idsRecordELB records a load balancer, a target group, and a listener naming both — three +// draws of the ELB suffix, each carried in an ARN a later request must match. +func idsRecordELB(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + var lb struct { + ARNs []string `xml:"CreateLoadBalancerResult>LoadBalancers>member>LoadBalancerArn"` + } + require.NoError(t, xml.Unmarshal(idsELBCall(t, ts, map[string]string{ + "Action": "CreateLoadBalancer", "Name": "ids-tier3-lb", + "Subnets.member.1": "subnet-0123456789abcdef0", + }), &lb)) + require.Len(t, lb.ARNs, 1) + + var tg struct { + ARNs []string `xml:"CreateTargetGroupResult>TargetGroups>member>TargetGroupArn"` + } + require.NoError(t, xml.Unmarshal(idsELBCall(t, ts, map[string]string{ + "Action": "CreateTargetGroup", "Name": "ids-tier3-tg", + "Protocol": "HTTP", "Port": "80", "VpcId": "vpc-0123456789abcdef0", + }), &tg)) + require.Len(t, tg.ARNs, 1) + + idsELBCall(t, ts, map[string]string{ + "Action": "CreateListener", "LoadBalancerArn": lb.ARNs[0], + "Protocol": "HTTP", "Port": "80", + "DefaultActions.member.1.Type": "forward", + "DefaultActions.member.1.TargetGroupArn": tg.ARNs[0], + }) + idsELBCall(t, ts, map[string]string{ + "Action": "DescribeListeners", "LoadBalancerArn": lb.ARNs[0], + }) +} + +// idsRecordBatchAndEMRServerless records a Batch job and an EMR Serverless application and job +// run, each followed by a read naming what was minted. +func idsRecordBatchAndEMRServerless(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + var job struct { + JobID string `json:"jobId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBatchHost, http.MethodPost, + "/v1/submitjob", map[string]any{ + "jobName": "ids-tier3", "jobQueue": "ids-queue", "jobDefinition": "ids-def", + }), &job)) + require.NotEmpty(t, job.JobID) + idsRESTCall(t, ts, idsBatchHost, http.MethodPost, "/v1/describejobs", + map[string]any{"jobs": []string{job.JobID}}) + + var app struct { + ApplicationID string `json:"applicationId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsEMRServerlessHost, http.MethodPost, + "/applications", map[string]any{ + "name": "ids-tier3", "type": "SPARK", "releaseLabel": "emr-6.9.0", + }), &app)) + require.NotEmpty(t, app.ApplicationID) + + var run struct { + JobRunID string `json:"jobRunId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsEMRServerlessHost, http.MethodPost, + "/applications/"+app.ApplicationID+"/jobruns", map[string]any{"name": "ids-run"}), &run)) + require.NotEmpty(t, run.JobRunID) + idsRESTCall(t, ts, idsEMRServerlessHost, http.MethodGet, + "/applications/"+app.ApplicationID+"/jobruns/"+run.JobRunID, nil) +} + +// idsRecordECR records a repository, an image whose digest the emulator mints, and a +// BatchGetImage naming that digest. +func idsRecordECR(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + idsECRCall(t, ts, "CreateRepository", map[string]any{"repositoryName": "ids-tier3"}) + + var put struct { + Image struct { + ImageID struct { + ImageDigest string `json:"imageDigest"` + } `json:"imageId"` + } `json:"image"` + } + require.NoError(t, json.Unmarshal(idsECRCall(t, ts, "PutImage", map[string]any{ + "repositoryName": "ids-tier3", "imageTag": "v1", + "imageManifest": `{"schemaVersion":2}`, + }), &put)) + digest := put.Image.ImageID.ImageDigest + require.NotEmpty(t, digest) + + idsECRCall(t, ts, "BatchGetImage", map[string]any{ + "repositoryName": "ids-tier3", + "imageIds": []map[string]string{{"imageDigest": digest}}, + }) +} + +// The hosts the tier-3 services are addressed at. Substrate routes by Host header, so these +// are what select the plugin for a request that carries no X-Amz-Target. +const ( + idsAPIGatewayHost = "apigateway.us-east-1.amazonaws.com" + idsAppSyncHost = "appsync.us-east-1.amazonaws.com" + idsRoute53Host = "route53.amazonaws.com" + idsBatchHost = "batch.us-east-1.amazonaws.com" + idsEMRServerlessHost = "emr-serverless.us-east-1.amazonaws.com" + idsECRHost = "api.ecr.us-east-1.amazonaws.com" + idsELBHost = "elasticloadbalancing.us-east-1.amazonaws.com" +) + +// idsELBCall issues one unsigned ELBv2 query-protocol request and returns the response body. +func idsELBCall(t *testing.T, ts *emulator.TestServer, params map[string]string) []byte { + t.Helper() + return idsQueryCall(t, ts, idsELBHost, "2015-12-01", params) +} + +// idsRESTCall issues one REST/JSON request against host and requires a 2xx. +func idsRESTCall(t *testing.T, ts *emulator.TestServer, host, method, path string, body any) []byte { + t.Helper() + + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + reader = strings.NewReader(string(raw)) + } + req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, reader) + require.NoError(t, err) + req.Host = host + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Less(t, resp.StatusCode, 300, "%s %s %s: %s", host, method, path, out) + return out +} + +// idsXMLCall issues one REST/XML request against host and requires a 2xx. +func idsXMLCall(t *testing.T, ts *emulator.TestServer, host, method, path, body string) []byte { + t.Helper() + + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, reader) + require.NoError(t, err) + req.Host = host + if body != "" { + req.Header.Set("Content-Type", "application/xml") + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Less(t, resp.StatusCode, 300, "%s %s %s: %s", host, method, path, out) + return out +} + +// idsECRCall issues one ECR JSON-1.1 request, which is target-routed rather than path-routed. +func idsECRCall(t *testing.T, ts *emulator.TestServer, op string, body any) []byte { + t.Helper() + + raw, err := json.Marshal(body) + require.NoError(t, err) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, ts.URL+"/", + strings.NewReader(string(raw))) + require.NoError(t, err) + req.Host = idsECRHost + req.Header.Set("Content-Type", "application/x-amz-json-1.1") + req.Header.Set("X-Amz-Target", "AmazonEC2ContainerRegistry_V20150921."+op) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + out, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, "%s: %s", op, out) + return out +} diff --git a/emulator/lambda_plugin.go b/emulator/lambda_plugin.go index b970285a..a53933f3 100644 --- a/emulator/lambda_plugin.go +++ b/emulator/lambda_plugin.go @@ -279,7 +279,7 @@ func (p *LambdaPlugin) createFunction(ctx *RequestContext, req *AWSRequest) (*AW Environment: body.Environment.Variables, CodeSize: 0, CodeSha256: "", - RevisionID: generateLambdaRevisionID(ctx.IDs), + RevisionID: ctx.IDs.HexUUID(), State: "Active", PackageType: pkgType, Architectures: archs, @@ -394,7 +394,7 @@ func (p *LambdaPlugin) updateFunctionCode(ctx *RequestContext, req *AWSRequest, // fresh random value on every update — a caller comparing it across two updates // is asking whether the code changed, and a random value answers "always". A // RevisionID is not a digest of anything and stays random. - fn.RevisionID = generateLambdaRevisionID(ctx.IDs) + fn.RevisionID = ctx.IDs.HexUUID() fn.LastModified = p.tc.Now() switch { @@ -480,7 +480,7 @@ func (p *LambdaPlugin) updateFunctionConfiguration(ctx *RequestContext, req *AWS if body.Environment.Variables != nil { fn.Environment = body.Environment.Variables } - fn.RevisionID = generateLambdaRevisionID(ctx.IDs) + fn.RevisionID = ctx.IDs.HexUUID() fn.LastModified = p.tc.Now() resp, err := p.saveFunctionAndRespond(ctx.AccountID, ctx.Region, fn, http.StatusOK) @@ -1127,24 +1127,6 @@ func (p *LambdaPlugin) sizeS3Package(bucket, key, versionID string) (int64, stri return obj.Size, strings.Trim(obj.ETag, `"`) } -// generateLambdaRevisionID returns a UUID-shaped revision/code ID minted from m. -// -// This is the second shared draw site after [randomHex], and the wider one of the two: -// six other services publish an identifier from here — ECS task IDs, Step Functions -// execution names, SQS message IDs, EventBridge event IDs, CloudWatch Logs sequence -// tokens and Service Quotas request IDs — so it moved to [IDMint] together with Lambda's -// own revision IDs rather than waiting for each of those services' turn (#856). -// -// The rendering is the raw hex of sixteen bytes in UUID *shape*, not an RFC 4122 -// version-4 UUID with its version and variant bits set. That is what this function -// published before; #856 is about making an identifier reproducible across a replay, not -// about changing which bytes a caller sees, so [IDMint.UUID] is deliberately not used -// here. -func generateLambdaRevisionID(m *IDMint) string { - h := m.Hex(16) - return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] -} - // parseInt parses a string into an int. func parseInt(s string) (int, error) { var n int @@ -1272,7 +1254,7 @@ func (p *LambdaPlugin) createEventSourceMapping(ctx *RequestContext, req *AWSReq functionARN = "arn:aws:lambda:" + ctx.Region + ":" + ctx.AccountID + ":function:" + input.FunctionName } - uuid := generateLambdaRevisionID(ctx.IDs) + uuid := ctx.IDs.HexUUID() esm := &ESMConfig{ UUID: uuid, FunctionARN: functionARN, diff --git a/emulator/route53_plugin.go b/emulator/route53_plugin.go index a4f8fe2a..28bf82f7 100644 --- a/emulator/route53_plugin.go +++ b/emulator/route53_plugin.go @@ -95,7 +95,7 @@ func (p *Route53Plugin) createHostedZone(reqCtx *RequestContext, req *AWSRequest } isPrivate := strings.EqualFold(xmlReq.Config.PrivateZone, "true") - suffix := generateHostedZoneID() + suffix := generateHostedZoneID(reqCtx.IDs) zoneID := "Z" + suffix fullID := "/hostedzone/" + zoneID @@ -118,7 +118,7 @@ func (p *Route53Plugin) createHostedZone(reqCtx *RequestContext, req *AWSRequest } changeInfo := Route53ChangeInfo{ - ID: generateChangeID(), + ID: generateChangeID(reqCtx.IDs), Status: "INSYNC", SubmittedAt: p.now().UTC(), } @@ -280,7 +280,7 @@ func (p *Route53Plugin) deleteHostedZone(reqCtx *RequestContext, _ *AWSRequest, p.removeFromList(reqCtx.AccountID, "hostedzone_ids", id) changeInfo := Route53ChangeInfo{ - ID: generateChangeID(), + ID: generateChangeID(reqCtx.IDs), Status: "INSYNC", SubmittedAt: p.now().UTC(), } @@ -306,7 +306,7 @@ func (p *Route53Plugin) deleteHostedZone(reqCtx *RequestContext, _ *AWSRequest, // --- Resource Record Set operations --- -func (p *Route53Plugin) changeResourceRecordSets(_ *RequestContext, req *AWSRequest, zoneID string) (*AWSResponse, error) { +func (p *Route53Plugin) changeResourceRecordSets(reqCtx *RequestContext, req *AWSRequest, zoneID string) (*AWSResponse, error) { id := r53ZoneSuffix(zoneID) // Parse the XML change batch. @@ -380,7 +380,7 @@ func (p *Route53Plugin) changeResourceRecordSets(_ *RequestContext, req *AWSRequ } changeInfo := Route53ChangeInfo{ - ID: generateChangeID(), + ID: generateChangeID(reqCtx.IDs), Status: "INSYNC", SubmittedAt: p.now().UTC(), Comment: xmlReq.ChangeBatch.Comment, diff --git a/emulator/route53_types.go b/emulator/route53_types.go index 7df54f1e..d756ee8d 100644 --- a/emulator/route53_types.go +++ b/emulator/route53_types.go @@ -93,14 +93,14 @@ type Route53ChangeInfo struct { Comment string `json:"Comment,omitempty"` } -// generateHostedZoneID generates a random hosted zone ID suffix (12 uppercase hex chars). -func generateHostedZoneID() string { - return strings.ToUpper(randomHex(6)) +// generateHostedZoneID mints a hosted zone ID suffix from m (12 uppercase hex chars). +func generateHostedZoneID(m *IDMint) string { + return strings.ToUpper(m.Hex(6)) } -// generateChangeID generates a random Route 53 change ID. -func generateChangeID() string { - return "/change/C" + strings.ToUpper(randomHex(8)) +// generateChangeID mints a Route 53 change ID from m. +func generateChangeID(m *IDMint) string { + return "/change/C" + strings.ToUpper(m.Hex(8)) } // parseRoute53Operation extracts the operation name and zone ID from the HTTP diff --git a/emulator/sqs_plugin.go b/emulator/sqs_plugin.go index 9e06bcfa..1cb1e813 100644 --- a/emulator/sqs_plugin.go +++ b/emulator/sqs_plugin.go @@ -1834,9 +1834,10 @@ func getAttrOrDefault(attrs map[string]string, key, fallback string) string { return fallback } -// generateSQSMessageID mints a unique SQS message ID from m. +// generateSQSMessageID mints a unique SQS message ID from m, in the UUID shape SQS publishes +// one in. func generateSQSMessageID(m *IDMint) string { - return generateLambdaRevisionID(m) // Reuse UUID-style generator. + return m.HexUUID() } // generateSQSReceiptHandle mints a unique receipt handle from m. diff --git a/emulator/stepfunctions_plugin.go b/emulator/stepfunctions_plugin.go index e724ee7b..1a065ce0 100644 --- a/emulator/stepfunctions_plugin.go +++ b/emulator/stepfunctions_plugin.go @@ -665,7 +665,7 @@ func (p *StepFunctionsPlugin) startExecution(ctx *RequestContext, req *AWSReques execName := input.Name if execName == "" { - execName = "exec-" + generateLambdaRevisionID(ctx.IDs)[:8] + execName = "exec-" + ctx.IDs.HexUUID()[:8] } // The execution belongs to the state machine, so its ARN, its record and its index entry are all @@ -760,7 +760,7 @@ func (p *StepFunctionsPlugin) startSyncExecution(ctx *RequestContext, req *AWSRe execName := input.Name if execName == "" { - execName = "sync-" + generateLambdaRevisionID(ctx.IDs)[:8] + execName = "sync-" + ctx.IDs.HexUUID()[:8] } execArn := fmt.Sprintf("arn:aws:states:%s:%s:express:%s:%s", target.Region, target.AccountID, target.Name, execName)