The AWS adapter pattern. Wraps Aws::*::Client per service, three modes (live, replay, mock), backed by the AWS SDK's built-in stub_responses mechanism. Same boundary surface as the wanderland-core HTTP adapter; different binding underneath. Replays sit on disk as YAML trip files; recording produces them by hooking the SDK's stub-callback path during a live deploy.
The point of building it once cleanly: the same shape services every Vivarta boundary that touches AWS — CFN, IAM, S3, ECR, ECS, EC2 — and the testing/replay story is the same everywhere. A boundary that calls CloudFormation's create_stack reads the same way as a boundary that calls IAM's create_role.
One adapter per AWS service. Boundary contract names it:
class ProvisionCfnStack
include Wanderland::Boundary
boundary :provision_cfn_stack,
identity: IDENTITY,
capabilities: [:provision],
description: "Create or update a CloudFormation stack",
adapters: [:cfn]
def call(input)
cfn = input.adapter(:cfn)
resp = cfn.create_stack(stack_name: ..., template_body: ..., parameters: [...])
# ...
end
end
input.adapter(:cfn) returns a Vivarta::Adapters::AWS instance bound to the CloudFormation service. Method calls delegate to the wrapped Aws::CloudFormation::Client. The boundary doesn't see mode, cassette, or recording — just the SDK's normal call surface.
The AWS SDK ships its own stubbing mechanism via Aws::*::Client.new(stub_responses: true). We don't proxy at the HTTP/network level (the way the HTTP adapter does with cassette-vs-Net::HTTP) — we hook in at the SDK's per-operation stub callback. That gives us the SDK's response object shape for free; the boundary's call body never has to care about JSON parsing or response-type construction.
| Mode | Behavior |
|---|---|
:live |
Plain Aws::CFN::Client.new(region:, credentials:). Real API calls. Production. |
:replay |
Aws::CFN::Client.new(stub_responses: true). Per-operation stub callbacks read the next matching trip from a cassette file. Misses raise CassetteMissError. |
:mock |
Aws::CFN::Client.new(stub_responses: true). Per-operation stub callbacks consult an in-process registry seeded by tests via register_mock(:create_stack, response_hash). |
The replay/mock implementation is essentially:
client = Aws::CloudFormation::Client.new(stub_responses: true)
client.stub_responses(:create_stack, lambda do |context|
trip = next_trip_for(:create_stack, context.params) # cassette or mock lookup
trip ? trip[:response] : raise(CassetteMissError, ...)
end)
That's exactly the deployoryah Stubs::Responses shape (gem/lib/aws/resources/cloudformation/stubs/responses.rb:9-148), generalized so we don't hand-write per-service stub registration. The adapter introspects the Aws::*::Client.api.operation_names list and registers the same lambda for every operation; the lambda dispatches to the trip lookup based on context.operation_name.
Two reasonable shapes — comparing wanderland-core HTTP's per-request-digest layout and deployoryah's per-command-with-trips layout:
| Concern | HTTP adapter (per-trip files) | Deployoryah (one file per command) |
|---|---|---|
| File layout | <root>/<adapter>/<sha1>.yml — one envelope per file |
<root>/<gem>/<command>-<tags>.yml — array of trips ordered by sequence |
| Diff readability | Excellent — one interaction per file, git diff shows exactly which trip changed |
Per-command granularity; bigger diffs |
| Polling support | Each poll is a separate file (different request body or sequence-stamped) | Trips are explicitly sequence-numbered; same-shape calls return successive trips |
| Hand-editability | One file per trip — easy to edit one response | One file per command — see all polling responses in context |
Lean: per-trip files with sequence in the digest input. Same diff-friendliness as the HTTP adapter; sequence keeps polling sane. File path:
cassettes/cfn/<sha1(canonical(operation, params, sequence))>.yml
Cassette envelope:
request:
service: CloudFormation
operation: create_stack
region: us-east-1
sequence: 1 # nth time this (operation, params) tuple has been called in the run
params:
stack_name: proquest-pqp-alb-edge-dev-main-001-mvp
template_body: |
AWSTemplateFormatVersion: "2010-09-09"
...
parameters:
- { parameter_key: ALBScheme, parameter_value: internal }
response:
data:
stack_id: arn:aws:cloudformation:us-east-1:123456789012:stack/...
# OR for an error trip:
error:
class: Aws::CloudFormation::Errors::ValidationError
message: "Template format error: ..."
The body is the same shape the SDK's stub callback returns: a Hash that the SDK constructs into the response struct. Hand-editable, literal-block scalars for multi-line template bodies.
The wanderland-core HTTP adapter has the seam (recording_http.rb) but no command-level orchestration. Deployoryah's CommandRecorder (singleton, monkey-patched into Commander's runner — gem/lib/util/command_recorder.rb:142-164) brackets each command run, accumulates trips via log_request/log_response, flushes per-gem YAML at command end.
For Vivarta the bracketing happens around a run, not a Commander command. The natural seam is the start_run / complete_run boundary pair (existing in the ALB walkthrough). Between them, every adapter call writes a trip to an in-memory recorder; on completion the recorder flushes per-service YAML.
CLI / env-var pattern matching the HTTP adapter:
# Record a live run's AWS interactions
vivarta deploy config.yml --wanderland-record-to cassettes/
# Replay against the recorded trips (no AWS network)
WANDERLAND_AWS_MODE=replay vivarta deploy config.yml
The replay path then resolves cassette files keyed by the run's deterministic identity (system/env/sub_env + operation + params), not by the run-id (run-ids are per-deploy, cassettes are per-recorded-shape).
| Concern | HTTP adapter | AWS adapter |
|---|---|---|
| Live transport | Net::HTTP.request(...) |
Aws::*::Client.new(stub_responses: false) |
| Replay seam | Read YAML by request digest, return {status, body, headers} |
SDK's stub_responses(:op, lambda{...}) callback returns a Hash |
| Mock seam | register_mock(method, url, response_hash) |
register_mock(:operation, response_hash) |
| Identity for replay | sha1(method, url, body, headers) |
sha1(operation, params, sequence) |
| Polling | Different bodies / sequence-stamped | Sequence-aware: same params returns successive trips |
| Boundary call shape | adapter.get(url) returns a hash |
adapter.create_stack(...) returns the SDK's response struct |
| Escape hatch | adapter.client → Net::HTTP handle |
adapter.client → Aws::*::Client handle |
| Recording | --wanderland-record-to flag (seam present, command-level wiring TBD) |
Same flag; bracketed by start_run/complete_run |
Same pattern, different binding. The contract a boundary depends on (adapters: [:cfn], input.adapter(:cfn).method(...), three-mode discipline) is identical.
What carries forward:
Util::Clients factory pattern (gem/lib/aws/util/clients.rb) — caching by (class, region[, account]), optional credential handoff. Vivarta's adapter registry mounts adapters by (service, region); multiple regions = multiple adapter instances.Stubs::Responses registration shape (stubs/responses.rb:9-148) — the lambda-per-operation pattern. Vivarta's adapter generates these dynamically from client.api.operation_names.ResponseFactory#generate_response dispatch (mixins/response_factory.rb) — the gem-name → service-name dispatch and the trip-selection by (operation, params, sequence) is the model for the cassette lookup logic.CommandRecorder trip accumulation (util/command_recorder.rb) — captures the request/response pairs during a live run. The hook point moves from Commander's runner to wanderland-core's run-bracket.What we don't carry forward:
STUB_MAP literal mapping every Aws::*::Client to a hand-written stub class. Adapter generates the registration dynamically.executable + command + tags filename scheme — replaced by the per-trip digest path.allow_unstubbed fallback that lets a missing trip fall through to a real client call. Replay mode is strict; live mode is live; no third "kinda live" mode.Hashdiff-based parameter matching — exact-match on canonical-JSON-of-params is the wanderland-core pattern. If we need fuzzy match later, it's a layered concern.Resolved before implementation:
adapters: [:cfn, :iam, :s3, ...]. Each service mounts its own adapter instance. Per-service mode flexibility (e.g. replay CFN while running IAM live). Matches the AWS SDK gem split.:cfn mounted for us-east-1 is one instance; us-east-2 is another. Region is the only scoping dimension for now. Account scoping (STS-assume-role) is supported at mount time via a credentials hash, but not a first-class config dimension yet.<root>/<service>/<sha1(operation, params, sequence)>.yml. Diff ergonomics over per-command file size; we revisit if it bites.(operation, params) tuple within a run. Recorder stamps; replayer tracks consumed sequence per tuple to avoid double-replay. Direct lift from deployoryah's Cache.sequences.--wanderland-record-to <dir> CLI flag, plus WANDERLAND_RECORD_TO env for Docker / HTTP-triggered runs. Single flag captures HTTP and AWS interactions into one cassette tree, separated by the <adapter>/ segment.error: { class, message } for callbacks that should raise. Mock mode supports the same shape. Recovery-path tests read identically across modes.#client escape hatch. Once a boundary has the Aws::*::Client handle, it can configure whatever retry / timeout / paginator behavior it needs. The adapter doesn't need to mirror every SDK knob — exposing the client cleanly is enough.Every adapter call is observable as a crossing in the run's sub-stream:
input.adapter(:cfn).create_stack(...).:types:aws:cfn:create_stack:requested crossing before the call and a :types:aws:cfn:create_stack:responded crossing after.So the cassette is derivable from the run's crossings — record once, the trips fall out as a side-effect of the audit log already being captured. Replay is just feeding crossings forward. The substrate's append-only nature is what makes the recording free.
wanderland-core/lib/wanderland/adapters/http.rb — pattern to mirror; three modes, escape hatch, mock seeding.wanderland-core/lib/wanderland/adapters/cassette.rb — YAML-on-disk format with literal-block scalars.wanderland-core/lib/wanderland/adapters/registry.rb — per-runtime adapter registry; same machinery hosts AWS instances.devops-deployoryah/gem/lib/aws/util/clients.rb — client factory + stub_responses gating + STUB_MAP dispatch.devops-deployoryah/gem/lib/aws/mixins/response_factory.rb — per-gem dispatch to Generator.devops-deployoryah/gem/lib/aws/responses/generator.rb — trip-selection by (operation, params, sequence).devops-deployoryah/gem/lib/util/command_recorder.rb — CommandRecorder + Commander monkey-patch for live recording.devops-deployoryah/gem/lib/aws/resources/cloudformation/stubs/responses.rb — per-service stub-registration shape.