diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a868ea..fae99f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/services.md b/docs/services.md index c49c624..9ae3084 100644 --- a/docs/services.md +++ b/docs/services.md @@ -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 @@ -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) @@ -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 diff --git a/docs/testing-guide.md b/docs/testing-guide.md index ecc835d..9d7d2e2 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -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 diff --git a/emulator/codebuild_plugin.go b/emulator/codebuild_plugin.go index 2e45c4f..372045e 100644 --- a/emulator/codebuild_plugin.go +++ b/emulator/codebuild_plugin.go @@ -2,8 +2,6 @@ package emulator import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -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() @@ -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 diff --git a/emulator/codedeploy_plugin.go b/emulator/codedeploy_plugin.go index d15005f..297e795 100644 --- a/emulator/codedeploy_plugin.go +++ b/emulator/codedeploy_plugin.go @@ -2,8 +2,6 @@ package emulator import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -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, @@ -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, @@ -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, @@ -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 diff --git a/emulator/codedeploy_types.go b/emulator/codedeploy_types.go index 67ea91b..6d90fb8 100644 --- a/emulator/codedeploy_types.go +++ b/emulator/codedeploy_types.go @@ -1,8 +1,6 @@ package emulator import ( - "crypto/rand" - "fmt" "time" ) @@ -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) } diff --git a/emulator/codepipeline_plugin.go b/emulator/codepipeline_plugin.go index 6a65411..dd79b01 100644 --- a/emulator/codepipeline_plugin.go +++ b/emulator/codepipeline_plugin.go @@ -2,8 +2,6 @@ package emulator import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -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, @@ -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 diff --git a/emulator/ec2_types.go b/emulator/ec2_types.go index 1ead457..4fd3f16 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): 11 draw sites remain on crypto/rand, tiered by service family on the issue; +// TODO(#856): 6 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/ids.go b/emulator/ids.go index 60b5ad0..1d2facf 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): 11 draw sites remain on crypto/rand, tiered by service family on the issue. +// TODO(#856): 6 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. diff --git a/emulator/ids_test.go b/emulator/ids_test.go index dd221dc..e336393 100644 --- a/emulator/ids_test.go +++ b/emulator/ids_test.go @@ -1796,3 +1796,229 @@ func idsNDJSONCall(t *testing.T, ts *emulator.TestServer, host, path, body strin require.Less(t, resp.StatusCode, 300, "%s %s: %s", host, path, out) return out } + +// Tier 6 of #856: the CI/CD family — CodeBuild, CodeDeploy and CodePipeline. +// +// What this tier adds is that *three renderings* of the same sixteen derived bytes coexist in one +// family, and which one a site gets is decided by what its caller already parses rather than by +// what looks tidiest. CodePipeline publishes a UUID pattern and its draw site set RFC 4122's +// version and variant nibbles, so it mints through [emulator.IDMint.UUID]. CodeBuild's build id and +// CodeDeploy's two identity ids publish no pattern at all and their draw sites did *not* set those +// nibbles, so they keep [emulator.IDMint.HexUUID] — #856 changes where an identifier comes from, +// not which bytes a caller sees. CodeDeploy's deployment id is neither: it is `d-` and nine +// uppercase alphanumerics, a shape with no published pattern that AWS's own sample response shows. +// +// The tier has no unbounded-draw site — every one of the five operations mints exactly once — so the +// ordinal assertion the tiers above make on a batch has nothing to bite on here. What replaces it is +// the rendering assertion below, which is the property this family can get wrong. + +// TestIDs_TheCICDFamilyKeepsTheRenderingsItsCallersParse pins the three shapes against the two +// things that decide them: the published pattern where there is one, and the bytes the crypto/rand +// form produced where there is not. +// +// The substantive claim is the version nibble. A CodePipeline execution id is published as +// `[0-9a-f]{8}-…`, which admits any hex digit in the version position and so would be satisfied by +// a plain hex rendering too; substrate keeps the `4` because its draw site always set it and a +// consumer validating the value as a version-4 UUID would start failing if it vanished. +func TestIDs_TheCICDFamilyKeepsTheRenderingsItsCallersParse(t *testing.T) { + t.Parallel() + ts := emulator.StartTestServer(t) + ts.FreezeTime() + + idsJSONTargetCall(t, ts, idsCodePipelineHost, "CodePipeline_20150709.CreatePipeline", + map[string]any{"pipeline": map[string]any{"name": "ids-tier6-pipeline"}}) + var started struct { + PipelineExecutionID string `json:"pipelineExecutionId"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodePipelineHost, + "CodePipeline_20150709.StartPipelineExecution", + map[string]any{"name": "ids-tier6-pipeline"}), &started)) + idsRequireHexUUID(t, started.PipelineExecutionID, "a pipeline execution id") + assert.Equal(t, "4", started.PipelineExecutionID[14:15], + "a pipeline execution id is a version-4 UUID: %q", started.PipelineExecutionID) + assert.Contains(t, "89ab", started.PipelineExecutionID[19:20], + "and carries RFC 4122's variant bits: %q", started.PipelineExecutionID) + + var deployed struct { + DeploymentID string `json:"deploymentId"` + } + idsJSONTargetCall(t, ts, idsCodeDeployHost, "CodeDeploy_20141006.CreateApplication", + map[string]any{"applicationName": "ids-tier6-app"}) + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodeDeployHost, + "CodeDeploy_20141006.CreateDeployment", + map[string]any{"applicationName": "ids-tier6-app"}), &deployed)) + require.Len(t, deployed.DeploymentID, len("d-")+9, + "a deployment id is d- and nine characters, as AWS's own sample response shows") + assert.Regexp(t, `^d-[A-Z0-9]{9}$`, deployed.DeploymentID, + "in uppercase letters and digits: %q", deployed.DeploymentID) +} + +// TestReplay_TheCICDFamilyReplaysWithTheIdentifiersItMinted is the wire-level assertion for tier 6, +// the same claim the five tiers above make for their families. +// +// Two of the five identifiers are *addressed* by a later request in the stream, and those are the +// ones a fresh draw refuses outright: `BatchGetBuilds` reports a re-minted build id under +// `buildsNotFound` — a 200 whose `builds` list is empty, which is how an underived id stalls a +// consumer's poll loop rather than failing it — and `GetPipelineExecution` answers +// `PipelineExecutionNotFoundException`. The other three are reported rather than addressed: +// CodeDeploy's application and deployment-group ids are echoed by reads keyed on names, so what a +// fresh draw costs there is a body difference and a `state_hash_after` mismatch on the create, +// which is still enough to make the recording unreplayable. `GetDeployment` sits on the addressed +// side with the deployment id. +func TestReplay_TheCICDFamilyReplaysWithTheIdentifiersItMinted(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") + + idsRecordCICDCreates(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 CI/CD 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) +} + +// idsRecordCICDCreates records the tier-6 stream, one interlocked group per service. +func idsRecordCICDCreates(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + // Frozen for the reason [idsRecordInterlockedCreates] gives: all three plugins stamp their + // records off the time controller, so a live clock diverges in the state hash for a reason that + // has nothing to do with an identifier. + ts.FreezeTime() + + idsRecordCodeBuild(t, ts) + idsRecordCodeDeploy(t, ts) + idsRecordCodePipeline(t, ts) +} + +// idsRecordCodeBuild records a project, a build of it, and the BatchGetBuilds that names the build +// id — the one read in the family that answers a wrong id with a 200. +func idsRecordCodeBuild(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + const project = "ids-tier6-project" + + idsJSONTargetCall(t, ts, idsCodeBuildHost, "CodeBuild_20161006.CreateProject", map[string]any{ + "name": project, + "serviceRole": "arn:aws:iam::123456789012:role/CodeBuildRole", + "source": map[string]any{"type": "GITHUB", "location": "https://example.invalid/r"}, + }) + + var build struct { + Build struct { + ID string `json:"id"` + } `json:"build"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodeBuildHost, + "CodeBuild_20161006.StartBuild", map[string]any{"projectName": project}), &build)) + uuid, ok := strings.CutPrefix(build.Build.ID, project+":") + require.True(t, ok, "a build id is the project name and a colon, then the minted half: %q", + build.Build.ID) + idsRequireHexUUID(t, uuid, "the minted half of a build id") + + var got struct { + Builds []map[string]any `json:"builds"` + BuildsNotFound []string `json:"buildsNotFound"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodeBuildHost, + "CodeBuild_20161006.BatchGetBuilds", + map[string]any{"ids": []string{build.Build.ID}}), &got)) + require.Len(t, got.Builds, 1, "the recorded read reaches the build by its minted id") + require.Empty(t, got.BuildsNotFound, + "and a wrong id would land here, in a 200 rather than an error") +} + +// idsRecordCodeDeploy records an application, a deployment group, a deployment, and a read of each. +// +// The group exercises both halves of the tier. GetApplication and GetDeploymentGroup are keyed on +// the caller's own names and merely echo the two identity ids, so what an underived one costs there +// is a body difference and a state-hash mismatch; GetDeployment takes the deployment id itself, and +// an underived one is refused outright. +func idsRecordCodeDeploy(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + const ( + app = "ids-tier6-app" + group = "ids-tier6-group" + ) + + var created struct { + ApplicationID string `json:"applicationId"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodeDeployHost, + "CodeDeploy_20141006.CreateApplication", map[string]any{ + "applicationName": app, "computePlatform": "Server", + }), &created)) + idsRequireHexUUID(t, created.ApplicationID, "a CodeDeploy application id") + idsJSONTargetCall(t, ts, idsCodeDeployHost, "CodeDeploy_20141006.GetApplication", + map[string]any{"applicationName": app}) + + var grouped struct { + DeploymentGroupID string `json:"deploymentGroupId"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodeDeployHost, + "CodeDeploy_20141006.CreateDeploymentGroup", map[string]any{ + "applicationName": app, "deploymentGroupName": group, + "serviceRoleArn": "arn:aws:iam::123456789012:role/CodeDeployRole", + }), &grouped)) + idsRequireHexUUID(t, grouped.DeploymentGroupID, "a CodeDeploy deployment-group id") + require.NotEqual(t, created.ApplicationID, grouped.DeploymentGroupID, + "two requests minting the same shape still mint different values") + idsJSONTargetCall(t, ts, idsCodeDeployHost, "CodeDeploy_20141006.GetDeploymentGroup", + map[string]any{"applicationName": app, "deploymentGroupName": group}) + + var deployed struct { + DeploymentID string `json:"deploymentId"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodeDeployHost, + "CodeDeploy_20141006.CreateDeployment", map[string]any{ + "applicationName": app, "deploymentGroupName": group, + }), &deployed)) + require.Regexp(t, `^d-[A-Z0-9]{9}$`, deployed.DeploymentID) + idsJSONTargetCall(t, ts, idsCodeDeployHost, "CodeDeploy_20141006.GetDeployment", + map[string]any{"deploymentId": deployed.DeploymentID}) +} + +// idsRecordCodePipeline records a pipeline, an execution of it, and the GetPipelineExecution that +// addresses the execution by the id StartPipelineExecution minted. +func idsRecordCodePipeline(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + const pipeline = "ids-tier6-pipeline" + + idsJSONTargetCall(t, ts, idsCodePipelineHost, "CodePipeline_20150709.CreatePipeline", + map[string]any{"pipeline": map[string]any{ + "name": pipeline, + "roleArn": "arn:aws:iam::123456789012:role/CodePipelineRole", + }}) + + var started struct { + PipelineExecutionID string `json:"pipelineExecutionId"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsCodePipelineHost, + "CodePipeline_20150709.StartPipelineExecution", + map[string]any{"name": pipeline}), &started)) + idsRequireHexUUID(t, started.PipelineExecutionID, "a pipeline execution id") + + idsJSONTargetCall(t, ts, idsCodePipelineHost, "CodePipeline_20150709.GetPipelineExecution", + map[string]any{ + "pipelineName": pipeline, + "pipelineExecutionId": started.PipelineExecutionID, + }) +} + +// The hosts the tier-6 services are addressed at. +const ( + idsCodeBuildHost = "codebuild.us-east-1.amazonaws.com" + idsCodeDeployHost = "codedeploy.us-east-1.amazonaws.com" + idsCodePipelineHost = "codepipeline.us-east-1.amazonaws.com" +)