Wanderland

Vivarta AWS Adapter

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.

Shape

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.

Three modes, AWS SDK as the test seam

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.

Cassette format

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.

Recording

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

Comparison: wanderland-core HTTP vs Vivarta AWS

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.clientNet::HTTP handle adapter.clientAws::*::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.

Map to deployoryah's existing pieces

What carries forward:

What we don't carry forward:

Shape decisions

Resolved before implementation:

Deferred

Why this lands cleanly on the substrate

Every adapter call is observable as a crossing in the run's sub-stream:

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.

Source pointers