Why
A job runs in one container, so a test that needs a database, a queue, or a cache
has to bring the server into its own image. That means the image carries every
service the tests use, its client, and a startup script; a workflow cannot use the
upstream postgres image as it is. This is the largest remaining gap in #138 for
ordinary CI.
The work has to preserve the two things the runner already promises: a job with
network: none has no network, and a job is one container that cannot see the
host. A service is a second container, so the issue is as much about the boundary
as about the schema.
Current shape
ci/containerstarts one container per job, with--network none|bridge,--cap-drop ALL,--security-opt no-new-privileges, and the job tree as its one host mount.ci/agentresolvesjob.Networkbefore the image is pulled. Abridgejob is only leased to a runner started with--container-network bridge(#159, #160).ci/runspins the job’s fields in its row, so a queued job carries the values it was queued with.envandsecretsare JSON columns; services would follow the same path..vole/workflows/*.yamlhas noservices:field. The reference lists service containers under “What a workflow cannot express”.
Proposed design
Schema
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 ./...
A service is a named mapping. Job.Services is a slice of
Service{ID, Image, Env} in source order, so startup and logs are deterministic.
imageis a registry reference, notimage-file:. A service is an upstream container, not a build; a workflow that needs a custom service can publish an image. The reference is pinned with the run, and a digest is recommended for the same reason it is for jobs.envis literal, with the same bounds as a job’senv. No repository secrets: the job’s secret file is a mount in the job container, and a service has no job tree. A test credential belongs in the workflow; a real one is a separate design.- At most three services per job. A service is a process on the runner, not a declaration.
- The service name is a DNS label (
^[a-z][a-z0-9-]{0,31}$) and is the hostname a step uses.
Network
A job that declares services must also declare network: bridge. services: with
network: none, or with no network:, is a validation error. Otherwise the
service could only be reached by quietly giving the job a network it did not ask
for, which is the rule network: exists to keep.
The runner creates one user-defined network per attempt, named
vole-<runner-id>-<run-id>-<attempt> and labelled with the runner and run. The job
and its services join it; the job keeps the outbound access it asked for. Service
containers carry --network-alias <name>, so db:5432 is the address a step uses,
and two services can listen on the same port. No host port is published: that would
put the service on the runner’s network and beyond the job.
A bridge job is already only leased to a runner that advertises bridge, so no new
capability flag is needed. A local run defaults to bridge; with
--container-network none it refuses a service job before starting anything, the
same way it refuses a bridge job today.
Lifecycle
- Start the services after the job image is resolved and before the job’s steps. The job container starts after them, so DNS is ready for the first step.
- A service that is not running when the steps are about to start fails the job with the service’s output. A database that exits on a missing environment variable should not surface as “connection refused” ten seconds later.
- There is no health check in this slice. A step that needs a server to be ready
waits for it, as it would with a compose file. A
ready:command can be a follow-up if workflows ask for one. - Service logs are read before the service is removed and appended to the attempt
log under
=== service db ===, bounded per service, when the attempt failed, timed out, or was canceled. A successful run does not carry them. - Stop the job, then the services, then remove the network, on success, failure,
timeout, and cancel. A killed runner leaves the containers and the network; the
next start removes them by label after checking that the owning pid is gone, as
cleanupStaleContainersdoes for job containers. - Service containers get the same user, userns mode, capabilities, and resource limits as the job. They have no mount, no secrets file, and no credential.
/tmpis a tmpfs for a service too, so the postgres example works by pointingPGDATAat it rather than by running as root.
Pinning and display
servicesis pinned in the run row likeenv, and carried in the lease. A later edit to the workflow does not change a queued job.- The run page’s Environment card names the services (
db · postgres:17), and the attempt log begins with their names and images. The run record is how a reader knows what actually ran.
Scope
- Schema:
servicesonJob,Service{ID, Image, Env}, the three-service limit, name and env bounds, registry-only image, and thenetwork: bridgerequirement. - Validation reports
jobs.<id>.services.<name>.<field>for every problem and refuses unknown keys. - Pin services in the run row and lease payload; show them on the run page and in the attempt log header.
- Container: service argument list with the job’s isolation, no job mount, no
entrypoint override,
--network-alias, labels, and a/tmptmpfs. - Runner: create and remove the per-attempt network, start services before the job, fail on an early exit, attach logs, and clean up on every exit path.
- Local run passes the same network and refuses the same combination.
- Docs: a reference section, a postgres example in
workflows.md, the runner’s network and cleanup rules, and remove service containers from “What a workflow cannot express”. - Tests: parser and validator,
RunArgs, runs round-trip, agent lifecycle with a fake runtime (start order, early exit, failure logs, cancel, cleanup), and one real-runtime test with a small web server or postgres.
Open questions
- Which uid do services run as? The proposal keeps the job’s uid, so one container is as privileged as the other. A service image that insists on its own user would need a custom image; running it as root inside the container would weaken the boundary the job container has today. This is the main trust decision.
- Three services. Enough for a database, a queue, and a cache? The count is also a bound on how much a job can spend on the runner, because the resource flags apply per container.
- Logs on success. The proposal keeps service logs for failures only. A passing run that hides a warning from the service is the trade.
- Later, not now: a
ready:command,command:/args, service secrets, service artifacts, and more than one network.
Acceptance criteria
- A job with
network: bridgeandservices:runs, and a step reaches a service by its declared name and port. services:withnetwork: noneor nonetwork:is refused before anything is pulled.- A service that exits before the steps fails the job and its output is in the attempt log.
- Services and their network are removed after success, failure, timeout, cancel, and after a killed runner’s next start.
- A local run behaves the same, and refuses the same combination.
- The reference and
workflows.mddescribe the field and its limits.
Related
- #138 records the gap and the other workflow decisions.
- #159 and #160 added
network:and the runner capability it depends on. - #11 is the runner trust boundary this must preserve.
- #154 is the secret delivery path that services deliberately do not use.
- #161 and #162 added the job budget and cancellation that cleanup must follow.