diff --git a/CHANGELOG.md b/CHANGELOG.md index fae99f6d..5e30fefb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -386,6 +386,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- **The long tail of draw sites is derived** (#856). Four generators across four unrelated services + move onto `IDMint`: an SSM Run Command `CommandId`, the identifier inside a RAM `resourceShareArn`, + AWS Backup's `BackupPlanId`, `VersionId` and `SelectionId`, and a Bedrock batch-inference job ID. + Three of the four keep the exact rendering their `crypto/rand` version produced, because their pages + publish no pattern to follow (#671). Backup is the only draw site in the tree that mints **twice in + one request** — `CreateBackupPlan` answers a plan ID and a version ID, and `UpdateBackupPlan` mints a + third — so the mint's counter is what keeps a plan from being reported as its own version. Every one + of the four identifiers is addressed by a later call, so an underived one was refused rather than + merely reported differently: reverting the Backup minter alone to confirm the tier's replay assertion + is not vacuous produces 14 differences and 3 refused reads out of a 10-request stream, because a + selection is stored *under* its plan ID and a re-minted plan strands it too. 2 draw sites remain on + `crypto/rand`: `randomHex`, whose one caller is the CloudFormation deployer, and `IDMint`'s own + seedless fallback, which goes when the deployer threads a request ID through. +- **A Bedrock batch-inference job ID is twelve characters, not a UUID** (#856). `jobArn` is published as + `arn:aws:bedrock:{region}:{account}:model-invocation-job/[a-z0-9]{12}` and `jobIdentifier` as that ARN + or a bare `[a-z0-9]{12}` — an alphabet that **excludes the hyphen** and a length fixed at twelve, and + the 36-character UUID substrate minted satisfied neither, so a consumer validating the ARN it was + handed, or handing the bare ID back to `GetModelInvocationJob`, was validating against a value AWS + would never issue. This is the one place in #856 where the rendering changes: the + no-byte-changes rule protects a rendering the model permits and cannot protect one the model forbids. + `GetModelInvocationJob`'s own sample request shows `BATCHJOB1234`, which the pattern beside it would + reject; the pattern is the model. +- **An AWS Backup plan ARN's case is recorded as an observed difference** (#856). AWS's sample is + `arn:aws:backup:us-east-1:123456789012:plan:8F81F553-3A74-4A3F-B93D-B3360DC80C50`, an **uppercase** + UUID where substrate renders lowercase. `BackupPlanId` publishes no pattern and no length, so this is + observed from the example rather than required; it is documented beside the `backup-plan` vs. `plan` + segment gap (#1181) rather than folded into the derivation, since it is a rendering question and not + one about where the bytes come from. - **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 9ae30842..5842d4c9 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, QuickSight, CodeBuild, CodeDeploy and CodePipeline identifiers are derived today. A + OpenSearch, QuickSight, CodeBuild, CodeDeploy, CodePipeline, Systems Manager Run Command, RAM, + AWS Backup and Bedrock batch inference 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 @@ -2295,6 +2296,28 @@ CodeBuild's, which is worse than a refusal — `BatchGetBuilds` reports an unkno `buildsNotFound` in a **200**, so an underived one stalled a consumer's poll loop instead of failing it. +**A published pattern outranks the rendering a draw site had.** Everywhere else in #856 the +identifier a caller sees is byte-for-byte what substrate published before it was derived, because the +point of the change is where the bytes come from and not what they look like. Bedrock's batch-inference +job ID is the one exception, and the API model forces it: `CreateModelInvocationJob` publishes `jobArn` +as `arn:aws:bedrock:{region}:{account}:model-invocation-job/[a-z0-9]{12}` and `GetModelInvocationJob` +publishes `jobIdentifier` as that ARN or a bare `[a-z0-9]{12}`, which **excludes the hyphen** and fixes +the length at twelve — and the 36-character UUID substrate minted satisfied neither. The +no-byte-changes rule protects a rendering the model permits; it cannot protect one the model forbids, +so a job ID is now twelve characters of the published alphabet. A consumer that validates the ARN it +was handed, or hands the bare ID back to `GetModelInvocationJob`, is what the pattern exists for. +(`GetModelInvocationJob`'s own sample request shows `BATCHJOB1234`, which the pattern beside it would +reject. The pattern is the model.) + +The other three services in that family publish no pattern to follow. An SSM `CommandId` publishes a +**fixed length of 36** and no pattern, which the UUID shape already met; a RAM `resourceShareArn` and +an AWS Backup `BackupPlanId`, `VersionId` and `SelectionId` publish neither, so all four keep the +rendering their draw sites produced (#671). Backup's is the only site in the tree that mints **twice +in one request** — `CreateBackupPlan` answers a plan ID and a version ID, and `UpdateBackupPlan` mints +a third — so the counter is what keeps a plan from being its own version. AWS's own sample plan ARN +shows the UUID in *upper* case where substrate renders lower; that is an observed difference the model +does not require, tracked with the [resource segment](#arn-shapes) rather than with the derivation. + 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) @@ -12928,7 +12951,7 @@ Secrets Manager API calls: $0.05 per 10,000 API calls. | RemoveTagsFromResource | Removes only the named keys | | ListTagsForResource | Reports `TagList` sorted by key; an empty list, never `null` | | LabelParameterVersion | Accepted; always reports `ParameterVersion: 1` | -| SendCommand | Run Command; records the intent — substrate does not execute the command | +| SendCommand | Run Command; records the intent — substrate does not execute the command. The `CommandId` is the published fixed length of 36, [derived from the request ID](#derived-identifiers) ([#856](https://github.com/scttfrdmn/substrate/issues/856)) | | GetCommandInvocation | | | DescribeInstanceInformation | | @@ -19506,6 +19529,12 @@ vault matches; its plan does not, in the API handler and in the CloudFormation d IAM policy or an ARN parser written against Substrate's plan ARN matches nothing on AWS ([#1181](https://github.com/scttfrdmn/substrate/issues/1181)). +The published example differs in one more way the table does not show: AWS's sample is +`arn:aws:backup:us-east-1:123456789012:plan:8F81F553-3A74-4A3F-B93D-B3360DC80C50`, an **uppercase** +UUID, where Substrate renders lowercase. `BackupPlanId` publishes no pattern and no length, so this is +observed from the example rather than required by the model, and it is a rendering question rather than +a derivation one — the plan ID is [derived from the request ID](#derived-identifiers) either way. + ### Cost `CreateBackupPlan` is attributed $0.000001 per call. Real AWS Backup charges for protected storage @@ -19538,7 +19567,7 @@ running. |-----------|-------| | InvokeModel | `POST /model/{modelId}/invoke`. Answers a [seeded response body](#seeding-a-model-response) verbatim, or a canned Claude Messages body naming the requested model. Nothing but the model ID is read — not the body, not `accept` or `contentType`, and [not the guardrail headers](#invokemodel-reads-nothing-but-the-model-id) | | ApplyGuardrail | `POST /guardrail/{guardrailIdentifier}/version/{guardrailVersion}/apply`. [`NONE` or `GUARDRAIL_INTERVENED`, decided by a blocklist](#how-a-guardrail-decides); the version is discarded | -| CreateModelInvocationJob | `POST /model-invocation-job`. Answers `{"jobArn"}`, exactly the published shape, and records the job as `Submitted` — the first state the page documents, so a batch job is deliberately not terminal at birth. None of the five members marked `Required: Yes` is checked | +| CreateModelInvocationJob | `POST /model-invocation-job`. Answers `{"jobArn"}`, exactly the published shape, and records the job as `Submitted` — the first state the page documents, so a batch job is deliberately not terminal at birth. None of the five members marked `Required: Yes` is checked. The job ID is twelve characters of `[a-z0-9]`, the published `jobArn` pattern, [derived from the request ID](#derived-identifiers) ([#856](https://github.com/scttfrdmn/substrate/issues/856)) | | GetModelInvocationJob | `GET /model-invocation-job/{jobIdentifier}`. Returns the stored record whole, so `accountID` and `region` reach the wire ([#756](https://github.com/scttfrdmn/substrate/issues/756)), and reports a [seeded status](#seeding-a-batch-job-status) if one is set | | ListModelInvocationJobs | `GET /model-invocation-jobs`. `invocationJobSummaries` of five members each; the seeded status is applied here too, so a poll on either operation agrees. Every published query filter is ignored and no `nextToken` is emitted | | StopModelInvocationJob | `POST /model-invocation-job/{jobIdentifier}/stop`. [Stops a job in any state and skips `Stopping`](#stopping-a-batch-job-is-immediate) | diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 9d7d2e26..fd3c2dc7 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -339,8 +339,9 @@ 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, QuickSight, CodeBuild, CodeDeploy and -CodePipeline are; the rest are migrating one family at a time, and until a service moves, a +Redshift Data, Glue, Timestream, OpenSearch, QuickSight, CodeBuild, CodeDeploy, +CodePipeline, SSM Run Command, RAM, AWS Backup and Bedrock batch inference 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/backup_plugin.go b/emulator/backup_plugin.go index 876df039..3926ba78 100644 --- a/emulator/backup_plugin.go +++ b/emulator/backup_plugin.go @@ -2,8 +2,6 @@ package emulator import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -177,8 +175,8 @@ func (p *BackupPlugin) createBackupPlan(reqCtx *RequestContext, req *AWSRequest) return nil, &AWSError{Code: "InvalidRequestException", Message: "BackupPlanName is required", HTTPStatus: http.StatusBadRequest} } - planID := generateBackupUUID() - versionID := generateBackupUUID() + planID := generateBackupUUID(reqCtx.IDs) + versionID := generateBackupUUID(reqCtx.IDs) now := p.tc.Now() plan := BackupPlan{ @@ -252,7 +250,7 @@ func (p *BackupPlugin) updateBackupPlan(reqCtx *RequestContext, req *AWSRequest, if input.BackupPlan.Rules != nil { plan.Rules = input.BackupPlan.Rules } - plan.VersionID = generateBackupUUID() + plan.VersionID = generateBackupUUID(reqCtx.IDs) goCtx := context.Background() data, err := json.Marshal(plan) @@ -331,7 +329,7 @@ func (p *BackupPlugin) createBackupSelection(reqCtx *RequestContext, req *AWSReq return nil, &AWSError{Code: "InvalidRequestException", Message: "SelectionName is required", HTTPStatus: http.StatusBadRequest} } - selectionID := generateBackupUUID() + selectionID := generateBackupUUID(reqCtx.IDs) now := p.tc.Now() selection := BackupSelection{ SelectionID: selectionID, @@ -455,15 +453,24 @@ func (p *BackupPlugin) loadSelection(acct, region, planID, selectionID string) ( return &selection, nil } -// generateBackupUUID generates a UUID-style string for backup resource IDs. -func generateBackupUUID() 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]) +// generateBackupUUID mints one of AWS Backup's three identifiers from m — a backup-plan ID, a plan +// version ID or a selection ID — derived from the request id so a replayed create reports what the +// recording reported (#856). +// +// It is the tier's one generator with more than one call site, and the only one that draws *twice in +// one request*: `CreateBackupPlan` mints a plan ID and a version ID, so the ordinal is what keeps +// them apart. Both are addressed — every later `GetBackupPlan`, `UpdateBackupPlan`, +// `DeleteBackupPlan` and `CreateBackupSelection` takes the plan ID in its URL path — and a plan +// version is how Backup reports which revision a read answered from. +// +// Nothing about the shape is published: `BackupPlanId` is a String with no pattern and no length, +// and `VersionId` is documented only as "unique, randomly generated … at most 1,024 bytes long". So +// the rendering is the one the crypto/rand form produced, [IDMint.HexUUID] (#671). AWS's own sample +// ARN — `arn:aws:backup:us-east-1:123456789012:plan:8F81F553-3A74-4A3F-B93D-B3360DC80C50` — shows +// the UUID in *upper* case where substrate renders lower; that is an observed difference the model +// does not require, and changing it is a rendering change rather than part of deriving the value. +func generateBackupUUID(m *IDMint) string { + return m.HexUUID() } // backupJSONResponse serializes v to JSON and returns an AWSResponse with diff --git a/emulator/bedrock_runtime_plugin.go b/emulator/bedrock_runtime_plugin.go index fff59e45..532ba86c 100644 --- a/emulator/bedrock_runtime_plugin.go +++ b/emulator/bedrock_runtime_plugin.go @@ -2,7 +2,6 @@ package emulator import ( "context" - "crypto/rand" "encoding/json" "fmt" "net/http" @@ -311,11 +310,26 @@ func bedrockModelInvocationJobIDsKey(acct, region string) string { return "job_ids:" + acct + "/" + region } -// generateBedrockJobID generates a UUID-formatted model invocation job ID. -func generateBedrockJobID() 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]) +// bedrockJobIDChars is the alphabet Bedrock publishes a batch-inference job ID in: `[a-z0-9]`, from +// the `jobArn` pattern on API_CreateModelInvocationJob and API_GetModelInvocationJob. +const bedrockJobIDChars = "abcdefghijklmnopqrstuvwxyz0123456789" + +// generateBedrockJobID mints a model-invocation job ID from m, derived from the request id so a +// replayed CreateModelInvocationJob reports the ARN the recording reported (#856). +// +// This is the tier's one *shape* change, and it is a fidelity fix rather than a consequence of +// deriving the value. Bedrock publishes `jobArn` as +// `arn:aws…:model-invocation-job/[a-z0-9]{12}` and `jobIdentifier` as that ARN or a bare +// `[a-z0-9]{12}`, so the published alphabet **excludes the hyphen** and the length is twelve — and +// the UUID substrate minted here satisfied neither. #856's rule is not to change the bytes a caller +// sees, but that rule exists to protect a rendering the API model permits; it cannot preserve one +// the model forbids. Twelve characters over the whole published set is what a caller validating the +// ARN, or handing the bare ID back to `GetModelInvocationJob`, can accept. +// +// The `GetModelInvocationJob` sample request shows `BATCHJOB1234`, which the pattern it sits beside +// would reject. The pattern is the model, so the rendering follows the pattern. +func generateBedrockJobID(m *IDMint) string { + return m.Chars(12, bedrockJobIDChars) } // createModelInvocationJob handles CreateModelInvocationJob, storing a new batch @@ -335,7 +349,7 @@ func (p *BedrockRuntimePlugin) createModelInvocationJob(ctx *RequestContext, req return nil, bedrockValidationError("jobName is required") } - jobID := generateBedrockJobID() + jobID := generateBedrockJobID(ctx.IDs) jobArn := fmt.Sprintf("arn:aws:bedrock:%s:%s:model-invocation-job/%s", ctx.Region, ctx.AccountID, jobID) job := BedrockModelInvocationJob{ JobArn: jobArn, diff --git a/emulator/ec2_types.go b/emulator/ec2_types.go index 4fd3f16a..a129f70a 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): 6 draw sites remain on crypto/rand, tiered by service family on the issue; +// TODO(#856): 2 draw sites remain on crypto/rand — this one and [IDMint.bytes]'s own fallback; // 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 1d2facf8..6edd39d8 100644 --- a/emulator/ids.go +++ b/emulator/ids.go @@ -61,7 +61,9 @@ 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): 6 draw sites remain on crypto/rand, tiered by service family on the issue. +// TODO(#856): 2 draw sites remain on crypto/rand — randomHex, whose one caller is the +// CloudFormation deployer, and bytes's own seedless fallback, which goes when the deployer +// threads a request id through. // IDMint mints the identifiers one request publishes, derived from that request's own id so // that replaying the request mints the same ones. @@ -203,9 +205,11 @@ func (m *IDMint) Base64URL(n int) string { // // 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. +// request ids, Batch job ids, EMR Serverless job-run ids, CodeBuild build and CodeDeploy +// application and deployment-group ids, SSM command ids, RAM resource-share ids and AWS Backup +// plan, version and selection 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 e336393e..c7c70f7f 100644 --- a/emulator/ids_test.go +++ b/emulator/ids_test.go @@ -2022,3 +2022,257 @@ const ( idsCodeDeployHost = "codedeploy.us-east-1.amazonaws.com" idsCodePipelineHost = "codepipeline.us-east-1.amazonaws.com" ) + +// Tier 7 of #856: the long tail — SSM Run Command, RAM, AWS Backup and Bedrock batch inference. +// +// The four services have nothing in common except being what is left, and that is the point: the tier +// is where the two rules the tiers above balanced finally point in opposite directions. Three of the +// four keep [emulator.IDMint.HexUUID] because their pages publish no pattern, so the rendering their +// draw sites produced is the rendering a caller parses and #856 does not touch it. Bedrock's does +// not: `jobArn` is published as `arn:aws…:model-invocation-job/[a-z0-9]{12}`, which excludes the +// hyphen and fixes the length at twelve, so the UUID substrate minted violated the model. The +// no-byte-changes rule protects a rendering the model permits; it cannot protect one the model +// forbids, and the shape changes here. +// +// AWS Backup also brings the ordinal test back, which tier 6 had to do without: `CreateBackupPlan` +// mints a plan id *and* a version id in one request, the only draw site in the tier that draws twice. + +// TestIDs_OneCreateBackupPlanMintsAPlanAndAVersion is the ordinal's test for this tier. +// +// A mint that ignored its ordinal would answer one value for both, and the collision would not be +// cosmetic: `GetBackupPlan` reports the version of the revision it answered from, so a plan whose +// version id equals its plan id tells a consumer tracking revisions that the plan is its own version +// — and `UpdateBackupPlan` mints a *third* value, which has to differ from both. +func TestIDs_OneCreateBackupPlanMintsAPlanAndAVersion(t *testing.T) { + t.Parallel() + ts := emulator.StartTestServer(t) + ts.FreezeTime() + + var created struct { + BackupPlanID string `json:"BackupPlanId"` + VersionID string `json:"VersionId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBackupHost, http.MethodPost, + "/backup/plans", map[string]any{ + "BackupPlan": map[string]any{"BackupPlanName": "ids-tier7-plan"}, + }), &created)) + idsRequireHexUUID(t, created.BackupPlanID, "a backup plan id") + idsRequireHexUUID(t, created.VersionID, "a backup plan version id") + require.NotEqual(t, created.BackupPlanID, created.VersionID, + "two draws in one request advance the ordinal") + + var updated struct { + VersionID string `json:"VersionId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBackupHost, http.MethodPost, + "/backup/plans/"+created.BackupPlanID, map[string]any{ + "BackupPlan": map[string]any{"BackupPlanName": "ids-tier7-plan-renamed"}, + }), &updated)) + idsRequireHexUUID(t, updated.VersionID, "the version id an update mints") + assert.NotEqual(t, created.VersionID, updated.VersionID, + "a revision is a new version: %q", updated.VersionID) +} + +// TestIDs_ABedrockJobIDIsTwelveCharactersOfThePublishedAlphabet is the tier's shape assertion, and +// the one place in the migration where a published pattern overrides the rendering a draw site had. +// +// The claim is narrow and checkable: the twelve characters come from `[a-z0-9]`, and the hyphens the +// UUID carried are gone. A caller that validates the ARN it was handed, or hands the bare id back to +// `GetModelInvocationJob`, is what the pattern exists for — and the round trip below is that caller. +func TestIDs_ABedrockJobIDIsTwelveCharactersOfThePublishedAlphabet(t *testing.T) { + t.Parallel() + ts := emulator.StartTestServer(t) + ts.FreezeTime() + + jobID := idsCreateBedrockJob(t, ts, "ids-tier7-job") + assert.Regexp(t, `^[a-z0-9]{12}$`, jobID, + "the published jobArn pattern is [a-z0-9]{12}: %q", jobID) + assert.NotContains(t, jobID, "-", + "which excludes the hyphens the crypto/rand UUID carried: %q", jobID) + + var got struct { + JobName string `json:"jobName"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBedrockHost, http.MethodGet, + "/model-invocation-job/"+jobID, nil), &got)) + assert.Equal(t, "ids-tier7-job", got.JobName, + "and the bare id is what GetModelInvocationJob takes") +} + +// TestReplay_TheLongTailReplaysWithTheIdentifiersItMinted is the wire-level assertion for tier 7, the +// same claim the six tiers above make for their families. +// +// Every one of the four identifiers is addressed by a later request in the stream, so an underived +// draw is refused rather than merely reported differently: `GetCommandInvocation` answers +// `InvocationDoesNotExist`, `UpdateResourceShare` answers `UnknownResourceException` for an ARN whose +// minted half moved, `GetBackupPlan` and `GetBackupSelection` answer +// `ResourceNotFoundException`, and so does `GetModelInvocationJob`. Backup is the strongest of the +// four because the selection is stored *under* the plan id, so a re-minted plan id strands the +// selection as well. +func TestReplay_TheLongTailReplaysWithTheIdentifiersItMinted(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") + + idsRecordLongTailCreates(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 long-tail 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) +} + +// idsRecordLongTailCreates records the tier-7 stream, one interlocked group per service. +func idsRecordLongTailCreates(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + // Frozen for the reason [idsRecordInterlockedCreates] gives: all four plugins stamp their records + // off the time controller — a command's RequestedDateTime, a share's LastUpdatedTime, a plan's + // CreationDate, a job's SubmitTime — so a live clock diverges in the state hash for a reason that + // has nothing to do with an identifier. + ts.FreezeTime() + + idsRecordSSMCommand(t, ts) + idsRecordRAMShare(t, ts) + idsRecordBackupPlan(t, ts) + idsRecordBedrockJob(t, ts) +} + +// idsRecordSSMCommand records a SendCommand and the GetCommandInvocation that addresses it by the +// command id — the only handle SendCommand hands back, since a command has no caller-chosen name. +func idsRecordSSMCommand(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + const instance = "i-0123456789abcdef0" + + var sent struct { + Command struct { + CommandID string `json:"CommandId"` + } `json:"Command"` + } + require.NoError(t, json.Unmarshal(idsJSONTargetCall(t, ts, idsSSMHost, "AmazonSSM.SendCommand", + map[string]any{ + "DocumentName": "AWS-RunShellScript", + "InstanceIds": []string{instance}, + "Parameters": map[string][]string{"commands": {"true"}}, + }), &sent)) + idsRequireHexUUID(t, sent.Command.CommandID, "an SSM command id") + require.Len(t, sent.Command.CommandID, 36, + "and is the fixed length of 36 the model publishes: %q", sent.Command.CommandID) + + idsJSONTargetCall(t, ts, idsSSMHost, "AmazonSSM.GetCommandInvocation", map[string]any{ + "CommandId": sent.Command.CommandID, "InstanceId": instance, + }) +} + +// idsRecordRAMShare records a CreateResourceShare and the UpdateResourceShare that addresses it by +// the whole ARN, which is how RAM names a share — the minted id never travels on its own. +func idsRecordRAMShare(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + var created struct { + ResourceShare struct { + ResourceShareArn string `json:"resourceShareArn"` + } `json:"resourceShare"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsRAMHost, http.MethodPost, + "/createresourceshare", map[string]any{"name": "ids-tier7-share"}), &created)) + arn := created.ResourceShare.ResourceShareArn + minted, ok := strings.CutPrefix(arn, "arn:aws:ram:us-east-1:123456789012:resource-share/") + require.True(t, ok, "a share ARN carries the minted id in its resource segment: %q", arn) + idsRequireHexUUID(t, minted, "the minted half of a resource-share ARN") + + idsRESTCall(t, ts, idsRAMHost, http.MethodPost, "/updateresourceshare", map[string]any{ + "resourceShareArn": arn, "name": "ids-tier7-share-renamed", + }) +} + +// idsRecordBackupPlan records a plan, a selection under it, and a read of each. +// +// The selection is the interlock worth having: its state key is built from the *plan* id, so a +// re-minted plan id strands the selection under a plan nothing addresses, and the recorded +// GetBackupSelection is refused for a reason two draws upstream of it. +func idsRecordBackupPlan(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + var plan struct { + BackupPlanID string `json:"BackupPlanId"` + VersionID string `json:"VersionId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBackupHost, http.MethodPost, + "/backup/plans", map[string]any{ + "BackupPlan": map[string]any{"BackupPlanName": "ids-tier7-plan"}, + }), &plan)) + idsRequireHexUUID(t, plan.BackupPlanID, "a backup plan id") + idsRequireHexUUID(t, plan.VersionID, "a backup plan version id") + idsRESTCall(t, ts, idsBackupHost, http.MethodGet, "/backup/plans/"+plan.BackupPlanID, nil) + + var selection struct { + SelectionID string `json:"SelectionId"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBackupHost, http.MethodPost, + "/backup/plans/"+plan.BackupPlanID+"/selections", map[string]any{ + "BackupSelection": map[string]any{ + "SelectionName": "ids-tier7-selection", + "IamRoleArn": "arn:aws:iam::123456789012:role/BackupRole", + "Resources": []string{"arn:aws:ec2:us-east-1:123456789012:volume/vol-0123456789abcdef0"}, + }, + }), &selection)) + idsRequireHexUUID(t, selection.SelectionID, "a backup selection id") + idsRESTCall(t, ts, idsBackupHost, http.MethodGet, + "/backup/plans/"+plan.BackupPlanID+"/selections/"+selection.SelectionID, nil) +} + +// idsRecordBedrockJob records a batch-inference job and the GetModelInvocationJob that addresses it +// by the bare twelve-character id its ARN carries. +func idsRecordBedrockJob(t *testing.T, ts *emulator.TestServer) { + t.Helper() + + jobID := idsCreateBedrockJob(t, ts, "ids-tier7-job") + require.Regexp(t, `^[a-z0-9]{12}$`, jobID) + idsRESTCall(t, ts, idsBedrockHost, http.MethodGet, "/model-invocation-job/"+jobID, nil) +} + +// idsCreateBedrockJob creates one model-invocation job and returns the id inside the ARN it reports, +// which is the only thing CreateModelInvocationJob answers with. +func idsCreateBedrockJob(t *testing.T, ts *emulator.TestServer, name string) string { + t.Helper() + + var created struct { + JobArn string `json:"jobArn"` + } + require.NoError(t, json.Unmarshal(idsRESTCall(t, ts, idsBedrockHost, http.MethodPost, + "/model-invocation-job", map[string]any{ + "jobName": name, + "modelId": "anthropic.claude-3-sonnet-20240229-v1:0", + "roleArn": "arn:aws:iam::123456789012:role/BedrockBatch", + "inputDataConfig": map[string]any{ + "s3InputDataConfig": map[string]string{"s3Uri": "s3://ids-tier7-in/"}, + }, + "outputDataConfig": map[string]any{ + "s3OutputDataConfig": map[string]string{"s3Uri": "s3://ids-tier7-out/"}, + }, + }), &created)) + + jobID, ok := strings.CutPrefix(created.JobArn, + "arn:aws:bedrock:us-east-1:123456789012:model-invocation-job/") + require.True(t, ok, "a job ARN carries the minted id in its resource segment: %q", created.JobArn) + return jobID +} + +// The hosts the tier-7 services are addressed at. Bedrock's batch-inference control plane answers on +// `bedrock.` rather than `bedrock-runtime.`, which is the alias parser.go records. +const ( + idsSSMHost = "ssm.us-east-1.amazonaws.com" + idsRAMHost = "ram.us-east-1.amazonaws.com" + idsBackupHost = "backup.us-east-1.amazonaws.com" + idsBedrockHost = "bedrock.us-east-1.amazonaws.com" +) diff --git a/emulator/ram_plugin.go b/emulator/ram_plugin.go index 6e874230..63baa83e 100644 --- a/emulator/ram_plugin.go +++ b/emulator/ram_plugin.go @@ -2,8 +2,6 @@ package emulator import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "net/http" @@ -83,7 +81,7 @@ func (p *RAMPlugin) createResourceShare(reqCtx *RequestContext, req *AWSRequest) goCtx := context.Background() now := p.tc.Now() - shareArn := fmt.Sprintf("arn:aws:ram:%s:%s:resource-share/%s", reqCtx.Region, reqCtx.AccountID, generateRAMShareID()) + shareArn := fmt.Sprintf("arn:aws:ram:%s:%s:resource-share/%s", reqCtx.Region, reqCtx.AccountID, generateRAMShareID(reqCtx.IDs)) share := RAMResourceShare{ ResourceShareArn: shareArn, Name: input.Name, @@ -451,15 +449,18 @@ func buildRAMAssociations(share *RAMResourceShare, principals, resourceArns []st return assocs } -// generateRAMShareID generates a UUID-style string for RAM resource share IDs. -func generateRAMShareID() 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]) +// generateRAMShareID mints the identifier inside a resource-share ARN from m, derived from the +// request id so a replayed CreateResourceShare reports the ARN the recording reported (#856). +// +// RAM addresses a share by its whole ARN rather than by this ID — `resourceShareArns` is what +// `GetResourceShares`, `AssociateResourceShare` and `DeleteResourceShare` take — so the ID is never +// sent back on its own, and a re-minted one breaks a recorded call by making the *ARN* unrecognized. +// +// Neither `API_CreateResourceShare` nor the `ResourceShare` type publishes a pattern or a length for +// `resourceShareArn`, so nothing constrains the rendering and it keeps the shape the crypto/rand form +// produced: [IDMint.HexUUID] (#671). +func generateRAMShareID(m *IDMint) string { + return m.HexUUID() } // ramJSONResponse serializes v to JSON and returns an AWSResponse with Content-Type application/json. diff --git a/emulator/ssm_plugin.go b/emulator/ssm_plugin.go index 02da3324..36245891 100644 --- a/emulator/ssm_plugin.go +++ b/emulator/ssm_plugin.go @@ -2,7 +2,6 @@ package emulator import ( "context" - "crypto/rand" "encoding/json" "fmt" "net/http" @@ -873,7 +872,7 @@ func (p *SSMPlugin) sendCommand(ctx *RequestContext, req *AWSRequest) (*AWSRespo return nil, &AWSError{Code: "InvalidDocument", Message: "DocumentName is required", HTTPStatus: http.StatusBadRequest} } - commandID := generateSSMCommandID() + commandID := generateSSMCommandID(ctx.IDs) nowUnix := float64(p.tc.Now().Unix()) cmd := SSMCommand{ @@ -1034,12 +1033,19 @@ func (p *SSMPlugin) describeInstanceInformation(ctx *RequestContext, _ *AWSReque }) } -// generateSSMCommandID generates a UUID-format command ID. -func generateSSMCommandID() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - panic(fmt.Sprintf("ssm: rand.Read: %v", err)) - } - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +// generateSSMCommandID mints a Run Command command ID from m, derived from the request id so a +// replayed SendCommand returns the ID the recording returned (#856). +// +// A command ID is addressed: `ListCommands`, `ListCommandInvocations` and `GetCommandInvocation` all +// take it, and `GetCommandInvocation` publishes `InvalidCommandId` for one it does not recognize. It +// is also the only handle SendCommand hands back — there is no caller-chosen name for a command the +// way there is for a document. +// +// `Command.CommandId` publishes a **fixed length of 36** and no pattern, on both the request and the +// response side. The 36 characters are what decides the rendering here: [IDMint.HexUUID] is exactly +// 36, so the published bound is met without changing a byte the crypto/rand form produced. AWS's own +// sample response shows a version-4 UUID, but the model pins no position, so the two nibbles +// [IDMint.UUID] would set stay unset (#671). +func generateSSMCommandID(m *IDMint) string { + return m.HexUUID() }