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)