Wanderland

Containerized Deployment: Cloning a Pipeline

Every service deployed by Vivarta that is buit into a container image and deployed to Kubernetes uses the same pipeline. There is a single archetype - containerized_deployment - that can configured for your service by creating a vivarta.yml next to its deployment manifests and filling in a handful of values. A static nginx site, a Spring Boot fat-jar, a Wanderland engine: same pipeline, different knobs. Once you've seen one, you can clone the next in about twenty minutes.

For the most part, creating a new pipeline is a matter of copy and change, and the goal of this tutorial is to show you how.
When you are don, you should be able to take a working vivarta.yml, understand which lines are structural (leave them alone upon pain of BOFH) and which are yours to change (and most importantly, why), and stand up a new pipeline without needing to know how the Wanderland engine actually works (it mostly creates a middleware chain and executes it in order with some neat signalling). We'll spend almost all our time in the two sections that actually matter — environments: and pipeline: — and on the one lovely bit of Wanderland syntax that makes it all work - !Environment.

I Wanna Know a Bit About Wanderland

Wanderland is a context engine that executes a feed forward pass through a chain of middleware stages it calls boundaries. At one end, input adapters greet the calling client and accept their capabilites which are stored the in context to frame the rest of the dicussion. Each boundary has access to the outputs of each that preceded it and writes a signed copy of its notes to the same context upon its completion. At the tail end, one or more formatters eaches back through the layers to the capabilities written by the client to ensure the output is appropriate for the caller's needs and the response is issued.

These boundary chains are organized as routes and a route and its arguments form a common dispatch shape that can be handled by Rack for web facing tools and Commander for the more intimate, CLI variety. In the CI/CD world, the same dispatch can be sent to RSpec with the engine's adapters (its bridge to the outside world) in mocked or VCR mode. A route can be defined as a simple chain of boundaries with their arguments in the YAML configuration presented to the engine and it will happily compile it. For more elaborate workflows, the chain can be written into the system as an archetype. These are the mad-libs of the workflow world, a series of pre-defined boundaries in a chain with placeholders to be compiled into the chain by the engine at boot time. Additionally, an injector system allows the workflow author or downstream implementer to inject additional boundaries into set points in the flow - mainly the beginning, end or interleaved interceptor style around each boundary.

When the route is called, the final chain is compiled and exceuted when presented with the dispatch negotiated by the inbound adapter.

Anatomy of a vivarta.yml

Open a working one from any of our repositories and you'll find these top-level sections. Most of them you copy as is, environments and pipelines are generally the ones that need to be customized.

The pipeline section — the shape of the work

pipeline: describes what a deploy is for your service, independent of where it's going. It has four parts.

pipeline:
  metadata:
    name: advaita-datahub
    image_name: !Environment image_name
    permissions:
      deployer_role_arn: !Environment permissions.deployer_role_arn
    tags:
      environment:     !Environment kube_context
      owner:           you@advaitabio
      sub_environment: bd
      system:          advaita-datahub
      tier:            advaita-ecs-workloads

  stages:
    collect:
      args:
        steps: !Environment collect_steps
    image: { type: !Environment image_stage_actor }
    deploy:
      type: component
      args:
        output_root: /tmp/advaita-datahub-deploy

  artifacts:
    images:
      datahub:
        type: docker_build
        args:
          source_path: !Environment source_path
          image_name:  !Context for_boundary.chain.image.name
          image_tag:   !Context for_boundary.chain.datahub.short_sha
          builder: buildx
          docker_build_args: !Environment docker_build_args

  components:
    advaita-datahub:
      type: k8s_provisioner
      args:
        kustomize_dir:   !Environment kustomize_dir
        name:            <ecr>/advaita/drive
        image:           !Environment image_uri
        namespace:       !Environment namespace
        deployment:      deploy/advaita-drive
        context:         !Environment kube_context
        load_restrictor: LoadRestrictionsNone
        wait_timeout_s:  180

That's the shape. It does not mention local, dev, or prod anywhere. It mentions slots where those environments will pour their values in.

The environments section — the per-environment columns

environments: is a map from environment name to a block of values. There's a default: block for shared fallbacks, then one block per environment. Whatever !Environment <path> asks for in the pipeline: section gets looked up here, under the currently-active environment.

environments:
  default:
    docker_build_args: ["--provenance=false"]
    source_path: !Env { name: DATAHUB_SOURCE_PATH, default: ../../../advaita-drive-service }

  local:
    kube_context:  orbstack
    namespace:     datahub-local
    kustomize_dir: k8s/overlays/local
    image_name:    <ecr>/advaita/drive
    image_stage_actor: artifacts
    collect_steps:
      - type: git_hash
        args: { key: datahub, path: !Environment source_path }
    image_uri: !Context for_boundary.artifacts.image_ref
    permissions:
      deployer_role_arn: arn:aws:iam::…:role/advaita-cfn-deployer-ecs-workloads

  dev:
    kube_context:  useast13-dev
    namespace:     datahub-dev
    kustomize_dir: k8s/overlays/dev
    docker_build_args: ["--platform", "linux/amd64", "--provenance=false"]
    image_name:    <ecr>/advaita/drive
    image_stage_actor: artifacts
    collect_steps:
      - type: git_hash
        args: { key: datahub, path: !Environment source_path }
    image_uri: !Context for_boundary.artifacts.image_ref
    permissions:
      deployer_role_arn: arn:aws:iam::…:role/advaita-cfn-deployer-ecs-workloads

  prod:
    kube_context:  useast13-prod
    namespace:     drive
    kustomize_dir: k8s/overlays/prod
    image_stage_actor: noop
    collect_steps:
      - type: k8s_live_image
        args: { deployment: advaita-drive, namespace: drive-qa, context: useast13-dev }
    image_name: !Context for_boundary.chain.image_name
    image_uri:  !Context for_boundary.chain.image_uri
    permissions:
      deployer_role_arn: arn:aws:iam::…:role/advaita-cfn-deployer-ecs-workloads-prod

Read it as a spreadsheet. Each environment is a column; each key is a row. When the pipeline asks for !Environment kube_context, it reads the kube_context row of the active column. Set WANDERLAND_ENVIRONMENT=dev and every !Environment in the pipeline: section resolves against the dev: block, with default: filling any gaps.

The !Environment trick — why some things live here and not there

Here's the rule that makes the whole thing click, and it's worth saying plainly because it's the one idea you actually need:

The pipeline: section holds what is the same across every environment (the shape). The environments: section holds what differs (the values). !Environment is the seam between them.

So the question "should this value be inline in pipeline:, or should it be !Environment pulled from environments:?" answers itself: does it change between local, dev, and prod? If no, write it inline. If yes, make it !Environment and put the per-environment values in the columns.

A hostname changes per environment → !Environment. The image tag's format (buildx, --provenance=false) is the same everywhere → inline. The kube context is different for every cluster → !Environment. The system: tag is the service's name and never changes → inline.

You could, in principle, hardcode three separate vivarta.yml files. The reason we don't is the same reason we don't copy-paste functions: one shape, one place to fix a bug, and the diff between environments is visible in one spreadsheet instead of smeared across three files.

The same trick shapes the deploy and the build, not just values

This is where it gets genuinely useful, and it's the part people miss. !Environment doesn't only substitute scalars like hostnames. It can substitute the actor that runs a stage, the arguments to the build, and the entire set of things you deploy. The pipeline shape stays fixed; the environment column decides how that shape behaves.

Shaping the image build. Look at docker_build_args. Locally you build native (fast, runs on your laptop's OrbStack without emulation). For the CoreWeave clusters you must build linux/amd64. Same build stage, different flags, chosen by the column:

# environments.default
docker_build_args: ["--provenance=false"]
# environments.dev overrides it
docker_build_args: ["--platform", "linux/amd64", "--provenance=false"]

Shaping the image stage itself — build vs promote. This is the sharpest example. In dev you build a fresh image; in prod you don't rebuild at all — you promote the exact image dev is already running. Two different behaviours out of one image: stage, because the stage's actor is !Environment image_stage_actor:

Notice both the actor (image_stage_actor) and the fact-gathering (collect_steps) and where the final ref comes from (image_uri) flip together, per column. The pipeline: section never learned the words "promote" or "build" — it just has slots, and the environment fills them to mean one or the other.

Shaping what you deploy. The components: map — the set of things the deploy stage applies — can itself be !Environment. The echo frontend does exactly this: locally it deploys one component (the workload); in dev and prod it deploys two — a CloudFormation Route53 DNS record and the workload, DNS first so the TLS challenge can resolve. The deploy stage iterates whatever the column hands it:

# pipeline.components is a single line:
components: !Environment components
# environments.local  → { workload }
# environments.dev    → { dns, workload }

So a mixed AWS-plus-Kubernetes deploy in dev collapses to a plain Kubernetes deploy locally, with no change to the pipeline shape — only the column differs. The same lever that swaps a hostname swaps whether you provision cloud DNS at all.

The mental move is always the same: find the thing that differs, replace it in pipeline: with !Environment <name>, and add <name> as a row in each environment column. It works for strings, for lists, for the name of the boundary that runs a stage, and for the whole deploy manifest.

Copy this, change these, because…

Take a working vivarta.yml (the datahub or echo-backend one). Here is the full change list to retarget it at a new service. Nothing outside this list needs to move.

If your service is a single container that just needs to run (like datahub — a Spring Boot jar behind a ClusterIP), you're done: one image, one k8s_provisioner component, and the columns. If it needs a public hostname with TLS, add a DNS component to the dev/prod columns as shown above. If it promotes rather than rebuilds in prod, wire the image_stage_actor: noop + k8s_live_image pair into the prod column.

What this pipeline does not own

One boundary worth drawing, because it saves confusion. The pipeline does the routine deploy: build, push, pin, apply, wait. It does not do first-time bootstrap — creating the namespace, materializing the image-pull secret, provisioning TLS certificate issuers, seeding application secrets, cutting the initial DNS record. Those are a one-time setup step (a setup.sh or a bootstrap drone) run once per cluster before the pipeline ever fires. The pipeline assumes the namespace and secrets already exist. Keep that split in your head and you won't try to make vivarta.yml do a job that isn't its.

Running it

From the directory holding your vivarta.yml:

# default environment (dev)
bundle exec wanderland --type cli vivarta.yml deploy

# any other column
WANDERLAND_ENVIRONMENT=prod bundle exec wanderland --type cli vivarta.yml deploy

Before you trust it, dry the resolution: --wanderland-render prints the fully-resolved config with every !Environment and !Env already substituted, so you can confirm the active column poured the right values into the shape without running a build. It's the fastest way to catch a column with a missing row.

Pointers