This tutorial picks up where AWS Adapter Modes left off and adds a deployment route to the same project. Yesterday we wrote one boundary that wraps one adapter call. Today we wire a second route — /deploy — to a chain that ships pre-built in the wanderland-pipelines-pack gem. Instead of writing each step in our lib/ directory, we name a registered archetype, fill in its knobs from config, and watch the chain fire end-to-end.
The deliverable is a working /deploy route. Today's chain runs through four no-op stubs — each one logs that it ran and returns a small payload. That's enough to see the chain end-to-end, confirm the wiring, and set the stage for the next tutorial where we replace the stubs with real shell-executor calls and start writing crossings under a per-run address.
A route's chain is the sequence of boundaries the engine walks when the route is hit. Yesterday's /readout/:instance_id route declared its chain inline by naming instance_readout as a single boundary. The chain was bespoke to our project.
An archetype is a registered template for that chain. It lives in a YAML file shipped by a pack gem and looks like this:
name: infrastructure_deployment
slots:
- name: start_run
boundary: start_run
args:
system: !UserConfig system
environment: !UserConfig environment
- name: provision
boundary: !UserConfig provisioner
args:
template: !UserConfig template
parameters: !UserConfig parameters
- name: publish
boundary: publish_stack
- name: complete
boundary: complete_run
Some slots name a fixed boundary (start_run, publish_stack, complete_run). One — the provision slot — names its boundary as a !UserConfig thunk, so the user's config decides which boundary runs there. The args carry !UserConfig thunks too, so the values that vary per deploy come from your config rather than being hard-coded into the archetype.
When you put archetype: infrastructure_deployment on a route, the engine looks up the registered archetype, resolves the !UserConfig thunks against your route plus config data, and uses the resolved slot list as the route's chain. The chain shape is the archetype's; the values are yours; the implementation behind each knob is the one you named.
wanderland-pipelines-pack is a gem we add to the Gemfile. When Bundler requires it, the gem does two things automatically:
boundaries/ directory and requires each file. The boundary :name, ... DSL inside each one self-registers, so the project gets start_run, shell_provisioner, publish_stack, complete_run available without any per-boundary require in your code.archetypes/ directory and registers each YAML through Wanderland::Archetypes.register(name, path). Today that's infrastructure_deployment. Routes name it via archetype: and the engine will find it.Adding the pack costs one Gemfile line. The pack handles registration. Other packs (wanderland-aws-pack from yesterday, future wanderland-deploy-cdk-pack, ...) follow the same convention: glob and self-register at gem load.
In Gemfile, alongside wanderland-core and the AWS pack from yesterday:
# frozen_string_literal: true
source "https://rubygems.org"
gem "puma", "~> 6.0"
gem "wanderland-core", path: "../wanderland-core"
gem "wanderland-aws-pack", path: "../wanderland-aws-pack"
gem "wanderland-pipelines-pack", path: "../wanderland-pipelines-pack"
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 pack lands; wanderland-core is already there.
config.ru from yesterday already calls run Wanderland.boot(...). Bundler's default-group autoload handles the pipelines pack the same way it does the AWS pack — no require line to add.
Open config.yml. Underneath the existing /readout/:instance_id route, add:
routes:
/readout/:instance_id:
method: get
boundary: instance_readout
name: instance-readout
/deploy:
method: post
name: deploy
archetype: infrastructure_deployment
provisioner: shell_provisioner
system: web
environment: prod
template: stacks/web.yml
parameters:
instance_type: t3.medium
tags:
Owner: vivarta
Anatomy:
archetype: infrastructure_deployment names the registered template. The engine looks it up through Wanderland::Archetypes. An unknown name fails at boot with the list of registered archetypes, so a typo lands as a clear startup error rather than a runtime surprise.provisioner: shell_provisioner fills the archetype's !UserConfig provisioner knob. It selects the boundary that runs in the provision slot. The pipelines pack ships shell_provisioner (no-op stub today, real shell later). Other provisioners — cfn_provisioner, cdk_provisioner when those land — drop into the same slot.system, environment, template, parameters, tags fill the other !UserConfig thunks. These flow into the boundary args at the positions the archetype names. The exact set of knobs depends on the archetype; reading the archetype's YAML tells you which ones it consumes.chain: field. The engine uses the resolved archetype slot list as the chain.To run the chain through wanderland --type test, declare a scenario for the route:
scenarios:
deploy:
happy_path:
description: "infrastructure_deployment archetype runs end-to-end"
input:
params: {}
expected:
completed: true
Anatomy:
completed: true because that's what the chain's last boundary (complete_run) returns. Each boundary in a chain projects its OK payload into the route's response context; the test runner compares the final response against expected:.params: {} is enough because the archetype reads its values from config rather than from request params. When per-request inputs matter — say, a deploy targeted at a specific environment from the request body — they come through here.WANDERLAND_AWS_MODE=mock bundle exec wanderland --type test config.yml
WANDERLAND_AWS_MODE=mock keeps yesterday's instance-readout scenarios passing without hitting AWS. The deploy scenario doesn't use the AWS adapter, so the env var doesn't affect it.
The output (abbreviated):
[start_run] ▶ run 01KR42WHDM3XG90S89Y1RQ2959 web/prod
[shell_provisioner] ▶ template="stacks/web.yml" parameters={"instance_type" => "t3.medium"}
[publish_stack] ▶ publish phase ran
[complete_run] ▶ run complete
deploy
✓ happy_path — infrastructure_deployment archetype runs end-to-end
instance-readout
✓ instance_is_running
✓ instance_not_found
✓ instance_is_lazarus
✓ instance_is_lazarus_but_precise
The four boundaries log in chain order. Each one returns its OK signal; the engine stitches the payloads together into the route's response context. The completed: true from complete_run is what the scenario asserts against.
start_run mints a ULID-shaped run id and emits a crossing carrying (run_id, system, environment, sub_environment). Downstream slots can read run_id from context.start_run.run_id. Today the run id only lives in the log line; the next tutorial threads it into a per-run storage address so the rest of the chain writes there.shell_provisioner is the boundary that fills the provision slot when your config names it. It reads template, parameters, tags from its args — values resolved from your config's !UserConfig thunks. Today it logs the inputs and returns. Tomorrow it shells out through a kind: shell adapter that follows the live / replay / mock discipline from yesterday's tutorial.publish_stack is a no-op today. When the artifacts primitive lands, this is where we write a :claims-artifact: crossing pointing at the published image, template, or stack outputs the upstream produced.complete_run closes the run with completed: true. The test scenario asserts on this; the next tutorial extends it to write a closing crossing under the run-tree address.The chain's structure is the archetype's contribution. The values are yours. The choice of which boundary fills each !UserConfig slot is yours.
The framework's introspection routes show what the engine mounted. Boot the HTTP runner in a second shell:
bundle exec puma config.ru -p 9298
Then ask for the route definition:
curl -s http://localhost:9298/inspect/route/deploy | python3 -m json.tool
{
"name": "deploy",
"method": "post",
"path": "/deploy",
"chain": ["start_run", "shell_provisioner", "publish_stack", "complete_run"]
}
The chain field shows the resolved boundary names. The archetype's !UserConfig provisioner thunk has been replaced with shell_provisioner because that's what your config named.
To see what changes when you swap implementations, change provisioner: shell_provisioner in config.yml to a different registered boundary, restart, and re-inspect. The same archetype yields a different chain because a different boundary fills the provision slot.
The same archetype: field works at two scopes. At the top level it's the default for every route:
archetype: infrastructure_deployment
provisioner: shell_provisioner
system: web
environment: prod
template: stacks/web.yml
parameters:
instance_type: t3.medium
routes:
/deploy:
method: post
name: deploy
/destroy:
method: post
name: destroy
Both /deploy and /destroy use infrastructure_deployment with shell_provisioner. Per-route configurations layer on top; route-level fields override the top-level ones with the same key:
archetype: infrastructure_deployment
provisioner: shell_provisioner
system: web
environment: prod
template: stacks/web.yml
routes:
/deploy:
method: post
provisioner: cdk_provisioner # this route uses cdk
/destroy:
method: post
# falls back to shell_provisioner
/deploy runs the chain with cdk_provisioner filling the provision slot. /destroy falls back to the top-level shell_provisioner. Route-level overrides everywhere — provisioner, system, template, anything the archetype reads.
When every route uses the same archetype, declaring it once at the top level is cleaner than repeating it per route. When routes use different archetypes (one runs infra, another runs container deploys), declare per route and leave the top level open.
instance-readout/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml # /readout from yesterday + /deploy from today
├── lib/
│ ├── instance_readout.rb
│ └── instance_readout/
│ └── boundaries/
│ └── instance_readout.rb
└── spec/
├── spec_helper.rb
├── scenarios_spec.rb
└── fixtures/
└── cassettes/
└── ec2/
└── ...yml
No new files in your project. The deploy chain's boundaries live in wanderland-pipelines-pack; your config picks them. The same shape extends to other archetypes the pack will ship — containerized_deployment, tool_invocation — by adding them to the gem and naming them in archetype:.
start_run's run id into a per-run address (:advaita:<system>:<env>:runs:<run_id>:...) and has each subsequent boundary write its crossing there. Replay-by-run-id falls out for free; a deploy that fails halfway becomes inspectable, retryable, auditable.shell_provisioner's no-op body with a kind: shell adapter call that runs the actual provisioner script. The shell adapter follows the live / replay / mock discipline from yesterday's tutorial; cassettes capture the script's stdout, stderr, and exit code.cfn_provisioner (CloudFormation through the AWS adapter), cdk_provisioner (CDK synthesis plus deploy), terraform_provisioner (terraform plan / apply). Each is a boundary that fills the same provision slot; the rest of the chain stays the same.containerized_deployment for ECS / Compose-based deploys; tool_invocation for one-shot tool calls (an SDK script, a notification fan-out). Each ships in a pack, registers at gem load, and a route names it through the same archetype: field.wanderland.dev