Upgrading the bundled components

The gateway ships with third-party components around it: a directory, an identity provider, a secret store, a database, an object store, a mail trap and the monitoring pair. Every one of them runs from a container image that is pinned to a digest, and upgrading one is always the same two steps — rewrite the pin, re-run the role. What differs, component by component, is what the restart costs. This page is that list.

How a component is pinned

Each image reference in the shipped automation has the form tag@sha256:…: the tag is for the reader, the digest is the binding. A version tag alone does not bind — an upstream release can move a tag to new bytes without anything in this repository changing — so the digest is what the container engine pulls. The agreement between the places that name the same image is itself measured, by make ansible-image-pin-check.

The pin lives in the role's own defaults, one variable per component:

Component Pin variable
PostgreSQL isg_shared_store_postgres_image
OpenBao isg_shared_store_bao_image
RustFS (S3 object store) isg_shared_store_rustfs_image
OpenLDAP isg_directory_ldap_image
Keycloak isg_directory_idp_image
Mailpit isg_directory_mail_image
Prometheus isg_monitoring_prom_image
Grafana isg_monitoring_grafana_image
node_exporter isg_monitoring_node_exporter_image

An upgrade is therefore: change the variable, run site.yml with the --limit that selects the hosts the component runs on, and the role's handler restarts the unit. There is no upgrade playbook for these components — the one that exists, upgrade-rolling.yml, upgrades the ISG brokers themselves (drain, replace, verify, one broker at a time), not the components around them.

Before and after: the version report

Run the report before you change a pin and again after the role has run:

ansible-playbook -i inventory/<environment>.ini -e isg_env=<environment> \
  playbooks/component-versions.yml

It only reads. Per host it prints one line per container with three columns — the pin, the image actually running (name and digest), and the version the software reports about itself — and then one summary line per component for the whole fleet. Each line carries a verdict:

Verdict What it means
match the pin's tag is the measured version, and the running digest is the pin's digest
digest-match the pin carries no version tag for this software — a floating tag, a digest-only pin, or an image that tags its own version rather than the packaged software's — so the digest is the only claim and the version is printed for information
mismatch the running digest is not the pin's digest, or the tag is not the measured version
not_measured the container or the version command could not be measured; the reason is on the line

A mismatch makes the run fail — after the table and the summary are printed, so one run gives you the whole picture. A not_measured does not: a host that is down is not a version discrepancy.

For a before-snapshot of an upgrade — the pin already names the new version, the host still runs the old one — mismatch is the expected state. Add -e isg_cv_fail_on_mismatch=false: the report is printed (and written) the same way, and the run does not fail.

What an upgrade costs, component by component

OpenLDAP — the directory is rebuilt from the seed

Replacing the image recreates the container, and the directory is built again from the seed. The read-only accounts the gateway binds with are put back by the role; entries added while the directory was running are gone, and the brokers' bind drops for a short while during the restart. Measured twice, on 2026-10-08.

Treat it accordingly: if anything other than the seeded content lives in this directory, export it first.

OpenBao — sealed after every restart

A restart leaves the secret store sealed. This is not a failure mode to be worked around: the role says it out loud, in red, and stops rather than pretending the store is usable. Unsealing is a separate, deliberate step, and it has its own playbook — bao-init.yml. Plan the unseal into the same maintenance window as the upgrade; until it runs, anything that needs a secret from the store will fail.

PostgreSQL — a major version is a dump and a restore

The major version is part of the pin. The automation does not move a deployment across a major version: a new data directory is needed, and the data reaches it through a dump and a restore. From 18 on, the image also keeps its data in a different layout — the store's data directory is mounted as /var/lib/postgresql, and the cluster lives in its 18/docker subdirectory.

The role refuses to start an 18 image on a data directory in the old layout (PG_VERSION at its root): started there, Postgres would initialise a new, empty database next to the old files. Measured once (2026-10-11, a lab deployment, 16.15 → 18.6): a fresh backup, the unit stopped, the old directory renamed aside, an empty one restored from the dump with the new image, then the role — the row counts matched the backup's manifest. A new deployment starts on the pinned major directly and needs none of this.

A minor-version image swap on the existing data volume is expected to be uneventful — but that is a hypothesis, not a measurement: it has not been measured in this deployment. Back up the volume before you try it, and read the upstream release notes for the minor you are moving to.

Keycloak — the built-in database is on a volume

The identity provider runs in start-dev mode with its built-in database on the keycloak/data volume. Swapping the image therefore implies a database migration of that volume, performed by the new version on first start. Measured once (2026-10-11, a lab deployment): 26.3.5 → 26.8.0, five minor versions in one jump — the migration ran on the first start, the identity provider answered after about half a minute, and the realm, the client and the LDAP federation came through unchanged. One measurement, not a guarantee for every jump.

So: back up the volume first, then change the pin. If the migration goes wrong, the backup is the way back to the state before it. The files on the volume belong to the container's user, which on the host is a subordinate uid — read and restore them inside the user namespace, for example:

podman unshare tar czf <archive> -C <keycloak-home> data

From 26.8 on, Keycloak asks for the bind password again whenever the LDAP federation's connection URL or bind DN changes; a masked password is refused. The role's own path for this (the retransport switch) already sends the real password. A manual change in the admin console needs the password typed in again.

RustFS, Prometheus, Grafana, node_exporter and Mailpit

For these five the automation has no extra step: change the pin, run the role, and the unit restarts with the new image. Whether a given version jump changes the on-disk format of a component's data is not measured here — read the upstream release notes before a jump.

One of them deserves an explicit note: Mailpit is a test SMTP trap. It is there so that a deployment can see the mail the gateway sends without sending it; it is not a mail server for anything real, and nothing is lost when its container is replaced.

What the report does not tell you

The report does not ask upstream what the newest version is, and it makes no outbound call at all: the one version command that would check online by default, Mailpit's, runs with --no-release-check. Comparing a measured version against what upstream publishes is a separate step, on a machine you choose for it.

Monthly upstream check

That separate step is playbooks/component-upstream.yml. It runs on the controller only, asks each registry for the highest stable tag of every pinned component, and gives each one a verdict: current, behind or not_measured. A behind is not a failure — it is an input to a decision.

What runs, when, and where it writes. playbooks/upstream-schedule.yml installs a systemd user timer on the controller (the isg_upstream_schedule role). It fires on the first day of every month at 07:20, with up to 30 minutes of random delay, and Persistent=true brings a missed run back after the controller comes up. Each run writes the report as a JSON and a Markdown file into the isg-component-reports directory in the controller user's home (isg_upstream_schedule_report_dir). The timer needs no root, no token and no sudo password: every play in the check runs on localhost without become, and the role measures this on the tree the timer runs from before it installs anything.

cd ansible
ansible-playbook -i inventory/<environment>.ini -e isg_env=<environment> \
  playbooks/upstream-schedule.yml
journalctl --user -u isg-upstream-<environment>.service    # the runs

When a mail goes out, and to whom. When at least one component is behind, the run mails the Markdown table to the addresses in isg_cfg_upstream_notify_to, over the deployment's existing SMTP path (isg_cfg_smtp_host, isg_cfg_smtp_port, isg_cfg_smtp_from). Nothing behind means no mail, and the log says so in one line; so does an empty recipient list. An SMTP error turns the run red, naming the host and port — a mail that silently did not go out is exactly what the monthly check exists to prevent. The sender speaks plain SMTP without TLS or authentication; a real relay is the operator's to provide.

What to do with a behind. Do not upgrade one component the day its mail arrives. Collect the behind rows into one batch, then upgrade the batch together: change each pin to the new tag's index digest, take a version report before and after (above), and read the per-component costs on this page for anything that needs more than a restart.

The firewall on the SMTP path. On the directory host the SMTP port opens only to the brokers by default (isg_directory_firewall_client_sources); the mail now also comes from the controller, so its address goes into isg_directory_firewall_smtp_extra_sources. Without that entry the firewall rejects the controller (measured: "No route to host"), and the run fails red, naming the host and port.