Deployment¶
The Helm chart in
helm/ installs
Pubgrade and a MongoDB on Kubernetes or OpenShift, into any namespace, without
special rights. Install one release per namespace, as resource names are
fixed.
Installation¶
- Install kubectl and Helm.
- Create a namespace:
kubectl create namespace pubgrade - Copy
pubgrade/config.yaml, add your admin users with new tokens underendpoints.subscriptions.admin_users(see Configuration), and store it in a Secret:kubectl create secret generic pubgrade-config -n pubgrade --from-file=config.yaml=<your copy>. Secrets don't belong in values files, nor in the checkout, from where they would end up in Git or in images you build. - Install:
helm install pubgrade helm/ -n pubgrade --set pubgrade.configSecret=pubgrade-config --set hostName=<host name>
The service is exposed by a Route if the cluster serves Routes (OpenShift; TLS
at the router); on other clusters, enable an Ingress with
--set pubgrade.ingress.enabled=true, which requests a certificate from
cert-manager by default. See
helm/values.yaml
for all values. Pubgrade's pods run as any user ID in group 0, with no fixed
ID, as OpenShift assigns its own.
Security contexts¶
The security contexts of the pods and containers are values, in Helm's usual
form: podSecurityContext and securityContext under pubgrade,
builds.updater and mongodb. By default, the containers of Pubgrade and the
updater run as non-root, without privilege escalation or capabilities, and
with the container runtime's seccomp profile (RuntimeDefault); the pods, and
MongoDB, have none. Values you set are merged with these defaults; set a key to
null to remove it.
Some clusters set their own seccomp profile and reject pods that name one, e.g., CSC Rahti (OpenShift) since November 2026. There, remove the profile:
helm install pubgrade helm/ -n pubgrade --set pubgrade.configSecret=pubgrade-config \
--set hostName=<host name> --set pubgrade.securityContext.seccompProfile=null
or, in a values file:
With builds on, do the same for builds.updater.securityContext. An empty
map (seccompProfile: {}) doesn't remove the profile, as Helm merges it with
the default; seccompProfile.type: null does.
Up to chart 0.2.0, the security contexts were fixed in the templates. When
upgrading a release installed with such a chart, don't use
helm upgrade --reuse-values: it takes the old release's values instead of
the new chart's defaults, which have no security contexts, so the containers
would run without them. Pass your values again (-f or --set), or use
--reset-then-reuse-values (Helm 3.14 or later).
The images are elixircloud/pubgrade and elixircloud/pubgrade-updater on
Docker Hub. The chart of a release (attached to the GitHub release) uses the
images of that release (X.Y.Z); the chart in the repository uses dev,
which follows the branch dev. Pin X.Y.Z or sha-<commit> to control
upgrades. latest is the highest stable release (from 0.2.0 on; before, it
was an outdated image).
Configuration¶
Pubgrade reads its configuration from pubgrade/config.yaml in the image, or
from the Secret given by pubgrade.configSecret. The environment variable
MONGO_HOST takes precedence over db.host. Settings under endpoints:
subscriptions.admin_users: the users that can verify other users, each withname,uidanduser_access_token. At each start, the database is made to match this list: after changing the Secret, restart Pubgrade. Quote tokens that YAML would read as numbers (e.g.,"123456"): tokens that aren't strings match no request.repository.registration_enabled,user.registration_enabledandsubscriptions.registration_enabled(default: on): switch these off once repositories, users and subscriptions are set up. Further registrations then get a 403.subscriptions.notification_timeout(default: 10): seconds for each call to a subscriber, to connect and for the response. Publishing waits for this, so with both, keep it below the time limit of the Route or Ingress (e.g., 30 seconds by default on OpenShift).subscriptions.max_per_repository(default: 100): the most subscriptions a repository takes; each one gets its own thread when publishing.builds.enabled(default: off): see Builds.-
repository.id_charset,user.uid_charsetandaccess_token.charset(default: lowercase letters, digits,.and-): the characters of generated identifiers and access tokens, drawn with Python'ssecretsmodule. Each takes one of these forms:- a list of names of character sets (without quotes) and literal
characters, optionally in quotes:
[string.ascii_lowercase, string.digits, '.', '-']; - a string joining such names and quoted literal characters with
+, or listing them in brackets:string.ascii_lowercase + string.digits + '.' + '-'; - a single name or quoted string:
string.ascii_letters,'abcdef'; - any other string, as literal characters:
abcdef.
The names are
string.ascii_letters,string.ascii_lowercase,string.ascii_uppercase,string.digitsandstring.hexdigits; anything else that looks like a name (e.g., in quotes, misspelt, or withoutstring.) is an error. Literal characters are ASCII letters, digits,.,-and~; forrepository.id_charset, whose identifiers also name the Kubernetes pods of builds, only lowercase letters, digits,.and-. Each setting needs at least two characters. Generated values neither start nor end with.,-or a digit, so each setting needs at least one other character. Pubgrade checks these settings at start and stops with an error naming the setting if one can't be used. - a list of names of character sets (without quotes) and literal
characters, optionally in quotes:
Pubgrade compares the access tokens of requests with the stored ones in
constant time (hmac.compare_digest); a missing or empty token matches none.
Log messages name repositories, users, subscriptions, builds and pods by their identifiers; they contain no access tokens or other credentials. Failed calls to the Kubernetes API are logged with status and reason, and requests to GitHub that fail (e.g., no connection) with the type of the error, and those that GitHub doesn't accept with the status of its response.
Builds¶
By default, Pubgrade doesn't build images: a repository's CI builds and pushes
the image, and publishes it to Pubgrade (see Usage).
To let Pubgrade build images instead, set endpoints.builds.enabled in the
configuration and builds.enabled in the chart, which must match, with the
other values under builds. Publishing then also needs a location for the
image and a dockerhub_token. This adds an updater, a shared volume and rights
to manage pods in the namespace. Every 5 seconds, the updater reports build
pods that succeeded to Pubgrade, waiting up to 30 seconds to connect and
again for the response; a report that fails is logged and doesn't stop the
others. Each build pod names its repository (REPOSITORYID), so any
repository.id_length works; pods of earlier versions, without it, are
reported to the repository named by the first 6 characters of the build's
identifier. The volume needs a storage class with ReadWriteMany
(builds.volume.storageClass). Build pods run as root, so
builds don't work under OpenShift's default restrictions. With builds off,
requests that need them get a 403, as does
PUT /repositories/{id}/builds/{build_id}.
Builds are for trusted repositories only
With builds on, Pubgrade clones the repositories it is given and builds
their Dockerfiles in its own namespace, in pods that run as root and get
the credentials of the intermediate registry and the repository's access
token. Whatever a repository's Dockerfile does runs there. Register only
repositories whose code and maintainers you trust, run Pubgrade in a
namespace of its own, and switch off the registration of repositories
once they are set up (repository.registration_enabled). Where you can,
leave builds off and let each repository's own CI build, sign and push
its images, then publish them to Pubgrade.
Pubgrade clones the repository and checks out the branch (and the
commit_sha, if given) or the tag in head_commit. The clone's directory
is named after the last part of the repository's URL, without .git (e.g.,
tesk-api), or repository if that isn't a plain file name, so remote URLs
of other forms than https://host/owner/repo work too, e.g., of GitLab's
subgroups. Branches and tags must
be given by their exact names, and commit_sha as a commit hash (4 to 64
hexadecimal digits); revision expressions such as dev~1 aren't accepted.
If one of them is empty, isn't in the repository or (a short hash) isn't
unique, the request gets a 400 that says so (before: a 500). A build that
can't start, for this or another reason (e.g., a Dockerfile that can't be
read, which still gets a 500), is removed again, with its files: only
builds whose identifier the client got are kept. While the repository is
cloned, the build is listed as QUEUED. If only creating the build pod
fails (a 500), the build and its files are kept, as the pod may have been
created nonetheless.
Deleting a repository (DELETE /repositories/{id}) stops its builds: their
pods are deleted and their files removed, also the files left by finished
builds whose cleanup failed. A build started while the repository is deleted
is stopped and removed again, and its request gets a 404. If a pod or a
build's files can't be removed (the Kubernetes API gets 10 seconds per pod),
the request gets a 500; the repository, its builds and subscriptions stay
(pods and files removed until then stay removed), so the request can be
repeated.
Canceling a running build (DELETE /repositories/{id}/builds/{build_id}, see
Usage) stops it the same way: its pod is deleted
and its files are removed, and the build is kept with the status CANCELED.
If the pod or the files can't be removed, the request gets a 500, and the
build stays canceled; repeat the request to stop it again (it gets a 409
only once the build is stopped). Deleting the repository stops it too. A build canceled while it starts is stopped
once its pod has been created. When the updater reports a canceled build as
finished, it gets a 409: the build doesn't succeed, its image isn't signed,
no one is notified, and a pod that is left is deleted. Pubgrade records a
build as SUCCEEDED before it triggers the signing of its image (back to its
previous status if that fails); a build canceled from then on counts as
finished: the signing goes ahead, and subscriptions aren't notified unless
the notifications have started already. Finished builds are only marked;
their files, if their cleanup failed, are removed when their repository is
deleted.
Registries¶
Images can come from any registry that implements the OCI distribution API
(e.g., Docker Hub, Harbor, GitLab, Quay or GHCR). With builds off, Pubgrade
never contacts a registry: it passes the image name of the request,
including its registry host (e.g., harbor.example.org/project/app), to
the subscribers, which check for the image themselves (see the sidecar's
Deployment).
With builds on, build pods push each image to an intermediate registry, set
by the chart values builds.registry.host (default docker.io) and
builds.registry.namespace (e.g., a Harbor project, or a Docker Hub
organization, which Docker Hub needs): the image
harbor.example.org/project/app:1.0 goes to <host>/<namespace>/app:build-<build ID>.
Without the chart, set the environment variables INTERMEDIATE_REGISTRY_HOST
and INTERMEDIATE_REGISTRY_NAMESPACE. The credentials
are base64 of <user>:<password> (e.g., of a Harbor robot account that may
push), by default in the key intermediate_registry_token of
builds.existingSecret, or in builds.registry.existingSecret and
builds.registry.key. The setting
endpoints.builds.intermediate_registery_format of the app's configuration
(a path with {} for the image's name) still works, and takes precedence
over the chart values if it is set; without a tag in it, each build
gets the tag build-<build ID>, and with one (e.g., ttl.sh/{}:1h), the
image name gets a suffix derived from the build ID, so that each build has
its own image there.
The signing workflow (endpoints.builds.gh_action_path) then pulls the
image from the intermediate registry, signs it and pushes it to its final
name. It logs in to Docker Hub only, with the request's dockerhub_token:
with builds on, images are still published to Docker Hub, so the names of
the images to build must be Docker Hub names (e.g., org/app:1.0, not
harbor.example.org/project/app:1.0), and the intermediate registry must
be one that the workflow can pull from.
The build's record keeps the dockerhub_token only as long as the build
needs it: it is removed once the build's completion is recorded (after the
signing is triggered and its pod and files are removed), and when the build
is canceled. GET never returns it.
If GitHub can't be reached for the signing or doesn't accept it (any
response but 204, e.g., a 401 for a wrong GH_ACCESS_TOKEN or a 404
for a wrong gh_action_path), the build goes back to its previous status.
Then, as when the pod can't be deleted, the request gets a 500 and the token
stays: the pod's next report, 5 seconds later, triggers the signing again
and completes the build (files and pods that are gone already count as
removed). A signing that GitHub keeps refusing is thus requested again every
5 seconds, with an error in the log each time, until the setting is fixed
or the build is canceled.
A build whose pod was deleted, but whose completion then failed (e.g.,
Pubgrade stopped), keeps its token and isn't reported again; cancel it to
remove the token. Once a build is complete,
reporting it again changes nothing: its image isn't signed again, and no one
is notified again (the results of the notifications are stored with the
build, see Usage). Builds that earlier versions
recorded: canceled ones lose their token at Pubgrade's start; others keep it
until they are canceled or their repository is deleted, or, if earlier
versions recorded them as SUCCEEDED, until their pod reports them again:
they are then signed and their subscribers notified once more, as before.
Upgrading to API 2.1.0¶
Version 2.1.0 of the API completes the subscriptions API:
POST /subscriptionsneedstypeandvalue; without them, it gets a- Before, such subscriptions were stored, and every build of their
repository then failed to notify them. Stored subscriptions without
typeorvaluenow match no build (their notification is skipped): delete them and subscribe again. POST /subscriptionsandPOST /repositories/{id}/buildsstore only the top-level fields that the API defines; others are ignored instead of stored (and returned byGET). Fields withinimagesandhead_commitare stored as given. Subscriptions and builds stored before keep their fields, andGETstill returns them.GET /subscriptionsgives an empty list to a user without subscriptions, instead of a 404.DELETE /subscriptions/{subscription_id}also removes the subscription from the lists of its user and its repository, soGET /subscriptionsno longer lists it. Subscriptions deleted before stay listed for their user and repository: deleting them again removes them from both lists (the request gets a 404, as they are gone).- Verifying or unverifying a user changes only that flag, so it no longer undoes a subscription that the user registers or deletes meanwhile.
GET /repositories/{id}/builds/{build_id}gets a 404 for a build of another repository than{id}. The spec declares the 404 of the calls that can answer it.
Upgrading to FOCA 0.22 (API 2.0.0)¶
From November 2026, the image runs on FOCA 0.22 and Python 3.13 instead of FOCA 0.7 and Python 3.9. The chart and its values stay the same. What changes:
- Configuration: a configuration of your own (
pubgrade.configSecret) needs the changes of FOCA's migration guides; compare it withpubgrade/config.yaml. In short: removeserver.environmentandserver.use_reloader; giveapi.specsas in the image's configuration; replace the sectionexceptionswithmapping: pubgrade.errors.exceptions.exceptions; setserver.max_content_length. Unknown or removed settings stop the app at start, with an error naming them. The settings underendpointsstay as they are, but the character sets (repository.id_charset,user.uid_charset,access_token.charset) take only the forms listed under Configuration: names of character sets and literal characters (ASCII letters, digits,.,-,~; forrepository.id_charsetonly lowercase letters, digits,.and-), at least two of them, no other Python expressions and no other characters (e.g.,_, spaces,!, non-ASCII). The image's values are unchanged; a value that can't be used stops the app at start, with an error naming the setting. - Errors are problem details (see API), with
title,statusanddetailinstead ofmsgandstatus_code, and for requests that don't match the API's schemas, the invalid fields inerrors. - Timestamps are in UTC with time zone, and
finished_atof an unfinished build isnull. At each start, Pubgrade brings stored timestamps into this format (those without time zone count as UTC), as responses are checked against the API; so do responses with builds and subscriptions that the previous version writes during a rolling update. Until it is replaced, the pod of the previous version answers requests for unfinished builds started by the new one with a 500 (finished_atisnull). Other values (not written by Pubgrade) stay as they are, and responses with them fail with a 500, as do subscriptions whosecallback_urlis not a URI with scheme (e.g.,10.0.0.5:8080/update): subscribe again with a full URL. - Server: the image serves the app with Gunicorn instead of Flask's
development server, still on port 8080, with one worker and 16 threads
(
pubgrade/gunicorn_config.py; change Gunicorn's settings viaGUNICORN_CMD_ARGS, e.g.,--threads 32). Each thread serves one request at a time: a publish holds one until its subscribers answer (up to twicenotification_timeout, plus 5 seconds), and a slow client holds one while it sends its request. Raise--threadsfor many concurrent publishes, and serve Pubgrade behind a proxy that buffers request bodies (as ingress-nginx does by default). On shutdown, Gunicorn lets running requests finish for up to 28 seconds, within Kubernetes' default grace period of 30 seconds; with anotification_timeoutabove 10, a publish may be cut off then. Request bodies larger than 1 MiB are rejected. - Repositories:
PUT /repositories/{id}changes only the URL (and the build types, if given); before, the repository lost its builds and subscriptions.DELETE /repositories/{id}also deletes the repository's builds and subscriptions and stops its builds (see Builds); before, they were left behind. Unknown repositories get a 404 for both. A configuration of your own should add the index onrepository_idof the collectionsubscriptions(seepubgrade/config.yaml), which speeds up deletions and the limit of subscriptions per repository. - Build types (new, optional): repositories may declare
build_types, builds may name abuild_type, and subscriptions may ask for somebuild_typesonly (see Usage). Nothing needs to change: stored repositories, builds and subscriptions have none, so subscriptions get all builds, as before. Responses have these fields where they are set (the response ofPOST /repositoriesstaysidandaccess_tokenonly): clients that reject unknown fields need to accept them. - Builds:
DELETE /repositories/{id}/builds/{build_id}cancels a build (see Usage), and the status of builds has the new valueCANCELED: clients that checkstatusagainst a fixed list of values need to accept it. Completion reports of canceled builds get a - The intermediate registry can be set with the chart values
builds.registry(see Registries); before, only withendpoints.builds.intermediate_registery_format, which still works and takes precedence. Images whose name has a registry host or no namespace (e.g.,harbor.example.org/project/app,app) are built under their last name part,app, and the tagbuild-<build ID>(unlessintermediate_registery_formathas a tag; then the name gets a suffix derived from the build ID), so that builds of images with the same name no longer replace each other's image before it is signed; before, a registry host broke the build. The credentials of a Docker Hub intermediate registry are stored under Docker's key for Docker Hub, so that build pods can push there. Let running builds finish before the upgrade, and before changing the intermediate registry: Pubgrade works out the image to sign again when a build completes. - Image: it runs as user 1000 (group 0), and its app files are read-only; any user ID works, e.g., on OpenShift.
- MongoDB: FOCA 0.22 needs MongoDB 4.4 or later; the chart's MongoDB is newer.
Moving from chart 0.1¶
Chart 0.2 can't upgrade a 0.1 release, as its selectors and names changed:
- Remove the old release and install anew. MongoDB's volume has a new name and a newer major version, so its data doesn't carry over: register repositories and subscriptions again.
- Values were renamed:
pubgrade_webservertopubgrade,pubgrade_mongodbtomongodb,pubgrade_build_complete_updatertobuilds.updater,pubgrade_urltobuilds.pubgradeUrl, and the Ingress'surltohostName. - The configuration in the image has no admin users any more, and no debug mode: bring your own, with new tokens, in a Secret (step 3 of Installation).
- The Secret
pubgrade-secretis gone: with builds on, create your own (builds.existingSecret).
Subscribers¶
Deploy Pubgrade-Sidecar next to each Deployment that should follow new images, and subscribe its callback URL (see Usage).
Checking a deployment¶
After an install or upgrade,
tests/e2e/check.sh
checks the whole path from outside: it sets up a test Deployment with a
Pubgrade-Sidecar in a namespace of your current kubectl context, registers a
repository, a user and a subscription of the sidecar, publishes a prebuilt
image and checks that the sidecar rolls it out. Afterwards, also when a step
fails, it removes everything it created and names anything it couldn't
remove. It prints one line per step and exits with a non-zero status on
failure. It needs curl, jq and kubectl (or oc, with KUBECTL=oc).
Credentials come from the environment only, never from the command line:
export PUBGRADE_URL=https://pubgrade.example.org
export PUBGRADE_ADMIN_ID=<admin user ID>
read -rs PUBGRADE_ADMIN_TOKEN && export PUBGRADE_ADMIN_TOKEN # admin access token
tests/e2e/check.sh
The admin user is one of subscriptions.admin_users (see
Configuration); it verifies the test user. Registrations of
repositories, users and subscriptions must be on (the default). The check
publishes the way a repository's CI does with builds off (the default, see
Publish an image); with builds on, Pubgrade would
build the test repository instead, so the check doesn't apply: it fails when
Pubgrade can't clone the test repository, whose URL can't be cloned.
Further variables, all optional:
Variable Default
CHECK_NAMESPACE pubgrade-check-<run>, created and deleted; a namespace
you name must exist, and is kept: only the check's
objects (label pubgrade-check=<run>) are deleted
CHECK_NAME pubgrade-check-<run>, prefix of the names of the user
and the objects: <prefix>-target, <prefix>-sidecar
CHECK_CALLBACK_URL http://<sidecar Service>.<namespace>.svc:8080/deployment
(the sidecar's PUT /deployment)
CHECK_SIDECAR_IMAGE elixircloud/pubgrade-sidecar:0.2.0
CHECK_IMAGE rancher/mirrored-pause (on Docker Hub)
CHECK_OLD_TAG 3.9, the tag the test Deployment starts with
CHECK_NEW_TAG 3.10, the tag the check publishes
CHECK_REPOSITORY_URL https://example.org/pubgrade-check.git (only stored)
CHECK_TIMEOUT 300, seconds to wait for each rollout and each call
to Pubgrade
KUBECTL kubectl
Your kubectl context needs the rights of the role admin in that namespace
(to create, watch and delete Deployments, a Service, a ServiceAccount, a
Role, a RoleBinding and a Secret, and to read EndpointSlices and logs), and to
create and delete the namespace, unless you name an existing one. In a named
namespace, objects of the check's names (<prefix>-target,
<prefix>-sidecar) must not exist: the check fails then and leaves them
alone. The test pods run as non-root, without privilege escalation or
capabilities, but name no seccomp profile, as some clusters set their own:
namespaces that enforce the Pod Security level restricted reject them, and
the check fails with "didn't start". The sidecar checks that the new tag exists in the image's
registry, so the cluster needs access to Docker Hub, or set CHECK_IMAGE and
the tags to an image of a registry it can reach.
The default callback URL works if Pubgrade runs in the same cluster and may
reach the namespace: if a NetworkPolicy keeps it out, the check fails with the
notification failed. For a Pubgrade elsewhere, expose the sidecar's Service:
set up a Route or Ingress, with TLS, to the Service <prefix>-sidecar (port
8080) in a namespace of your own, then run the check with CHECK_NAMESPACE,
CHECK_NAME=<prefix> and CHECK_CALLBACK_URL set to
<sidecar URL>/deployment. Only one check at a time can use that prefix.