Skip to content

Releases: rhobuild/runpool

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 02 Sep 22:48
v1.3.0
f1a2ab3

Changes since v1.2 make credential file permissions an explicit, reviewable
deployment choice while preserving the owner-only default.

Deploy this release

Point the Compose deployment at this controller digest and recreate
the container:

RUNPOOL_IMAGE=ghcr.io/rhobuild/runpool@sha256:943da33950688b4903c83507f4717d1466fe7aebb4ef73e910a68d302221ef74

The controller carries the capsule digest below stamped into it and
refuses any other, so there is one image to set. Read the
deployment guide
before a first installation, and
the runbook
before moving an existing one.

What changed

Security and deployment

  • Credential file permissions are an explicit deployment policy.
    credentials[].filePermissions is a ladder of four named policies,
    narrowest first: owner-only, the default, which refuses every group or
    other bit as before; allow-group-read; allow-world-read, for a platform
    that creates a world-readable mount; and ignore-mode-and-owner,
    which consults neither mode nor owner. Every policy above the default is
    warned by doctor and at startup. The two middle policies require the
    controller uid only when the file actually uses widened group or other
    bits, so every rung still accepts an operator-owned 0600 mount. A refusal
    names the narrowest policy that would accept the file. Independently of the
    selected rung, a credential must be a regular file no larger than 1 MiB.
    RUNPOOL_CREDENTIAL_FILE_PERMISSIONS sets the policy in Quick Start.

Provenance

Release-qualified commit f1a2ab328374b76d348cf4e7a010c1988e6cb290
Controller ghcr.io/rhobuild/runpool@sha256:943da33950688b4903c83507f4717d1466fe7aebb4ef73e910a68d302221ef74
Capsule ghcr.io/rhobuild/runpool/capsule@sha256:604f5301cfbc77fab6595c2ed6327cb680ef00a6f1300639b40b1f39b239d23a

Publication runs only after qualification passed against these exact
digests, so an attestation signed here says more than that GitHub
built it. Verify a digest before it reaches a deployment:

gh attestation verify oci://ghcr.io/rhobuild/runpool@sha256:943da33950688b4903c83507f4717d1466fe7aebb4ef73e910a68d302221ef74 \
  --repo rhobuild/runpool \
  --signer-workflow rhobuild/runpool/.github/workflows/release.yml \
  --source-ref refs/tags/v1.3.0 \
  --deny-self-hosted-runners

Attached: standalone linux/amd64 and linux/arm64 binaries with
checksums.txt, an SBOM for each image, and the qualification
record this release was authorized against.

v1.2.0

Choose a tag to compare

@github-actions github-actions released this 31 Aug 07:33
v1.2.0
2b6f8f3

Changes since v1.1 strengthen durable delivery, admission, qualification and
large-backlog operation. The status document advances to v2, the state
schema advances through forward-only migrations, and release evidence now
proves the exact cases, platform and candidate images it represents. The
runbook covers upgrade, rollback and the conditions that
need an operator.

Deploy this release

Point the Compose deployment at this controller digest and recreate
the container:

RUNPOOL_IMAGE=ghcr.io/rhobuild/runpool@sha256:009fa48c7e28681b9778d8c0b719c53f96e3624d2bef126ca0ea06b6d527e849

The controller carries the capsule digest below stamped into it and
refuses any other, so there is one image to set. Read the
deployment guide
before a first installation, and
the runbook
before moving an existing one.

What changed

Reliability and scheduling

  • Disk admission starts closed until a fresh measurement succeeds. Missing,
    stale or failed pressure measurements can no longer advertise provider
    capacity. Worsening pressure closes admission before persistence; recovery
    opens it only after the improved verdict commits.
  • Discovery credit follows the poll that owns it. Immutable allocation
    plans carry a generation and pool holder; an empty result can rotate only
    its own current holder, and capacity transfers revoke the previous holder
    before granting the next one.
  • The discovery credit no longer waits on a busy holder. A binding
    holding the credit that found work kept it until it happened to drain and
    poll empty, which nothing bounds — and every other silent binding in the
    tier announced zero to the broker for the duration. The credit now moves
    to the next silent binding the moment its holder gains demand or work,
    and the thaw after a disk-pressure hold re-checks the pointer, since
    bindings keep gaining work while held.
  • Delivery fingerprints use an unambiguous canonical encoding. New rows use
    length-prefixed SHA-256 input. Historical rows retain the delimiter encoder
    named on their row, so exact redelivery remains idempotent after upgrade.
  • Durable internal formats have semantic names. Delivery keys, configured
    binding keys and fingerprint encodings identify their contents instead of a
    chronological vN. The migration preserves row ids and existing digest
    bytes.

Scale and operations

  • Allocation publishes one immutable plan per mutation. Batched
    water-filling replaces credit-by-credit scans, and concurrent readers share
    the same generation.
  • Scheduling reads bounded ready batches. A large durable backlog is not
    materialized merely to admit the few attempts current capacity can serve.
  • Operational attempt lists are paginated. Stable cursors and totals bound
    status --json and allow attempts list to continue through the full queue.
  • status --json is now v2. The binding identity field is
    configured_binding_key, matching its source in operator configuration;
    persistence column names remain private.

Qualification

  • Every qualification boundary emits structured evidence. The assembler
    rejects missing or skipped cases, mismatched commits, platforms and image
    digests, and evidence from the wrong suite.
  • Kernel resource claims are exercised independently under pressure. One
    live capsule contract reaches the gateway PID ceiling, reaps the pressure
    processes and proves the relay recovers. A fresh capsule observes swap use
    beyond memory.max and attributes an inner workload's OOM to the aggregate
    capsule cgroup, so PID exhaustion cannot invalidate the memory evidence.
  • Platform evidence is emitted by a typed collector. Release qualification
    reads Docker through its API and serializes host facts with Go's JSON encoder;
    shell remains responsible only for workflow orchestration.
  • Controller crash adoption is a real SIGKILL. The end-to-end workload
    kills the active controller, starts a successor on the same state and proves
    adoption, completion, cache isolation and ownership-safe cleanup. A portable
    hosted leg keeps the regression executable; the exact reference-host leg is
    the release authority.

Isolation and egress

  • An egress sandbox that closed itself says so. Rediscovery runs every
    five minutes, and a pass it cannot complete closes every gateway on the
    host to all egress — the right answer to a policy that cannot be shown to
    be current, and also every running job losing its network at once, for as
    long as discovery keeps failing. The same failure refuses to let serve
    start at all; once running, the only account of it was a line in the
    controller's log. runpool status --json now carries egress_sandbox
    with the last pass's time and, when it failed, why.

Operations

  • A release page says what changed. The body carried the qualified commit
    and the two image digests and nothing else, so the page every reader reaches
    a version from answered what exactly this is and never answered what is in
    it. It now opens with the changelog entry for the tag — the same bytes the
    gate checked, carried from the step that checked them rather than read a
    second time — and follows with the digest to deploy, the grouped detail, and
    the provenance and the command that verifies it.
  • A capsule that died before it could explain itself now carries what it
    said.
    An operator's own capsule image is built by deriving from the
    published one, and the published one stays root deliberately: the
    supervisor is PID 1, boots the inner Docker daemon, and drops the runner
    to uid 1001 itself. A derived image ending USER runner cannot write the
    root-owned control surface the launcher mounts for it — so it cannot write
    the abort that would have said so either, and every attempt on that tier
    was held with an exit code and the words "the capsule image and this
    controller are not a pair", which sends an operator to re-check a digest
    that was correct. The reason was in the container's log the whole time,
    and the refusal now quotes it — in the controller's log, where the
    refusal is reported. What the held attempt itself records is unchanged:
    still capsule_incompatible, so runpool attempts reads as before.
  • A step that fetches over the network is tried again when it fails.
    Four release cycles were lost to services answering badly and nothing
    asking twice — a digest verification that could not reach Docker Hub, a
    base image fetched through a 502, a module proxy that reset mid-download,
    and a push accepted layer by layer and then called an unknown blob — each
    costing a manual re-run, one of them after the tag was already cut. Every
    build, push, pull and index assembly goes through the retry, and so does
    the module download each job now does once, so the builds and the tests
    that follow it work from a warm cache. Three reaches are still asked
    once, and naming them is the point: a step that is an action rather than
    a command — the SBOM scan and the attestations — which is not a command
    line to wrap; docker login, whose token arrives on a pipe the first
    attempt consumes; and govulncheck, which fetches its vulnerability
    index on every run and would, if retried, print two spurious attempts
    over a real finding.
  • A binding that cannot persist what its provider hands it no longer
    reports as healthy.
    The poll that carries a message records contact when
    it succeeds, and a delivery that could not be persisted was left for
    redelivery — which arrives through another poll, which records contact
    again. A binding wedged that way refreshed its own health forever while
    nothing it was offered ever became an attempt, so the work was not queued
    either: it read as a binding in perfect health from every angle an
    operator has. The failure is now recorded, as the acquisition failure
    beside it already was.
  • The absence of a metrics endpoint is a decision, and the runbook now
    says what to watch instead.
    observability.metrics.enabled was refused
    with the word "yet", which is a promise nobody had a date for. There is no
    endpoint and there will not be one at this scope: runpool status --json
    is the machine-readable account, versioned for exactly this, and deciding
    when a person has to look belongs to the host. The runbook's new "Watching
    an instance" names the ten conditions that need one and gives a command
    that prints a line per condition and nothing when there is nothing to look
    at. The field is still accepted — configuration parsing is strict and the
    shipped example writes it, so removing it would fail the startup of every
    deployment that copied that example — and removing it would be a
    major-version break, which classifies the change rather than schedules it. See
    the decision record.

Provenance

Release-qualified commit 2b6f8f314a8f4a725acf91a152a38e5fc8b2360f
Controller ghcr.io/rhobuild/runpool@sha256:009fa48c7e28681b9778d8c0b719c53f96e3624d2bef126ca0ea06b6d527e849
Capsule ghcr.io/rhobuild/runpool/capsule@sha256:e21bb40c9a4990ddeb49ff5bdc14c5a05b2966fe04ee547646429a771e53129b

Publication runs only after qualification passed against these exact
digests, so an attestation signed here says more than that GitHub
built it. Verify a digest before it reaches a deployment:

gh attestation verify oci://ghcr.io/rhobuild/runpool@sha256:009fa48c7e28681b9778d8c0b719c53f96e3624d2bef126ca0ea06b6d527e849 \
  --repo rhobuild/runpool \
  --signer-workflow rhobuild/runpool/.github/workflows/release.ym...
Read more

v1.1.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 03:57
v1.1.0
42b7d8b

A capsule adopted across a controller replacement is read under its own
protocol, a held attempt is resolved without stopping the controller, and a
registry that blinked no longer costs an attempt. The schema is unchanged, so
an installation moves by repointing the image and recreating the container;
the runbook names the commands and what to do if it has to
go back.

Deploy this release

Point the Compose deployment at this controller digest and recreate
the container:

RUNPOOL_IMAGE=ghcr.io/rhobuild/runpool@sha256:ca22bcee93141da22e2c9406835668664522e501a22aa71c46422ef48f77a034

The controller carries the capsule digest below stamped into it and
refuses any other, so there is one image to set. Read the
deployment guide
before a first installation, and
the runbook
before moving an existing one.

What changed

Isolation and execution

  • A capsule adopted across a controller replacement is read under the
    protocol it declares, not the one this build speaks.
    Both bumps this
    protocol has had moved what an existing word means, and a word read under
    the wrong version is understood rather than refused — waiting from an
    older supervisor reads as proof the runner never started, and returns to
    the queue an assignment its capsule is forking a runner for. A capsule
    whose protocol this controller does not speak now holds its attempt for a
    person. Its exit code is still trusted from every version, and is frozen:
    it is the only account a stopped capsule leaves. See
    the ADR.

Operations

  • Resolving a held attempt no longer stops the controller. runpool attempts resolve --apply hands the decision to the controller serving
    that state directory, which applies it; with none running it writes
    directly under the lock as before. One command either way. Every write
    still belongs to whoever holds the lock — the decision travels to it
    rather than around it — and the audit record names the operator who
    decided, never the controller that carried it. See
    the ADR.
  • A pull the registry refused in a way it might not repeat is tried again,
    up to three times, instead of spending an attempt's retry budget on a
    blip. An answer that will not change — an unpublished reference, a
    credential that does not carry, a malformed reference — is not retried.

Provenance

Release-qualified commit 42b7d8b2d7eaf1aa65b88937cea3661ece859d35
Controller ghcr.io/rhobuild/runpool@sha256:ca22bcee93141da22e2c9406835668664522e501a22aa71c46422ef48f77a034
Capsule ghcr.io/rhobuild/runpool/capsule@sha256:35f57c69d1ffd0efd3a5bf9f0dd4b108ae40405694d6ba503d6cfb595dee6dc0

Publication runs only after qualification passed against these exact
digests, so an attestation signed here says more than that GitHub built
it. Verify a digest before it reaches a deployment:

gh attestation verify oci://ghcr.io/rhobuild/runpool@sha256:ca22bcee93141da22e2c9406835668664522e501a22aa71c46422ef48f77a034 \
  --repo rhobuild/runpool \
  --signer-workflow rhobuild/runpool/.github/workflows/release.yml \
  --source-ref refs/tags/v1.1.0 \
  --deny-self-hosted-runners

Attached: standalone linux/amd64 and linux/arm64 binaries with
checksums.txt, an SBOM for each image, and the qualification record
this release was authorized against.

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 27 Aug 20:27
v1.0.0
6d356eb

The first release. It publishes only what its qualification proved for this
exact commit and these exact image digests: the live and end-to-end suites on
the reference host, the upstream provider contracts against real fixtures, and
the artifact checksums. The gates it must clear, and the evidence each one
leaves, are in release readiness.

This section records the product delivered by the first release, grouped
by its public and operational effects.

Deploy this release

Point the Compose deployment at this controller digest and recreate
the container:

RUNPOOL_IMAGE=ghcr.io/rhobuild/runpool@sha256:6738ee84d0568ac363843f62e8d7786545c27f43bbb9cd8076ae35a743f467f2

The controller carries the capsule digest below stamped into it and
refuses any other, so there is one image to set. Read the
deployment guide
before a first installation, and
the runbook
before moving an existing one.

What changed

The first release documents every surface it publishes. Expand for the full entry.

Isolation and egress

  • A job runs in one capsule: supervisor, runner and the job's own
    Docker daemon in a single container under one aggregate cgroup.
  • The egress gateway always runs the capsule this build ships.
    Extending the image a tier's jobs run in does not replace the container
    that applies the network policy confining them.
  • A tier may name its own capsule image. tiers[].capsuleImage takes
    a digest-qualified image built from the published capsule, so a
    deployment can add packages and toolchains to its jobs without a fork.
    The controller reads the protocol version the capsule declares before
    handing it anything and refuses one it does not speak; that attempt is
    held for review as capsule_incompatible rather than retried, because
    the next attempt would launch the same image. That version is read
    before readiness is waited on, so an image that is not this build's
    half of the protocol is refused in about a second rather than after a
    readiness deadline that was never going to be met. runpool status
    reports the image each tier runs, because a replaced capsule is outside
    the configuration the release gates observed.
  • A prepared capsule has a daemon that answers. The state the
    controller delivers a credential on is written by the supervisor only
    once the job's own Docker daemon is up, so readiness is something the
    capsule proves rather than something it announces on the way to
    proving it.
  • An assignment is requeued only on proof that no runner ever
    started.
    A capsule that has accepted a start authorization says so
    before the authorization lands, and keeps saying it until the runner is
    forked or the attempt is abandoned. Without that the whole preamble —
    reading the credential bundle, materializing it, removing it, forking —
    answered with the same state as a capsule holding an unstarted runner,
    so an authorization whose call returned an error after taking effect
    would put the assignment back in the queue while the capsule was
    handing it to a runner, and it would run twice. The controller now
    holds such an attempt for a person, because at that moment neither
    answer is available — and an authorization that could not be written at
    all says so, so an assignment nothing ever started is still simply
    served again.
    That proof outlives the pass that took it. Cleanup can fail against a
    wedged daemon, and the retry is a different pass with nothing measured:
    it either measured nothing or found the capsule a measurement would
    have come from already gone, which is the one this cleanup is removing.
    What a serving establishes is kept with its lease, so the retry reads it
    back rather than settling a job that never ran as one that did. A later
    measurement that does establish something still wins, because the
    accounts arrive in order — the daemon's, then the capsule's own, then
    the provider's.
  • Under the restricted network profile a capsule has no route out.
    Its only egress is a per-capsule gateway that resolves and connects
    on its behalf under a default-deny policy, which is also the DNS
    rebinding defence.
  • The gateway is inside the lease's budget. The tier is split
    between capsule and gateway under one parent cgroup, so the two
    together are the tier and never more.
  • Policy changes are classified. A restriction that cannot be
    installed closes the affected gateway; discovery failing at all
    closes every gateway. The previous policy is never treated as a safe
    fallback. A policy counts as in force only once it has reached every
    gateway that could be named, so a pass that could not name one leaves
    the books where they were and the next pass installs the change
    instead of comparing it against itself.
  • A revoked address stops being reachable, transfers under way
    included.
    A destination the policy no longer allows is checked when a
    connection is dialled, and the pool a job keeps warm never dials again
    — the kernel does not intervene either, since the ruleset accepts
    established traffic ahead of every reject. A policy that moves
    therefore retires the pool it authorised rather than closing the
    connections that happen to be idle in it. Retiring the pool does not
    reach a transfer already running: the connection carrying it is left
    alone, and a large download can hold one for as long as the job likes.
    So both shapes the relay serves re-check the address they are joined to
    while they run — a CONNECT tunnel and a plain HTTP transfer alike — and
    the longest a revoked destination keeps flowing is one poll interval,
    whatever it is carrying. A transfer stopped that way reaches the job as
    a failed read rather than as a complete response with a short body:
    once the status line is out there is nothing left to say "this was
    cut" except how the connection ends, so it ends without the terminator
    a client would otherwise read as the end of the data.
  • An install takes effect whenever it lands. Whether a new policy is
    in force is decided by the document's contents rather than by its
    modification time and size. Two documents of equal length installed
    inside one clock tick carry the same modification time, so deciding on
    that pair meant the second never reached the relay at all: the
    tightening would have been reported as installed and silently not
    applied. A document larger than a policy may be is refused rather than
    cut down to the limit, since a cut one can still parse as a policy
    nobody wrote.
  • The two policy installers cannot overwrite each other. A reload
    and an emergency close arrive as separate processes into the same
    container, so the install is serialized by a lock the kernel holds and
    publishes through a private temporary file: neither a lost close nor a
    spliced document the relay would refuse to read.
  • The relay's CONNECT port set, header handling, parser strictness and
    every concurrency and size bound are explicit, tested and fuzzed.
  • A tunnel ends when both of its directions do. A client that shuts
    down its write side once its request is out has half-closed, not hung
    up: the relay forwards that half-close and keeps carrying the reply,
    so the response arrives whole. And the idle bound belongs to the
    tunnel rather than to each direction, so a large upload or a slow
    download — silent one way for its whole duration — is not cut off
    while it is streaming.
  • Containers a job starts take the same relay. A daemon does not pass
    its own environment to what it runs, so the capsule's proxy did not
    reach a docker build or a docker run the job issued — and under the
    restricted profile those have no route out either. The supervisor
    writes the proxy into the runner's Docker client configuration, which
    is where the client injects it into every container and build it
    creates.
  • An operator allowance is bounded by what it reopens.
    network.allowPrivateCIDRs punches holes through the built-in deny
    set, so an entry broader than a withheld range would reopen that
    whole range as a side effect; a policy carrying one is refused. The
    rule belongs to the policy rather than to the configuration file, so
    the live reload channel is held to it too.
    An entry naming what no connection reaches — loopback, multicast,
    broadcast, the unspecified address — is refused for the opposite
    reason: it cannot take effect, and accepting it would leave an allow
    rule in the rendered firewall while the gateway refused every request
    through it. Link-local is not in that group: it stays denied by
    default, because a cloud instance keeps its own credentials there, and
    an allowance naming one address in it now reaches that address rather
    than being accepted and quietly ignored. One address is the whole of
    what it can reach: an entry covering more of the range is refused,
    which the rule above does not do for it, since the baseline withholds
    link-local as a single range and an entry of exactly that is broader
    than nothing.
    Both lists are also held to being written as IPv4. An address in the
    v4-in-v6 form renders into the ruleset and matches nothing at decision
    time, which is the same split arrived at through notation.
    Public prefixes are accepted because the profile already permits the
    public internet — which is how a capsule reaches a service on the
    host's own public address, an address the runtime deny set withholds
    from facts static validation cannot see.
  • A capsule runs under the deny set in force, not the one its launch was
    handed.
    A policy change reaches the gateways that exist when it is
    installed, and a gateway still being created is not...
Read more