This tutorial picks up the adapter pattern from Adapter Modes and applies it to the AWS SDK. In the previous tutorial, we showed how to create the HTTP adapter so that it wraps Net::HTTP in a three-mode facade — live, replay, mock. The AWS adapter does the same against Aws::*::Client. A boundary that calls cfn.describe_stacks will act the same way as a boundary that calls http.get. Both use the same contract, the same live|mock|replay model selections and both use the same YAML based cassettes, the only difference is the IO with the external system.
The example through this tutorial is a small diagnostic boundary. Given an EC2 instance, describe the instance and report its status and properties. It's the kind of one-step runbook entry — "look up this instance, tell me how it's configured" — that gets composed into multi-step tools later. The adapter pattern is what makes the tool testable, replayable offline, and mockable for the failure paths the live API won't reliably produce on demand.
The AWS SDK ships every service as its own gem (aws-sdk-ec2, aws-sdk-cloudformation, aws-sdk-iam, aws-sdk-s3, ...) and every gem mints clients via its own Aws::*::Client.new(...). The adapter wraps one of these clients in a Wanderland-shaped facade. Calls to the adapter delegate to the wrapped client in one of three modes:
:live — the wrapped client points at AWS. This is production.:replay — the wrapped client is constructed with stub_responses: true and per-operation callbacks read recorded responses from a cassette directory. Offline; deterministic.:mock — same stub_responses: true foundation, but the per-operation callbacks consult an in-process registry seeded by tests via register_mock.The boundary's call body is the SDK's normal call surface. A boundary that calls ec2.describe_instances(instance_ids: ["i-0abc..."]) gets back the SDK's DescribeInstancesResult struct in all three modes. The adapter only controls where the response comes from, otherwise it's a straight pass through of what the SDK produces.
There is one adapter per service/region combination, memoized for reuse as some AWS services like ECS can rate limit client creation with credential rate limits. A site that talks to EC2 in us-east-1 mounts one :ec2 adapter. If it also talks to EC2 in us-west-2, that's a second adapter with its own mode and cassette directory. Different services (EC2, IAM, S3) are separate adapters. The naming strategy is up to you when you're developing your site/tool, the registry doesn't enforce a convention.
This tutorial is self-contained. We'll set up a small new app that holds one boundary, then exercise the adapter end-to-end against EC2.
mkdir -p ~/working/runbooks/instance-readout
cd ~/working/runbooks/instance-readout
mkdir -p lib/instance_readout/boundaries spec/fixtures/cassettes
Create Gemfile:
# frozen_string_literal: true
source "https://rubygems.org"
gem "puma", "~> 6.0"
gem "wanderland-core", path: "../../wanderland-core"
gem "aws-sdk-ec2", "~> 1.0"
gem "rake"
group :development, :test do
gem "rspec", "~> 3.0"
gem "rack-test", "~> 2.0"
end
Run bundle install.
The aws-sdk-ec2 dependency is the SDK gem the adapter wraps. The adapter uses whichever version of the gem you declare, so you pin SDK versions independently of the adapter. Other AWS services (CloudFormation, IAM, S3) follow the same pattern. We declare the per-service SDK gem in the Gemfile and then mount an adapter for it.
The AWS adapter and the :aws factory both ship inside wanderland-core today. Eventually they'll move into a dedicated adapter-pack gem so wanderland-core stays provider-agnostic — when that happens, the Gemfile gains one more line and the tutorial updates. For now, no extra gem is needed.
:ec2 adapter in config.ymlservice: instance-readout
port: 9298
boundary_path: lib/instance_readout/boundaries
resolvers:
Fixture:
fixture_dirs: [spec/fixtures]
adapters:
ec2:
kind: aws
service: ec2
mode: !Env { name: WANDERLAND_AWS_MODE, default: live }
region: us-east-1
routes:
/readout/:instance_id:
method: get
boundary: instance_readout
name: instance-readout
Anatomy:
resolvers.Fixture.fixture_dirs tells the !Fixture resolver where to look for cassette files when scenarios reference them. We point it at spec/fixtures because that's where the cassette directory lives in section 5. Without this block, !Fixture can't find anything and silently returns an error envelope — confusing, since the failure shows up later as "mock envelope requires request.operation" when the empty result reaches the mock loader.kind: aws tells the boot slot to use the AWS adapter factory. wanderland-core ships the factory; once we have an adapter pack gem, that's where it'll live instead.service: ec2 selects which Aws::*::Client to wrap. The name matches the SDK gem's suffix. aws-sdk-ec2 becomes ec2, aws-sdk-cloudformation becomes cloudformation, and so on.mode: !Env { name: ..., default: live } reads from the environment at config load with a fallback. Production runs live. CI runs replay. Dev defaults to live and flips when you want to run offline.region: us-east-1 binds the client to a region. One adapter, one region. If you need multi-region, declare multiple adapters with different names.A site that needs EC2 in two regions:
adapters:
ec2_us_east_1:
kind: aws
service: ec2
region: us-east-1
ec2_us_west_2:
kind: aws
service: ec2
region: us-west-2
A boundary names which one it expects: adapters: [:ec2_us_east_1]. Two regions, two adapters, two cassette trees, two mode flags if you want per-region control.
instance_readout boundaryCreate lib/instance_readout/boundaries/instance_readout.rb:
# frozen_string_literal: true
module InstanceReadout
module Boundaries
class InstanceReadout
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:instance_readout",
name: "InstanceReadout",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :instance_readout,
identity: IDENTITY,
requirements: [:read],
capabilities: [:instance_readout],
description: "Describe an EC2 instance and report its state and properties",
input_shape: { "params" => true },
adapters: [:ec2]
def call(input)
instance_id = input.find_or_fail("params.instance_id")
ec2 = input.adapter(:ec2)
result = ec2.describe_instances(instance_ids: [instance_id])
instance = result.reservations.flat_map(&:instances).first
return Wanderland::Signal.halt(status: 404, error: "no instance with id '#{instance_id}'") if instance.nil?
{
"instance_id" => instance.instance_id,
"state" => instance.state.name,
"instance_type" => instance.instance_type,
"private_ip" => instance.private_ip_address,
"public_ip" => instance.public_ip_address,
"vpc_id" => instance.vpc_id,
"subnet_id" => instance.subnet_id,
"tags" => instance.tags.to_h { |t| [t.key, t.value] }
}
end
end
end
end
Anatomy:
adapters: [:ec2] declares the boundary's contract. The runtime checks at first call that an adapter named :ec2 is mounted. If it's missing, the boundary raises Wanderland::Adapters::UnknownAdapterError with the names of the adapters that did mount.ec2.describe_instances(instance_ids: [instance_id]) is the same call we'd make against Aws::EC2::Client directly. result is the SDK's DescribeInstancesResult struct.Signal.ok crossing whose payload is what the response formatter renders. The halt on "not found" becomes a 404 to the caller.find_or_fail("params.instance_id") produces a 400 halt with "instance_id is required" if the path capture is missing. Same behaviour as the HTTP-adapter tutorials.Add lib/instance_readout.rb (the namespace anchor) and config.ru:
# lib/instance_readout.rb
# frozen_string_literal: true
require "wanderland-core"
module InstanceReadout
end
# config.ru
# frozen_string_literal: true
require "wanderland-core"
run Wanderland.boot(ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))).engine
The require "wanderland-core" line pulls in the AWS adapter and registers the :aws factory. When the adapter pack lives in its own gem, you'll add one require line for that and the rest of config.ru stays the same.
Boot the server. The mode defaults to live because WANDERLAND_AWS_MODE isn't set:
bundle exec puma config.ru -p 9298
In a second shell, hit it against an AWS account where we have a running instance:
curl -s http://localhost:9298/readout/i-0abc123def456789a | python3 -m json.tool
# {
# "instance_id": "i-0abc123def456789a",
# "state": "running",
# "instance_type": "t3.medium",
# "private_ip": "10.0.1.42",
# "public_ip": null,
# "vpc_id": "vpc-0abc123def456789",
# "subnet_id": "subnet-0aaa111",
# "tags": {
# "Name": "advaita-builder-1",
# "environment": "dev",
# "system": "advaita"
# }
# }
Live mode is transparent. The call goes through the adapter facade, then Aws::EC2::Client, then AWS. The boundary doesn't see the difference between modes.
CLI mode works the same way:
bundle exec wanderland --type cli config.yml instance_readout instance_id=i-0abc123def456789a
To use replay mode, we need cassettes. We generate them by running a live request with --wanderland-record-to. The cassette directory lives under spec/fixtures/ so the !Fixture resolver can lift cassettes back as mocks in section 7:
bundle exec wanderland --type cli config.yml instance_readout instance_id=i-0abc123def456789a \
--wanderland-record-to spec/fixtures/cassettes
The flag works in HTTP mode too. Pointing curl at the running server with the equivalent header records the same interactions:
curl -s -H "X-Wanderland-Record-To: spec/fixtures/cassettes" \
http://localhost:9298/readout/i-0abc123def456789a | python3 -m json.tool
Each successful AWS interaction lands as a YAML file under spec/fixtures/cassettes/ec2/<digest>.yml. One file per (operation, params, sequence) interaction, keyed by SHA-1 of the canonical request shape.
Look at what landed:
ls spec/fixtures/cassettes/ec2/
# 9e5c8879cb8265eae3b71928a68c2f4b7f0497dd.yml
---
request:
service: EC2
operation: describe_instances
region: us-east-1
sequence: 1
params:
instance_ids:
- i-0abc123def456789a
response:
data:
reservations:
- reservation_id: r-0abc123def456789a
owner_id: "123456789012"
instances:
- instance_id: i-0abc123def456789a
state:
name: running
code: 16
instance_type: t3.medium
private_ip_address: 10.0.1.42
public_ip_address: null
vpc_id: vpc-0abc123def456789
subnet_id: subnet-0aaa111
tags:
- key: Name
value: advaita-builder-1
- key: environment
value: dev
- key: system
value: advaita
YAML's literal-block scalars keep multi-line response fields readable as themselves rather than as escaped one-liners. Same convention as the HTTP adapter's cassettes. We can hand-edit a cassette when the upstream's contract changes and we'd rather follow than re-record.
The sequence: 1 field is there to disambiguate polling. If a boundary calls the same operation with the same params multiple times during a wait loop — say, polling describe_instances until the state is running — each call lands as its own cassette file with a different sequence number. Replay returns them in order.
Error responses round-trip through the same envelope:
---
request:
service: EC2
operation: describe_instances
region: us-east-1
sequence: 1
params:
instance_ids:
- i-doesnotexist
response:
error:
class: Aws::EC2::Errors::InvalidInstanceIDNotFound
message: "The instance ID 'i-doesnotexist' does not exist"
response.error.class names the SDK error class to instantiate. response.error.message is the string the SDK constructor receives. Replay raises the same error live mode would have raised. Recovery-path tests read identically across modes.
Flip the mode to replay. We can do this two ways: change the config file for a permanent switch, or set the environment variable for a one-off session.
Static, in config.yml:
adapters:
ec2:
kind: aws
service: ec2
mode: replay
cassette_dir: spec/fixtures/cassettes
region: us-east-1
Or leave the !Env default in place from section 2 and flip at invocation time:
WANDERLAND_AWS_MODE=replay bundle exec puma config.ru -p 9298
Hit it:
curl -s http://localhost:9298/readout/i-0abc123def456789a | python3 -m json.tool
# (same response as live mode, served from cassette)
Take the network down. The request still succeeds. A request the cassettes don't cover raises CassetteMissError with the file path the adapter looked for, so a test that drifts from its fixtures fails loudly instead of silently hitting AWS.
A common workflow: re-record a single interaction by deleting its cassette, switching back to mode: live (or unsetting the env var), and re-running with --wanderland-record-to. The single missing trip lands. The rest stay frozen. A git diff shows exactly what changed in the upstream's response. Then we flip back to replay.
Replay covers the case where we want to test the boundary against the actual upstream's response. Mock covers the case where we need to test against a response shape the upstream won't reliably produce on demand — an InvalidInstanceIDNotFound error, an instance in stopping state during a wait loop, an empty tags array, a sudden nil for the public IP.
Mocks are scenario-scoped. They get declared in scenario YAML, registered into the adapter just before dispatch, and cleared right after.
There are three ways to declare a mock. The vocabulary is the same as the HTTP adapter's mocks: block. The only thing that changes is the request keys: operation and params instead of method and url.
When we want to assert the boundary's behaviour against an exact response shape, usually for an edge case the live API won't reliably produce, we declare it inline. Sane defaults fill in the request fields we don't care about. region defaults to the adapter's mounted region. sequence defaults to 1.
scenarios:
instance-readout:
instance_not_found:
description: "Missing instance returns 404"
mocks:
ec2:
- request:
operation: describe_instances
params:
instance_ids: [i-doesnotexist]
response:
error:
class: Aws::EC2::Errors::InvalidInstanceIDNotFound
message: "The instance ID 'i-doesnotexist' does not exist"
input:
params:
instance_id: i-doesnotexist
expected:
status: 404
error: { matches: "no instance with id 'i-doesnotexist'" }
request.operation is the only required field. The runner registers this on the runtime's EC2 adapter at the top of dispatch. The boundary's ec2.describe_instances(...) raises the canned error. The runner clears the registration afterwards.
!FixtureWhen we want to assert the boundary handles a specific real response shape — a known-good interaction we don't want to keep re-fetching from live — we point at a recorded cassette. The cassette and the mock share the same envelope shape, so !Fixture lifts it directly:
scenarios:
instance-readout:
running_from_cassette:
description: "Lift the recorded cassette as a mock via !Fixture"
mocks:
ec2:
- !Fixture cassettes/ec2/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd
input:
params:
instance_id: i-0abc123def456789a
expected:
state: running
tags:
has_key: environment
!Fixture resolves at scenario-load time. It returns the full {request:, response:} envelope from the cassette file. The mock loader registers it the same way the inline form does.
!PatchWhen you want a recorded cassette with one or two specific values changed and everything else preserved exactly, use !Patch. Where !Merge overlays by hash structure (and replaces arrays whole), !Patch reaches a specific slot by dot-path and writes a single value. The rest of the cassette is untouched.
scenarios:
instance-readout:
instance_is_lazarus_but_precise:
description: "Terminated instance comes back to life, all other fields preserved"
mocks:
ec2:
- !Patch
- !Fixture cassettes/ec2/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd
- response.data.reservations.0.instances.0.state.name: running
response.data.reservations.0.instances.0.state.code: 1
input:
params:
instance_id: i-0abc123def456789a
expected:
state: running
instance_type: t3.medium
tags:
has_key: environment
The first array element is the cassette envelope. The second is a flat hash of path-string → value pairs. Each path is dot-separated; numeric segments index arrays. reservations.0 is the first reservation, instances.0 is the first instance, state.name is the field. Each pair gets applied as a deep-set against a deep-dup of the cassette.
The cassette's instance_id, instance_type, private_ip, vpc_id, subnet_id, tags, reservation_id, owner_id, creation_time all survive intact. Only the two state fields change. The scenario's expected block can assert on any of those preserved fields and they'll match.
Hash-form alternative when the base is large enough that the array form is hard to scan:
mocks:
ec2:
- !Patch
base: !Fixture cassettes/ec2/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd
set:
response.data.reservations.0.instances.0.state.name: running
response.data.reservations.0.instances.0.state.code: 1
Same result, more verbose, easier to read when the base reference is long.
A few rules !Patch follows:
!Patch in a brand-new field, not just edit an existing one.!Merge to replace the whole array.!MergeWhen we want a recorded shape with one or two fields changed — a real response with the instance state moved to stopping, a real response with a different IP, a real response with a tag swapped — we combine !Fixture with !Merge. The Merge tag takes an array of maps and deep-merges them right-over-left:
scenarios:
instance-readout:
stopping_state:
description: "Cassette + !Merge overlay to simulate the instance stopping"
mocks:
ec2:
- !Merge
- !Fixture cassettes/ec2/9e5c8879cb8265eae3b71928a68c2f4b7f0497dd
- response:
data:
reservations:
- instances:
- state:
name: stopping
code: 64
input:
params:
instance_id: i-0abc123def456789a
expected:
state: stopping
The first array element is the cassette envelope. The second is the overlay. Only the fields in the overlay win. Everything else comes through from the cassette. The merger recurses into nested hashes. Arrays at the same key replace whole.
Mocks are consumed by the AWS adapter when its mode is mock. To run a scenario with mocks, flip the mode at invocation:
WANDERLAND_AWS_MODE=mock bundle exec wanderland --type test config.yml --route instance-readout
Mock mode is strict: a request the scenario didn't mock raises CassetteMissError. We use the --scenario filter when mock-only scenarios sit beside replay-driven ones in the same config.
!Fixture lifts a recorded cassette as-is. It's the cheapest option. There's no upstream drift because the response is exactly what was recorded. Use it when you want to test the boundary against a known-good response.
!Patch is the right call when you need to change one or two specific values and keep everything else from the cassette intact. The path-addressed override means your scenario's expected block can still assert against the preserved fields without those fields disappearing into a merge collapse. This is usually what you want when testing state transitions or single-field drift.
!Merge overlays by hash structure and replaces arrays at the same key whole. It's the right shape when you want to swap out a sub-tree (a whole reservations array, a whole tags block) rather than tweak individual values. Hand-editing the cassette achieves the same thing; !Merge keeps the original file pristine and the variation visible at the scenario level.
Inline envelope writes the response from scratch. It's best for shapes the upstream wouldn't produce on demand: errors, malformed bodies, edge states. The defaults keep the envelope short.
Production runs live. CI runs replay. Dev defaults to live and flips to replay for an offline run. The !Env tag from section 2 already wires this up. Setting the override at invocation is enough:
WANDERLAND_AWS_MODE=replay bundle exec wanderland --type test config.yml
# 4 scenarios, 4 passed, 0 failed (offline)
!Env reads the process environment at the moment config.yml is loaded. The value is then static for the lifetime of the runtime. There are no live re-reads. The HTTP adapter uses the same convention. The env variable name (WANDERLAND_AWS_MODE vs WANDERLAND_HTTP_MODE) is the only thing that differs between the two adapters.
The facade returns the SDK's response struct directly. When a boundary needs SDK behaviour the facade doesn't expose — paginators, custom retry config, the higher-level resource API — we reach the bare client through the escape hatch:
def call(input)
ec2 = input.adapter(:ec2)
result = ec2.describe_instances(instance_ids: [instance_id]) # facade
client = ec2.client # Aws::EC2::Client handle
# Bare-client work below this line.
paginator = client.describe_instances(filters: [...])
paginator.each_page do |page|
page.reservations.flat_map(&:instances).each { |i| ... }
end
end
In :live mode, #client is the configured Aws::EC2::Client. In :replay and :mock modes, it's the same client constructed with stub_responses: true. That's useful when a test needs to register a per-operation stub for an unusual scenario the mock vocabulary doesn't cover. A boundary that reaches for #client is coupling itself to the SDK. The trade-off is intentional and visible in the diff.
instance-readout/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── lib/
│ ├── instance_readout.rb
│ └── instance_readout/
│ └── boundaries/
│ └── instance_readout.rb
└── spec/
├── spec_helper.rb
├── scenarios_spec.rb
└── fixtures/
└── cassettes/
└── ec2/
└── 9e5c8879cb8265eae3b71928a68c2f4b7f0497dd.yml
One boundary, one route, one adapter, three modes. Scenarios cover the happy path and the failure paths. The same shape extends to any AWS service the runbook needs to read: iam.list_roles, s3.list_objects_v2, cloudformation.describe_stacks, elbv2.describe_load_balancers. Each becomes a new adapter declaration in config.yml with kind: aws and a different service. We add the per-service gem to Gemfile, then write a new boundary that reads against it. The pattern doesn't change.
stub_responses is the seam, what the cassette format pins down.when_shape, serves, output_shape, custom BASE_DEFAULT_WHEN recovery.