Repository navigation
Releases: rhobuild/runpool
Release list
v1.3.0
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:943da33950688b4903c83507f4717d1466fe7aebb4ef73e910a68d302221ef74The 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[].filePermissionsis 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; andignore-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-owned0600mount. 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_PERMISSIONSsets 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-runnersAttached: 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
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:009fa48c7e28681b9778d8c0b719c53f96e3624d2bef126ca0ea06b6d527e849The 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
chronologicalvN. 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 --jsonand allowattempts listto continue through the full queue. status --jsonis nowv2. 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
beyondmemory.maxand 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 letserve
start at all; once running, the only account of it was a line in the
controller's log.runpool status --jsonnow carriesegress_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 endingUSER runnercannot 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:
stillcapsule_incompatible, sorunpool attemptsreads 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; andgovulncheck, 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.enabledwas 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...v1.1.0
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:ca22bcee93141da22e2c9406835668664522e501a22aa71c46422ef48f77a034The 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 —waitingfrom 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 --applyhands 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-runnersAttached: 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
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:6738ee84d0568ac363843f62e8d7786545c27f43bbb9cd8076ae35a743f467f2The 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[].capsuleImagetakes
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 ascapsule_incompatiblerather 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 adocker buildor adocker runthe 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.allowPrivateCIDRspunches 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...