N? and `info PKG` now print three lines, none indented:
PKG: <description>
reason: explicitly installed | installed as a dependency | unknown | not installed
required by: <installed dependents, comma separated> | nothing
The reason comes from a new <be>_reason backend function: apt-mark
showauto / showmanual (dpkg decides for a package in neither list), the
Install Reason of pacman -Qi, dnf repoquery --installed %{reason} (dnf4
and dnf5 words) and the Installed line of zypper info (rpm plus libzypp's
AutoInstalled when zypper is locked). A row whose old version is "-" is
not installed and the backend is not asked. The "(installed)" qualifier
moves to --help, README and DESIGN.
The rpm E2E fixture and e2e_reset now also mark su-dummy-a and su-dummy-c
as user-installed on dnf, which records plain rpm installs as "unknown",
so 1? and 2? show both reasons on all 12 distros. The verified commands
are recorded in discovery/{apt,pacman,rpm}.md.
625 lines
54 KiB
Markdown
625 lines
54 KiB
Markdown
# APT family (Debian / Ubuntu) - discovery for skeptical-updater
|
|
|
|
Everything below was verified empirically in throwaway containers (`docker run --rm -i <image> bash ...`) on
|
|
2026-10-09, always with `LC_ALL=C DEBIAN_FRONTEND=noninteractive`. Exit codes are quoted as `[exit=N]`.
|
|
Sample outputs are real; real-world package versions drift daily, the `su-dummy-*` fixture ones do not.
|
|
|
|
Companion files in `discovery/apt/` (all runnable, all proven on the five images):
|
|
|
|
| file | purpose |
|
|
|---|---|
|
|
| `fixture.sh` | builds the local flat repo + dummy packages (item 10) |
|
|
| `smoke.sh` | asserts every recipe in this document on top of the fixture (32 checks, passes on all five images) |
|
|
| `parse-inst.awk` | `Inst` line -> `name<TAB>old<TAB>new<TAB>repo` (item 2) |
|
|
| `closure-dpkg.awk` | exact installed dependency closure from the dpkg database (item 5) |
|
|
|
|
## 0. TL;DR - chosen commands
|
|
|
|
| need | command (all with `LC_ALL=C DEBIAN_FRONTEND=noninteractive`) |
|
|
|---|---|
|
|
| refresh | `apt-get update -qq -o APT::Update::Error-Mode=any` (without the `-o`, unreachable mirrors still give exit 0!) |
|
|
| list what the upgrade will do | `apt-get -s --with-new-pkgs upgrade \| awk -f parse-inst.awk` (same verb + flags as the real upgrade) |
|
|
| list kept-back/held | same output, block `The following packages have been kept back:` (do NOT use `-qq`, it drops it); `apt-mark showhold` for real holds |
|
|
| cross-check / "everything newer" | `apt list --upgradable 2>/dev/null` (stderr carries a warning, stdout has a `Listing...` header) |
|
|
| one-liner description | `apt-cache show --no-all-versions P \| sed -n 's/^Description: //p'` |
|
|
| full description (candidate) | `apt-cache show --no-all-versions P \| awk '<see item 3>'` |
|
|
| installed reverse deps | `apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P \| awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ \|]+/,"");print}' \| sort -u` |
|
|
| installed dep closure | exact: `dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' \| awk -F'\t' -v roots="P" -f closure-dpkg.awk` (== apt's own parser, verified). The single-call `apt-cache depends --recurse ... P` is **inexact in both modes** (with `--installed` it loses virtual-provided deps, without it over-approximates 2-8x): see item 5 |
|
|
| per-run exclusion | `apt-mark hold P` for packages not already held, restore from a persisted snapshot with `comm` (item 6) |
|
|
| upgrade | `apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade` |
|
|
| purge | `apt-get -y purge P`, then `apt-get -y autoremove --purge` |
|
|
| bats | `apt-get install -y --no-install-recommends bats` (1.2.1 .. 1.13.0 depending on image) |
|
|
|
|
Top gotchas (details in the sections):
|
|
|
|
1. `apt-get update` exits **0** when every mirror is unreachable (only `W:` lines). Use `-o APT::Update::Error-Mode=any` (or `--error-on=any`) to get exit 100. Hard errors (unsigned repo, no root) are exit 100 regardless.
|
|
2. `apt list` / `apt show` are the "unstable CLI": a warning is printed on **stderr** whenever stdout is not a tty. Use `apt-get` / `apt-cache` / `dpkg-query` / `apt-mark` for parsing.
|
|
3. All five images run as **root with no sudo, no python3**; `apt-get update` must be run first (lists are empty).
|
|
4. `-y` upgrade with a *locally modified conffile* that the new package also changes => dpkg prompts, hits EOF on stdin, fails, **exit 100**, package left in `iU` state. Always pass `--force-confdef --force-confold`.
|
|
5. `apt-cache depends --recurse` cannot produce an exact installed closure: with `--installed` it silently loses everything reachable only through a **virtual package** (e.g. `base-files` -> `awk` -> `mawk`: closure of 1 instead of 5 on debian:12) and may still print non-installed provider names; without `--installed` it walks through non-installed alternatives/providers and uses *candidate* metadata (debian:12 `bash`: 19 names vs the exact 8). Use the dpkg-database awk (`closure-dpkg.awk`).
|
|
6. `apt-cache rdepends` does not follow `Provides` (nobody is listed as reverse dependency of `mawk` although `base-files` needs `awk`), lists duplicates (one per version), and `--installed` filters on the *package* being installed, not the *version* that has the dependency.
|
|
7. `apt-get` takes the dpkg lock: a concurrent apt gives exit 100 `Could not get lock /var/lib/dpkg/lock-frontend` (also `apt-mark hold`, with a misleading "Are you root?"). `-o DPkg::Lock::Timeout=N` waits instead.
|
|
8. Holding a package also keeps back everything that needs a newer version of it (hold `b` => `a` kept back). `apt-mark hold` with a mix of valid/invalid names holds the valid ones **and** exits 100.
|
|
9. stderr noise is not failure: minimal images print `debconf: delaying package configuration, since apt-utils is not installed` on every dpkg run. Trust exit codes.
|
|
10. Plain `apt-get install bats` pulls `parallel` + perl (39 pkgs / 85 MB on debian:12, 60 / 159 MB on ubuntu:24.04) and may upgrade unrelated packages; use `--no-install-recommends`.
|
|
11. `ubuntu:latest` is now **26.04 (apt 3.2.0, new solver3)**: prints `Solving dependencies...`, and `dist-upgrade` refuses to remove manually installed packages (keeps them back instead).
|
|
|
|
## 1. Test matrix and base-image facts
|
|
|
|
| image | resolves to | image id | apt | dpkg | bash | awk | bats (apt) |
|
|
|---|---|---|---|---|---|---|---|
|
|
| debian:12 | Debian 12 bookworm | 94926f0fd968 | 2.6.1 | 1.21.23 | 5.2.15 | mawk 1.3.4 20200120 | 1.8.2-1 |
|
|
| debian:13 | Debian 13 trixie | 99339bdd341a | 3.0.3 | 1.22.22 | 5.2.37 | mawk 1.3.4 20250131 | 1.11.1-1 |
|
|
| ubuntu:22.04 | Ubuntu 22.04.5 jammy | e8df6c74f650 | 2.4.14 | 1.21.1 | 5.1.16 | mawk 1.3.4 20200120 | 1.2.1-3build1 |
|
|
| ubuntu:24.04 | Ubuntu 24.04.5 noble | 69f919497cad | 2.8.3 | 1.22.6 | 5.2.21 | mawk 1.3.4 20240123 | 1.10.0-1 |
|
|
| ubuntu:latest | **Ubuntu 26.04.1 resolute** | 6e92c4fd8dfd | 3.2.0 | 1.23.7 | 5.3.9 | mawk 1.3.4 20260129 | 1.13.0-1 |
|
|
|
|
Identical on all five (item 9):
|
|
|
|
```
|
|
$ id -u; whoami -> 0, root (docker default user; no USER directive)
|
|
$ command -v sudo -> (nothing) sudo is NOT installed
|
|
$ command -v python3 -> (nothing) python3 is NOT installed
|
|
$ command -v bash -> /usr/bin/bash bash present (5.1 .. 5.3); /bin/sh is dash
|
|
perl, flock, sed (GNU), grep, awk (mawk), dpkg-deb: present. curl, wget, gpg, dpkg-scanpackages, apt-ftparchive: absent.
|
|
```
|
|
|
|
- Do not rely on gawk features: `awk` is **mawk** everywhere and `sed` is GNU sed 4.8/4.9. Verified on mawk: `gensub()` -> `function gensub never defined`; 3-argument `match(s,r,arr)` -> syntax error; regex brace repetition `a{2}` is matched **literally** on mawk 1.3.4 20200120 (debian:12, ubuntu:22.04) and works on 20240123+ (debian:13, ubuntu:24.04, ubuntu:latest) - so avoid `{n}` in awk regexes.
|
|
- `/var/lib/apt/lists` is empty at start: nothing works (`apt-cache policy` has no candidates) until `apt-get update`.
|
|
- Docker-specific apt config shipped in the images: `docker-clean` (`Dir::Cache::pkgcache ""` => **no binary cache**, every `apt-get/apt-cache/apt-mark` call re-parses all lists), `docker-no-languages` (`Acquire::Languages "none"` => descriptions come from the `Packages` file, no Translation files), `docker-gzip-indexes`, `docker-autoremove-suggests` (`SuggestsImportant "false"`), `docker-disable-periodic-update` (ubuntu), `/usr/sbin/policy-rc.d` (`exit 101`, services are not started by maintainer scripts).
|
|
- Sources point to the **live** mirrors (`deb.debian.org`, `archive.ubuntu.com`, `security.ubuntu.com`) although the debian images carry a commented snapshot date. Therefore real-world upgradable packages exist in the images and change over time. Seen on 2026-10-09:
|
|
|
|
| image | real-world upgradable right now |
|
|
|---|---|
|
|
| debian:12 | `perl-base` 5.36.0-7+deb12u3 -> u4 (`oldstable-security`) |
|
|
| debian:13 | `liblzma5` 5.8.1-1+deb13u1 -> u2 (`stable-security`) |
|
|
| ubuntu:22.04 | `libssl3` 3.0.2-0ubuntu1.29 -> .30 |
|
|
| ubuntu:24.04 | `libaudit-common`, `libaudit1`, `libssl3t64` |
|
|
| ubuntu:latest | `libssl3t64`, `openssl-provider-legacy` 3.5.5-1ubuntu3.5 -> .7 |
|
|
|
|
- bats (item 9): `apt-get install -y --no-install-recommends bats` works on all five (exit 0, 0.7-1.7 s, 1 package, <200 kB; `bats --version` as in the table; `bats --tap` works). Helper libs `bats-support bats-assert bats-file` exist on every image **except ubuntu:22.04**. Oldest is **1.2.1** (jammy) which lacks `run --separate-stderr`, `bats_require_minimum_version`, `setup_suite`: restrict tests to the 1.2.1 feature set or vendor bats-core. `Recommends: parallel` (that is what bloats a plain install).
|
|
|
|
## 2. Item 1 - refreshing metadata: `apt-get update`
|
|
|
|
```
|
|
$ apt-get update (debian:12, root, network ok) [exit=0]
|
|
Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
|
|
Get:2 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
|
|
Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [34.8 kB]
|
|
Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8790 kB]
|
|
Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages [6924 B]
|
|
...
|
|
Fetched 9388 kB in 2s (5412 kB/s)
|
|
Reading package lists...
|
|
```
|
|
Second run: `Hit:1 ... InRelease` lines, exit 0. `-qq` prints nothing on success (still exit 0); warnings/errors still go to **stderr** under `-qq`.
|
|
|
|
| situation | exit | where |
|
|
|---|---|---|
|
|
| success / nothing new (`Hit:`) | 0 | |
|
|
| not root | **100** | `E: Could not open lock file /var/lib/apt/lists/lock - open (13: Permission denied)` + `E: Unable to lock directory /var/lib/apt/lists/` |
|
|
| all mirrors unreachable (`--network none`) | **0** (!) | stdout `Err:1 ...` + `Temporary failure resolving 'deb.debian.org'`; stderr `W: Failed to fetch ...` and `W: Some index files failed to download. They have been ignored, or old ones used instead.` |
|
|
| one of several sources unreachable | **0** (!) | same `W:` lines |
|
|
| same two cases with `-o APT::Update::Error-Mode=any` (or `--error-on=any`) | **100** | the `W:` become `E:` (same text) |
|
|
| unsigned repo (no `[trusted=yes]`) | **100** | `E: The repository 'file:/srv/su-repo ./ Release' is not signed.` |
|
|
|
|
The "unreachable => exit 0" row and the `Error-Mode=any` fix are identical on all five images. After a failed update the old lists are kept (verified with a dead `Acquire::http::Proxy`: exit 0, `W: Failed to fetch ...`, the 3 `Packages` files and the candidate versions are unchanged = stale data) - so a tool must either use `Error-Mode=any` or parse stderr for `^W: Failed to fetch`.
|
|
|
|
Other notes: `apt-get update --print-uris` lists the index URLs without fetching. A second `update` of a `file:` repo regenerated within the same second can be seen as unchanged (mtime resolution): `fixture.sh` removes the repo's list files first.
|
|
|
|
## 3. Item 2 - listing upgradable packages
|
|
|
|
### 3.1 The candidates compared (real data, ubuntu:24.04)
|
|
|
|
`apt list --upgradable` (stdout and stderr captured separately) [exit=0]:
|
|
```
|
|
stdout:
|
|
Listing...
|
|
libaudit-common/noble-updates 1:3.1.2-2.1ubuntu0.1 all [upgradable from: 1:3.1.2-2.1build1.1]
|
|
libaudit1/noble-updates 1:3.1.2-2.1ubuntu0.1 amd64 [upgradable from: 1:3.1.2-2.1build1.1]
|
|
libssl3t64/noble-updates,noble-security 3.0.13-0ubuntu3.16 amd64 [upgradable from: 3.0.13-0ubuntu3.15]
|
|
stderr:
|
|
(empty line)
|
|
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
|
|
```
|
|
- stderr warning on all five images whenever stdout is not a tty; `Listing...` header on stdout; origin column is only the suite(s) comma-separated (no Label/Version); the name has **no arch qualifier** (with multiarch the same name appears twice: `su-dummy-m/su-repo 2.0 amd64 ...` and `su-dummy-m/su-repo 2.0 i386 ...`); it lists **every** package whose candidate is newer, including held and kept-back ones (it is not "what the upgrade will do").
|
|
|
|
`apt-get -s --with-new-pkgs upgrade` (ubuntu:24.04) [exit=0]:
|
|
```
|
|
Reading package lists...
|
|
Building dependency tree...
|
|
Reading state information...
|
|
Calculating upgrade...
|
|
The following packages will be upgraded:
|
|
libaudit-common libaudit1 libssl3t64
|
|
3 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
|
|
Inst libaudit-common [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
|
|
Conf libaudit-common (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [all])
|
|
Inst libaudit1 [1:3.1.2-2.1build1.1] (1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates [amd64])
|
|
Inst libssl3t64 [3.0.13-0ubuntu3.15] (3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
|
```
|
|
debian:12: `Inst perl-base [5.36.0-7+deb12u3] (5.36.0-7+deb12u4 Debian-Security:12/oldstable-security [amd64])`.
|
|
`apt-get -s dist-upgrade` prints the identical `Inst` lines when no removals/new packages are involved (all three verbs gave the same count on the real data).
|
|
|
|
### 3.2 Decision
|
|
|
|
Use **`LC_ALL=C apt-get -s --with-new-pkgs upgrade`** and parse the `Inst` lines:
|
|
|
|
- it lists exactly what the real `apt-get --with-new-pkgs upgrade` will do (same resolver, same holds/pins), including *new* dependency packages and the correct order;
|
|
- no stderr warning, works **as non-root** (prints a `NOTE: This is only a simulation!` block on stdout, harmless), needs no lock;
|
|
- carries old version, new version, full origin and arch;
|
|
- `Inst`/`Conf`/`Remv`/`Purg` line prefixes are not translated, but the `kept back` header is, hence `LC_ALL=C`.
|
|
|
|
Keep `apt list --upgradable 2>/dev/null` only as a cross-check (it is the superset: includes held/kept-back).
|
|
|
|
### 3.3 Exact parse -> `name<TAB>old<TAB>new<TAB>repo` (`discovery/apt/parse-inst.awk`, POSIX awk, tested with mawk)
|
|
|
|
```awk
|
|
/^Inst / {
|
|
name = $2
|
|
rest = $0
|
|
sub(/^Inst [^ ]+ /, "", rest)
|
|
old = "-"
|
|
if (substr(rest, 1, 1) == "[") { # "[old] (new origins [arch])"
|
|
old = rest
|
|
sub(/^\[/, "", old)
|
|
sub(/\].*$/, "", old)
|
|
sub(/^\[[^]]*\] /, "", rest)
|
|
}
|
|
sub(/^\(/, "", rest) # "new origins [arch]) ..."
|
|
new = rest
|
|
sub(/ .*$/, "", new)
|
|
sub(/^[^ ]+ ?/, "", rest) # "origins [arch]) ..."
|
|
i = index(rest, "])")
|
|
repo = substr(rest, 1, i - 1) # "origins [arch"
|
|
sub(/ ?\[[^[]*$/, "", repo) # drop " [arch"
|
|
printf "%s\t%s\t%s\t%s\n", name, old, new, repo
|
|
}
|
|
```
|
|
Usage: `LC_ALL=C apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk -f parse-inst.awk`. Real output:
|
|
```
|
|
perl-base 5.36.0-7+deb12u3 5.36.0-7+deb12u4 Debian-Security:12/oldstable-security
|
|
libssl3t64 3.0.13-0ubuntu3.15 3.0.13-0ubuntu3.16 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security
|
|
libaudit-common 1:3.1.2-2.1build1.1 1:3.1.2-2.1ubuntu0.1 Ubuntu:24.04/noble-updates
|
|
su-dummy-newdep - 1.0 su-fixture:su-repo
|
|
```
|
|
Edge cases, proven with a unit test on literal lines (real lines plus two hand-written ones modelled on them, the `libc6:i386` line and the `Inst su-dummy-slow` line with an empty origin) - input on the left, TSV output on the right:
|
|
```
|
|
Inst su-dummy-newdep (1.0 su-fixture:su-repo [all]) -> su-dummy-newdep - 1.0 su-fixture:su-repo
|
|
Inst su-dummy-slow [0.9] (1.0 [all]) -> su-dummy-slow 0.9 1.0 (empty repo)
|
|
Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]
|
|
-> su-dummy-m 1.0 2.0 su-fixture:su-repo
|
|
Inst libc6:i386 [2.36-9] (2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable [i386]) []
|
|
-> libc6:i386 2.36-9 2.36-9+deb12u10 Debian-Security:12/oldstable-security, Debian:12.12/oldstable
|
|
Conf ... / Remv su-dummy-f [1.0] -> (ignored)
|
|
```
|
|
(`smoke.sh` asserts the plain fixture line and the new-package `-` case on every image; the real-data lines above come from the images themselves.)
|
|
- **new package** (pulled in as dependency): `Inst su-dummy-newdep (1.0 su-fixture:su-repo [all])` has no `[old]` => old column is `-` (not empty: `IFS=$'\t' read` collapses empty tab fields because tab is IFS-whitespace).
|
|
- **multiple origins**: `Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security` (comma+space, kept verbatim in the repo column).
|
|
- **empty origin** (package from a local .deb): `(1.0 [all])` -> repo is empty.
|
|
- **foreign arch**: name carries the qualifier, `libc6:i386`; native arch has none.
|
|
- **trailing junk after the closing paren** (multiarch pairs): `Inst su-dummy-m [1.0] (2.0 su-fixture:su-repo [amd64]) [su-dummy-m:amd64 on su-dummy-m:i386] [su-dummy-m:i386 ]` - hence the parser cuts at the first `])`, never anchors on `$`. A bare ` []` may also trail.
|
|
- `Inst` order is **dependency order** (b before a), not alphabetical: `sort` if a stable order is needed.
|
|
- Repo string format is `Label:Version/Suite` (from the Release file; `Debian-Security:12/oldstable-security`, `Ubuntu:24.04/noble-updates`); fixture repo (no Version) gives `su-fixture:su-repo`; with neither Label nor Suite apt prints the host (`localhost`).
|
|
|
|
Secondary parse of `apt list --upgradable` (if ever needed), same 4 columns (repo = suite list):
|
|
```sh
|
|
apt list --upgradable 2>/dev/null | sed -nE 's#^([^/]+)/([^ ]+) ([^ ]+) ([^ ]+) \[upgradable from: ([^]]+)\]$#\1\t\5\t\3\t\2#p'
|
|
```
|
|
(`Listing...` is skipped by the anchored pattern; no arch disambiguation.)
|
|
|
|
### 3.4 Held and kept-back packages
|
|
|
|
Held packages never appear as `Inst`. They show up in the `kept back` block together with packages kept back for dependency reasons, and in the "N not upgraded" count; `apt list --upgradable` still lists them. Real output (fixture, `su-dummy-c` held, `-d` needs a new package, `-e` needs a removal):
|
|
```
|
|
$ apt-mark hold su-dummy-c ; apt-get -s upgrade
|
|
The following packages have been kept back:
|
|
su-dummy-c su-dummy-d su-dummy-e
|
|
The following packages will be upgraded:
|
|
su-dummy-a su-dummy-b su-dummy-w
|
|
3 upgraded, 0 newly installed, 0 to remove and 3 not upgraded.
|
|
```
|
|
Parse of the block (names are space separated, indented by two spaces, wrapped at 80 columns):
|
|
```sh
|
|
awk '/^The following packages have been kept back:/{k=1;next} k&&/^ /{for(i=1;i<=NF;i++)print $i;next} {k=0}'
|
|
```
|
|
Distinguish "held" from "kept back for other reasons" with `apt-mark showhold`. **Do not use `-qq`** with `-s` when you need this block: `apt-get -qq -s ...` prints only the `Inst`/`Conf` lines (verified).
|
|
|
|
Behaviour of the three upgrade verbs on the same state (fixture `SU_FIXTURE_EXTRA=1`: d needs a NEW dependency, e conflicts with installed f; debian:12/13, ubuntu:22.04/24.04 identical):
|
|
|
|
| verb | upgraded | newly installed | removed | kept back |
|
|
|---|---|---|---|---|
|
|
| `apt-get upgrade` | a b c w | - | - | d e |
|
|
| `apt-get --with-new-pkgs upgrade` | a b c d w | su-dummy-newdep | - | e |
|
|
| `apt-get dist-upgrade` | a b c d e w | su-dummy-newdep | su-dummy-f | - |
|
|
|
|
(`ubuntu:latest`/apt 3.2 solver3: `dist-upgrade` does **not** remove the manually installed `su-dummy-f`, so `e` stays kept back there: 5 upgraded, 1 new, 0 removed.) `--with-new-pkgs upgrade` is the safe choice: it never removes anything.
|
|
|
|
## 4. Item 3 - description of a package
|
|
|
|
Candidate vs installed matters. Real data (debian:12, `perl-base` installed u3, candidate u4):
|
|
```
|
|
$ apt-cache policy perl-base
|
|
perl-base:
|
|
Installed: 5.36.0-7+deb12u3
|
|
Candidate: 5.36.0-7+deb12u4
|
|
Version table:
|
|
5.36.0-7+deb12u4 500
|
|
500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages
|
|
*** 5.36.0-7+deb12u3 500
|
|
500 http://deb.debian.org/debian bookworm/main amd64 Packages
|
|
100 /var/lib/dpkg/status
|
|
```
|
|
| command | content | notes |
|
|
|---|---|---|
|
|
| `apt-cache policy P` | Installed / Candidate / version table with pins and origins | no description at all; exit 0 and **empty output** for an unknown package |
|
|
| `apt-cache show P` | full control record(s) **of all versions** (default `--all-versions`: 2 stanzas for perl-base) | `--no-all-versions` => only the candidate (1 stanza); `P=1.0` selects one version; unknown package: `E: No packages found` **exit 100**; purely virtual: no output, exit 0 |
|
|
| `dpkg -s P` / `dpkg-query -W -f '${Description}' P` | record of the **installed** version only | non-installed (known or unknown) package: `dpkg-query: package 'x' is not installed and no information is available`, **exit 1** (`dpkg-query -W -f '${binary:Summary}'` prints `(no description available)` with exit 0) |
|
|
| `apt show P` | like `apt-cache show` plus formatting | stderr warning, not for scripts |
|
|
|
|
Short description (one line):
|
|
```sh
|
|
apt-cache show --no-all-versions "$pkg" | sed -n 's/^Description: //p' # candidate -> minimal Perl system
|
|
dpkg-query -W -f='${binary:Summary}\n' "$pkg" # installed version
|
|
apt-cache search --names-only "^$pkg\$" # "perl-base - minimal Perl system"
|
|
```
|
|
Full Description paragraph of the **candidate** (first line, continuation lines with the single leading space stripped, ` .` turned into an empty line, stops at the next field):
|
|
```sh
|
|
apt-cache show --no-all-versions "$pkg" | awk '
|
|
/^Description(-[a-z][a-z](_[A-Z][A-Z])?)?:/ { p=1; sub(/^[^:]*: ?/,""); print; next }
|
|
p && /^ / { l=substr($0,2); print (l=="." ? "" : l); next }
|
|
p { exit }'
|
|
```
|
|
Verified output for the fixture package (identical on debian:12 and ubuntu:24.04; `Description-md5:` is correctly not matched):
|
|
```
|
|
dummy package su-dummy-a for skeptical-updater tests
|
|
This package only exists to exercise the package manager.
|
|
|
|
It ships one data file and one conffile (version 2.0).
|
|
```
|
|
Raw record order differs per distro (`Description` is followed by `Description-md5`, `Homepage`, ... on Debian; Ubuntu 24.04+/Debian 13 print an empty `Description-md5:` for installed-only stanzas) - that is why the awk stops at the first non-continuation line instead of counting lines. `dpkg -s` shows the same text for the installed version (`... (version 1.0)` in the fixture, `2.0` for the candidate).
|
|
|
|
## 5. Item 4 - installed reverse dependencies
|
|
|
|
Flags: `apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P`.
|
|
Without the `--no-conflicts/--no-breaks/...` flags Conflicts/Breaks/Replaces are included (fixture: `su-dummy-e` shows up as "reverse dependency" of `su-dummy-f` only because of `Conflicts`; on debian:12 `libc6`: 76 entries default vs 75 filtered). Pre-Depends are always included (good).
|
|
|
|
Output format (fixture, debian:12) [exit=0]:
|
|
```
|
|
su-dummy-b
|
|
Reverse Depends:
|
|
su-dummy-a
|
|
su-dummy-a
|
|
```
|
|
real data (`gpgv`, debian:12, an alternative `gpgv | gpgv2 | gpgv1` in apt's Depends):
|
|
```
|
|
gpgv
|
|
Reverse Depends:
|
|
|apt
|
|
```
|
|
- line 1 = the package, line 2 = `Reverse Depends:`, then one dependent per line, 2 spaces indent; a leading ` |` marks "I depend on this **or an alternative**" (the `|` replaces the first space).
|
|
- **duplicates**: one entry per package *version* that has the dependency (installed 1.0 and candidate 2.0 both => twice; ubuntu repeats per pocket) => `sort -u`.
|
|
- unknown package: `E: No packages found` **[exit=100]**. Purely virtual name (`awk`, `su-virtual`): prints only `<awk>` [exit=0], never the dependents.
|
|
- Not-installed package with `--installed`: still answers (lists installed dependents).
|
|
|
|
Parse to plain names (one package per call):
|
|
```sh
|
|
apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "$pkg" \
|
|
| awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,""); print}' | sort -u
|
|
```
|
|
Real: `libgcrypt20` -> `gpgv libapt-pkg6.0 libsystemd0` (debian:12), `gpgv libapt-pkg6.0t64 libsystemd0` (ubuntu:24.04); without `--installed` the same query returns 155 / 171 names.
|
|
For several packages in one call the output is a repeated block (`pkg` / `Reverse Depends:` / entries); track the block header (first non-indented line) if needed.
|
|
|
|
Known inexactness (all verified):
|
|
1. **`Provides` is not followed**: `apt-cache rdepends --installed ... mawk` => empty, although `base-files` (Pre-)Depends on the virtual `awk` that `mawk` provides. Fixture: `su-dummy-w` depends on `su-virtual | su-dummy-alt`, `su-dummy-v` provides `su-virtual` => `rdepends su-dummy-v` is empty. To be complete, also query every name in the target's `Provides:` (`apt-cache show --no-all-versions P | sed -n 's/^Provides: //p'`) - but `rdepends <virtual>` itself prints only `<virtual>`, so you must instead scan for the virtual in `apt-cache showpkg <virtual>` ("Reverse Depends:" section lists `su-dummy-w,su-virtual`) or use the dpkg database.
|
|
2. `--installed` filters on the dependent **package** being installed, not on the installed **version** having the dependency: `rdepends --installed su-dummy-newdep` lists `su-dummy-d` although only the not-yet-installed d 2.0 depends on it.
|
|
3. Exact alternative for "what would break if I removed P": `apt-get -s purge P | awk '/^Purg /{print $2}'` (transitive, uses the installed versions, the output includes P itself): fixture `apt-get -s purge su-dummy-b` => `Purg su-dummy-a [1.0]`, `Purg su-dummy-b [1.0]` [exit=0]. For Essential chains it errors (`E: Error, pkgProblemResolver::Resolve generated breaks`, `apt-get -s remove libgcrypt20`).
|
|
|
|
## 6. Item 5 - recursive dependency closure restricted to installed packages
|
|
|
|
Requested form: `apt-cache depends --recurse --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances P`.
|
|
|
|
### 6.1 Output format (debian:12, `apt`)
|
|
|
|
```
|
|
apt <- column 0: a package header (one stanza per reachable package; --recurse output is FLAT, not a tree)
|
|
Depends: adduser <- 2 spaces: dependency ("PreDepends:" for Pre-Depends)
|
|
|Depends: gpgv <- leading " |": member of an alternative group "gpgv | gpgv2 | gpgv1"; the last member has no '|'
|
|
Depends: libapt-pkg6.0
|
|
Depends: libsystemd0
|
|
libelogind0 <- 4 spaces: packages that PROVIDE the dependency name above (not filtered by --installed!)
|
|
adduser
|
|
Depends: passwd
|
|
gpgv
|
|
Depends: libbz2-1.0
|
|
...
|
|
```
|
|
Without `--installed` the group is `|Depends: gpgv`, `|Depends: gpgv2`, ` Depends: gpgv1`. A purely virtual dependency is printed in angle brackets with its providers below it, and later as its own (empty) stanza:
|
|
```
|
|
ca-certificates (NOT installed; plain depends) [exit=0]
|
|
Depends: openssl
|
|
|Depends: debconf
|
|
Depends: <debconf-2.0>
|
|
cdebconf
|
|
debconf
|
|
```
|
|
- Package **not installed**: exit 0, the header and its dependencies are printed anyway; with `--installed` only the installed dependencies (` |Depends: debconf`; `openssl` and `<debconf-2.0>` disappear), and `--recurse --installed` yields `ca-certificates` (the root, although not installed) + `debconf`.
|
|
- Unknown package: `E: No packages found` [exit=100]. Purely virtual root: prints `<name>` [exit=0].
|
|
- `depends P` uses the **candidate** version's dependencies (fixture: installed `su-dummy-d 1.0` has none, candidate 2.0 needs `su-dummy-newdep` => `apt-cache depends su-dummy-d` shows it; `apt-cache depends su-dummy-d=1.0` shows nothing). Pin a version with `P=VERSION` to get the installed version's dependencies.
|
|
|
|
### 6.2 What the single-call forms really return (real lists, all five images)
|
|
|
|
Reference ("exact") = the installed versions' Pre-Depends/Depends, expanded only through installed packages, every installed alternative followed, virtuals resolved through installed providers. It was computed two independent ways that agree 100% (`comm` diff empty) for 5 roots x 5 images: (D) a BFS driving `apt-cache depends --no-... pkg=INSTALLED_VERSION` (apt's own parser), and (C) `closure-dpkg.awk` over the dpkg database (6.4).
|
|
|
|
**A. with `--installed`** (as requested) - under-approximates, and is not even purely "installed":
|
|
```
|
|
$ apt-cache depends --recurse --installed <flags> base-files (debian:12)
|
|
base-files <- the whole closure is ONE package (exact: 5)
|
|
```
|
|
Virtual packages are not "installed", so `--installed` prunes every dependency satisfied through `Provides` and the recursion stops there (`base-files` has `PreDepends: <awk>`, provider `mawk`). Sizes `--installed` vs exact: `base-files` 1 vs 5 (debian:12/13), 5 vs 6 (ubuntu 22.04/24.04), 6 vs 7 (ubuntu:latest); `bash` 7 vs 8, 7 vs 8, 8 vs 9, 8 vs 9, 9 vs 10 (always missing `mawk`); fixture `su-dummy-w` -> `<su-virtual>` -> `su-dummy-v` lost the same way (1 vs 2). Conversely the provider lines are not filtered and get recursed: non-installed names such as `libelogind0` (debian:12 `apt`: 46 vs 45) or `gpgv-from-sq` (ubuntu:latest `apt`: 27 vs 26) appear as column-0 headers. A non-installed root is still printed as the first stanza.
|
|
|
|
**B. without `--installed`, then intersect with dpkg-installed names** - over-approximates, because apt-cache walks into *non-installed* alternatives/providers (`debconf-2.0` -> `cdebconf`, `gpgv2`, ...) and uses the *candidate* version's dependencies:
|
|
|
|
| root | exact | B | | root | exact | B |
|
|
|---|---|---|---|---|---|---|
|
|
| debian:12 `apt` | 45 | 50 | | debian:13 `apt` | 27 | 46 |
|
|
| debian:12 `base-files` | 5 | 17 | | ubuntu:24.04 `apt` | 35 | 49 |
|
|
| debian:12 `bash` | 8 | 19 | | ubuntu:latest `apt` | 26 | 45 |
|
|
| debian:12 `tzdata` | 2 | 16 | | ubuntu:22.04 `bash` | 9 | 19 |
|
|
|
|
(the extras are installed packages such as `dpkg libacl1 tar libselinux1 libtinfo6 ...` that are reached only by recursing into **non-installed** providers/alternatives: `cdebconf` (+ `libnewt0.52 libslang2 ...`) for `tzdata`'s `debconf | debconf-2.0`, `gawk`/`original-awk` (+ `libmpfr6 libreadline8 ...`) for the virtual `awk` of `base-files`/`bash`; verified by listing the non-installed column-0 stanzas of the recursion). In addition `depends` uses the *candidate* version's dependencies (fixture `su-dummy-d`), which differ from the installed ones whenever an upgrade changes dependencies. B is a superset of the exact closure, A is neither a subset nor a superset. With the offline fixture (no real lists, apt only knows installed versions) A, B and exact differ only by the virtual case.
|
|
|
|
Other facts: cost of the recursion itself is nil (0.35 s with and without `--installed` on debian:12, dominated by re-parsing the lists); the foreign-arch names come as `name:arch`.
|
|
|
|
### 6.3 If a single apt-cache call is preferred anyway (superset semantics, e.g. "candidate/post-upgrade closure")
|
|
|
|
```sh
|
|
FLAGS="--no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances"
|
|
installed_names() { dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\n' | awk -F'\t' 'substr($1,2,1) !~ /[nc]/ {print $2}' | sort -u; }
|
|
comm -12 \
|
|
<(apt-cache depends --recurse $FLAGS "$pkg" | awk '/^[^ <]/' | sort -u) \
|
|
<(installed_names)
|
|
```
|
|
- `awk '/^[^ <]/'` = column-0 lines = every package that was reached, minus `<virtual>` headers; the `|` markers (` |Depends: x`) and the 4-space provider lines are indented, hence ignored (`|alternatives` are therefore all followed, installed or not).
|
|
- `substr($1,2,1) !~ /[nc]/`: dpkg state is not `n`(ot-installed)/`c`(onfig-files only); `ii`, `iU`, `iF` count as installed, `rc` does not.
|
|
- Use `${Package}:${Architecture}` on both sides if foreign architectures matter (apt-cache prints `name:arch` for non-native only).
|
|
|
|
### 6.4 Recommended (exact, no apt call): `discovery/apt/closure-dpkg.awk`
|
|
|
|
```sh
|
|
dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' \
|
|
| awk -F'\t' -v roots="pkg1 pkg2" -f closure-dpkg.awk | sort -u
|
|
```
|
|
Follows Pre-Depends + Depends of the **installed** versions, every installed alternative of an `a | b` group, virtual names through the `Provides` of installed packages (versioned `Provides: x (= 1.2)` and `:any` qualifiers handled); prints plain names, roots included if installed (a non-installed root yields nothing). ~0 s (single dpkg-query). Output verified identical to the apt-cache BFS reference on `apt base-files bash libpam-runtime tzdata` on all five images (e.g. debian:12 `apt` 45 = 45, `bash` 8 = 8, `base-files` 5 = 5 incl. `mawk`; ubuntu:24.04 `tzdata` not installed => empty).
|
|
|
|
## 7. Item 6 - per-run exclusion with `apt-mark hold / unhold / showhold`
|
|
|
|
Real outputs (fixture, debian:12):
|
|
```
|
|
$ apt-mark hold su-dummy-c su-dummy-c set on hold. [exit=0]
|
|
$ apt-mark hold su-dummy-c su-dummy-c was already set on hold. [exit=0]
|
|
$ apt-mark showhold su-dummy-c [exit=0] (works as non-root too)
|
|
$ apt-mark unhold su-dummy-c Canceled hold on su-dummy-c. [exit=0]
|
|
$ apt-mark unhold su-dummy-a su-dummy-a was already not on hold. [exit=0]
|
|
$ apt-mark hold no-such-pkg-xyz E: Unable to locate package no-such-pkg-xyz / E: No packages found [exit=100]
|
|
$ apt-mark hold su-dummy-newdep no-such-pkg-xyz su-dummy-newdep set on hold. / E: Unable to locate package ... [exit=100] <- the valid one IS held
|
|
$ (non-root) apt-mark hold X dpkg: error: required read/write access to the dpkg database directory / E: Executing dpkg failed. Are you root? [exit=100]
|
|
$ dpkg-query -W -f '${Package} ${Status}\n' su-dummy-c su-dummy-c hold ok installed
|
|
$ dpkg --get-selections | grep hold su-dummy-c<TABs>hold
|
|
```
|
|
- A hold is a dpkg selection state (stored in `/var/lib/dpkg/status`): persistent, visible to `dpkg`, needs root **and the dpkg lock** (`apt-mark hold` while another apt runs: `dpkg: error: dpkg frontend lock was locked by another process`, exit 100).
|
|
- Holding a package that is **not installed** works (`set on hold`), shows in `showhold`, and blocks its installation as a new dependency (it made `su-dummy-d` kept back), but `dpkg --get-selections` does not list it.
|
|
- Foreign arch is shown as `name:i386`.
|
|
|
|
**`apt-get --with-new-pkgs upgrade` skips held packages** - proven (fixture, real run, `su-dummy-c` held):
|
|
```
|
|
$ apt-get -y --with-new-pkgs upgrade [exit=0]
|
|
The following NEW packages will be installed: su-dummy-newdep
|
|
The following packages have been kept back: su-dummy-c su-dummy-e
|
|
The following packages will be upgraded: su-dummy-a su-dummy-b su-dummy-d su-dummy-w
|
|
4 upgraded, 1 newly installed, 0 to remove and 2 not upgraded.
|
|
=> a=2.0 b=2.0 c=1.0 (held, untouched) d=2.0 e=1.0 (kept back)
|
|
```
|
|
- The hold cascades: holding `b` (while `a` 2.0 needs `b (>= 2.0)`) keeps **a** back too (`kept back: su-dummy-a su-dummy-b su-dummy-c ...`, exit 0). Always re-run the simulation after holding and show the user the real result.
|
|
- `dist-upgrade` honours holds the same way. `apt-get -y purge <held>` fails: `E: Held packages were changed and -y was used without --allow-change-held-packages.` [exit=100].
|
|
- No-state alternative (inclusion list instead of exclusion): `apt-get -y --only-upgrade install P1 P2 ...` upgrades only those (plus whatever they need: `a` drags `b` along; `d` drags `su-dummy-newdep`; a not-installed name is skipped with exit 0; if the needed dependency is held it fails `E: Unable to correct problems, you have held broken packages.` exit 100).
|
|
|
|
### Recipe: hold temporarily and restore the previous hold set EXACTLY (verified, incl. crash recovery)
|
|
|
|
Idea: never touch what the admin already holds; journal (write-ahead) every hold *we* add to a state file; restore = unhold exactly the journaled packages that are still held. The journal survives `kill -9`, so the next run can undo a leaked hold without disturbing holds the admin added/removed in the meantime (a snapshot-and-diff restore would revert such admin edits - tested, rejected).
|
|
```bash
|
|
SU_STATE_DIR=${SU_STATE_DIR:-/var/lib/skeptical-updater}
|
|
SU_HOLDS_ADDED=$SU_STATE_DIR/holds.added # write-ahead journal: the holds WE put (one name per line)
|
|
|
|
su_holds_begin() { # su_holds_begin PKG... (packages to exclude from this run)
|
|
mkdir -p "$SU_STATE_DIR" || return 1
|
|
local held p; held=$(apt-mark showhold) || return 1
|
|
: > "$SU_HOLDS_ADDED"
|
|
for p in "$@"; do
|
|
grep -qxF -- "$p" <<<"$held" && continue # already held by the admin: not ours, never touched
|
|
echo "$p" >> "$SU_HOLDS_ADDED" # journal first (write-ahead) ...
|
|
apt-mark hold "$p" >/dev/null || return 1 # ... then hold (on failure the caller runs su_holds_restore)
|
|
done
|
|
}
|
|
su_holds_restore() { # idempotent; also call it at the START of every run (crash recovery)
|
|
[ -f "$SU_HOLDS_ADDED" ] || return 0
|
|
apt-mark showhold | sort -u | comm -12 - <(sort -u "$SU_HOLDS_ADDED") | xargs -r apt-mark unhold >/dev/null
|
|
rm -f "$SU_HOLDS_ADDED"
|
|
}
|
|
trap su_holds_restore EXIT # plus INT/TERM; SIGKILL cannot be trapped -> journal + start-of-run call
|
|
```
|
|
Verified scenarios (debian:12; the same logic is asserted by `smoke.sh` on all five images):
|
|
```
|
|
admin before : [su-dummy-a su-dummy-alt ] <- a installed, alt NOT installed
|
|
su_holds_begin su-dummy-a su-dummy-c journal: [su-dummy-c ] (a is the admin's: not journaled)
|
|
during : [su-dummy-a su-dummy-alt su-dummy-c ]
|
|
upgrade exit=0 a=1.0 (held) c=1.0 (held), b/d/w upgraded
|
|
after restore: [su-dummy-a su-dummy-alt ] <- identical to "before"; a 2nd restore is a no-op (rc=0)
|
|
|
|
crash: su_holds_begin su-dummy-c su-dummy-b ; kill -9 ; admin: hold su-dummy-e, unhold su-dummy-alt
|
|
leaked : [su-dummy-a su-dummy-alt su-dummy-b su-dummy-c ]
|
|
admin edits : [su-dummy-a su-dummy-b su-dummy-c su-dummy-e ]
|
|
recovered : [su-dummy-a su-dummy-e ] <- only b,c released; the admin's new hold e kept, the admin's removal of alt respected
|
|
```
|
|
Notes: each `apt-mark` call costs 0.3-0.6 s on these images (no cache) - pass many names to one call where possible (a bad name in a multi-name call holds the valid ones and exits 100, so validate names first: `apt-cache show --no-all-versions N >/dev/null`); `apt-mark`/`xargs -r` needs root and the dpkg lock (use the same `DPkg::Lock::Timeout` discipline: `apt-mark hold` has no timeout option, retry on exit 100 when the message is `frontend lock was locked`).
|
|
|
|
## 8. Item 7 - upgrading non-interactively
|
|
|
|
```
|
|
$ apt-get -y --with-new-pkgs upgrade (fixture; held c) [exit=0]
|
|
...
|
|
Need to get 0 B/4188 B of archives.
|
|
Get:1 file:/srv/su-repo ./ su-dummy-b 2.0 [826 B]
|
|
(Reading database ... 6114 files and directories currently installed.)
|
|
Preparing to unpack ..././su-dummy-b_2.0_all.deb ...
|
|
Unpacking su-dummy-b (2.0) over (1.0) ...
|
|
Setting up su-dummy-b (2.0) ...
|
|
Installing new version of config file /etc/su-dummy/su-dummy-b.conf ...
|
|
```
|
|
Exit codes (identical on the five images unless noted):
|
|
|
|
| case | exit | evidence |
|
|
|---|---|---|
|
|
| success | 0 | |
|
|
| nothing to upgrade (all three verbs) | **0**, ends with `0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.` | `apt-get -qq -s` prints nothing |
|
|
| kept back / held packages remain | **0** | summary `... and 2 not upgraded.` |
|
|
| dpkg fails (postinst `exit 1`, fixture `SU_FIXTURE_BAD=1`) | **100** | stdout `Errors were encountered while processing:` + pkg; stderr `E: Sub-process /usr/bin/dpkg returned an error code (1)`; other packages in the same run are still configured; the bad package is left `iF` (half-configured) and every later `apt-get upgrade`/`install -f` fails again with 100 until fixed |
|
|
| conffile conflict, no `--force-conf*` | **100** | see below |
|
|
| lock held by another apt | **100** | `E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 191 (apt-get)` + `E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), is another process using it?` |
|
|
| `-o DPkg::Lock::Timeout=30` | waits (7 s in the test) then proceeds, exit 0 | |
|
|
| not root (real run) | **100** | `E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)` + `E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?` (`-s` works as non-root) |
|
|
| unknown package (`install`/`purge`) | **100** | `E: Unable to locate package no-such-pkg-xyz` |
|
|
| unsatisfiable (held dependency) | **100** | `E: Unable to correct problems, you have held broken packages.` |
|
|
|
|
**Conffile prompt** (fixture: `/etc/su-dummy/su-dummy-c.conf` edited locally, 2.0 ships a different one, stdin = /dev/null, `-y` only):
|
|
```
|
|
Configuration file '/etc/su-dummy/su-dummy-c.conf'
|
|
==> Modified (by you or by a script) since installation.
|
|
==> Package distributor has shipped an updated version.
|
|
What would you like to do about it ? Your options are: ...
|
|
*** su-dummy-c.conf (Y/I/N/O/D/Z) [default=N] ? dpkg: error processing package su-dummy-c (--configure):
|
|
end of file on stdin at conffile prompt [exit=100], state iU, su-dummy-c.conf.dpkg-new left behind
|
|
```
|
|
With a tty it would block waiting for input instead. Results of the fix options (same scenario, separate containers, all exit 0, state `ii`):
|
|
|
|
| `-o Dpkg::Options::=...` | conf after | leftover |
|
|
|---|---|---|
|
|
| `--force-confold` | `local-edit=1` (kept) | `su-dummy-c.conf.dpkg-dist` (new version) |
|
|
| `--force-confnew` | `version=2.0` (replaced) | `su-dummy-c.conf.dpkg-old` (your edit) |
|
|
| `--force-confdef --force-confold` | kept | `.dpkg-dist` |
|
|
|
|
Use `--force-confdef --force-confold` (never silently overwrite admin edits; unmodified conffiles are still updated: `Installing new version of config file ...`).
|
|
|
|
`upgrade` vs `dist-upgrade`: see the table in 3.4. `apt-get -y dist-upgrade` on the fixture removed `su-dummy-f` (`Removing su-dummy-f (1.0) ...` - note it stays `rc` in dpkg, `dpkg-query -W` still prints its version; always check `${db:Status-Abbrev}`) with no extra confirmation. Recommendation: `--with-new-pkgs upgrade`; use `dist-upgrade` only when explicitly asked, and then show the `Remv` lines first.
|
|
|
|
Output noise (non-tty): `(Reading database ... 5%(Reading database ...` progress is in stdout, `-qq` drops the apt summary but keeps dpkg's output; `-o Dpkg::Use-Pty=0` / `Dpkg::Progress-Fancy=0` change nothing in non-tty runs. stderr always has `debconf: delaying package configuration, since apt-utils is not installed` on the minimal images (harmless).
|
|
|
|
## 9. Item 8 - purge and autoremove
|
|
|
|
```
|
|
$ apt-get -s purge su-dummy-b (a depends on b) [exit=0]
|
|
The following packages will be REMOVED:
|
|
su-dummy-a* su-dummy-b* <- '*' = purge
|
|
0 upgraded, 0 newly installed, 2 to remove and 4 not upgraded.
|
|
Purg su-dummy-a [1.0]
|
|
Purg su-dummy-b [1.0]
|
|
|
|
$ apt-get -y purge su-dummy-b [exit=0]
|
|
Removing su-dummy-a (1.0) ... <- reverse dependencies are removed too, NO extra confirmation with -y
|
|
Removing su-dummy-b (1.0) ...
|
|
Purging configuration files for su-dummy-b (1.0) ...
|
|
Purging configuration files for su-dummy-a (1.0) ...
|
|
```
|
|
- `apt-get -y remove P` leaves conffiles and state `rc` (`rc su-dummy-c=1.0`, `/etc/su-dummy/su-dummy-c.conf` still there); a following `apt-get -y purge P` prints `Purging configuration files for P` and deletes them (state: no dpkg-query entry).
|
|
- Not installed / already purged / known-but-not-installed: `Package 'su-dummy-a' is not installed, so not removed` [exit=0]. Unknown name: `E: Unable to locate package no-such-pkg-xyz` [exit=100].
|
|
- Essential protection: `apt-get -y purge login` -> `WARNING: The following essential packages will be removed. This should NOT be done unless you know exactly what you are doing!` ... `E: Essential packages were removed and -y was used without --allow-remove-essential.` [exit=100], nothing is removed (debian:12/13, ubuntu:22.04/24.04; `coreutils` gives the same on debian:13, ubuntu:22.04 and ubuntu:latest). `login` is **not Essential on ubuntu 26.04** and was really purged there (exit 0) - use `coreutils` for a cross-image Essential test.
|
|
- Held package: `E: Held packages were changed and -y was used without --allow-change-held-packages.` [exit=100] (with the flag it works).
|
|
|
|
Autoremove (fixture: `su-dummy-d` 2.0 pulled `su-dummy-newdep` as automatically installed):
|
|
```
|
|
$ apt-get -y purge su-dummy-d [exit=0]
|
|
The following package was automatically installed and is no longer required:
|
|
su-dummy-newdep
|
|
Use 'apt autoremove' to remove it.
|
|
The following packages will be REMOVED:
|
|
su-dummy-d*
|
|
$ apt-get -s autoremove --purge -> Purg su-dummy-newdep [1.0]
|
|
$ apt-get -y autoremove --purge [exit=0]
|
|
Removing su-dummy-newdep (1.0) ...
|
|
Purging configuration files for su-dummy-newdep (1.0) ...
|
|
$ apt-get -y autoremove --purge (nothing left) ... 0 to remove ... [exit=0]
|
|
```
|
|
`apt-get autopurge` is an equivalent alias (verified: `Purg su-dummy-newdep [1.0]` / `Removing ...` / `Purging configuration files ...`, exit 0). Packages installed with `dpkg -i` are *manual*; `apt-mark auto P` / `apt-mark showauto` control the auto flag. The images set `APT::AutoRemove::SuggestsImportant "false"`.
|
|
Simulation lines to preview: `Purg name [ver]` for purge/autoremove --purge, `Remv name [ver]` for plain remove.
|
|
|
|
## 10. Item 10 - E2E fixture: `discovery/apt/fixture.sh`
|
|
|
|
Builds `/srv/su-repo` (flat repo: `*.deb`, `Packages`, `Release`), source line `deb [trusted=yes] file:/srv/su-repo ./` in `/etc/apt/sources.list.d/su-repo.list`, installs the 1.0 packages with `dpkg -i` (from `/srv/su-old`, deliberately not published), runs `apt-get update` and **asserts** that `su-dummy-a/b/c` are upgradable via both `apt list --upgradable` and `apt-get -s --with-new-pkgs upgrade` (exit 1 otherwise).
|
|
|
|
- `su-dummy-a` 1.0 `Depends: su-dummy-b`; 2.0 `Depends: su-dummy-b (>= 2.0)`; `su-dummy-b`; `su-dummy-c` standalone. Arch `all` (runs on any host arch). Each ships one data file and one conffile (`/etc/su-dummy/<name>.conf`, content `version=<ver>`), multi-line Description.
|
|
- `dpkg-deb --root-owner-group -Zgzip --build`. Index: default `SU_FIXTURE_INDEXER=shell` (dpkg-deb -f + md5sum/sha256sum + hand-written `Release` with Origin/Label `su-fixture`, Suite/Codename `su-repo` and a valid `Date`) => **no extra packages, no network needed**. `scanpackages` (installs `dpkg-dev`) and `ftparchive` (installs `apt-utils`) are also implemented and verified; both need the real mirrors, and installing the tool can upgrade packages as a side effect (on debian:12 installing dpkg-dev upgraded `perl-base`, i.e. the "real-world upgradable" package disappeared from the list) - prefer `shell`.
|
|
- Missing `Date:` in Release gives `W: Invalid 'Date' entry in Release file`; the generated Release has one. A Release without any file also works with `[trusted=yes]` but is noisy (`Method gave a blank filename` x6), hence the generated Release.
|
|
- Optional knobs: `SU_FIXTURE_OFFLINE=1` (moves the distro's real sources to `/srv/su-disabled-sources` => hermetic, 0.4 s, works with `--network none`), `SU_FIXTURE_EXTRA=1` (su-dummy-d/newdep/e/f/v/w/alt scenarios for kept-back, removal, virtual and alternatives), `SU_FIXTURE_BAD=1` (`su-dummy-bad` 2.0 with failing postinst). Wipe guard: repo/old dirs must match `/srv/su-*`, `/tmp/su-*`, `/var/tmp/su-*`. Re-running in a dirty container works (uses `--force-confnew`, `</dev/null`; a downgrade is reported on stderr).
|
|
- Real-world upgradable packages of the base image are **kept** (not touched) and printed as a `NOTE:` line, e.g. debian:12 `perl-base`, debian:13 `liblzma5`, ubuntu:22.04 `libssl3`, ubuntu:24.04 `libaudit-common libaudit1 libssl3t64`, ubuntu:latest `libssl3t64 openssl-provider-legacy`. They are time dependent: tests must not hard-code them; assert only on `su-dummy-*`.
|
|
|
|
Proof, exact required invocation (`docker run --rm -i -v <project>/discovery/apt:/d:ro IMAGE bash /d/fixture.sh`), all [exit=0], no stderr output:
|
|
```
|
|
== debian:12 [fixture] repo /srv/su-repo indexed with 'shell': 3 packages
|
|
[fixture] installed (old): su-dummy-a=1.0 su-dummy-b=1.0 su-dummy-c=1.0
|
|
[fixture] upgradable dummy packages: su-dummy-a su-dummy-b su-dummy-c
|
|
[fixture] NOTE: real-world upgradable packages present in this base image: perl-base
|
|
[fixture] OK in 3s (image: Debian GNU/Linux 12 (bookworm))
|
|
== ubuntu:24.04 ... upgradable dummy packages: su-dummy-a su-dummy-b su-dummy-c
|
|
NOTE: ... libaudit-common libaudit1 libssl3t64 ; OK in 5s (image: Ubuntu 24.04.5 LTS)
|
|
== debian:13, ubuntu:22.04, ubuntu:latest(26.04.1): same, OK in 3s / 7s / 6s
|
|
```
|
|
Also verified: `SU_FIXTURE_INDEXER=scanpackages|ftparchive` on debian:12, debian:13, ubuntu:24.04, ubuntu:latest; `SU_FIXTURE_OFFLINE=1 SU_FIXTURE_EXTRA=1 SU_FIXTURE_BAD=1 --network none` on all five; `smoke.sh` (32 checks: Inst parse, kept-back parse, description awk, rdepends, three closure methods, hold/restore, upgrade, purge, autoremove) `ALL PASSED` on all five images.
|
|
|
|
Fixture-state facts useful for tests: `apt list --upgradable` => `su-dummy-a/su-repo 2.0 all [upgradable from: 1.0]`; `Inst su-dummy-a [1.0] (2.0 su-fixture:su-repo [all])`; `apt-cache policy su-dummy-a` => Installed 1.0 / Candidate 2.0, table `2.0 500 / 500 file:/srv/su-repo ./ Packages` and ` *** 1.0 100 / 100 /var/lib/dpkg/status`.
|
|
|
|
## 11. Item 11 - timing (rough, this host, 3 runs each, network OK)
|
|
|
|
| image | `apt-get update` cold (lists empty) | lists size | `fixture.sh` default (incl. real update + 3 apt calls) | `fixture.sh` OFFLINE (`--network none`) | one `apt-get -s upgrade` / `apt list` / `apt-cache policy` |
|
|
|---|---|---|---|---|---|
|
|
| debian:12 | 1.1 s | 19 MB | 2.8-3.1 s (docker wall 3.2-3.4) | 0.4 s | 0.72 / 0.35 / 0.31 s |
|
|
| debian:13 | 1.0-1.1 s | 21 MB | 3.0-3.3 s | 0.4 s | 0.82 / 0.44 / 0.31 s |
|
|
| ubuntu:22.04 | 2.5-3.4 s | 78 MB | 5.7-5.8 s | 0.4 s | 1.62 / 0.92 / 0.71 s |
|
|
| ubuntu:24.04 | 2.2-3.3 s | 52 MB | 4.9 s | 0.3-0.4 s | 1.11 / 0.63 / 0.47 s |
|
|
| ubuntu:latest | 1.7-3.1 s | 42 MB | 4.1-4.4 s | 0.5 s | 1.00 / 0.56 / 0.40 s |
|
|
|
|
Container start overhead is ~0.2-0.3 s; `docker pull` of the five images took 3-8 s each. Network variance dominates `update`. `smoke.sh` (hermetic) takes a few seconds per image. Because the images have no apt binary cache, budget ~0.3-1.6 s **per apt invocation** in tests, and prefer one batched call over per-package loops. `apt-get install bats` (no recommends) +0.7-1.7 s; with recommends 5.7 s (debian:12) / 13 s (ubuntu:24.04).
|
|
|
|
## 12. Differences between the five images (summary)
|
|
|
|
| aspect | debian:12 | debian:13 | ubuntu:22.04 | ubuntu:24.04 | ubuntu:latest (26.04) |
|
|
|---|---|---|---|---|---|
|
|
| apt / solver | 2.6.1 classic | 3.0.3 classic (behaves like 12) | 2.4.14 classic | 2.8.3 classic | **3.2.0, solver3 default** |
|
|
| sources format | `debian.sources` (deb822) | deb822 | `sources.list` (one-line) | `ubuntu.sources` (deb822) | `ubuntu.sources` |
|
|
| repo string in `Inst` (real) | `Debian:12.15/oldstable`, `Debian-Security:12/oldstable-security` | `Debian:13.7/stable`, `Debian-Security:13/stable-security` | `Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security` | `Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security` | `Ubuntu:26.04/resolute-updates, Ubuntu:26.04/resolute-security` |
|
|
| `Solving dependencies...` line | no | no | no | no | **yes** (in simulations/removals, stdout) |
|
|
| `dist-upgrade` removes a manually-installed conflicting pkg | yes | yes | yes | yes | **no** (kept back) |
|
|
| `Description-md5:` in `apt-cache show` of installed-only | value | empty | value | empty | empty |
|
|
| Essential `login` (`apt-get -y purge login`) | protected | protected | protected | protected | **not Essential: purged without error** |
|
|
| bats | 1.8.2 | 1.11.1 | **1.2.1**, no helper libs | 1.10.0 | 1.13.0 |
|
|
|
|
Everything else compared with `diff` over a 385-line transcript of ~60 commands (update, list, simulate, hold, show/policy/dpkg -s, depends/rdepends, real upgrade, dist-upgrade, purge, autoremove, error paths): identical wording and exit codes across the five images apart from the rows above and checksums/sizes. Specifically identical: `apt list` stderr warning, `Inst`/`Conf`/`Remv`/`Purg` formats, `kept back` block, `apt-mark` messages, `apt-cache depends/rdepends` formats, `E:`/`W:` texts and exit codes (0 / 100), lock message, conffile failure, Essential protection message.
|
|
|
|
Pre-existing docker images were not touched (`images-before.tsv` compared: nothing missing or changed); only `debian:12`, `debian:13`, `ubuntu:22.04`, `ubuntu:24.04`, `ubuntu:latest` were pulled and only `docker run --rm` containers were used (one hung throwaway container of mine, caused by a test that re-ran the fixture over a locally modified conffile, was `docker kill`ed).
|
|
|
|
## 13. Item 13 - install reason (`N?` / `info`): `apt-mark showauto` / `showmanual`
|
|
|
|
Verified 2026-10-10 on debian:12 (apt 2.6.1), debian:13 (3.0.3), ubuntu:22.04 (2.4.14), ubuntu:24.04 (2.8.3) and ubuntu:latest (26.04.1, apt 3.2.0), fixture `SU_FIXTURE_OFFLINE=1 SU_FIXTURE_ORPHAN=1 SU_FIXTURE_B_AUTO=1`, `LC_ALL=C`. Identical on all five:
|
|
|
|
```
|
|
$ apt-mark showmanual su-dummy-a -> su-dummy-a (rc 0) $ apt-mark showauto su-dummy-a -> (empty, rc 0)
|
|
$ apt-mark showmanual su-dummy-b -> (empty, rc 0) $ apt-mark showauto su-dummy-b -> su-dummy-b (rc 0)
|
|
$ apt-mark showauto su-dummy-orphan -> su-dummy-orphan $ apt-mark showmanual no-such-pkg -> (empty, rc 0)
|
|
$ apt-mark showmanual su-dummy-a su-dummy-b su-dummy-c -> su-dummy-a (only the named packages)
|
|
```
|
|
|
|
* Chosen: `apt-mark showauto PKG` -> dependency; else `apt-mark showmanual PKG` -> explicitly installed; in neither list -> `dpkg-query -W -f '${db:Status-Abbrev}' PKG`: installed (2nd character neither `n` nor `c`) counts as manual, else not installed. No image had an installed package in neither list (installed = manual + auto exactly: debian:12 92 = 2 + 90, ubuntu:24.04 96 = 93 + 3, ...), so that branch is a safety net.
|
|
* Read-only: both work as `nobody` (`setpriv --reuid=65534`) and while another process holds dpkg's `lock-frontend` and `lock` (fcntl, like dpkg; checked with `apt-mark hold`, which fails then with `dpkg frontend lock was locked by another process with pid N`, exit 100): 0 s wait, same answers.
|
|
* A removed package with its configuration files kept (`rc`) is in neither list: not installed. A package installed with `dpkg -i` is manual (also `su-dummy-c` reinstalled with `dpkg -i` after `dpkg -r`).
|
|
* apt-mark prints a native package without its architecture qualifier (`apt-mark showauto libc6:amd64` -> `libc6`) and nothing for an arch that is not installed (`libc6:i386` -> empty); a name that is not a package is treated as a pattern (`apt-mark showmanual 'su-dummy-?'` and `'su-dummy-.'` -> `su-dummy-a`), exact names win (`libapt-pkg6.0`, `libbz2-1.0`, `libdb5.3` answered for themselves). Hence the tool matches the output line by line against `PKG` (or `PKG` without `:arch`).
|
|
* Fixture: `su-dummy-a`/`-c` are manual (`dpkg -i`), `su-dummy-b` is auto with `SU_FIXTURE_B_AUTO=1` (`apt-mark auto su-dummy-b`); no change needed.
|