Wanderland

AWS Adapter Modes

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.

What an AWS adapter is

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:

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.

1. Scaffold the project

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.

2. Wire the :ec2 adapter in config.yml

service: 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:

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.

3. Write the instance_readout boundary

Create 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:

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.

4. Try it live

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

5. Record cassettes

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.

6. Replay

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.

7. Mock for scenario-specific shapes

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.

Inline envelope

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.

Lift a cassette via !Fixture

When 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.

Override exact slots with !Patch

When 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:

Twist a cassette with !Merge

When 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.

Mock mode

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.

When to reach for which

!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.

8. Override mode via env

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.

9. The escape hatch

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.

10. Directory shape now

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.

What's next