707 lines
63 KiB
Markdown
707 lines
63 KiB
Markdown
# RPM family discovery: DNF (AlmaLinux / Rocky / Fedora) and ZYPPER (openSUSE)
|
|
|
|
Everything below was run in throwaway `docker run --rm -i` containers (Docker 29.8.2 on the host, 2026-10-09).
|
|
All outputs are real; "exit=N" is the real exit status. All commands run with `LC_ALL=C`.
|
|
Companion files (all in `discovery/rpm/`, all proven on every image listed in section 0):
|
|
|
|
| file | purpose |
|
|
|---|---|
|
|
| `fixture.sh` | E2E fixture: builds 3 dummy RPMs (+ optional extras), local repo, 1.0 installed / 2.0 offered. dnf vs zypper auto-detected |
|
|
| `lib-rpm.sh` | the proven bash building blocks quoted in this document (`su_refresh`, `su_upgradable`, `su_describe`, `su_rdeps`, `su_closure`, `su_upgrade`, `su_plan`, `su_remove`, ...) |
|
|
| `proof.sh` | runs the fixture + every `lib-rpm.sh` function and asserts the results (43-53 PASS lines per image) |
|
|
|
|
Reproduce on any image: `docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh` or `... bash /d/proof.sh`.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 0. Verdict, images, and the surprises
|
|
|
|
### 0.1 Chosen commands (one line each)
|
|
|
|
| need | DNF (dnf4 and dnf5, `yum` is the same binary) | ZYPPER |
|
|
|---|---|---|
|
|
| refresh | `dnf -q --refresh --setopt=skip_if_unavailable=0 makecache >/dev/null` (exit 0/1) | `zypper --non-interactive --quiet refresh` (exit 0; 4 repo error; 3 unknown repo; 6 no repos) |
|
|
| upgradable list | `dnf -q check-update --setopt=skip_if_unavailable=0` (exit 100 = updates, 0 = none, 1 = error), parse 3 columns `name.arch evr repo`, old version from `rpm -qa` | `zypper --non-interactive --xmlout list-updates` (exit 0 always), parse `<update ... name edition edition-old arch>` + `<source alias>` |
|
|
| summary/description | `dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' PKG`, fallback `rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' PKG` | `zypper -q -n info PKG` (shows candidate, else installed), parse `Summary :` / indented `Description :` |
|
|
| installed reverse deps | pure rpm: provides + owned files joined against all REQUIRENAMEs (`su_rdeps`); == `dnf repoquery --installed --whatrequires` | same pure-rpm function (identical on both families) |
|
|
| recursive installed closure | pure rpm BFS `su_closure` (rpm -q REQUIRENAME + rpm -q --whatprovides); == dnf4 `repoquery --installed --requires --resolve --recursive`; dnf5 has no `--resolve` | same function |
|
|
| per-run exclusion | `dnf -y upgrade --exclude=a,b` (comma list; repeated flags also work; dnf4 and dnf5) | no `--exclude`. Chosen: private `ZYPP_CONF` with its own `locksfile.path` (`su_zypper_excluding`). Alternative with exact restore: snapshot/restore `/etc/zypp/locks` + trap (`su_zypper_excluding_snapshot`) |
|
|
| non-interactive upgrade | `dnf -y upgrade` | Leap/SLE: `zypper --non-interactive update`; Tumbleweed (os-release `ID=opensuse-tumbleweed`): `zypper --non-interactive dup` |
|
|
| remove + unneeded deps | `dnf -y remove PKG` (`clean_requirements_on_remove=1` is the default on dnf4 and dnf5) | `zypper --non-interactive remove --clean-deps PKG` |
|
|
| plan preview (new deps, removals, obsoletes) | `dnf --assumeno upgrade [--exclude=..]` + table parse (`su_plan_dnf`) | `zypper --non-interactive --xmlout update` or `dup` plus `--dry-run` (`<install-summary><to-upgrade>/<to-install>/<to-remove>`) |
|
|
|
|
### 0.2 Correct image names
|
|
|
|
| asked | result |
|
|
|---|---|
|
|
| `almalinux:9` | exists. AlmaLinux 9.8, **dnf 4.14.0**, rpm 4.16.1.3, bash 5.1.8 |
|
|
| `almalinux:10` | exists. AlmaLinux 10.2, **dnf 4.20.0 (NOT dnf5)**, rpm 4.19.1.1, bash 5.2.26 |
|
|
| `rockylinux:9` | exists but **stale**: Docker "official" library image = Rocky 9.3 created 2023-11-28 (96 pending upgrades on day one), dnf 4.14.0. Fresh equivalent: `rockylinux/rockylinux:9` or `quay.io/rockylinux/rockylinux:9` = Rocky 9.8 (same image id `686e4558d0a5`) |
|
|
| `rockylinux:10` | **does not exist**: `docker manifest inspect rockylinux:10` -> "no such manifest" (also `:10.0`, `:10.1`, `:10-minimal` missing). **Correct: `rockylinux/rockylinux:10`** (Docker Hub) **or `quay.io/rockylinux/rockylinux:10`** |
|
|
| quay vs Docker Hub Rocky 10 | **same image**: both resolve to id `sha256:0b5329d806d0...`, repo digest `sha256:827d37bc128288ccf160ee318bb3cb92d591164cb217e92f8bc61e3982ae1834`. Rocky 10.2, dnf 4.20.0, rpm 4.19.1.1. (`fixture.sh` also passes on the quay tag, 8 s.) |
|
|
| `opensuse/tumbleweed:latest` | openSUSE Tumbleweed (os-release `ID=opensuse-tumbleweed`, `VERSION_ID=20261007`), zypper 1.14.101, rpm 4.20.1, bash 5.3.20 |
|
|
| `opensuse/leap:latest` | **Leap 16.0** (`ID=opensuse-leap`), image created 2025-08-26, zypper 1.14.101, rpm 4.20.1, bash 5.2.37 |
|
|
| extra, for dnf5 only | `fedora:latest` = Fedora 44, **dnf5 5.4.3.0**, rpm 6.0.2 (pulled only to verify dnf5 syntax; not a target) |
|
|
|
|
### 0.3 Surprises that change the design
|
|
|
|
1. **EL10 is dnf4 (4.20.0), not dnf5.** AlmaLinux 10 has no `dnf5` package anywhere (BaseOS/AppStream/CRB/Extras/EPEL: `dnf -q --enablerepo='*' list --available dnf5` -> "No matching Packages"; only `microdnf 3.10.1`). Rocky 10 ships `dnf5 5.1.12` only in the *disabled* `devel` repo. So dnf5 is only met on Fedora (or a user-installed dnf5). The tool must support **dnf4 and dnf5 from one code path**; the only target images that run dnf5 are not in the requested list, so dnf5 was verified on `fedora:latest`.
|
|
2. `dnf5` silently ignores a dead repo (exit 0, `-q` hides the warning) unless `--setopt=skip_if_unavailable=0`. dnf4 errors by default (exit 1).
|
|
3. `rpm -qa --qf '[%{REQUIRENAME}\t%{NAME}\n]'` is **broken** (`error: incorrect format: array iterator used with different sized arrays`, ~50% of packages) on every rpm 4.16..6.0; the scalar tag must be written `%{=NAME}` inside `[...]`.
|
|
4. Missing tools in base images: no `awk`/`find`/`xargs`/`hostname`/`python3`/`xmllint` on Tumbleweed; no `find`/`xargs`/`python3`/`xmllint` on Leap; no `diff`/`cmp`/`ps`/`which`/`sudo` almost everywhere. All proven code uses only bash + rpm + sed/grep/tr/sort/join/cut/mktemp.
|
|
5. `rpm -q --whatrequires bash` returns **1** package on EL9 where `dnf repoquery --installed --whatrequires bash` and `su_rdeps bash` return **52** (file deps `/bin/sh`, `/usr/bin/bash`, sonames).
|
|
6. `zypper removelock` is not an undo (deletes *all* matching locks, leaves an empty `/etc/zypp/locks` file even if there was none).
|
|
7. `dnf list --upgrades` and (dnf4) `repoquery --upgrades` ignore the versionlock plugin; only `check-update` honours it on both majors.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
### 0.4 Base image facts (this is item 9 of the brief; repeated as a pointer in section 9)
|
|
|
|
| | alma:9 | alma:10 | rocky:9 (9.3 lib) | rocky 10 | tumbleweed | leap 16.0 |
|
|
|---|---|---|---|---|---|---|
|
|
| package mgr | dnf 4.14.0 | dnf 4.20.0 | dnf 4.14.0 | dnf 4.20.0 | zypper 1.14.101 | zypper 1.14.101 |
|
|
| `yum` | symlink -> `dnf-3` | `dnf-3` | `dnf-3` | `dnf-3` | absent | absent |
|
|
| rpm | 4.16.1.3 | 4.19.1.1 | 4.16.1.3 | 4.19.1.1 | 4.20.1 | 4.20.1 |
|
|
| bash | 5.1.8 | 5.2.26 | 5.1.8 | 5.2.26 | 5.3.20 | 5.2.37 |
|
|
| `sudo` | no (installable, baseos) | no | no | no | no (installable) | no (installable) |
|
|
| `su` | yes | **no** | yes | yes | yes | yes |
|
|
| `python3` | yes | yes | yes | yes | **no** | **no** |
|
|
| awk / find / xargs | yes | yes | yes | yes | **no / no / no** | awk yes, **find, xargs no** |
|
|
| xmllint | yes | yes | yes | yes | no | no |
|
|
| runs as | root | root | root | root | root | root |
|
|
|
|
`bats`:
|
|
* EL9/EL10 (Alma and Rocky): **not in BaseOS/AppStream/CRB**; needs EPEL: `dnf -y install epel-release && dnf -y install bats` (epel-release is in `extras`, enabled by default). Verified: EL9 -> `Bats 1.8.0` (bats-1.8.0-1.el9), EL10 -> `Bats 1.11.1` (bats-1.11.1-1.el10_0). `bats` install itself took 3-5 s; `epel-release` 1-12 s (measured on all four EL images).
|
|
* Tumbleweed: `zypper -n in bats` -> `Bats 1.14.0` (bats-1.14.0-1.1), main OSS repo, no extra repo. Leap 16.0: `Bats 1.11.1` (bats-1.11.1-160000.2.2), main repo. (install of bats+sudo: 8-9 s.)
|
|
* `sudo` installable everywhere: EL baseos `1.9.17p2` / `1.9.17-10.p2.el10_2.7`; Tumbleweed `1.9.17p2`; Leap `1.9.17p1`.
|
|
* `python3` on openSUSE: Leap has `python3` / `python313`; Tumbleweed has versioned `python311`/`python312`/... (no unversioned package).
|
|
* `rpm-build` and `createrepo_c` are installable on all six images (BaseOS/AppStream on EL, main OSS on openSUSE). EL9: rpm-build 4.16.1.3-40.el9 + createrepo_c 0.20.1-4.el9; EL10: rpm-build 4.19.1.1-23.el10 + createrepo_c 1.1.2-4.el10; Tumbleweed: rpm-build 4.20.1-11.1 + createrepo_c 1.2.3-1.4; Leap 16: rpm-build 4.20.1-160000.3.1 + createrepo_c 1.1.0-160000.2.2. No EPEL needed for them.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 1. Refresh metadata non-interactively
|
|
|
|
### DNF
|
|
|
|
```
|
|
$ dnf makecache # alma9 (dnf4), cold cache, 9.0 s
|
|
AlmaLinux 9 - AppStream 23 MB/s | 28 MB 00:01
|
|
AlmaLinux 9 - BaseOS 32 MB/s | 45 MB 00:01
|
|
AlmaLinux 9 - Extras 52 kB/s | 33 kB 00:00
|
|
Last metadata expiration check: 0:00:01 ago on Fri Oct 9 08:20:43 2026.
|
|
Metadata cache created. exit=0
|
|
```
|
|
|
|
Variants tested (stdout lines / stderr lines / exit):
|
|
|
|
| command | dnf4 (alma9/10) | dnf5 (fedora 44) |
|
|
|---|---|---|
|
|
| `dnf makecache` | 5 / 0 / 0 (progress + "Metadata cache created." on **stdout**) | 1 / 3 / 0 (progress "Updating and loading repositories:" ... "Repositories loaded." on **stderr**, "Metadata cache created." on stdout) |
|
|
| `dnf -q makecache` | 0 / 0 / 0 (silent) | 1 / 0 / 0 (**still prints "Metadata cache created." to stdout**) -> `>/dev/null` |
|
|
| `dnf --refresh makecache` and `dnf makecache --refresh` | both accepted, forces re-download | both accepted |
|
|
| plain `makecache` (no `--refresh`) | no re-download while the cache is younger than `metadata_expire` (dnf4 dump on alma9: `metadata_expire = 172800` = 48 h; ~1-2 s of revalidation measured) | same |
|
|
|
|
* `--refresh` (= "set metadata as expired") is the right switch for a skeptical tool: `dnf -q --refresh makecache` re-downloads every enabled repo (alma9 1.6-7 s, alma10 2.5-7 s, rocky 1-1.4 s, fedora 1.7 s measured warm; cold 3-9.5 s, section 11).
|
|
* `dnf check-update` exit codes (verified on dnf4 and dnf5): **100** = updates available, **0** = nothing (also 0 when a name pattern matches nothing: `dnf -q check-update 'nonexist*'` -> exit=0), **1** = error.
|
|
* **Dead repo handling differs** (repodata removed from the local repo):
|
|
|
|
| command | dnf4 alma10 | dnf5 fedora |
|
|
|---|---|---|
|
|
| `dnf -q --refresh makecache` | exit 1: `Error: Failed to download metadata for repo 'su-dummy': Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried` | **exit 0**, no message (repo silently ignored, default `skip_if_unavailable=true` for repos that do not set it) |
|
|
| same + `--setopt=skip_if_unavailable=0` | exit 1 (same message) | exit 1: `Failed to download metadata (baseurl: "file:///var/lib/su-dummy-repo") for repository "su-dummy": Usable URL not found` |
|
|
| `dnf -y upgrade su-dummy-a` | exit 1 | **exit 0, prints `Nothing to do.`; su-dummy-a stays 1.0** (the dead repo is only a stderr warning `>>> Curl error (37) ...`, absent with `-q`) unless `skip_if_unavailable=0` |
|
|
|
|
So always pass `--setopt=skip_if_unavailable=0` (harmless on dnf4) and never rely on `-q` to see problems.
|
|
* Oddity seen on dnf 4.14.0 (EL9): with the repo gone, `dnf -q check-update` still returned **100 from stale cache**, while `makecache --refresh`, `repoquery` and `upgrade` failed with exit 1. => run the explicit refresh first and abort on its failure, do not infer repo health from check-update.
|
|
* Real-world flake observed once on `rockylinux:9`: `Error: Failed to download metadata for repo 'extras': repomd.xml parser error: Parse error at line: 1 (Extra content at the end of the document` (mirror served garbage), exit 1; re-run was clean. The refresh step correctly failed the run; the tool should surface this and let the user retry.
|
|
* `yum`: `/usr/bin/yum -> dnf-3` on all EL images (prints `4.14.0` / `4.20.0`); `/usr/bin/yum -> dnf5` on Fedora. Detection: `dnf --version | head -1` starts with `dnf5 version` on dnf5, with a bare version (`4.14.0`) on dnf4.
|
|
|
|
### ZYPPER
|
|
|
|
```
|
|
$ zypper --non-interactive refresh # tumbleweed, metadata fresh, 0.6 s
|
|
Repository 'openSUSE-Tumbleweed-Update' is up to date.
|
|
Repository 'openSUSE-Tumbleweed-Non-Oss' is up to date.
|
|
Repository 'Open H.264 Codec (openSUSE Tumbleweed)' is up to date.
|
|
Repository 'openSUSE-Tumbleweed-Oss' is up to date.
|
|
Repository 'su-dummy' is up to date.
|
|
All repositories have been refreshed. exit=0
|
|
```
|
|
* `-q` silences it; exit 0 either way. `zypper refresh --force su-dummy` re-downloads ("Forcing raw metadata refresh ... Building repository 'su-dummy' cache [....done]", exit 0).
|
|
* Normal `refresh` only checks whether the repo changed (cheap); it is also what `zypper lu/up/dup` do implicitly (autorefresh=on).
|
|
* Broken repo: `Retrieving repository 'su-dummy' metadata [.error]` ... `Could not refresh the repositories because of errors.` **exit=4**. Unknown name (`refresh nonexist`): `Repository 'nonexist' not found by its alias, number, or URI.` **exit=3**.
|
|
* Leap prints `Refreshing service 'openSUSE'.` as first line of nearly every command (it is a service, not a repo) - in `-q` mode it disappears, in `--xmlout` mode it is a `<message type="info">`.
|
|
* GPG: the test repo is `--no-gpgcheck`. For real third-party repos `--non-interactive` would refuse unknown keys; `--gpg-auto-import-keys` is the (security-relevant) opt-in. Not used by the tool.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 2. Machine-parsable list of upgradable packages (`name<TAB>old<TAB>new<TAB>repo`)
|
|
|
|
### 2.1 DNF: candidates compared (alma9/alma10 = dnf4, fedora = dnf5)
|
|
|
|
| | `dnf -q check-update` | `dnf -q repoquery --upgrades --queryformat ...` | `dnf -q list --upgrades` |
|
|
|---|---|---|---|
|
|
| first line(s) | dnf4: one **blank line**; dnf5: a header line `Upgrades` (printed even with `-q`) | none | `Available Upgrades` (dnf4) / `Available upgrades` (dnf5) |
|
|
| columns | 3: `name.arch evr repo` (arch suffix attached, epoch shown as `2:...` when non-zero) | whatever you ask for | same 3 columns |
|
|
| honours `--exclude` / `excludepkgs` | yes (dnf4, dnf5) | yes | yes |
|
|
| honours **versionlock** plugin | **yes** (dnf4 and dnf5) | dnf4: **no** (locked pkg still listed), dnf5: yes | **no** on both |
|
|
| same code path as `dnf upgrade` | yes | no | no |
|
|
| "Obsoleting Packages" | dnf4: trailing section `Obsoleting Packages` (+ indented old pkg `@System`); dnf5: not listed | not listed | not listed |
|
|
| exit | 100 / 0 / 1 | 0 | 0 |
|
|
| queryformat escapes | - | dnf4 expands `\t` `\n` **and appends its own newline** (blank line after each record); dnf5 does **not** expand `\t` (prints literal `\t`), expands `\n`, appends nothing | - |
|
|
| old (installed) version | not given | not given (no installed-version tag) | not given |
|
|
|
|
Samples (`cat -A`; EL9 shown, EL10 identical in shape):
|
|
|
|
```
|
|
$ dnf -q check-update | cat -A # dnf4, alma9, exit=100
|
|
$
|
|
gnutls.x86_64 3.8.10-9.el9_8 baseos $
|
|
su-dummy-a.noarch 2.0-1 su-dummy$
|
|
su-dummy-b.noarch 2.0-1 su-dummy$
|
|
su-dummy-c.x86_64 2.0-1 su-dummy$
|
|
vim-minimal.x86_64 2:8.2.2637-26.el9_8.23 baseos $
|
|
|
|
$ dnf -q check-update | head -3 # dnf5, fedora 44, exit=100
|
|
Upgrades
|
|
ca-certificates.noarch 2026.2.90_v9.0.317-1.fc44 updates
|
|
curl.x86_64 8.18.0-12.fc44 updates
|
|
|
|
$ dnf -q check-update 'nonexist*' ; echo exit=$? # both: nothing printed, exit=0
|
|
$ dnf -q repoquery --upgrades --queryformat '%{name}\t%{evr}\t%{repoid}\n' 'su-dummy-*'
|
|
su-dummy-a 2.0-1 su-dummy # dnf4: ...followed by a BLANK line after every record
|
|
su-dummy-a\t2.0-1\tsu-dummy # dnf5: literal "\t" (not expanded!)
|
|
$ dnf -q repoquery --upgrades --queryformat $'%{name}\t%{arch}\t%{evr}\t%{repoid}\n' 'su-dummy-*' | cat -A
|
|
su-dummy-a^Inoarch^I2.0-1^Isu-dummy$ # dnf4 and dnf5 identical when real TAB chars are passed
|
|
$ dnf -q repoquery --upgrades # default format on both: name-epoch:ver-rel.arch
|
|
su-dummy-a-0:2.0-1.noarch
|
|
```
|
|
Other tag differences: `%{reponame}` is the repo *id* on dnf4 but the human name on dnf5 (`skeptical-updater dummy packages`); `%{repoid}` is the id on both. `%{evr}` omits epoch `0` on both, includes `1:` when non-zero (`su-dummy-e` with `Epoch: 1` -> `1:2.0-1`).
|
|
|
|
dnf4 long names do **not** wrap in a non-tty even at `COLUMNS=30` (a 108-character package name stays on one line, padded): the 3-field read is safe; the same holds on dnf5.
|
|
|
|
**Decision: parse `dnf -q check-update`** (only listing that honours excludes *and* versionlock on both majors and is the same solver view as `dnf upgrade`), take the *old* version from `rpm -qa`, and use `repoquery` only for summary/description. Parse (from `lib-rpm.sh`, proven on 5 dnf images):
|
|
|
|
```bash
|
|
_su_installed_evr() { # "name.arch<TAB>evr", ascending so the highest version (installonly pkgs have several) wins
|
|
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2V
|
|
}
|
|
su_upgradable_dnf() {
|
|
local out rc na ver repo rest name arch k evr; local -A old=() oldn=()
|
|
out=$(dnf -q check-update --setopt=skip_if_unavailable=0); rc=$?
|
|
case $rc in 0) return 0 ;; 100) ;; *) return "$rc" ;; esac
|
|
while IFS=$'\t' read -r k evr; do evr=${evr#0:}; old[$k]=$evr; oldn[${k%.*}]=$evr; done < <(_su_installed_evr)
|
|
while read -r na ver repo rest; do
|
|
[ "$na" = Obsoleting ] && break # dnf4 trailing "Obsoleting Packages" section
|
|
[ -n "$repo" ] && [ -z "$rest" ] || continue # drops the blank line (dnf4), the "Upgrades" header (dnf5), anything not 3 fields
|
|
name=${na%.*}; arch=${na##*.} # plain name; the arch never contains a dot
|
|
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo" "$arch"
|
|
done <<<"$out" | LC_ALL=C sort
|
|
}
|
|
```
|
|
Output from `proof.sh` (alma10, tab shown as `^I`):
|
|
```
|
|
gnutls^I3.8.10-4.el10_2^I3.8.10-5.el10_2^Ibaseos^Ix86_64$
|
|
rpm-sequoia^I1.10.1.1-2.el10^I1.10.2.1-1.el10_2^Ibaseos^Ix86_64$
|
|
su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
|
|
su-dummy-b^I1.0-1^I2.0-1^Isu-dummy^Inoarch$
|
|
su-dummy-c^I1.0-1^I2.0-1^Isu-dummy^Ix86_64$
|
|
```
|
|
Epoch example (alma9): `vim-minimal 2:8.2.2637-26.el9_8.21 2:8.2.2637-26.el9_8.23 baseos x86_64`.
|
|
|
|
**Excluded / locked packages**: both are *removed from the sack*, so they simply do not appear in `check-update`/`repoquery` (with `--exclude=a,b`: only `su-dummy-c` remains). To see them anyway: dnf4 `--disableexcludes=all`; **dnf5 has no such option** (`Unknown argument "--disableexcludes=all" for command "repoquery"`, also no `--disable-excludes`/`--no-excludes`). Versionlock: dnf4 `python3-dnf-plugin-versionlock` (`/etc/dnf/plugins/versionlock.list`, line `su-dummy-a-0:1.0-1.*`), dnf5 `dnf5-plugins` (`/etc/dnf/versionlock.toml`); after `versionlock add su-dummy-a`, `check-update` lists only b and c on both.
|
|
|
|
**Blind spot - obsoletes**: a package that *obsoletes* an installed one (`su-dummy-new` obsoleting `su-dummy-old`) is listed only by dnf4 `check-update` (in the trailing section, which the parser drops) and by **no** `repoquery --upgrades`/dnf5 listing, but `dnf upgrade` will install it and remove the old one:
|
|
```
|
|
$ dnf --assumeno upgrade # dnf4
|
|
Installing:
|
|
su-dummy-new noarch 1.0-1 su-dummy 6.5 k
|
|
replacing su-dummy-old.noarch 1.0-1
|
|
```
|
|
=> use the plan preview of section 7 (`su_plan`) when the user must see *everything* that will change.
|
|
|
|
### 2.2 ZYPPER: candidates compared
|
|
|
|
| | `zypper -n --xmlout list-updates` | `zypper -q -n list-updates` (table) | `... lu --all` |
|
|
|---|---|---|---|
|
|
| exit | 0 (also with updates; there is no 100) | 0 | 0 |
|
|
| noise | `<message>` tags (`Loading repository data...`, Leap: `Refreshing service 'openSUSE'.`) | `-q` removes all non-table lines | |
|
|
| fields | name, edition (new), edition-old, arch, summary, description, `<source alias=...>` | `S \| Repository \| Name \| Current Version \| Available Version \| Arch` (repo = display *name*, e.g. `openSUSE-Tumbleweed-Update`) | same table, locked rows get status `vl` |
|
|
| locked packages | hidden | hidden | **shown with status `vl`** |
|
|
| vendor-change candidates | hidden | hidden | shown (`v`) |
|
|
| when nothing | empty `<update-list>` | prints nothing (even no header) | |
|
|
|
|
```
|
|
$ zypper -q -n list-updates # tumbleweed
|
|
S | Repository | Name | Current Version | Available Version | Arch
|
|
---+----------------------------+--------------------+-----------------+-------------------+-------
|
|
v | openSUSE-Tumbleweed-Update | openSUSE-build-key | 1.0-68.1 | 1.0-69.1 | x86_64
|
|
v | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
|
|
v | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
|
|
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
|
|
|
|
$ zypper -n --xmlout list-updates # one <update> element per package; attributes + summary + description (candidate!) + source
|
|
<update-list>
|
|
<update kind="package" name="su-dummy-a" edition="2.0-1" arch="noarch" edition-old="1.0-1"><summary>skeptical-updater dummy su-dummy-a (v2.0)</summary><description>Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
|
|
...</description><license/><source url="file:/var/lib/su-dummy-repo" alias="su-dummy"/></update><update kind="package" ...
|
|
```
|
|
Note the XML puts *all `<update>` elements on one physical line* and descriptions span several lines, `xmllint` does not exist on SUSE images, `python3` neither. Parse with `tr`+`sed`+bash (proven on Tumbleweed and Leap):
|
|
|
|
```bash
|
|
_su_xattr() { # _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent. ' edition="' never matches ' edition-old="'
|
|
local v=${1#* $2=\"}; if [ "$v" = "$1" ]; then REPLY=; return 1; fi
|
|
v=${v%%\"*}; v=${v//</<}; v=${v//>/>}; v=${v//"/\"}; v=${v//'/\'}; v=${v//&/&}; REPLY=$v
|
|
}
|
|
su_upgradable_zypper() {
|
|
local xml rc line tag name new old arch src alias
|
|
xml=$(zypper --non-interactive --xmlout list-updates); rc=$?; [ "$rc" -eq 0 ] || return "$rc"
|
|
# tr leaves no trailing newline and GNU sed keeps it that way: without `|| [ -n "$line" ]` the LAST package is silently dropped
|
|
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [ -n "$line" ]; do
|
|
case $line in '<update kind="package" '*) ;; *) continue ;; esac
|
|
tag=${line%%>*}
|
|
_su_xattr "$tag" name; name=$REPLY; _su_xattr "$tag" edition; new=$REPLY
|
|
_su_xattr "$tag" edition-old; old=$REPLY; _su_xattr "$tag" arch; arch=$REPLY
|
|
src=${line#*<source }; alias=; [ "$src" != "$line" ] && { _su_xattr "$src" alias && alias=$REPLY; }
|
|
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$alias" "$arch"
|
|
done | LC_ALL=C sort
|
|
}
|
|
```
|
|
Output (tumbleweed): `openSUSE-build-key^I1.0-68.1^I1.0-69.1^Irepo-update^Ix86_64$`, `su-dummy-a^I1.0-1^I2.0-1^Isu-dummy^Inoarch$` ... The repo column is the stable **alias** (`repo-update`, Leap: `openSUSE:repo-oss`).
|
|
|
|
Table fallback (also proven; repo = display name, may contain spaces, e.g. `repo-oss (16.0)`):
|
|
```bash
|
|
trim() { local v=$1; v=${v#"${v%%[![:space:]]*}"}; v=${v%"${v##*[![:space:]]}"}; REPLY=$v; }
|
|
zypper -q -n list-updates | while IFS='|' read -r s repo name cur avail arch; do
|
|
case $s in v*) ;; *) continue ;; esac # data rows only (header/separator rows do not start with "v")
|
|
trim "$repo"; repo=$REPLY; trim "$name"; name=$REPLY; trim "$cur"; cur=$REPLY; trim "$avail"; avail=$REPLY; trim "$arch"; arch=$REPLY
|
|
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$cur" "$avail" "$repo" "$arch"
|
|
done
|
|
```
|
|
zypper already gives plain names (arch is a separate attribute/column).
|
|
|
|
**Locks** (see section 6): after `zypper addlock su-dummy-a su-dummy-b`, `lu` shows only `su-dummy-c`; `lu --all` shows
|
|
```
|
|
vl | su-dummy | su-dummy-a | 1.0-1 | 2.0-1 | noarch
|
|
vl | su-dummy | su-dummy-b | 1.0-1 | 2.0-1 | noarch
|
|
v | su-dummy | su-dummy-c | 1.0-1 | 2.0-1 | x86_64
|
|
```
|
|
**Vendor change**: package whose candidate has another vendor (`Vendor: FooCorp` installed, `BarCorp` in repo) is hidden by `lu`, shown by `lu --all`, and `zypper up` prints `There is an update candidate for 'su-dummy-v' from vendor 'BarCorp', while the current vendor is 'FooCorp'. Use 'zypper install su-dummy-v-2.0-1.noarch' to install this candidate.` followed by `Nothing to do.` (the update is skipped).
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 3. One-line description and full info dump
|
|
|
|
Fixture packages carry `Summary: skeptical-updater dummy su-dummy-a (v1.0|v2.0)` and a two-paragraph description (blank line between paragraphs) so installed vs candidate and paragraph handling are distinguishable.
|
|
|
|
### 3.1 Installed version (all backends): rpm
|
|
|
|
```
|
|
$ rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' su-dummy-a
|
|
skeptical-updater dummy su-dummy-a (v1.0)
|
|
Dummy package su-dummy-a version 1.0 for skeptical-updater tests.
|
|
|
|
Second paragraph of su-dummy-a 1.0 (blank line above on purpose).
|
|
$ rpm -qi su-dummy-a # Name/Version/Release/Architecture/Install Date/Group/Size/License/Signature/Source RPM/Build Date/Build Host/Summary/Description/Distribution
|
|
```
|
|
|
|
### 3.2 Candidate (new) version
|
|
|
|
**DNF**: `dnf info` prints *both* an `Installed Packages` and an `Available Packages` block (dnf4 `Description : first line` + ` : continuation` lines; dnf5 `info` adds `Epoch`, `From repository : <unknown>`, `Vendor`). `dnf info --upgrades PKG` (dnf4 and dnf5) prints only the candidate block (`Available Upgrades` / `Available upgrades`), as does `dnf repoquery -i --upgrades PKG`. For machine use take `repoquery` with a queryformat (same on dnf4/dnf5):
|
|
|
|
```bash
|
|
dnf -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n%{description}\n' su-dummy-a
|
|
# -> line 1 summary, then the description with paragraph breaks intact; empty output => no candidate => fall back to rpm -q
|
|
```
|
|
dnf4 appends one extra blank line (harmless; `$(...)` strips it). Verified output (both dnf4 and dnf5): `skeptical-updater dummy su-dummy-a (v2.0)` / `Dummy package su-dummy-a version 2.0 ...` / blank / `Second paragraph of su-dummy-a 2.0 ...`.
|
|
Caveat: dnf4 `repoquery --upgrades` does not honour versionlock, so the "candidate" may be one the lock blocks; for the summary only this is irrelevant.
|
|
|
|
**ZYPPER**: `zypper info PKG` already shows the *best candidate* (and marks the installed state), falling back to the installed package when no repo has it:
|
|
```
|
|
$ zypper -q -n info su-dummy-a
|
|
Information for package su-dummy-a:
|
|
-----------------------------------
|
|
Repository : su-dummy
|
|
Name : su-dummy-a
|
|
Version : 2.0-1
|
|
Arch : noarch
|
|
Vendor :
|
|
Installed Size : 15 B
|
|
Installed : Yes
|
|
Status : out-of-date (version 1.0-1 installed)
|
|
Source package : su-dummy-a-2.0-1.src
|
|
Summary : skeptical-updater dummy su-dummy-a (v2.0)
|
|
Description :
|
|
Dummy package su-dummy-a version 2.0 for skeptical-updater tests.
|
|
|
|
Second paragraph of su-dummy-a 2.0 (blank line above on purpose).
|
|
```
|
|
`zypper info nonexistentpkg` -> `package 'nonexistentpkg' not found.` **exit=104**. `--xmlout info` is useless (one big `<message>` blob). `-q` does not remove the `Information for package` banner. Description lines are indented 4 spaces; **paragraph breaks are real empty lines and the block also ends with an empty line** (and a second empty line before the next `Information for package` banner when several names are given), so a plain `/^Description/,/^$/` sed range truncates after the first paragraph. State machine (proven):
|
|
|
|
```bash
|
|
zypper --non-interactive --quiet info "$pkg" 2>/dev/null | {
|
|
indesc=0; pend=
|
|
while IFS= read -r l; do
|
|
if [ $indesc = 1 ]; then
|
|
case $l in ' '*) printf '%s%s\n' "$pend" "${l# }"; pend= ;; # text line: flush held blank lines first
|
|
'') pend+=$'\n' ;; # blank: paragraph break OR end of block - decide on next line
|
|
*) break ;; esac # anything else = next block
|
|
else
|
|
case $l in Summary*:*) [ -z "${sum:-}" ] && { sum=${l#*: }; printf '%s\n' "$sum"; } ;; Description*:*) indesc=1 ;; esac
|
|
fi
|
|
done; }
|
|
# if zypper info exits non-zero (104) fall back to: rpm -q --qf '%{SUMMARY}\n%{DESCRIPTION}\n' "$pkg"
|
|
```
|
|
The XML of `list-updates`/`update --dry-run` (sections 2.2 and 7) also carries the candidate `summary`+`description` for upgradable packages.
|
|
|
|
`su_describe PKG` in `lib-rpm.sh` wraps all of this; `proof.sh` asserts candidate summary `skeptical-updater dummy su-dummy-a (v2.0)`, the preserved blank line, and installed-only `su-dummy-d (v1.0)` on all 7 images.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 4. Installed reverse dependencies
|
|
|
|
Candidates and what they really do:
|
|
|
|
| command | result on EL9 for `bash` | verdict |
|
|
|---|---|---|
|
|
| `rpm -q --whatrequires bash` | **1** package (`ca-certificates`) - matches only a Requires entry literally named `bash` | misses file deps (`/bin/sh`, `/usr/bin/bash`), sonames, virtual provides |
|
|
| `dnf repoquery --installed --whatrequires bash` | **52** (dnf4 default = `--alldeps`: all provides *and files*; `--exactdeps` drops to 1) | correct, dnf only; works on dnf4 and dnf5 (fedora: 42) |
|
|
| `zypper search --installed-only -t package --requires bash` | = naive (name only); `--requires-pkg bash` uses all provides but over-reports on Tumbleweed (43 hits vs 4 direct: includes packages that depend via `/bin/sh` -> `bash-sh` -> `bash`) | not the reference |
|
|
| **pure rpm `su_rdeps`** (below) | EL9 52, EL10 40, Fedora 42, Tumbleweed 4 direct (69 recursive), Leap 5 direct (67 recursive) | **chosen**; identical to the dnf answer wherever dnf can answer |
|
|
|
|
Why name != capability: on Tumbleweed `/bin/sh` is provided by `bash-sh`, not `bash` (`rpm -q --whatprovides /bin/sh` -> `bash-sh`), so `aaa_base`, `binutils`, ... are *direct* dependents of `bash-sh` and only *transitive* dependents of `bash`; `su_rdeps_recursive bash` (69) is a superset of zypper's 43 (`comm -23` empty).
|
|
|
|
Algorithm: (1) the set of names the package is known by = `[%{PROVIDENAME}\n]` + `rpm -ql` (owned files, so file deps like `/bin/sh` match); (2) one table of every installed package's requirements `capability<TAB>package` from `rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]'` with `rpmlib(...)` dropped and rich deps expanded; (3) `join` the two on the capability, drop the package itself. **Mandatory trick `%{=NAME}`** (a bare `%{NAME}` inside `[...]` makes rpm print `error: incorrect format: array iterator used with different sized arrays` for roughly half of the packages; reproduced on rpm 4.16.1.3, 4.19.1.1, 4.20.1, 6.0.2 - and the same error appears for `[%{PROVIDENAME}\t%{NAME}\n]`).
|
|
|
|
```bash
|
|
su_req_table() { # "capability<TAB>requiring-package", sorted; rich deps expanded via su_cap_names (section 5)
|
|
local out=$1 cap name t all; all=$(mktemp)
|
|
rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' > "$all"
|
|
{ grep -v -e '^rpmlib(' -e '^(' "$all" || true
|
|
{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
|
|
su_cap_names <<<"$cap" | while IFS= read -r t; do printf '%s\t%s\n' "$t" "$name"; done
|
|
done
|
|
} | LC_ALL=C sort -u > "$out"; rm -f "$all"
|
|
}
|
|
su_rdeps() { # direct installed dependents of PKG, plain names, sorted
|
|
local pkg=$1 tab caps own=0
|
|
if [ -n "${SU_REQ_TAB:-}" ] && [ -s "$SU_REQ_TAB" ]; then tab=$SU_REQ_TAB; else tab=$(mktemp); su_req_table "$tab"; own=1; fi
|
|
caps=$(mktemp)
|
|
{ rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg"; rpm -ql "$pkg" | grep -v -e '^(contains no files)$'; } | LC_ALL=C sort -u > "$caps"
|
|
LC_ALL=C join -t $'\t' "$caps" "$tab" | cut -f2 | LC_ALL=C sort -u | grep -v -x -F -e "$pkg" || true
|
|
rm -f "$caps"; [ "$own" = 1 ] && rm -f "$tab"; return 0
|
|
}
|
|
```
|
|
`su_rdeps_recursive PKG...` (also in the lib) loops `su_rdeps` with one shared table. Proof assertions: `su_rdeps su-dummy-b` = `su-dummy-a su-dummy-d`; `su_rdeps su-dummy-c` = `su-dummy-d` (c is only required through the rich dep `(su-dummy-c if su-dummy-b)`); `su_rdeps su-dummy-d` = empty; `su_rdeps(bash)` equals `dnf repoquery --installed --whatrequires bash` (EL9: 52 = 52, EL10: 40 = 40, Fedora/dnf5: 42 = 42). Cost: ~60-130 ms on these minimal images.
|
|
|
|
Plain names: `%{NAME}` has no arch/version; multilib duplicates collapse through `sort -u`. `dnf repoquery ... --queryformat $'%{name}\n' | sed '/^$/d'` is the dnf-native equivalent (dnf4 blank lines).
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 5. Recursive dependency closure restricted to installed packages
|
|
|
|
* `dnf repoquery --installed --requires --resolve --recursive --queryformat $'%{name}\n' PKG`: **works on dnf4** (alma9/alma10); **dnf5 rejects it**: `Unknown argument "--resolve" for command "repoquery". ... The argument is available for commands: download` (rc=2). Also dnf-only.
|
|
* **Chosen: pure-rpm breadth-first search**, identical on dnf and zypper distros. Per BFS level: `rpm -q --qf '[%{REQUIRENAME}\n]' <frontier>` -> filter -> `rpm -q --whatprovides --qf '%{NAME}\n' <caps>`.
|
|
Input handling (all verified):
|
|
|
|
| dependency kind | example | handling |
|
|
|---|---|---|
|
|
| `rpmlib(...)` | `rpmlib(CompressedFileNames)` | dropped (not a package; `--whatprovides` would just say "no package provides") |
|
|
| file deps | `/bin/sh`, `/usr/bin/perl` | looked up with `rpm -q --whatprovides /bin/sh` (file owner or explicit Provides: `bash` on EL, `bash-sh` on SUSE) |
|
|
| soname / virtual | `libc.so.6(GLIBC_2.34)(64bit)`, `perl(Foo::Bar)`, `config(bash)` | kept verbatim (REQUIRENAME already carries no version); contain parentheses but do not *start* with `(` |
|
|
| versioned | `su-dummy-b >= 1.0` | `%{REQUIRENAME}` returns only `su-dummy-b`; the constraint is ignored (installed state is already consistent) |
|
|
| rich / boolean | `(su-dummy-c if su-dummy-b)`, `(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config)`, `(account-utils or shadow-pw-mgmt = 4.20.3)`, `(annobin if (gcc or clang))` | `su_cap_names` tokenises with paren-depth tracking: members kept, comparison operators and the version token after them dropped, `and/or/with/without` ignored, the **condition** after `if`/`unless` dropped (until `else` or the closing paren). Conditions are not evaluated -> safe superset |
|
|
| unresolvable | nothing installed provides it | `no package provides X` lines are filtered out (rpm prints them on **stdout**, exit 1) |
|
|
|
|
`su_cap_names` unit results (proven, EL9):
|
|
```
|
|
rpmlib(CompressedFileNames) =>
|
|
/bin/sh => /bin/sh
|
|
libc.so.6(GLIBC_2.34)(64bit) => libc.so.6(GLIBC_2.34)(64bit)
|
|
(su-dummy-c if su-dummy-b) => su-dummy-c
|
|
(glibc-gconv-extra(x86-64) = 2.34-275.el9_8 if redhat-rpm-config) => glibc-gconv-extra(x86-64)
|
|
(foo >= 1.0-2 or (bar and baz)) => foo bar baz
|
|
((a or b) if c) => a b
|
|
(a if b else c) => a c
|
|
(a unless (b and c) else d) => a d
|
|
(libx.so.1()(64bit) if foo) => libx.so.1()(64bit)
|
|
(annobin if (gcc or clang)) => annobin
|
|
(account-utils or shadow-pw-mgmt = 4.20.3) => account-utils shadow-pw-mgmt
|
|
```
|
|
Why the condition must be dropped: with the naive "all tokens" version, `glibc` on EL9 (rich dep `(glibc-gconv-extra ... if redhat-rpm-config)`) appeared to depend on `redhat-rpm-config`, which dragged `rpm`, `rpm-build`, `binutils`, `gdb-minimal` and the perl modules into the closure of `filesystem` (167 packages instead of ~15).
|
|
|
|
```bash
|
|
su_closure() { # su_closure PKG... -> installed packages PKG... need transitively (plain names, sorted, roots excluded)
|
|
local -A seen=(); local -a frontier=("$@") caps provs next out=(); local p
|
|
for p in "$@"; do seen[$p]=1; done
|
|
while [ "${#frontier[@]}" -gt 0 ]; do
|
|
mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" 2>/dev/null | sort -u | su_cap_names | sort -u)
|
|
[ "${#caps[@]}" -gt 0 ] || break
|
|
mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
|
|
next=()
|
|
for p in "${provs[@]}"; do [ -n "$p" ] && [ -z "${seen[$p]:-}" ] && { seen[$p]=1; next+=("$p"); out+=("$p"); }; done
|
|
frontier=("${next[@]}")
|
|
done
|
|
[ "${#out[@]}" -gt 0 ] && printf '%s\n' "${out[@]}" | sort -u; return 0
|
|
}
|
|
```
|
|
Proof (`proof.sh`, every image): `su_closure su-dummy-a` = `su-dummy-b`; `su_closure su-dummy-d` contains `su-dummy-a su-dummy-b su-dummy-c glibc` and the provider of `/bin/sh` (`bash` on EL, `bash-sh` on SUSE), no `rpmlib`, not the root. On dnf4 the result **equals** `dnf repoquery --installed --requires --resolve --recursive`: alma9 18 = 18, alma10 17 = 17 (dnf lists `bash` in its own closure because of the `filesystem` <-> `bash` cycle; ours excludes roots by design). Sample (alma10): `almalinux-gpg-keys almalinux-release almalinux-repos basesystem bash filesystem glibc glibc-common glibc-gconv-extra glibc-minimal-langpack libgcc ncurses-base ncurses-libs setup su-dummy-a su-dummy-b su-dummy-c tzdata`; Tumbleweed `closure(su-dummy-d)` = `bash bash-sh filesystem glibc libncurses6 libreadline8 su-dummy-a su-dummy-b su-dummy-c system-user-root terminfo-base`. Timing: 70-130 ms.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 6. Per-run exclusion
|
|
|
|
### 6.1 DNF (`--exclude`, verified on dnf4 4.14/4.20 and dnf5 5.4.3)
|
|
|
|
| form | dnf4 | dnf5 |
|
|
|---|---|---|
|
|
| `dnf -y upgrade --exclude=su-dummy-a,su-dummy-b` (comma list) | ok | ok (documented as `-x package,..., --exclude=package,...`, global option) |
|
|
| `--exclude=a --exclude=b` (repeated) | ok | ok |
|
|
| `-x a -x b` | ok | ok |
|
|
| globs `--exclude='su-dummy-[ab]'` | ok | ok |
|
|
| space separated in one value `--exclude='a b'` | ok (repoquery) | ok (repoquery) |
|
|
| works on `check-update`, `repoquery`, `upgrade` | yes | yes |
|
|
| `--disableexcludes=all` to see them again | yes | **not available** |
|
|
|
|
Result of `dnf -y upgrade --exclude=su-dummy-a,su-dummy-b 'su-dummy-*'` (dnf4 and dnf5): `su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0` (both syntaxes). Excluded packages disappear from `check-update` too (only `su-dummy-c` left).
|
|
|
|
Edge cases (verified):
|
|
* all packages matched by an explicit pattern are excluded: dnf4 `No match for argument: su-dummy-*` / `Error: No packages marked for upgrade.` **exit 1**; dnf5 `Argument 'su-dummy-*' matches only excluded packages.` **exit 1**. With *no* package arguments and nothing to do both print `Nothing to do.` **exit 0**.
|
|
* **an excluded dependency blocks the upgrade of its dependant** (`su-dummy-p` 2.0 `Requires: su-dummy-q >= 2.0`, `--exclude=su-dummy-q`):
|
|
* dnf4 (EL default `best = 1`, `skip_if_unavailable = 0` in the dnf4 config dump): `Error: Problem: package su-dummy-p-2.0-1.noarch from su-dummy requires su-dummy-q >= 2.0, but none of the providers can be installed ... package su-dummy-q-2.0-1.noarch from su-dummy is filtered out by exclude filtering` **exit 1**; `--skip-broken` does *not* help (same error); `--nobest` -> `Skip 1 Package ... Nothing to do.` exit 0.
|
|
* dnf5: `Skipping packages with broken dependencies: su-dummy-p noarch 2.0-1 su-dummy` then `Nothing to do.` **exit 0** (no flag needed; `--skip-broken` is rejected on `upgrade`: `Unknown argument "--skip-broken" for command "upgrade"`).
|
|
=> exit codes cannot tell "everything upgraded" from "some skipped because of the exclusion": re-run `su_upgradable` afterwards (the proof does) and report what is left.
|
|
|
|
### 6.2 ZYPPER (no `--exclude`)
|
|
|
|
Findings that drive the recipe:
|
|
* `zypper update/dup` have no exclude option. Locks (`zypper addlock NAME...`, glob via `*`/`?`) are honoured by the solver: a **full** `zypper update` (no package args) silently skips locked packages; a lock on `a`,`b` -> only `c` is upgraded.
|
|
* An **explicit** `zypper update LOCKED` or an update whose dependency is locked ends in an interactive "Problem" which `--non-interactive` answers with *cancel*: **exit 4**:
|
|
```
|
|
Problem: 1: Detailed information: - conflicting requests
|
|
Solution 1: Following actions will be done: remove lock to allow installation of su-dummy-a-2.0-1.noarch[su-dummy] ...
|
|
Solution 2: do not ask to install a solvable providing su-dummy-a.noarch = 2.0-1
|
|
Choose from above solutions by number or skip, retry or cancel [1/2/s/r/c/d/?] (c): c exit=4
|
|
```
|
|
while a full update with a locked dependency of a newer package just skips both: `The following 2 package updates will NOT be installed: su-dummy-p su-dummy-q` (exit 0), `p`/`q` stay 1.0.
|
|
* Naming the packages explicitly instead (`zypper update a b c`) is **not** an exclusion mechanism: `zypper update su-dummy-p` (p 2.0 needs q >= 2.0) also upgraded `su-dummy-q` (dependency pull) - verified.
|
|
* Lock file `/etc/zypp/locks` (path = `locksfile.path` in zypp.conf; created on first `addlock`; on Tumbleweed `/etc/zypp/zypp.conf` does not exist at all, only `/usr/etc/zypp/zypp.conf`):
|
|
```
|
|
$ cat -A /etc/zypp/locks
|
|
$
|
|
type: package$
|
|
match_type: glob$
|
|
case_sensitive: on$
|
|
solvable_name: su-dummy-a$
|
|
$
|
|
```
|
|
`zypper locks` table: `# | Name | Type | Repository | Comment`; `zypper -q ll` prints the same.
|
|
* `zypper removelock NAME` is **not** a safe undo: it deletes *every* lock that matches NAME (a pre-existing user lock of the same name is destroyed - verified), and it leaves a 0-byte `/etc/zypp/locks` behind even when no file existed before (verified: `-rw-r--r-- 1 root root 0`).
|
|
|
|
#### Recipe A (chosen): private zypp.conf, nothing persisted - `su_zypper_excluding`
|
|
|
|
libzypp reads `$ZYPP_CONF` instead of `/etc/zypp/zypp.conf`. Copy the current config (fall back to `/usr/etc/zypp/zypp.conf` on Tumbleweed), append `[main] locksfile.path = <tmp>/locks`, seed `<tmp>/locks` with a copy of the real locks, `addlock` the exclusions *there*, run, `rm -rf` the temp dir. The real lock set is never opened for writing, so there is nothing to restore (Ctrl-C safe, concurrent-run safe).
|
|
|
|
```bash
|
|
su_zypper_excluding() { # su_zypper_excluding NAME... -- zypper-args... e.g. ... a b -- update | ... a -- --xmlout dup --dry-run
|
|
local -a ex=(); local d src locks rc
|
|
while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
|
|
d=$(mktemp -d) || return 1
|
|
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
|
|
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
|
|
cp "$src" "$d/zypp.conf" || { rm -rf "$d"; return 1; }
|
|
if [ -e "$locks" ]; then cp "$locks" "$d/locks"; else : > "$d/locks"; fi
|
|
printf '\n[main]\nlocksfile.path = %s\n' "$d/locks" >> "$d/zypp.conf" # second [main] block: last assignment wins (verified)
|
|
if [ "${#ex[@]}" -gt 0 ]; then
|
|
ZYPP_CONF=$d/zypp.conf zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || { rm -rf "$d"; return 1; }
|
|
fi
|
|
ZYPP_CONF=$d/zypp.conf zypper --non-interactive "$@"; rc=$?
|
|
rm -rf "$d"; return $rc
|
|
}
|
|
```
|
|
Proof (Tumbleweed `dup` and Leap `update`, `proof.sh` section 6): `su_upgrade --exclude su-dummy-a --exclude su-dummy-b` -> exit 0, `su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=2.0`; `/etc/zypp/locks` unchanged (absent before, absent after); no leftover `/tmp/tmp.*`; `su_upgradable` still lists a and b afterwards; the following unrestricted `su_upgrade` upgrades them (`a=2.0 b=2.0 c=2.0`).
|
|
|
|
#### Recipe B (alternative with exact restore of the real locks file) - `su_zypper_excluding_snapshot`
|
|
|
|
Use this only if the real locks must be the ones libzypp sees (e.g. another tool reads `/etc/zypp/locks` concurrently - note it is then visible to that tool for the duration).
|
|
```bash
|
|
su_zypper_excluding_snapshot() ( # subshell => traps are local
|
|
ex=(); while [ $# -gt 0 ] && [ "$1" != -- ]; do ex+=("$1"); shift; done; [ "${1:-}" = -- ] && shift
|
|
src=/etc/zypp/zypp.conf; [ -r "$src" ] || src=/usr/etc/zypp/zypp.conf
|
|
locks=$(sed -n 's/^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*//p' "$src" | tail -n1); : "${locks:=/etc/zypp/locks}"
|
|
bak=$(mktemp) || exit 1; had=0; restored=0
|
|
if [ -e "$locks" ]; then cp -p -- "$locks" "$bak" || exit 1; had=1; fi # snapshot: bytes + mode + mtime, or "absent"
|
|
_restore() { [ "$restored" = 1 ] && return 0; restored=1
|
|
if [ "$had" = 1 ]; then cp -p -- "$bak" "$locks"; else rm -f -- "$locks"; fi; rm -f -- "$bak"; }
|
|
trap '_restore' EXIT; trap '_restore; exit 130' INT; trap '_restore; exit 143' TERM HUP
|
|
if [ "${#ex[@]}" -gt 0 ]; then zypper --non-interactive --quiet addlock "${ex[@]}" >/dev/null || exit 1; fi
|
|
zypper --non-interactive "$@"
|
|
)
|
|
```
|
|
Proof (`proof.sh` section 10, Tumbleweed; Leap likewise): with a pre-existing user lock on `su-dummy-d` (comment "user lock"): `cksum`+mtime identical after a run (`834034310 97 1791570407` before and after); user lock still present, temporary locks gone; a user lock on the *same* name as an exclusion survives (`zypper removelock` would have deleted it - asserted); file absent before => absent after; run killed with SIGTERM mid-way (shadowed `zypper` that kills its parent subshell) -> exit 143 and locks restored byte-identical.
|
|
|
|
#### Recipe C (rejected): explicit package list
|
|
`zypper update <list of everything except X>`: dependencies pull X anyway (shown above) and any name that is already newest prints `No update candidate for ...`. Rejected.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 7. Non-interactive upgrade, exit codes, plan preview
|
|
|
|
### 7.1 DNF
|
|
|
|
* `dnf -y upgrade [--exclude=a,b] [--setopt=skip_if_unavailable=0]`
|
|
* Exit codes (4 and 5): **0** success *and* "Nothing to do." (also `Dependencies resolved. Nothing to do. Complete!` on dnf4); **1** any error (unknown package argument `No match for argument: X` + `Error: No packages marked for upgrade.`, dependency problem, dead repo with `skip_if_unavailable=0`); **100 exists only for `check-update`**; `--assumeno` (dry run) ends with **exit 1** (`Operation aborted.` / `Operation aborted by the user.`) after printing the transaction table.
|
|
* `-q` removes the progress chatter but dnf5 still prints `Nothing to do.`; dnf5 prints `Warning: skipped OpenPGP checks for 3 packages from repository: su-dummy` for gpgcheck=0 repos.
|
|
* On the stale `rockylinux:9` (9.3) the first `-y` transaction imports the Rocky 9 GPG key automatically (`Importing GPG key 0x350D275D: ... From : /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9`) - harmless with `-y`, but a non-`-y` path would prompt.
|
|
* There is no machine-readable transaction output in dnf4. `dnf5 check-upgrade --json` exists (dnf5 only), not used.
|
|
|
|
### 7.2 ZYPPER
|
|
|
|
Detection (verified): `/etc/os-release` `ID=opensuse-tumbleweed` (Tumbleweed) -> **`dup`**; `ID=opensuse-leap` (Leap 16.0) -> **`update`**; `ID_LIKE` is `opensuse suse` / `suse opensuse` on both, so test `ID`, not `ID_LIKE`. (`opensuse-slowroll` is rolling too and treated like Tumbleweed in the lib - not tested; MicroOS/Aeon/Leap Micro are transactional and out of scope.)
|
|
```bash
|
|
su_zypper_verb() { case "$(su_os_id)" in opensuse-tumbleweed|opensuse-slowroll) echo dup ;; *) echo update ;; esac; }
|
|
zypper --non-interactive dup # prints: Warning: You are about to do a distribution upgrade with all enabled repositories. ... Computing distribution upgrade...
|
|
zypper --non-interactive update
|
|
```
|
|
Observed exit codes:
|
|
|
|
| situation | exit |
|
|
|---|---|
|
|
| success / "Nothing to do." (`update` and `dup`; `No update candidate for 'su-dummy-a-2.0-1.noarch'. The highest available version is already installed.` for explicit names) | **0** |
|
|
| `list-updates` with or without updates; `patch-check` with 0 patches | 0 |
|
|
| repo cannot be refreshed (`Could not refresh the repositories because of errors.`) | **4** |
|
|
| unknown repository name | 3 |
|
|
| unresolved Problem answered by `--non-interactive` (default = cancel): explicit update of a locked package; dup with a vendor/"distupgrade repository" problem (`the installed su-dummy-v-1.0-1.noarch does not belong to a distupgrade repository and must be replaced` / Solution 1 install from vendor BarCorp / Solution 2 keep obsolete) | **4** |
|
|
| `info`/`remove` of a name nothing provides (`No provider of 'nonexistent-xx' found.`) | **104** |
|
|
| `update --dry-run` / `dup --dry-run` (even with `Continue? [y/n/...] (y): y` echoed) | 0 (dry run, nothing changed) |
|
|
| update of a package listed in `/etc/zypp/needreboot(.d)` | **0**, plus the text `Since the last system boot core libraries or services have been updated. Reboot is suggested to ensure that your system benefits from these updates.` |
|
|
|
|
Not reproduced here (documented zypper(8) semantics, no fixture can trigger them without real SUSE patches): `100`/`101` (`patch-check`: updates / security updates needed), `102` (reboot needed after a patch with the reboot flag), `103` (zypper itself/libzypp was updated, restart zypper), `106` (some repos skipped), `107` (an rpm scriptlet failed). The tool should treat any non-zero exit as failure to be shown, `0` as success, and treat 100-103 as "success with advice" if it ever sees them.
|
|
|
|
`dup` vs `update` on Tumbleweed: in this snapshot `update --dry-run` and `dup --dry-run` computed the same plan (4 upgrades `openSUSE-build-key su-dummy-a su-dummy-b su-dummy-c`, 11 new packages `krb5 libbrotlicommon1 ... libverto1`, 1 removal `libcurl-mini4` - i.e. a package *swap* that `list-updates` never shows). The documented difference shows with vendor changes: `list-updates` hides the candidate, `update` skips it with a message, `dup` raises the Problem above (exit 4 under `-n`) - so Tumbleweed must use `dup`, and a skeptical tool must expect exit 4 there and show the Problem text (re-run without `-n`... or `--dry-run` text) instead of retrying.
|
|
|
|
### 7.3 Plan preview (what would REALLY change: new deps, removals, obsoletes)
|
|
|
|
`list-updates`/`check-update` never show newly installed dependencies, removals or obsolete-replacements. `su_plan [--exclude N]...` (in `lib-rpm.sh`) prints `section<TAB>name<TAB>old<TAB>new<TAB>repo<TAB>arch<TAB>replaces`, honouring exclusions:
|
|
|
|
* zypper: `zypper --non-interactive --xmlout dup|update --dry-run` yields exact data (no table parsing):
|
|
```
|
|
<install-summary download-size="19047" space-usage-diff="0" ... packages-to-change="3" need-restart="0" need-reboot="0">
|
|
<to-upgrade>
|
|
<solvable type="package" name="su-dummy-a" edition="2.0-1" arch="noarch" repository="su-dummy" edition-old="1.0-1" arch-old="noarch" summary="..."><description>...</description></solvable>
|
|
...</to-upgrade><to-install>...</to-install><to-remove>...</to-remove></install-summary>
|
|
```
|
|
(the `<prompt>` and `<progress>` elements that follow are ignored; `need-reboot`/`need-restart` attributes are available).
|
|
* dnf: `dnf --assumeno upgrade` table parse - section headers are col-0 lines ending in `:` (`Upgrading:`, `Installing:`, `Installing dependencies:`, `Installing weak dependencies:`, `Removing:`, `Removing unused dependencies:`, `Skipping packages with broken dependencies:` ...), rows start with one space, parsing stops at `Transaction Summary`. dnf4 prints no `replacing` line for plain upgrades (old version then comes from `rpm -q`), dnf5 prints `replacing` rows with `0:`-prefixed epochs.
|
|
|
|
Proof section 9 (builds `su-dummy-new` with `Obsoletes: su-dummy-old < 2` + `Provides: su-dummy-old = 2`): with `--exclude su-dummy-a`:
|
|
```
|
|
zypper (tumbleweed) dnf4 (alma9) dnf5 (fedora)
|
|
upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch -
|
|
upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - upgrade su-dummy-b 1.0-1 2.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 -
|
|
install su-dummy-new - 1.0-1 su-dummy noarch - upgrade su-dummy-c 1.0-1 2.0-1 su-dummy x86_64 - install su-dummy-new - 1.0-1 su-dummy noarch su-dummy-old
|
|
remove su-dummy-old - 1.0-1 @System noarch -
|
|
```
|
|
and `su_upgradable` shows 0 rows for `su-dummy-new` on all of them (the blind spot).
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 8. Remove / purge
|
|
|
|
### DNF (dnf4 and dnf5 identical in effect)
|
|
* `clean_requirements_on_remove` default **1** on both (dnf4: `dnf config-manager --dump` -> `clean_requirements_on_remove = 1`; dnf5 behaves the same: its plan shows `Removing unused dependencies: su-dummy-s noarch 0:1.0-1 su-dummy`).
|
|
```
|
|
$ dnf -y install su-dummy-r # r Requires s; both installed by dnf
|
|
$ dnf -y remove su-dummy-r # dnf4: "Erasing: su-dummy-r-1.0-1.noarch ... Erasing: su-dummy-s-1.0-1.noarch" exit=0 (both removed)
|
|
$ dnf -y remove --setopt=clean_requirements_on_remove=0 su-dummy-r # only r removed; s stays
|
|
$ dnf -y autoremove # then removes the leftover s
|
|
```
|
|
* Only dependencies **installed by dnf as dependencies** are cleaned. `su-dummy-b` (installed by plain `rpm -Uvh`) stays after `dnf -y remove su-dummy-a` on both majors.
|
|
* `dnf remove nonexistent-xx` exits **0** on dnf4 (`Nothing to do.`) and dnf5 (`No packages to remove for argument: nonexistent-xx` / `Nothing to do.`) -> confirm with `rpm -q` afterwards.
|
|
* dnf4 refuses protected packages: `dnf -y remove dnf` -> `Error: Problem: The operation would result in broken dependencies for the following protected packages: yum ...` **exit 1**.
|
|
* There is no `apt purge` equivalent in the rpm world; `%config` handling on removal (`*.rpmsave`) was not exercised here.
|
|
|
|
### ZYPPER
|
|
* `zypper --non-interactive remove --clean-deps PKG` (`-u`). Plain `remove` leaves the dependency: after `zypper -n install su-dummy-r` (pulls `su-dummy-s`), `zypper -n remove su-dummy-r` -> only r removed, `rpm -q su-dummy-s` still present; with `--clean-deps` -> `2 packages to remove.` `(1/2) Removing: su-dummy-r ... (2/2) Removing: su-dummy-s`, exit 0.
|
|
* Which deps are "unneeded" is tracked by zypper in `/var/lib/zypp/AutoInstalled`; packages installed with plain `rpm` (the fixture's `su-dummy-b`) are never cleaned: `remove --clean-deps su-dummy-a` left `su-dummy-b` installed.
|
|
* Unknown name: `'nonexistent-xx' not found in package names. Trying capabilities.` ... `No provider of 'nonexistent-xx' found.` / `Nothing to do.` **exit 104**.
|
|
* `proof.sh` section 7 asserts all of the above through `su_remove` on every image.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 9. Base image facts
|
|
|
|
See section 0.4 for the table (bash versions, sudo/su/python3/awk/find/xmllint presence), bats (EPEL on EL9/EL10; main repos on openSUSE) and `rpm-build`/`createrepo_c` availability. Short version: bash is present everywhere (5.1-5.3), `sudo` nowhere, `python3` only on EL, `awk`/`find`/`xargs` missing on Tumbleweed, `find`/`xargs` missing on Leap, `su` missing on alma10; `bats` needs EPEL on Alma/Rocky; `rpm-build` and `createrepo_c` install from the base repos everywhere.
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 10. E2E fixture - `discovery/rpm/fixture.sh`
|
|
|
|
```
|
|
docker run --rm -i -v <project>/discovery/rpm:/d:ro <image> bash /d/fixture.sh # SU_FIXTURE_EXTRAS=1 for the extras
|
|
```
|
|
What it does (single script, detects `zypper` first, then `dnf`/`yum`; refuses to run as non-root):
|
|
1. installs `rpm-build` + `createrepo_c` (dnf: `dnf -y -q install --setopt=install_weak_deps=False ...`; zypper: `zypper --non-interactive --quiet install --no-recommends ...`) - no EPEL/extra repo needed anywhere;
|
|
2. generates minimal specs and builds with `rpmbuild -bb --define "_topdir /var/tmp/su-fixture"`: `su-dummy-a` (noarch, `Requires: su-dummy-b`), `su-dummy-b` (noarch), `su-dummy-c` (**arch-specific**, `%global debug_package %{nil}`, so the `.x86_64` suffix path is exercised), versions 1.0-1 and 2.0-1, one file each, `Summary: skeptical-updater dummy <name> (v<ver>)`, two-paragraph `%description`;
|
|
3. installs the three 1.0 packages with `rpm -Uvh --oldpackage --replacepkgs` (distro-agnostic; re-running the script resets to the 1.0 state);
|
|
4. publishes only the 2.0 packages in `/var/lib/su-dummy-repo` and runs `createrepo_c`;
|
|
5. registers the repo: dnf `/etc/yum.repos.d/su-dummy.repo` (`baseurl=file:///var/lib/su-dummy-repo`, `gpgcheck=0`, `repo_gpgcheck=0`, `metadata_expire=0`) + `dnf -q makecache --repo su-dummy`; zypper `zypper addrepo --no-gpgcheck --no-check --refresh file:///var/lib/su-dummy-repo su-dummy` + `zypper refresh su-dummy`;
|
|
6. verifies that `check-update` / `list-updates` lists `su-dummy-a`, `-b`, `-c`, else exits 1.
|
|
`SU_FIXTURE_EXTRAS=1` additionally installs `su-dummy-d` 1.0 (not upgradable; `Requires: su-dummy-a`, `/bin/sh`, `su-dummy-b >= 1.0`, `(su-dummy-c if su-dummy-b)`, `rpmlib(CompressedFileNames)`) for rdeps/closure tests and publishes (not installs) `su-dummy-r` (Requires `su-dummy-s`) / `su-dummy-s` for remove tests.
|
|
|
|
Side effect worth knowing: installing `rpm-build` pulls `redhat-rpm-config` etc. on EL, which flips the condition of rich dependencies such as glibc's `(glibc-gconv-extra if redhat-rpm-config)`; the closure code must therefore not treat conditions as dependencies (it does not).
|
|
|
|
Real output (`fixture.sh` standalone, each run exit 0):
|
|
```
|
|
[fixture] backend=dnf # almalinux:9
|
|
[fixture] build tools installed in 14s
|
|
[fixture] rpmbuild done in 0s
|
|
[fixture] repo refresh in 0s
|
|
[fixture] OK: su-dummy-{a,b,c} 1.0 installed and 2.0 offered by repo 'su-dummy' (total 15s, since start 15s)
|
|
```
|
|
and the same `OK` line (totals 8-16 s) on almalinux:10, rockylinux:9, rockylinux/rockylinux:10, quay.io/rockylinux/rockylinux:10, opensuse/tumbleweed (`backend=zypper`, 13 s), opensuse/leap (`backend=zypper`, 14 s), and fedora:latest (dnf5, 16 s). Also verified: a second run on the same container is a clean reset (`proof.sh` section 8).
|
|
|
|
`proof.sh` results (final run, all rc=0): almalinux:9 44 PASS, almalinux:10 44, rockylinux:9 44, rockylinux/rockylinux:10 44, opensuse/tumbleweed 53, opensuse/leap 53, fedora:latest 43 (dnf5). (zypper images run two extra sections: lock snapshot/restore.) One transient failure was seen and is not a code issue: the first `rockylinux:9` run failed section 1 (`su_refresh` exit 1: Rocky `extras` mirror returned a broken `repomd.xml`); re-run passed (the refresh gate did exactly what it is for).
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 11. Rough timings (host with fast network, Docker 29.8.2; wall clock, one run each, sequential)
|
|
|
|
| image | `docker pull` | cold metadata refresh (first `dnf makecache` / `zypper refresh` in the container) | warm refresh | `fixture.sh` wall (of which tool install) | `proof.sh` wall |
|
|
|---|---|---|---|---|---|
|
|
| almalinux:9 | 4.7 s | 9.5 s | 1.6 s | 15.1 s (14 s) | 29.5 s |
|
|
| almalinux:10 | 4.5 s | 5.9 s | 1.9 s | 11.5 s (9 s) | 25.5 s |
|
|
| rockylinux:9 (9.3 lib image, 96 pending upgrades) | 6.1 s | 8.4 s | 1.1 s | 16.6 s (15 s) | 29.8 s |
|
|
| rockylinux/rockylinux:10 | 5.9 s | 3.3 s | 1.6 s | 10.5 s (8 s) | 21.0 s |
|
|
| quay.io/rockylinux/rockylinux:10 | 1.0 s (layers cached) | 4.2 s | - | 8.1 s (7 s) | not run (same image) |
|
|
| opensuse/tumbleweed | 3.4 s | 5.9 s | 0.9 s | 14.3 s (12 s) | 32.7 s |
|
|
| opensuse/leap (16.0) | 3.5 s | 6.3 s | 0.6 s | 14.4 s (12 s) | 34.3 s |
|
|
| fedora:latest (dnf5, reference) | n/a | 5.3 s | 0.3 s | 16.0 s (15 s) | 29.2 s |
|
|
|
|
Inside `proof.sh` (warm containers): forced refresh (`su_refresh`) 0.5-2.6 s (up to 7 s on alma when the mirror is slow), `su_upgradable` 0.3-0.8 s, `su_closure` 0.07-0.13 s, `su_rdeps` ~0.06-0.13 s, dummy upgrade 0.1-0.3 s (dnf) / 1.6-7 s (zypper, because a full `update`/`dup` also upgrades real packages). "Warm refresh" for dnf is a plain `dnf makecache` without `--refresh` (it still revalidates); `--refresh` forces a full download and is the number to budget for a skeptical run (3-9 s cold).
|
|
|
|
---------------------------------------------------------------------------------------------------------
|
|
|
|
## 12. Gotcha checklist for the implementer
|
|
|
|
1. Never assume dnf5 on EL10; support dnf4 + dnf5 through one parser (`check-update`, 3 fields, tolerate blank line, `Upgrades` header, `Obsoleting Packages` section).
|
|
2. Always `--setopt=skip_if_unavailable=0` (dnf5 otherwise hides dead repos, `-q` hides the warning) and gate on the refresh exit status; do not trust `check-update` alone on dnf 4.14.
|
|
3. dnf `repoquery --queryformat`: pass real TAB/newline (`$'%{name}\t%{evr}\n'`), then `sed '/^$/d'` (dnf4 adds blank lines, dnf5 expands only `\n`, `\t` stays literal). `--qf` and `--queryformat` both work on both majors.
|
|
4. dnf4 `repoquery --upgrades` and `dnf list --upgrades` ignore versionlock; `check-update` does not. dnf5 has no `--disableexcludes`.
|
|
5. `check-update`/`list-updates` hide obsoleters, new dependencies and removals. Use `su_plan` when the user must see the real transaction; compare list vs result afterwards because exit codes do not reveal skipped packages (dnf5 skips broken-dependency packages with exit 0; dnf4 fails with exit 1 unless `--nobest`).
|
|
6. `rpm --qf '[%{=NAME}...]'`, never `%{NAME}` inside `[...]`.
|
|
7. `rpm -q --whatrequires pkg` is name-only; use provides + owned files (`su_rdeps`). Rich deps: members only, conditions dropped.
|
|
8. zypper XML: all `<update>` elements are on one line, descriptions multi-line, no xmllint/python on the images, trailing-newline trap with `tr`+`sed`+`read`.
|
|
9. zypper `list-updates` exits 0 even with updates; never look for 100. Locked and vendor-change candidates are hidden (`lu --all` shows them).
|
|
10. zypper has no `--exclude`: use `ZYPP_CONF` private locks (or snapshot/restore of the real locks file; `removelock` is not an undo). Tumbleweed has no `/etc/zypp/zypp.conf`, only `/usr/etc/zypp/zypp.conf`.
|
|
11. Tumbleweed -> `dup`, Leap -> `update`, decided from `ID=` in `/etc/os-release`; `dup`/`update` under `--non-interactive` turn unresolved Problems into exit 4 (cancel) - show the Problem, do not blindly retry.
|
|
12. Base images lack `awk`, `find`, `xargs`, `python3`, `xmllint`, `sudo`, `hostname`, `diff`; `rockylinux:9` is a stale 9.3 image (use `rockylinux/rockylinux:9`); `rockylinux:10` does not exist.
|
|
13. `dnf remove` of a non-existent package exits 0; zypper exits 104. Verify with `rpm -q`.
|
|
14. Packages installed with plain `rpm -i` are never auto-removed by `dnf remove` / `zypper remove --clean-deps`.
|
|
|
|
## 13. Not verified / limits
|
|
|
|
* dnf5 was verified on `fedora:latest` (Fedora 44, dnf5 5.4.3.0 - an image outside the requested list), not on an EL10 userland (no dnf5 package in Alma 10; Rocky 10 has `dnf5 5.1.12` only in the disabled `devel` repo and was not exercised).
|
|
* zypper exit codes 100-103, 106, 107 were not reproduced (see 7.2). `opensuse-slowroll`, MicroOS/Aeon (transactional), SLES were not tested.
|
|
* Multi-arch (x86_64 + i686) and installonly kernels were not exercised; the parser keys on `name.arch` and keeps the highest installed `evr` per key for the "old" column.
|
|
* Docker images pulled during this work (all kept, none removed or re-tagged): `almalinux:9`, `almalinux:10`, `rockylinux:9`, `rockylinux/rockylinux:9`, `rockylinux/rockylinux:10`, `quay.io/rockylinux/rockylinux:9`, `quay.io/rockylinux/rockylinux:10`, `opensuse/tumbleweed:latest`, `opensuse/leap:latest`, `fedora:latest`.
|