Wanderland

Spring Deploy Pipeline

This tutorial picks up where Pipelines Pack: Infrastructure Deployment left off and builds a real one. Yesterday's chain was four no-op stubs that logged and returned. Today we ship a working containerized-deployment pipeline: clone-relative checkout, Maven build, JAR publish to Reposilite, Docker image build, image push to a local registry, Compose deploy of the running app. The same archetype-and-knobs model runs the chain; the boundaries underneath now do the actual work.

The deliverable is a /deploy endpoint that, hit live, takes the hello-spring source repo from ~/working/hello-spring, runs it through the seven-slot chain, and leaves a running container behind. Recorded with --wanderland-record-to once, the same chain replays from cassette without touching network, registry, or shell.

The work splits into three sections. Section 1 is the standing infrastructure — a tiny Compose stack with a Docker registry and Reposilite (a Maven artifact repository), what Compose itself does, and where the config file lives. Section 2 brings that stack up. Section 3 builds the pipeline: project layout, three boundaries you write, two boundaries the pack ships, the archetype that wires them together, and a final run that produces a container hosting hello-spring.

When you're done you'll be able to:

What Docker Compose is

Compose is a tool for declaring multi-container stacks in a single YAML file and bringing the whole stack up with one command. The file describes services (containers to run), volumes (persistent storage), networks (private subnets connecting the services), and a few cross-cutting concerns like restart policy and environment variables. docker compose up reads the file, pulls the images that aren't local, creates the network and volumes, starts each service in dependency order, and detaches.

The shape of a Compose file:

services:
  web:
    image: nginx:1.27
    ports: ["8080:80"]
    volumes: ["./public:/usr/share/nginx/html:ro"]
    depends_on: [api]

  api:
    image: my-org/api:0.5.0
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgres://db/app
    depends_on: [db]

  db:
    image: postgres:16
    volumes: ["db-data:/var/lib/postgresql/data"]
    environment:
      POSTGRES_PASSWORD: dev

volumes:
  db-data:

Three services, one named volume. docker compose up -d (the -d detaches) brings all three up on a private network where service names resolve as DNS — the api container can connect to db:5432 without knowing or caring about IPs. docker compose down stops everything and removes the containers; volumes survive unless down -v is passed. docker compose logs <service> tails one service's output; docker compose ps lists what's running.

For local development infrastructure — the kind of thing your pipeline needs to push artifacts to or pull dependencies from — Compose is the lightest weight option that survives a reboot. The file is the source of truth; bringing the same stack up on a coworker's laptop is a git clone away.

What the local stack contains

This pipeline targets two pieces of standing infrastructure:

Both run as containers under one Compose file (docker-compose.infra.yml). Their data sits in named volumes so artifacts survive down/up cycles. The pipeline doesn't know or care that they're local; from the pipeline's view, they're just URLs.

1. Bringing up the infrastructure

The Compose file lives at the root of the spring-deploy-pipeline project:

# spring-deploy-pipeline/docker-compose.infra.yml
services:
  registry:
    image: registry:2
    container_name: spring-deploy-registry
    ports:
      - "5001:5000"
    volumes:
      - registry-data:/var/lib/registry
    environment:
      REGISTRY_HTTP_SECRET: spring-deploy-local-dev
    restart: unless-stopped

  reposilite:
    image: dzikoysk/reposilite:3.5.6
    container_name: spring-deploy-reposilite
    ports:
      - "8081:8080"
    volumes:
      - reposilite-data:/app/data
    tty: true
    stdin_open: true
    restart: unless-stopped

volumes:
  registry-data:
  reposilite-data:

Anatomy:

Bring it up:

cd ~/working/spring-deploy-pipeline
docker compose -f docker-compose.infra.yml up -d

You should see two containers come up. Check them:

docker compose -f docker-compose.infra.yml ps
# NAME                          STATUS    PORTS
# spring-deploy-registry        Up        0.0.0.0:5001->5000/tcp
# spring-deploy-reposilite      Up        0.0.0.0:8081->8080/tcp

The registry has no UI; ping its catalog endpoint:

curl -s http://localhost:5001/v2/_catalog
# {"repositories":[]}

If that returns 403 with Server: AirTunes/..., you're hitting macOS AirPlay — re-check that the host port in the Compose file is :5001 (or another non-AirPlay port) and that you brought the stack down + up after the change.

Bootstrap the Reposilite admin token. Reposilite ships with no users; the first one is created by the operator from inside the running container's CLI. Attach to the container:

docker attach spring-deploy-reposilite

You'll land at a prompt that reads Reposilite>. Run token-generate:

> token-generate admin m
# Reposilite responds with something like:
#   Generated new access token for admin with permissions [m]:
#   admin:K7qPx9mN2vR3Lf5jH8wT4yQ6sZ1aB...

m is the permissions flag for "manager" — a token allowed to deploy, manage repos, and create other tokens. Copy the secret half (the long random string after admin:); you'll paste it into config.yml in a moment.

Detach from the container with Ctrl+P then Ctrl+Q. Don't Ctrl+C — that kills the Reposilite process. The Ctrl+P Ctrl+Q sequence detaches without sending a signal.

The token is persisted in the SQLite database under the reposilite-data volume. Subsequent docker compose up cycles re-use the same database; the bootstrap is one-time. You'd repeat it only after docker compose down -v (which wipes the volume) or against a fresh Reposilite host.

Open http://localhost:8081 in a browser and log in with admin and the secret you just generated. The page lists the repositories Reposilite ships with by default (releases, snapshots, private); the snapshots repo is where the pipeline mvn deploys into.

The --token CLI flag exists in the documentation as a way to seed a temporary token at startup, but the dzikoysk/reposilite 3.5.6 image's entrypoint silently drops it whether or not a TTY is attached. The token-generate console command is the working path for this image.

A couple of operational notes worth carrying forward:

When you're done with the stack, tear it down:

docker compose -f docker-compose.infra.yml down       # stop, keep volumes (token persists)
docker compose -f docker-compose.infra.yml down -v    # stop, wipe volumes (re-bootstrap on next up)

2. The infrastructure step in context

You won't tear it down for the rest of the tutorial. Leave it up. Section 3 fills the registry with a built image and Reposilite with a deployed JAR.

While the stack is running, you can browse what the pipeline is going to write. Reposilite's UI at http://localhost:8081 will show artifacts under snapshots/ once the pipeline runs. The registry's catalog endpoint (http://localhost:5001/v2/_catalog) lists pushed images. Both are empty right now; that changes by the end of section 3.

3. Building the pipeline

The deliverable is a wanderland-core service called spring-deploy-pipeline whose /deploy route runs the seven-slot chain against the hello-spring source repo. Three repos are involved:

~/working/
├── hello-spring/                            # the app being deployed
│   ├── pom.xml
│   ├── src/main/java/dev/wanderland/hello/
│   │   ├── HelloApplication.java
│   │   └── HelloController.java
│   ├── src/main/resources/application.yml
│   └── Dockerfile
│
├── spring-deploy-pipeline/                  # operational repo: config + templates + cassettes
│   ├── Gemfile
│   ├── config.yml
│   ├── config.ru
│   ├── docker-compose.infra.yml             # section 1
│   ├── templates/
│   │   └── hello-spring.compose.yml.tpl
│   └── spec/
│       └── fixtures/
│           └── cassettes/
│
└── wanderland-pipelines-pack/               # the gem holding archetype + boundaries
    └── lib/
        └── wanderland_pipelines_pack/
            ├── archetypes/
            │   ├── infrastructure_deployment.yml
            │   └── containerized_deployment.yml
            └── boundaries/
                ├── start_run.rb              # ships with the pack
                ├── publish_jar.rb            # ships with the pack
                ├── publish_image.rb          # ships with the pack
                ├── complete_run.rb           # ships with the pack
                ├── compile_jar.rb            # you write
                ├── docker_build.rb           # you write
                └── compose_deploy.rb         # you write

Three concerns, three repos. hello-spring/ is an ordinary Spring Boot codebase that knows nothing about wanderland. spring-deploy-pipeline/ is the operational repo — config, the Compose stack for the registry and Reposilite, the templates, the cassettes; this is where you cd to run wanderland --type test. wanderland-pipelines-pack/ is the gem that ships the archetype and the boundaries.

Boundary 1 Compilejar

The first user-pluggable slot. Reads source_path (the directory holding pom.xml), runs mvn package -DskipTests through the :mvn shell adapter, emits the path to the built JAR.

Create wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/compile_jar.rb:

# frozen_string_literal: true

module WanderlandPipelinesPack
  module Boundaries
    class CompileJar
      include Wanderland::Boundary

      IDENTITY = Wanderland::Identity.new(
        id: "boundary:compile_jar",
        name: "CompileJar",
        roles: [:boundary],
        type: :service,
        scopes: [:execute]
      ).freeze

      boundary :compile_jar,
        identity: IDENTITY,
        requirements: [:execute],
        capabilities: [:build],
        description: "Compile a Maven project's JAR",
        input_shape: {
          "args" => {
            "source_path" => {
              required: true,
              description: "Repo root containing pom.xml — the directory mvn runs against"
            },
            "compile_args" => {
              required: false,
              description: "Extra mvn args, e.g. ['-Pprofile-x', '-DskipITs']"
            }
          }
        },
        output_shape: {
          "jar_path" => true,
          "source_path" => true,
          "duration_s" => true
        },
        adapters: [:mvn]

      def call(input)
        source_path = input.find_or_fail("args.source_path")
        extra_args  = Array(input.find("args.compile_args"))

        mvn = input.adapter(:mvn)
        info { "#{PLAY} mvn package #{source_path}" }

        result = mvn.run(
          "-f", File.join(source_path, "pom.xml"),
          "package",
          "-DskipTests",
          *extra_args
        )

        if result[:exit_code] != 0
          return Wanderland::Signal.halt(
            status: 502,
            error: "mvn package failed (exit #{result[:exit_code]}): #{result[:stderr].lines.last(8).join.strip}"
          )
        end

        jar_path = locate_jar(source_path)
        unless jar_path
          return Wanderland::Signal.halt(
            status: 502,
            error: "mvn package succeeded but no JAR found under #{source_path}/target/"
          )
        end

        Wanderland::Signal.ok(
          jar_path: jar_path,
          source_path: source_path,
          duration_s: result[:duration_s]
        )
      end

      private

      def locate_jar(source_path)
        target = File.join(source_path, "target")
        return nil unless Dir.exist?(target)
        candidates = Dir.glob(File.join(target, "*.jar")).reject { |f| f.end_with?(".original.jar") }
        candidates.max_by { |f| File.mtime(f) }
      end
    end
  end
end

Anatomy:

Boundary 3 Dockerbuild

Reads jar_url from the previous slot's context (the URL mvn announced when it uploaded the artifact), runs docker build with that URL as the JAR_URL build-arg, emits the image ref.

Create wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/docker_build.rb:

# frozen_string_literal: true

module WanderlandPipelinesPack
  module Boundaries
    class DockerBuild
      include Wanderland::Boundary

      IDENTITY = Wanderland::Identity.new(
        id: "boundary:docker_build",
        name: "DockerBuild",
        roles: [:boundary],
        type: :service,
        scopes: [:execute]
      ).freeze

      boundary :docker_build,
        identity: IDENTITY,
        requirements: [:execute],
        capabilities: [:build],
        description: "Build a container image, pulling the published JAR from the artifact repository",
        input_shape: {
          "args" => {
            "source_path" => {
              required: true,
              description: "Path to the build context (where the Dockerfile lives)"
            },
            "image_name" => {
              required: true,
              description: "Registry-qualified image name without tag, e.g. localhost:5001/hello-spring"
            },
            "image_tag" => {
              required: false,
              description: "Tag for the built image; defaults to 'latest'"
            },
            "docker_build_args" => {
              required: false,
              description: "Extra docker build flags, e.g. ['--no-cache', '--platform', 'linux/amd64']"
            }
          }
        },
        output_shape: {
          "image_ref" => true,
          "image_name" => true,
          "image_tag" => true,
          "jar_url" => true,
          "duration_s" => true
        },
        adapters: [:docker]

      def call(input)
        source_path = input.find_or_fail("args.source_path")
        image_name  = input.find_or_fail("args.image_name")
        image_tag   = input.find("args.image_tag") || "latest"
        ref         = "#{image_name}:#{image_tag}"

        # publish_jar scraped the actual upload URL out of mvn's
        # stdout and put it in context. We use it verbatim — no
        # path reconstruction, no snapshot timestamp resolution
        # in this boundary. Whatever mvn published, that's what
        # the Dockerfile pulls.
        jar_url = input.find_or_fail("context.jar_url")
        extra_args = Array(input.find("args.docker_build_args"))

        docker = input.adapter(:docker)
        info { "#{PLAY} build #{ref} #{ARROW} #{jar_url}" }

        result = docker.run(
          "build",
          "--build-arg", "JAR_URL=#{jar_url}",
          *extra_args,
          "-t", ref,
          source_path
        )

        if result[:exit_code] != 0
          return Wanderland::Signal.halt(
            status: 502,
            error: "docker build failed (exit #{result[:exit_code]}): #{result[:stderr].lines.last(8).join.strip}"
          )
        end

        Wanderland::Signal.ok(
          image_ref: ref,
          image_name: image_name,
          image_tag: image_tag,
          jar_url: jar_url,
          duration_s: result[:duration_s]
        )
      end
    end
  end
end

Anatomy:

Boundary 5 Composedeploy

Renders a Compose template against the image ref, writes it under a per-run output directory, runs docker compose up -d.

Create wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/compose_deploy.rb:

# frozen_string_literal: true

require "fileutils"

module WanderlandPipelinesPack
  module Boundaries
    class ComposeDeploy
      include Wanderland::Boundary

      IDENTITY = Wanderland::Identity.new(
        id: "boundary:compose_deploy",
        name: "ComposeDeploy",
        roles: [:boundary],
        type: :service,
        scopes: [:execute, :write]
      ).freeze

      boundary :compose_deploy,
        identity: IDENTITY,
        requirements: [:execute, :write],
        capabilities: [:deploy],
        description: "Render a Compose template and bring up the stack",
        input_shape: {
          "args" => {
            "compose_template" => {
              required: true,
              description: "Path to the Compose template (with ${PLACEHOLDER} tokens) to render"
            },
            "compose_parameters" => {
              required: true,
              description: "Hash of placeholder values; IMAGE_REF / IMAGE_NAME / IMAGE_TAG are added automatically"
            },
            "output_root" => {
              required: true,
              description: "Directory under which each run writes its rendered compose.yml; one subdirectory per run_id"
            }
          }
        },
        output_shape: {
          "compose_path" => true,
          "image_ref" => true,
          "parameters" => true,
          "duration_s" => true
        },
        adapters: [:docker]

      def call(input)
        template_path = input.find_or_fail("args.compose_template")
        parameters    = input.find_or_fail("args.compose_parameters")
        output_root   = input.find_or_fail("args.output_root")

        # Context's `[]` does last-writer-wins key projection across
        # all upstream crossings' results — no boundary-name segment
        # in the path. start_run emitted run_id; publish_image
        # emitted image_ref.
        run_id    = input.find_or_fail("context.run_id")
        image_ref = input.find_or_fail("context.image_ref")

        bindings = parameters.merge(
          "IMAGE_REF"  => image_ref,
          "IMAGE_NAME" => image_ref.split(":").first,
          "IMAGE_TAG"  => image_ref.split(":").last
        )

        rendered = render_template(template_path, bindings)
        compose_path = write_rendered_compose(output_root, run_id, rendered)

        docker = input.adapter(:docker)
        info { "#{PLAY} docker compose up -d (#{compose_path})" }

        result = docker.run("compose", "-f", compose_path, "up", "-d")

        if result[:exit_code] != 0
          return Wanderland::Signal.halt(
            status: 502,
            error: "docker compose up failed (exit #{result[:exit_code]}): #{result[:stderr].lines.last(8).join.strip}"
          )
        end

        Wanderland::Signal.ok(
          compose_path: compose_path,
          image_ref: image_ref,
          parameters: bindings,
          duration_s: result[:duration_s]
        )
      end

      private

      def render_template(template_path, bindings)
        template = File.read(template_path)
        bindings.reduce(template) do |acc, (k, v)|
          acc.gsub("${#{k}}", v.to_s)
        end
      end

      def write_rendered_compose(output_root, run_id, rendered)
        dir = File.join(output_root, run_id)
        FileUtils.mkdir_p(dir)
        path = File.join(dir, "compose.yml")
        File.write(path, rendered)
        path
      end
    end
  end
end

Anatomy:

Carry The Archetype Back To Vivarta

The same archetype works in your existing vivarta project. Open ~/working/vivarta/config.yml, find the /deploy route from yesterday's pipelines-pack tutorial, and swap its archetype:

# Before — yesterday's no-op chain
routes:
  /deploy:
    method: post
    name: deploy
    archetype: infrastructure_deployment
    provisioner: shell_provisioner
    system: web
    environment: prod
    template: stacks/web.yml
    parameters:
      instance_type: t3.medium

# After — today's real chain
routes:
  /deploy:
    method: post
    name: deploy
    archetype: containerized_deployment
    compiler: compile_jar
    image_builder: docker_build
    deployer: compose_deploy
    system: hello-spring
    environment: dev
    source_path: ../hello-spring
    repository_url: http://admin:<your-reposilite-secret>@localhost:8081/snapshots
    repository_id: local
    image_name: localhost:5001/hello-spring
    image_tag: 0.1.0-SNAPSHOT
    compose_template: templates/hello-spring.compose.yml.tpl
    compose_parameters:
      PORT: "8080"
      ENV: dev
    output_root: /tmp/vivarta-deploy

Two operational changes the route needs to actually run:

Then run the same wanderland --type test config.yml against vivarta. The /deploy route now runs the seven-slot containerized_deployment chain instead of the no-op chain. The /readout/:instance_id AWS route is untouched — different archetype, different chain, sharing the engine.

Putting the new boundaries inside the pack is the lever that makes them reusable. The pack auto-loads every *.rb file under lib/wanderland_pipelines_pack/boundaries/ at gem-require time; the boundary :name, ... DSL inside each one self-registers. Any project that bundles wanderland-pipelines-pack immediately sees compile_jar, docker_build, compose_deploy on the boundary registry alongside publish_jar and publish_image. Vivarta picks them up the same way spring-deploy-pipeline does — one Gemfile line, no per-project copying.

Directory shape now

~/working/
├── hello-spring/                            # source repo (unchanged)
│   ├── pom.xml
│   ├── src/...
│   └── Dockerfile
│
├── spring-deploy-pipeline/                  # operational repo
│   ├── Gemfile
│   ├── Gemfile.lock
│   ├── config.ru
│   ├── config.yml
│   ├── docker-compose.infra.yml
│   ├── templates/
│   │   └── hello-spring.compose.yml.tpl
│   └── spec/
│       └── fixtures/
│           └── cassettes/
│               ├── mvn/
│               │   ├── 4f1a... .yml         # mvn package
│               │   └── 9c8e... .yml         # mvn deploy
│               └── docker/
│                   ├── a72b... .yml         # docker build
│                   ├── d139... .yml         # docker push
│                   └── bb50... .yml         # docker compose up
│
└── wanderland-pipelines-pack/
    └── lib/
        └── wanderland_pipelines_pack/
            ├── archetypes/
            │   ├── infrastructure_deployment.yml
            │   └── containerized_deployment.yml
            └── boundaries/
                ├── start_run.rb
                ├── compile_jar.rb           # added
                ├── publish_jar.rb
                ├── docker_build.rb          # added
                ├── publish_image.rb
                ├── compose_deploy.rb        # added
                └── complete_run.rb

Seven boundaries in the pack (four already shipping, three you added), one new archetype, three shell adapters, five cassettes per recorded run. The operational repo holds config, the Compose stack, the template, and the cassettes — everything specific to this deploy. The pack holds the reusable mechanics. Same shape extends to other archetypes you build: a scanned_deployment adds an image-scanning boundary in the chain; a kubernetes_deployment swaps compose_deploy for a kubectl_apply boundary. The pack ships the building blocks; archetypes wire them.

What's next

Site Audit

wanderland.dev

oculus-view: fence: fence execute HTTP 404