updex is a Go SDK and CLI for managing systemd-sysext images. It provides the url-file transfer functionality of systemd-sysupdate on systems such as Debian Trixie that do not ship that tool. Use the CLI to manage extensions or import github.com/frostyard/updex/v2/updex from Go.
- Enable, disable, check, and update groups of sysext images using standard
.featureand.transferfiles. - Discover named systemd-sysupdate components alongside the legacy default directory.
- Browse and install extensions from configured sysext catalogs.
- Verify SHA256 hashes and, by default, GPG signatures on
SHA256SUMS; retry transient network failures and decompress xz, gz, or zstd images. - Retain configured versions and stage automatic updates through a systemd timer.
- Produce JSON output for scripts.
Download the latest CLI package for your system from the GitHub releases page:
| System | Release artifact |
|---|---|
| Debian/Ubuntu | frostyard-updex_<version>_amd64.deb or frostyard-updex_<version>_arm64.deb |
| Fedora/RHEL | frostyard-updex-<version>-1.x86_64.rpm or frostyard-updex-<version>-1.aarch64.rpm |
| Alpine | frostyard-updex_<version>_x86_64.apk or frostyard-updex_<version>_aarch64.apk |
| Other Linux distributions | updex_<version>_linux_amd64.tar.gz or updex_<version>_linux_arm64.tar.gz |
Download checksums.txt from the same release and verify the downloaded
artifact before installing it:
sha256sum --ignore-missing --check checksums.txt
gh attestation verify <downloaded-artifact> --repo frostyard/updexThe checksum detects corruption or truncation; the GitHub build-provenance
attestation binds the artifact to a tag release built by this repository's
workflow, rather than trusting only a checksum served alongside it. The
GitHub CLI is needed for attestation verification;
gh attestation verify also works on checksums.txt.
The packaged CLI needs no Go toolchain. It needs a systemd-based Linux system
with systemd-sysext; commands that modify system state require root.
The packages for deb, rpm and apk depend on erofs-utils >= 1.8.5. A
standalone binary from the tarball needs it on the host when a configured
sysext ships a compressed EROFS image: updex reads that image's
extension-release file with dump.erofs from erofs-utils, without mounting
the image, because the pure-Go reader handles uncompressed images only. All
frostyard sysexts are uncompressed, so an uncompressed-only setup needs
nothing. Without a new enough dump.erofs, an update of a compressed sysext
fails closed: nothing is linked, and the error says
erofs-utils >= 1.8.5 is required to read compressed EROFS images; install erofs-utils. See the
packaging and maintainer guide.
With Go 1.26.10 and make, run make build to
create build/updex, or make install to put the binary in GOPATH/bin.
Building and unit testing do not require systemd. See
CONTRIBUTING.md for the development workflow and the
SDK API reference to use the Go library.
Configured .feature and .transfer files are read from /etc, /run,
/usr/local/lib, and /usr/lib under sysupdate.d/ and discovered
sysupdate.<name>.d/ directories. Use updex components to see named
components. See the configuration reference
for file formats, search precedence, and examples.
updex features list # Show features and their origins
sudo updex features enable docker # Enable for the next update
sudo updex features enable docker --now # Enable and download now
sudo updex features disable docker # Stop future updates; stays merged until --now
sudo updex features disable docker --now # Unmerge and remove images now
sudo updex features disable docker --now --force # If currently merged; reboot required
sudo updex features update # Install newest versions
sudo updex features update --no-vacuum # Keep older versions
sudo updex features rollback docker # Link the previous staged version (next boot)
sudo updex features rollback docker --to r4 --now # A given version, applied by a refresh
sudo updex features rollback --clear docker # Delete the rollback record
sudo updex --dry-run features update # Preview without modifying state
updex features check # Check for updates (read-only)
updex components # List discovered named components
updex features list --component=docker # Inspect one named component
sudo updex features update --component=dockerWithout --component, features commands use the union of the legacy default
directory and discovered named components. The --component flag applies to
every features subcommand and cannot be combined with -C, --definitions,
which instead reads one explicit directory and bypasses discovery.
Features= and RequisiteFeatures=: a transfer is active when its
Features= is empty or at least one listed feature is enabled (OR), and every
feature in its RequisiteFeatures= is enabled (AND), as in
sysupdate.d(5). A feature that does not exist, is disabled or is masked is
not enabled. The feature commands act only on active transfers, and say which
ones they left alone:
features updateandfeatures checkskip an inactive transfer: nothing is downloaded, linked or vacuumed, and the output shows<feature>/<component>: inactive: waits for feature(s) lodge(orneeds one of features: maild; JSONinactive).features enable maild --nowinstalls the transfers that are active withmaildenabled and reports, without downloading, those that still wait for another feature. Enabling that feature later (enable lodge --now) installs them.features disable a --nowremoves only the transfers thatano longer activates. A transfer that another enabled feature in itsFeatures=keeps active (Features=a b, withbenabled) is kept, with its link and images, and reported as<component>: kept: still active through feature(s) b(JSONkept). When nothing is left to remove, there is no unmerge and no refresh.
Refresh failure: If the final systemd-sysext refresh fails after
enable --now, disable --now, or update, updex reports completed steps
and exits non-zero (refresh_error in JSON). After enable --now (and
rollback --now), reboot: a plain systemd-sysext refresh runs no
systemd-sysusers, so it can merge the new sysext without its accounts. After
disable --now, the remaining extensions may stay unmerged: run
systemd-sysext refresh manually or reboot (their accounts already exist).
Accounts and paths: with --now (and update without --no-refresh),
updex runs systemd-sysusers and systemd-tmpfiles --create for the merged
sysexts before the service manager reloads, so a sysext's own accounts and
directories exist at once, as after a reboot. A systemd-sysusers failure
fails the refresh with a message that the images are merged and a reboot
finishes the activation; a systemd-tmpfiles failure only prints a warning.
Older than systemd 255 gets the accounts after the merge.
Check errors: If a component's manifest cannot be fetched or verified,
features check reports UPDATE=error (JSON: error), continues reporting
healthy components, and exits non-zero. Do not interpret an errored row as
"up to date."
What "current" means: features check compares the newest available
version with the version the systemd-sysext link in /var/lib/extensions
points to, because that link is the image systemd-sysext merges. If no usable link
exists it falls back to the staged image. A link still on an older image
therefore shows an update, and features update repoints the link without
downloading again and reports that a reboot activates it. The JSON field
current_version keeps this meaning.
Running and linked versions: features check and features list show,
for each transfer, two more versions:
RUNNING(JSONrunning_version): the version merged now, read from the.systemd-sysextmetadata that systemd-sysext keeps under/usrand/opt.-(empty in JSON) means the image is not merged;unknownmeans it is merged but the metadata records no version.LINKED(JSONlinked_version): the version that/var/lib/extensions/<name>.<ext>names, which the next boot merges.-(empty) means there is no usable link (absent, dangling, or foreign).
When LINKED differs from RUNNING, an update is pending a reboot. The JSON
field pending_reboot is true and the text output ends with a
PENDING REBOOT: ... line that names each transfer (docker: r1 -> r2;
not merged when nothing runs). A host that cannot tell (RUNNING is
unknown, or the state could not be read) is not marked. features check
puts the two columns between CURRENT and NEWEST; features list appends
them after TRANSFERS and adds a transfer_status array to each feature in
JSON (component, running_version, linked_version, pending_reboot,
error). A feature with several transfers shows one value per transfer in
the order of TRANSFERS, for example 2.0.0, -, and error replaces both
values of a transfer whose state could not be read (the rest of the listing
still prints; features check reports it as that row's error).
Refresh warning: features update (without --no-refresh),
features enable --now and features disable --now run
systemd-sysext refresh, which activates every linked-but-not-merged sysext on
the host, not only the ones you asked about. Before the refresh, updex warns
and lists the others (systemd-sysext refresh will also activate 1 sysext(s) that are linked but not merged: beta: 1.0.0 -> 2.0.0). It also lists the
sysexts that are merged but have no link, because the refresh unmerges them
under their running workload (… will also deactivate 1 sysext(s) that are merged but not linked: beta: 1.0.0 -> not linked). The JSON result carries
the same list as pending_activations (component, running_version,
linked_version, and deactivate: true for a deactivation). For update, the enabled features are the run's own, so the
list holds transfers that are not part of an enabled feature, such as a disabled
feature whose link still names a newer image. With --dry-run it says what the
refresh would activate. With --no-refresh nothing is listed, because nothing
is refreshed. The warning does not prevent the activation. It sees only links
of transfers that updex's definitions describe.
A feature's transfers update together: features update (and
features enable --now) downloads every transfer of a feature first and links
them all only when every download succeeded. If one fails, the feature keeps
all its old links, the images already downloaded stay on disk for the next run,
and the table shows the failure on that transfer and
not linked: another transfer of feature <name> failed on the others. Other
features still update. If a link fails part-way, the links already moved are
put back.
Rolling back: updex features rollback <name> links every transfer of
a feature to an older version that is still staged on disk, and records the
version it rolled back from as rejected. By default each transfer goes to its
own newest staged version older than the one its link names; --to VERSION
sets one version for all of them. If any transfer has no such version on disk
(or no link), nothing changes and the command fails with, for example,
version r4 of docker is not on disk. The links move together: if one fails,
the others are put back. Without --now the rollback takes effect at the next
boot; with --now it takes effect through a systemd-sysext refresh (the
warning lists other sysexts that the refresh activates too; --no-refresh
skips it). --dry-run previews and needs no root.
The record is /var/lib/updex/rollback/<feature> (JSON, one rejected version
per transfer). While it exists, updex features update and the timer link
nothing at or below the rejected version and download nothing for that
feature: the run reports held by rollback: rejected <version> and exits 0.
When a newer version is published, the next update downloads and links it
(all transfers of the feature together) and deletes the record. A feature is
held as a whole: if one transfer has a newer version and another does not,
neither moves. updex features rollback --clear <name> deletes the record
without changing a link (a missing record is not an error); the next update
then links the newest version again. If the record cannot be read (malformed,
not a regular file), updates link nothing for that feature and report an
error until you fix or clear it. The vacuum keeps the rejected version while
the record exists. features check shows held (rejected <version>), or
held (by rollback of <feature>: …) when another component of the record is
the reason, in the UPDATE column (JSON rollback_rejected and
rollback_held_by), and features list ends with a HELD BY ROLLBACK: line.
A transfer can belong to more than one feature (Features= or
RequisiteFeatures= in its .transfer file). The hold covers every
feature that shares a transfer it acts on (an active transfer, see above):
features update, features enable --now, features check and
features list read the rollback records of all of them, whether or not a
feature is enabled. A record that applies only to a transfer that is not active
for a feature does not hold that feature, and a rollback records only the
feature's active transfers (it keeps an earlier rejection of a transfer that is
inactive at that time). When several records reject the same
transfer, the highest rejected version applies. If one of those records cannot
be read, updates link nothing for every feature that shares the transfer and
name the feature whose record failed.
While a rollback record of feature G exists, only G advances the transfers its
record names: G's transfers link all or none, and G's record is deleted only
after that link. Any other feature that shares such a transfer is held as a
whole instead: nothing is downloaded or linked for it, features update exits
0, and the message says held by rollback of grp: it advances only when grp updates (or run 'updex features rollback --clear grp'). So the all-or-none
guarantee holds in every processing order, and after a download or link failure
in G: an image that G's failed run staged is never linked by the other
feature. The hold lasts while the record exists, also when G is disabled or
masked; updex never clears the record by itself. features rollback --clear G,
or a successful update of G, ends it. If two features that share a transfer
both have a rollback record, each is held by the other's record; clear one of
them so the other can advance. features check shows it as
rollback_held_by.
G's own update resumes when every component its record names, including one
that G's current transfers do not use, has a version newer than the rejected
one. A component the record names whose transfer is not in the loaded
definitions (for example with -C or a narrowed --component) holds too,
because updex cannot tell whether a newer version exists.
A publisher that bumps only one component of a multi-transfer feature keeps the hold, because every transfer must have a version newer than its rejected one. A rollback across an image update can need the older OS image too: an older sysext revision may expect a file that a newer base image dropped (the newer revision then carried it). Roll back the image as well in that case.
Minimum image version: a sysext revision that needs a newer base image
can set UPDEX_MIN_IMAGE_VERSION=<digits> in its
usr/lib/extension-release.d/extension-release.<name> (snosi sets it by hand,
only on a revision that depends on a base change; systemd-sysext ignores the
key). After the download and before the link, features update (and
features enable --now) reads the key from the downloaded image, without
mounting it, and compares it with the IMAGE_VERSION of the image that boots
next: the rollback deployment when bootc status --json reports a queued
rollback (rollbackQueued), else the staged deployment, else the booted
/etc/os-release. Both values must be digits only (snosi uses
14-digit timestamps) and are compared as numbers. When the host image is older
than the minimum, or either value is missing or not a number, updex links
nothing of that feature (its transfers link together), keeps the old links
and the downloaded images, and reports needs image >= <value>. This is not an
error: other features continue and the run exits 0. The image stays staged, so
the next run checks it again and links it once the host image is new enough
(after the host stages or boots a newer image). The update table shows
not linked: needs image >= <value>, and the JSON result has needs_image
(and not_linked on the feature's other transfers). features check shows
the same for a staged image that is not linked: needs image >= <value> in the
UPDATE column (JSON needs_image, next_boot_image_version) and a
NEEDS NEWER IMAGE: line; features list ends with the same line. They never
download, so they cannot tell about a newer image that is only published.
Without the key nothing changes. An image with no EROFS filesystem at all (for
example a squashfs sysext from another catalog) is treated as having no key.
An uncompressed EROFS image is read in pure Go. A compressed one is read with
dump.erofs from erofs-utils >= 1.8.5, which prints the file out of the image;
a host without that fails closed for such an image instead of skipping the
check (see Installation).
The link and an immediate refresh are checked against different images.
The link is what the next boot merges, so it is checked against the next-boot
image. A systemd-sysext refresh merges into the image the host runs now: on a
bootc host /etc/os-release and /usr/lib/os-release describe the booted
deployment, not a staged one. Before every refresh it runs (features update
without --no-refresh, features enable --now, features rollback --now),
updex therefore compares the key of every sysext the refresh would merge with
the running image's IMAGE_VERSION too. It checks every link whose merged
version it cannot prove equal to the linked version, including a merged image
whose version the metadata does not record (unknown). A merged state or link
that cannot be read stops the refresh as well: updex fails closed. When that
check fails, including when
the running version is missing or not numeric, updex keeps the links (they
are right for the next boot), skips the refresh, and says so:
refresh skipped: <component> needs image >= <value> (running image <running>); it takes effect at the next boot (JSON refresh_skipped). This is not an
error: the command exits 0 and the change takes effect at the next boot. An
image with no EROFS filesystem is treated as having no key here as well.
features disable --now is the exception: it unmerges before its refresh, so
skipping the refresh would leave every sysext unmerged; it refuses the whole
command instead, changes nothing, and tells you to run it without --now and
reboot. The timer runs with --no-refresh and is unchanged.
An image that has an EROFS filesystem but whose extension-release file cannot
be read (a failed EROFS open, a failed open, stat or read of the file, a
missing file, several without a match, over 64 KiB, or a compressed image
whose dump.erofs fallback is missing, too old or fails), an image the link
step could not resolve, or a bootc that is installed but fails, is an error
for the features concerned: nothing is linked for them. A queued rollback or a
staged deployment whose version bootc does not report is an error too: bootc
reads that version from the OCI manifest annotation
org.opencontainers.image.version of the OS image, so an OS image without it
blocks these features while an update is staged, until the reboot into it.
What the vacuum keeps: after the links succeeded, features update
removes old images but always keeps, per transfer, the image the link names
(it merges at the next boot), the image merged now (the one the host runs),
ProtectVersion=, and the newest InstancesMax images (default 2). A host
that has linked a new revision but has not rebooted yet therefore keeps up to
3 images per transfer. If updex cannot read the link or the merged state, it
deletes nothing and warns.
One updex operation at a time: every command that changes state
(features update, features enable, features disable, catalog add,
catalog remove, daemon enable, daemon disable) takes an exclusive lock on
/run/updex/lock and never waits for it. Dry runs and read-only commands
(features list, features check, components, catalog list,
daemon status) do not take it. features rollback takes it too. When another updex operation holds the lock:
- in an interactive shell the command prints
another updex operation is runningand exits non-zero; updex features updaterun by systemd (the environment variableINVOCATION_IDis set, as for the timer's service) logs one line to stderr (skipping this run) and exits 0, because the next scheduled run tries again. Every other command still fails.
An offline host is a skip, not a failure: when no update source can be
reached at all (a DNS failure, a refused or impossible connection, no HTTP
response of any status), updex features update run by systemd prints its
normal output and warnings, adds the line
updex: no update source is reachable; skipping this run and exits 0, so updex-update.service does not stay failed until the next
successful run (ADR-0018).
A partial failure, a source that answered with an error status, a
verification failure and a failed refresh still exit non-zero, and a run in
an interactive shell still reports the error. --json output is unchanged.
updex catalog list # Browse configured catalogs
updex catalog search zoxide # Search by name
sudo updex catalog add fedora/zoxide # Install, enable, and download
sudo updex catalog remove fedora/zoxide # Remove generated files and imagesCatalogs must be configured first (see Sysext Catalogs).
Use REPO/NAME to disambiguate; a bare NAME works when unambiguous.
updex catalog list --no-cache forces a live listing.
sudo updex daemon enable # Enable and start the update timer
updex daemon status # Enabled/active, next run, last result
sudo updex --dry-run daemon enable # Preview what enable would do
sudo updex daemon disable # Stop and disable itThe frostyard-updex package ships updex-update.timer and
updex-update.service in /usr/lib/systemd/system, disabled. An OS image
can enable the timer with a preset; otherwise updex daemon enable runs
systemctl enable and start on it and writes nothing. When the package's
units are absent (updex installed without the package), daemon enable
writes the units to /etc/systemd/system instead, with a marker comment
on the first line, and daemon disable removes only files that carry it (and
the identical units that older updex releases wrote).
- Schedule: every 6 hours (
OnCalendar=*-*-* 00/6:00:00) with up to one hour of random delay; a run missed while the host was off happens at the next boot (Persistent=true). An administrator can change it with a drop-in such as/etc/systemd/system/updex-update.timer.d/override.conf, andupdex daemon statusshows the schedule that actually applies, drop-ins included. - What a run does:
updex features update --no-refreshdownloads, verifies and links the new revision. It never runssystemd-sysext refresh, so the new revision takes effect at the next boot. A run that finds nothing new fetches only the manifests. - Power and network: the service waits for
network-online.targetand skips a run while the host is on battery (ConditionACPower=true; a host without a battery always runs). updex does not detect metered networks. A host that is offline still reachesnetwork-online.targeton many setups; its downloads then fail, but a run that no source answered is a skip: after its usual warnings it logs one skip line and exits 0 (updex-update.servicestays successful). A partial failure, or a source that answered with an error, still fails the unit (ADR-0018). - Time limit: the service has
TimeoutStartSec=2h. systemd stops a stuck run after 2 hours, which releases the operation lock/run/updex/lock. - Lock:
updex daemon enableandupdex daemon disabletake the same operation lock as the other mutating commands, also when they only enable or disable the packaged timer. - Status:
updex daemon statusshows the unit files in use (packaged, oretcwhen a file in/etc/systemd/systemoverrides the package), whether the timer is masked (Masked: yes), the next run and the time and result of the last run. The run log isjournalctl -u updex-update.service. - Opt out:
sudo systemctl mask updex-update.timerstops automatic updates on the whole host and survives image updates. A mask is a symlink to/dev/null;updex daemon disablestops and disables the timer and removes the unit files updex wrote, but it never removes a mask. It reports the mask (masked_filesin JSON) and the command that undoes it (sudo systemctl unmask updex-update.timer). Stopping the timer alone does not survive an image update: an image preset can enable it again. To stop one sysext, runupdex features disable <name>.
| Flag | Description |
|---|---|
-C, --definitions |
Path to directory containing .transfer and .feature files |
--verify |
Force GPG signature verification on SHA256SUMS |
--no-refresh |
Skip running systemd-sysext refresh after install/update |
--json |
Output in JSON format (jq-compatible) |
-n, --dry-run |
Preview changes without modifying filesystem |
-v, --verbose |
Enable verbose output |
-s, --silent |
Suppress progress/reporting noise; with --json, still emit the final machine-readable result |
--verify only forces verification: without it, signatures are still
verified unless a transfer explicitly sets Verify=no (for catalog-added
sysexts, only when the catalog also sets AllowUnsigned=yes; see
Unsigned catalogs). CLI dry runs of
mutating feature commands still require root.
updex ships no built-in catalogs: they are specific to the host system.
Configure a repo with a <name>.catalog file in /etc/updex/catalogs.d/,
/run/updex/catalogs.d/, /usr/local/lib/updex/catalogs.d/, or
/usr/lib/updex/catalogs.d/ (earlier paths win). For example:
# /etc/updex/catalogs.d/fedora.catalog
[Catalog]
SiteURL=https://extensions.fcos.fr/fedora
ListURL=https://api.github.com/repos/fedora-sysexts/fedora/contents/SiteURL is required for artifacts; ListURL supplies catalog list and
search. After adding a sysext, ordinary features commands and the daemon
manage it. See the configuration reference
for catalog keys and the SDK API reference
for ownership and rollback behavior.
By default every catalog sysext must have a GPG-signed SHA256SUMS,
whatever Verify= the catalog's own .conf declares. Some catalogs,
including the Fedora and community catalogs,
publish SHA256SUMS without a SHA256SUMS.gpg and set Verify=false.
Adding one of their sysexts fails with an error suggesting AllowUnsigned=yes.
To accept such a catalog, opt in for that catalog only:
# /etc/updex/catalogs.d/fedora.catalog
[Catalog]
SiteURL=https://extensions.fcos.fr/fedora
ListURL=https://api.github.com/repos/fedora-sysexts/fedora/contents/
AllowUnsigned=yesAllowUnsigned=yes lets the catalog's Verify= decide. A catalog sysext
that declares Verify=yes, or omits it, still requires a signature, and
--verify still forces one. updex never falls back to an unsigned download
after a signature turns out to be missing or invalid. The policy is read from
the local .catalog file every time a sysext is checked or updated, so
removing AllowUnsigned requires signatures again on the next run.
What still protects an unsigned catalog: HTTPS authenticates the server and
protects transport (redirects from HTTPS to HTTP are refused, and
AllowUnsigned does not relax the HTTPS requirement), and every image must
match its SHA256SUMS entry before it is installed. What is lost: an
unsigned checksum does not authenticate the publisher, so anyone able to
replace both the image and SHA256SUMS on the hosting infrastructure can
serve a different image.
AllowUnsigned=yes cannot be combined with AllowInsecure=yes: without
signatures, HTTPS is the only check on where an image came from.
Upgrading from v2.0.0: sysexts added with v2.0.0 kept the catalog's
Verify=false and stop updating with a signature error until their catalog
sets AllowUnsigned=yes. No re-add is needed unless the catalog's SiteURL
has changed since (for example from http:// to https://): the opt-in
only covers sysexts whose transfer still fetches from the current SiteURL,
so re-run sudo updex catalog add <repo>/<name> for those.
Upgrading from v2.0.1 or v2.0.2: these releases dropped the catalog's
Verify= from generated transfers, so sysexts added with them keep requiring
a signature. Add AllowUnsigned=yes, then re-run
sudo updex catalog add <repo>/<name> for each affected sysext to regenerate
its transfer.
With --json, stdout carries only the command's result, as one JSON
document. Progress and warning events are JSON Lines on stderr, for example
{"type":"message","message":"Installed version 2.0.0",...}, so a consumer
can read stderr for live progress and still parse stdout with one
json.Unmarshal. A command that fails before it has a result (for example a
missing root privilege, or daemon disable with no timer installed) can print
nothing on stdout; it exits non-zero and writes the error to stderr. Check the
exit status before you parse stdout. The interactive download bar is off in JSON mode, and in
text mode it appears only when stderr is a terminal (under systemd the journal
would otherwise receive raw carriage-return text).
features update --json reports linked: true for a transfer whose link this
run moved, including one that needed no download because its image was already
staged; the text table shows linked in its STATUS column for that row, not
up to date.
Use updex features list --json | jq '.[] | select(.enabled)' to list enabled
features for a script.