A test that needs a database, a queue, or another server has had to bring it into
the job image, along with its client and a startup script. A workflow could not use
the upstream postgres image as it is. This adds services: to a job, so the
runner starts those containers beside the job and a step reaches them by name.
jobs:
test:
image: golang:1.27-bookworm
network: bridge
services:
db:
image: postgres:17
env:
POSTGRES_PASSWORD: test
PGDATA: /tmp/pgdata
steps:
- run: DATABASE_URL=postgres://postgres:test@db:5432/postgres go test ./...
What it does:
- Schema.
servicesis a named mapping on the job. The service name is a DNS label and the hostname a step uses. A service has an image and literalenvonly; it is not a job, has no checkout, secrets, or host mount, and at most three run beside one job. A registry reference is required;image-file:is refused. - Network. A job with services must declare
network: bridge. The runner creates one user-defined network for the attempt, starts the services with--network-alias, and starts the job on the same network. The job and its services are the only members, so names resolve and no host port is published. Withoutbridge, the push is refused; a service must not quietly give a job a network it did not ask for. - Lifecycle. Services start after the job image is resolved and before the first step. A service that exits before the steps fails the job with its output. The job stops before artifacts are read; services and the network are removed on success, failure, timeout, and cancel. A killed runner’s containers and networks are found by label on its next start.
- Logs. On a failed, timed-out, or canceled attempt, the last 256 KiB of each
service’s output goes into the attempt log under
=== service db ===. A passing run drops it. The runtime’s stdout and stderr share a locked tail buffer, so the two streams cannot corrupt it. - Pinning and display. Services are pinned in the run row and lease like the steps and environment, and the run page names them beside the image.
- Local run.
vole workflow runstarts the same services on the same kind of private network and refuses the same combination. Its fake-runtime tests cover start order, early exit, service logs, and cleanup.
Decisions worth reviewing:
- The job’s uid. A service runs as the job’s user, with the same capabilities,
user namespace, and resource limits. A service image that insists on root is not
supported; the postgres example points
PGDATAat/tmpand works as an unprivileged user. This keeps one container from being more privileged than the other. - No secrets. The job’s secret file is a mount in the job container, and a service has no job tree. A test credential is a literal in the workflow; real secrets stay with the job.
- No health check. A step that needs a server to accept connections waits for
it, as
pg_isreadydoes. This slice does not add a readiness command. - Resource flags are per container.
--container-memory,--container-cpus, and--container-pidsapply to each service too; the three-service cap is the bound. The docs say so.
Checks: vole workflow check and vole workflow run verify pass, including the
race test suite. I also ran the local command against a real runtime with
nginxinc/nginx-unprivileged:alpine and with postgres:17-alpine using the example
above; the step reached both by name, and no containers or networks were left
behind.
Closes #184.