Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`API_ExecuteStatement` documents `Id` as a UUID and publishes the dashed pattern. Neither constrains a
position, so both are indifferent to the RFC 4122 version and variant bits — which is why deriving
them preserved each rendering instead of quietly setting two nibbles (#671).
- **The CI/CD family of draw sites is derived** (#856). Five generators across three services move onto
`IDMint`: a CodeBuild build ID, a CodeDeploy `applicationId`, `deploymentGroupId` and `deploymentId`,
and a CodePipeline `pipelineExecutionId`. Every rendering is byte-for-byte the one the `crypto/rand`
version produced, including the `{projectName}:` a build ID is prefixed with, which is CodeBuild's own
composition rather than a derived value. 6 draw sites remain on `crypto/rand`.
- **An underived CodeBuild build ID stalled a poll loop instead of failing it** (#856). A build ID is the
only handle `StartBuild` hands back, and `BatchGetBuilds` reports an unknown one under `buildsNotFound`
in a **200** with an empty `builds` list — so a replay that re-minted it did not refuse the recorded
read, it answered a consumer's wait-for-`buildStatus` loop with nothing to wait on. CodeDeploy's
`deploymentId` and CodePipeline's `pipelineExecutionId` are the family's other addressed identifiers and
break the loud way, with `DeploymentDoesNotExistException` and `PipelineExecutionNotFoundException`.
Reverting the deployment-ID minter alone to confirm the family's replay assertion is not vacuous
produces 10 differences and one refused read out of a 12-request stream.
- **A CodePipeline execution ID keeps its version-4 nibbles where the rest of the family does not** (#856).
It is the one identifier of the five with a published pattern —
`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` — and the one draw site that set RFC
4122's version and variant bits. The pattern admits any hex digit in either position, so it is satisfied
by the looser shape too; substrate keeps the `4` because a consumer validating the value as a version-4
UUID would start failing if it vanished, and #856 changes where an identifier comes from rather than
which bytes a caller sees. CodeBuild's build ID and CodeDeploy's two identity IDs publish no pattern at
all and did not set those bits, so they keep the bare UUID shape (#671).
- **A CodeDeploy `deploymentId`'s shape is documented as observed, not published** (#856).
`API_CreateDeployment` gives `deploymentId` as a String with neither a pattern nor length constraints,
but the page's own sample response is `d-IIMHK0NHC` — which is the whole provenance for the `d-` prefix
and the nine uppercase alphanumeric characters substrate mints. Reading AWS's example is not the
borrowing-a-bound-from-a-sibling #671 rules out, and it is all the reference offers; the docs now say so
where they previously called the shape published.
- **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
Expand Down
28 changes: 26 additions & 2 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -2210,7 +2210,8 @@ Three kinds of value stay random, and one more is still migrating:
CloudWatch Logs, CloudFront, Service Quotas, API Gateway (v1 and v2), AppSync, Batch, EMR
Serverless, ECR, ELB, Route 53, Cognito (both the user-pool and the identity-pool API), IAM
Identity Center, KMS, ACM, Secrets Manager, WAFv2, Athena, Redshift Data, Glue, Timestream,
OpenSearch and QuickSight identifiers are derived today. A CloudFront
OpenSearch, QuickSight, CodeBuild, CodeDeploy and CodePipeline 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
Expand Down Expand Up @@ -2271,6 +2272,29 @@ document ID is the path of every later `GET`, `PUT` and `DELETE` of that documen
cursor goes straight back to `_search/scroll`, where a re-minted one answers a recorded continuation
with `search_context_missing_exception` against a cursor the recording had just opened.

**Three renderings of the same sixteen bytes coexist in the CI/CD family, and the published model
picks each one.** A CodePipeline `pipelineExecutionId` is the only identifier of the five that
publishes a pattern — `[0-9a-f]{8}-[0-9a-f]{4}-…` — and the only draw site that set RFC 4122's
version and variant nibbles; that pattern admits any hex digit in either position, so the nibbles
are substrate's own behaviour rather than a requirement, and they are kept because a consumer
validating the value as a version-4 UUID would start failing if the `4` vanished. CodeBuild's build
ID and CodeDeploy's `applicationId` and `deploymentGroupId` publish no pattern and no length
constraints at all, so they keep the looser UUID *shape* their `crypto/rand` form produced. The
`{projectName}:` a CodeBuild build ID carries is CodeBuild's own composition, not a derived value,
and stays outside the mint.

A CodeDeploy `deploymentId` is the family's one **observed** shape: `CreateDeployment` documents it
as a String with neither a pattern nor length constraints, but the page's own sample response is
`d-IIMHK0NHC`, which is where the `d-` prefix and nine uppercase alphanumeric characters come from.
Reading AWS's example is not the same as inventing a bound from a sibling operation — the rule
[#671](https://github.com/scttfrdmn/substrate/issues/671) settled — and the example is all the
reference offers here. It is also the family's only *addressed* identifier: `GetDeployment` and
`StopDeployment` take it, so a re-minted one answered a recorded read with
`DeploymentDoesNotExistException`. The other four are reported rather than addressed, except
CodeBuild's, which is worse than a refusal — `BatchGetBuilds` reports an unknown ID under
`buildsNotFound` in a **200**, so an underived one stalled a consumer's poll loop instead of
failing it.

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)
Expand Down Expand Up @@ -19924,7 +19948,7 @@ Before writing any test against this service, know that
| CreateDeploymentGroup | Verifies the application exists; `serviceRoleArn` is `Required: Yes` and [stored without a check](#no-codedeploy-name-role-or-compute-platform-is-checked). The other nineteen published members — `ec2TagFilters`, `deploymentStyle`, `blueGreenDeploymentConfiguration`, `alarmConfiguration`, `triggerConfigurations` and the rest — are not read |
| GetDeploymentGroup | Four of the twenty-three published `deploymentGroupInfo` members, [plus two of Substrate's own](#the-three-codedeploy-record-shapes-are-truncated) |
| DeleteDeploymentGroup | Answers the published `hooksNotCleanedUp` as an empty array, which is what AWS's own sample response shows, and [refuses an absent group under an unpublished code](#three-codedeploy-refusals-answer-codes-their-own-page-does-not-publish) |
| CreateDeployment | Verifies the application, and the deployment group when one is named. `revision` is `Required: No` and unread, so a deployment with no artifact at all succeeds. Answers the published `deploymentId` in the published `d-XXXXXXXXX` shape |
| CreateDeployment | Verifies the application, and the deployment group when one is named. `revision` is `Required: No` and unread, so a deployment with no artifact at all succeeds. Answers the published `deploymentId` in the `d-XXXXXXXXX` shape AWS's own sample response shows — the page publishes no pattern for it — derived from the request ID (#856) |
| GetDeployment | Six of the thirty-one published `deploymentInfo` members. [An absent `deploymentId` is reported as an absent deployment](#an-absent-deploymentid-is-reported-as-an-absent-deployment) |

The thirty-nine unrouted operations include everything that would let a consumer observe a deployment
Expand Down
4 changes: 2 additions & 2 deletions docs/testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,8 @@ Two caveats. **Not every service's identifiers are derived yet.** EC2, IAM, STS,
Lambda, EFS, FSx, Transfer, ECS, Step Functions, EventBridge, CloudWatch Logs, CloudFront,
Service Quotas, API Gateway (v1 and v2), AppSync, Batch, EMR Serverless, ECR, ELB, Route 53,
Cognito (both APIs), IAM Identity Center, KMS, ACM, Secrets Manager, WAFv2, Athena,
Redshift Data, Glue, Timestream, OpenSearch and QuickSight
are; the rest are migrating one family at a time, and until a service moves, a
Redshift Data, Glue, Timestream, OpenSearch, QuickSight, CodeBuild, CodeDeploy and
CodePipeline 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
Expand Down
29 changes: 17 additions & 12 deletions emulator/codebuild_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package emulator

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -253,7 +251,7 @@ func (p *CodeBuildPlugin) startBuild(reqCtx *RequestContext, req *AWSRequest) (*
return nil, err
}

buildUUID := generateCodeBuildUUID()
buildUUID := generateCodeBuildUUID(reqCtx.IDs)
buildID := input.ProjectName + ":" + buildUUID
now := p.tc.Now()

Expand Down Expand Up @@ -358,15 +356,22 @@ func codebuildBuildIDsKey(acct, region string) string {
return "build_ids:" + acct + "/" + region
}

// generateCodeBuildUUID generates a UUID-style string for build IDs.
func generateCodeBuildUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b[0:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
// generateCodeBuildUUID mints the UUID-shaped half of a build ID from m, derived from the
// request id so a replayed StartBuild returns the build ID the recording returned (#856).
//
// A build ID is the *only* handle StartBuild hands back: `BatchGetBuilds` takes `ids`, and the
// build ARN is that same string appended to `…:build/`. So a re-minted one turned the recorded
// read of a build into an empty `builds` list with the id reported under `buildsNotFound`,
// which is a 200 — the poll loop a consumer writes around `buildStatus` then never terminates
// rather than failing outright.
//
// `Build.id` publishes no pattern — API_Build gives the type as String with a minimum length
// of 1 and nothing more — so #671 leaves the rendering exactly as the crypto/rand form
// produced it: [IDMint.HexUUID], the 8-4-4-4-12 hex shape without RFC 4122's version and
// variant nibbles. The `{projectName}:` prefix its one caller prepends is CodeBuild's own
// composition, not something derived, and stays where it is.
func generateCodeBuildUUID(m *IDMint) string {
return m.HexUUID()
}

// codebuildJSONResponse serializes v to JSON and returns an AWSResponse with
Expand Down
52 changes: 29 additions & 23 deletions emulator/codedeploy_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package emulator

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -90,7 +88,7 @@ func (p *CodeDeployPlugin) createApplication(reqCtx *RequestContext, req *AWSReq
return nil, &AWSError{Code: "ApplicationAlreadyExistsException", Message: "Application " + input.ApplicationName + " already exists.", HTTPStatus: http.StatusBadRequest}
}

appID := generateCodeDeployAppID()
appID := generateCodeDeployAppID(reqCtx.IDs)
app := CodeDeployApp{
ApplicationID: appID,
ApplicationName: input.ApplicationName,
Expand Down Expand Up @@ -202,7 +200,7 @@ func (p *CodeDeployPlugin) createDeploymentGroup(reqCtx *RequestContext, req *AW
return nil, &AWSError{Code: "DeploymentGroupAlreadyExistsException", Message: "Deployment group " + input.DeploymentGroupName + " already exists.", HTTPStatus: http.StatusBadRequest}
}

groupID := generateCodeDeployGroupID()
groupID := generateCodeDeployGroupID(reqCtx.IDs)
group := CodeDeployGroup{
DeploymentGroupID: groupID,
DeploymentGroupName: input.DeploymentGroupName,
Expand Down Expand Up @@ -298,7 +296,7 @@ func (p *CodeDeployPlugin) createDeployment(reqCtx *RequestContext, req *AWSRequ
}
}

deploymentID := generateCodeDeployDeploymentID()
deploymentID := generateCodeDeployDeploymentID(reqCtx.IDs)
now := p.tc.Now()
deployment := CodeDeployDeployment{
DeploymentID: deploymentID,
Expand Down Expand Up @@ -420,26 +418,34 @@ func codedeployDeploymentKey(acct, region, deployID string) string {
return "deployment:" + acct + "/" + region + "/" + deployID
}

// generateCodeDeployAppID generates a UUID-style ID for CodeDeploy applications.
func generateCodeDeployAppID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b[0:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
// generateCodeDeployAppID mints a CodeDeploy application ID from m, derived from the request id
// so a replayed CreateApplication reports the ID the recording reported (#856).
//
// Both CodeDeploy identity IDs are reported rather than addressed: every operation here keys off
// `applicationName` and `deploymentGroupName`, so a re-minted application ID does not break a
// later call the way a deployment ID does. What it breaks is state validation — the ID is
// persisted in the application record, so a replayed create writes a record differing from the
// recorded one and `ValidateState` reports a `state_hash_after` mismatch for the create and for
// every event after it in the stream.
//
// `ApplicationInfo.applicationId` publishes neither a pattern nor length constraints — the type
// is String and the description is "The application ID" — so #671 keeps the rendering the
// crypto/rand form produced: [IDMint.HexUUID], 8-4-4-4-12 hex without RFC 4122's version and
// variant nibbles.
func generateCodeDeployAppID(m *IDMint) string {
return m.HexUUID()
}

// generateCodeDeployGroupID generates a UUID-style ID for CodeDeploy deployment groups.
func generateCodeDeployGroupID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b[0:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
// generateCodeDeployGroupID mints a CodeDeploy deployment-group ID from m, derived from the
// request id so a replayed CreateDeploymentGroup reports the ID the recording reported (#856).
//
// `DeploymentGroupInfo.deploymentGroupId` publishes as little as the application ID does — type
// String, no pattern, no length constraints — so it takes the same rendering, for the reason
// [generateCodeDeployAppID] records. It stays a separate function because the two IDs are
// separate concepts that happen to share a shape, and folding them together is how a Batch job
// id came to be minted by a function named for a Lambda revision (see [IDMint.HexUUID]).
func generateCodeDeployGroupID(m *IDMint) string {
return m.HexUUID()
}

// codedeployJSONResponse serializes v to JSON and returns an AWSResponse with
Expand Down
37 changes: 24 additions & 13 deletions emulator/codedeploy_types.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package emulator

import (
"crypto/rand"
"fmt"
"time"
)

Expand Down Expand Up @@ -61,15 +59,28 @@ type CodeDeployDeployment struct {
Region string `json:"region"`
}

// generateCodeDeployDeploymentID generates a deployment ID in the form d-XXXXXXXXX
// using 9 random uppercase alphanumeric characters, matching the real CodeDeploy format.
func generateCodeDeployDeploymentID() string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 9)
rnd := make([]byte, 9)
_, _ = rand.Read(rnd)
for i := range b {
b[i] = chars[int(rnd[i])%len(chars)]
}
return fmt.Sprintf("d-%s", b)
// codedeployDeploymentIDChars is the alphabet a `d-` deployment ID is rendered in: uppercase
// letters and digits, which is what AWS's own sample responses show and the narrowest alphabet
// consistent with them. See [generateCodeDeployDeploymentID] for why the alphabet is observed
// rather than published.
const codedeployDeploymentIDChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

// generateCodeDeployDeploymentID mints a deployment ID in the form `d-XXXXXXXXX` from m, derived
// from the request id so a replayed CreateDeployment returns the ID the recording returned (#856).
//
// This is the one identifier in the CI/CD family that is *addressed* rather than reported:
// `GetDeployment`, `StopDeployment` and `ListDeploymentTargets` all take `deploymentId`, and it is
// the only handle CreateDeployment hands back. A re-minted one made the recorded `GetDeployment`
// answer `DeploymentDoesNotExistException`, so a consumer's wait-for-`Succeeded` loop failed on a
// deployment the replay had just created.
//
// The shape has *observed* provenance, not published: `CreateDeployment` gives `deploymentId` as
// String with no pattern and no length constraints, but the page's own sample response is
// `{"deploymentId": "d-IIMHK0NHC"}` — the `d-` prefix, nine characters, uppercase alphanumeric.
// #671 forbids inventing a bound from a sibling operation; it does not forbid reading AWS's own
// example, and that example is all substrate has here. The rendering is therefore unchanged from
// the crypto/rand form: [IDMint.Chars] over the same alphabet, which reproduces its modulo
// mapping byte for byte.
func generateCodeDeployDeploymentID(m *IDMint) string {
return "d-" + m.Chars(9, codedeployDeploymentIDChars)
}
32 changes: 18 additions & 14 deletions emulator/codepipeline_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package emulator

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -272,7 +270,7 @@ func (p *CodePipelinePlugin) startPipelineExecution(reqCtx *RequestContext, req
return nil, err
}

execID := generateCodePipelineExecID()
execID := generateCodePipelineExecID(reqCtx.IDs)
exec := CodePipelineExecution{
PipelineExecutionID: execID,
PipelineName: pipeline.Name,
Expand Down Expand Up @@ -401,17 +399,23 @@ func codepipelineExecKey(acct, region, execID string) string {
return "execution:" + acct + "/" + region + "/" + execID
}

// generateCodePipelineExecID generates a UUID for pipeline execution IDs.
func generateCodePipelineExecID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return hex.EncodeToString(b[0:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
// generateCodePipelineExecID mints a pipeline execution ID from m, derived from the request id so
// a replayed StartPipelineExecution returns the ID the recording returned (#856).
//
// An execution ID is addressed: `GetPipelineExecution` takes `pipelineExecutionId`, so a
// re-minted one made the recorded read answer `PipelineExecutionNotFoundException` for the
// execution the replay had just started.
//
// This is the only identifier in the CI/CD family with a *published* pattern —
// `StartPipelineExecution` gives `pipelineExecutionId` as
// `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` — and it is also the only draw
// site in the family that set RFC 4122's version and variant nibbles. The pattern does not
// require them: `[0-9a-f]` admits any hex digit in either position, so it is satisfied by
// [IDMint.HexUUID] too. It uses [IDMint.UUID] anyway, because #856 is about making an identifier
// reproducible across a replay and not about changing which bytes a caller sees, and a consumer
// validating this as a version-4 UUID would start failing if the `4` disappeared.
func generateCodePipelineExecID(m *IDMint) string {
return m.UUID()
}

// codepipelineJSONResponse serializes v to JSON and returns an AWSResponse with
Expand Down
Loading
Loading