Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

#184

P2: A job can start service containers beside it

closed · opened by keenan 5d ago

keenan opened this 5d ago

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/container starts 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/agent resolves job.Network before the image is pulled. A bridge job is only leased to a runner started with --container-network bridge (#159, #160).
  • ci/runs pins the job’s fields in its row, so a queued job carries the values it was queued with. env and secrets are JSON columns; services would follow the same path.
  • .vole/workflows/*.yaml has no services: 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.

  • image is a registry reference, not image-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.
  • env is literal, with the same bounds as a job’s env. 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 cleanupStaleContainers does 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.
  • /tmp is a tmpfs for a service too, so the postgres example works by pointing PGDATA at it rather than by running as root.

Pinning and display

  • services is pinned in the run row like env, 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: services on Job, Service{ID, Image, Env}, the three-service limit, name and env bounds, registry-only image, and the network: bridge requirement.
  • 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 /tmp tmpfs.
  • 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

  1. 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.
  2. 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.
  3. Logs on success. The proposal keeps service logs for failures only. A passing run that hides a warning from the service is the trade.
  4. Later, not now: a ready: command, command:/args, service secrets, service artifacts, and more than one network.

Acceptance criteria

  • A job with network: bridge and services: runs, and a step reaches a service by its declared name and port.
  • services: with network: none or no network: 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.md describe 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.

Comments (1)

keenan 5d ago

Implemented as change request #185 (01M6KSCBCBZMWGPK1SATZ81HSE), landing-ready and waiting for an approval from another account.

What landed against the design:

  • jobs.<id>.services with image and literal env; at most three, registry references only, and the service name is the network hostname.
  • The job must declare network: bridge. The runner creates one user-defined network per attempt, starts services with aliases, then starts the job on the same network.
  • Services start after the image resolves and before the first step. A service that exits early fails the job with its output. The job stops before artifacts; the services and network are removed on every path, and a killed runner’s leftovers are cleaned by label on the next start.
  • Failed, timed-out, and canceled attempts carry the tail of each service’s output under === service <name> ===; passing runs drop it.
  • Services are pinned in the run row and lease, and the run page names them.
  • The local run starts the same services on the same kind of network, and refuses the same combination.

The open questions are decided in the change: the job’s uid, no secrets for a service, no readiness command in this slice, and the runner’s resource flags apply per container. The postgres example works as an unprivileged user by pointing PGDATA at /tmp.

vole workflow check and vole workflow run verify pass, including the race suite. I also ran the local command against Docker with nginxinc/nginx-unprivileged:alpine and postgres:17-alpine; both were reached by name and left nothing behind.