first commit

This commit is contained in:
bryanpedini committed 2026-10-10 10:37:53 +02:00
commit 1354db6c39
90 files changed
+13123

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
# skeptical-updater — design
A distro-agnostic, single-file bash applet for a *manual, conscious* update cycle.
It wraps the native package manager (apt, dnf/yum, pacman/pman, zypper), lists
upgradable packages, lets the user interrogate each one, and only then runs the
native upgrade. Packages the user has explicitly acknowledged are remembered in a
statefile and never questioned again.
## Goals
- One file, no runtime dependencies beyond bash >= 4.3 and the distro's package manager.
- Never reimplement package management: every action shells out to the native tool.
- Deterministic, testable: `--dry-run` prints the commands it would run; stdin is
the command channel (works piped, no TTY needed).
- Statefile is plain text, hand-editable, atomic writes.
## Backend detection
Order (first match wins, overridable with `--backend=<name>`):
| backend | detected by | notes |
|----------|-----------------------------|--------------------------------------------|
| `pacman` | `pacman` in PATH | `pman` present => label shown as `pman`, same commands; honor pacman `IgnorePkg` (`pacman -Qu` marks them `[ignored]`); the refresh syncs a throwaway `--dbpath` copy; at `<enter>` its reviewed databases are installed and applied with `pacman -Su` |
| `apt` | `apt-get` in PATH | Debian/Ubuntu |
| `dnf` | `dnf` in PATH (else `yum`) | dnf4 (EL9) and dnf5 (EL10) differences hidden in the backend |
| `zypper` | `zypper` in PATH | `dist-upgrade` on Tumbleweed (ID=opensuse-tumbleweed), `update` on Leap; on Tumbleweed the review list is the `dist-upgrade --dry-run` plan |
Each backend implements the same function set (see "Backend contract").
## Statefile
Directory: `/var/lib/skeptical-updater/` (override: `SKEPTICAL_UPDATER_STATE_DIR`).
Created `0755 root:root` on first write. The tool runs as root (it must, to upgrade);
when not root it re-execs itself through `sudo` if available.
File: `trusted.tsv` — tab-separated, UTF-8, LF, one record per line, sorted and
unique on `(backend, package)`. Lines starting with `#` are comments and are kept.
```
# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)
apt nodejs user 2026-10-09T09:40:12Z
apt libnode115 dep:nodejs 2026-10-09T09:40:12Z
```
- `reason` is `user` for a package the user confirmed with `N+`, or `dep:<root>`
for a package pulled in as part of the installed dependency closure of `<root>`
at the time of confirmation.
- `backend` is recorded so the file stays correct on hosts with more than one
package manager and stays readable when copied between hosts.
- Writes are atomic: write to `trusted.tsv.tmp.$$` in the same directory, `mv` over.
- Writes happen under a lock (`trusted.lock`: `flock(1)` when available, else an atomic
`mkdir` lock: a directory created under a temporary name with the owner's pid inside and
renamed into place; a lock whose owner is gone - a process of another user counts as alive -
is taken over by renaming it away first and checking its pid again there, so two waiters can
never remove a live lock; never busy-waited on; leftovers of crashed acquirers are removed).
At save time the
file is re-read and this session's additions/removals are replayed onto it, so a `trust` run
in another terminal meanwhile is not lost.
- Comment lines are kept, in their order, at the top of the file when it is rewritten;
malformed lines are kept verbatim (with a warning) in the same block.
- A package is "trusted" if a row with the current backend and that name exists.
Reason does not matter for filtering.
Why a stored closure instead of computing it every run: closure computation across
40+ upgradable packages on every run is slow on apt/rpm; computing it once at `N+`
time and storing `dep:<root>` rows keeps every run O(upgradable). A new dependency
that appears later is *new to the system* and deserves to be questioned once;
`skeptical-updater refresh` recomputes closures for all `user` roots when the user
wants to re-trust them wholesale.
No other state, except two crash-recovery files that only exist while an upgrade runs (or
after one was killed): `holds.added`, the apt hold journal (used under `holds.lock`, which is
kept until the holds are really released), and `zypper-private`, the path of the private zypper
configuration (used under `zypper.lock`). Both are replayed/removed at the start of the next root
run, before anything is listed - only when their lock is free, i.e. not while another run uses
them. Per-run exclusions (`N-`) are
intentionally not persisted: a skeptical user wants to be asked again next time. Permanent holds belong to the
package manager (`apt-mark hold`, `IgnorePkg`, `zypper addlock`, dnf `exclude=`).
## Interactive session
```
$ sudo skeptical-updater
Backend: apt (Debian GNU/Linux 12)
Refreshing package lists... done.
12 upgradable, 9 trusted (hidden, use --all to show), 3 to review:
[1] nodejs 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security
[2] libnode108 18.19.0+dfsg-6 -> 18.20.4+dfsg-1 bookworm-security
[3] zsh 5.9-4 -> 5.9-4+deb12u1 bookworm
<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever
> 1?
```
Command grammar (one line, whitespace-separated tokens, each `N` is the number in brackets):
| token | action |
|--------|---------------------------------------------------------------------------|
| empty line | proceed: run the native upgrade with the current exclusions |
| `N?` | print the one-line description and the list of installed packages that depend on it |
| `N??` | print the full native info dump (`apt-cache show` / `pacman -Qi` / `dnf info` / `zypper info --requires`) |
| `N-` | exclude from this run (toggle; `N-` again re-includes); shown with `-` marker |
| `Nx` | purge it now, following pacman's `-Rns` principle on every backend: if any installed package still depends on it, refuse and print who (the reverse dependencies `N?` shows) - never cascade; otherwise simulate the native purge of it together with its now-unneeded dependencies, show that removal set ("This also removes: ...") and ask `y/N`. As a safety check, a simulated set containing anything that is neither the target nor in its installed dependency closure is refused too. Only a purge that ran removes rows (of every package it removed). A row whose old version is `-` (a new dependency, not installed) cannot be purged |
| `N+` | trust it: write it and its installed dependency closure to the statefile; the row leaves the list (with `--all` it stays, marked `=`). Rows that this trust hides are re-included if they were excluded |
| `*+` / `*-` | apply `+` / `-` to every listed package; `*-` is an all-or-nothing toggle: when every listed row is already excluded it re-includes them all |
| `l` | redraw the list |
| `q` | quit without upgrading (exit 0) |
| `h`, `?` | help |
Numbers are stable for the whole session (removed rows leave a gap, never renumber).
All tokens of a line are validated first: an unknown token or a number that is not listed
prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts without
upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty
line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0).
Flags shown after a row: `[held]` = the package manager will not upgrade it on its own: on apt
a hold (`apt-mark hold`) or a package apt keeps back (the "kept back" block of
`apt-get -s --with-new-pkgs upgrade`), on pacman an `IgnorePkg` (`[ignored]` in `pacman -Qu`).
dnf excludes/versionlocks and zypper locks hide the package from the native listing instead.
Notes after the rows report what the backend says beyond them (new dependencies or
replacements the upgrade installs, packages a Tumbleweed `dist-upgrade` removes); with no row
but such pending work the prompt is still offered ("0 to review, but ... reports pending
changes").
After `<enter>`:
1. Build the exclusion set = `N-` rows.
2. Apply native per-run exclusion (`pacman --ignore a,b`, `dnf --exclude`,
apt temporary `apt-mark hold` of the packages not already held, journalled first and
released afterwards - also on failure, Ctrl-C, TERM, HUP, QUIT, a closed stdout - so the hold
set is exactly the prior one; zypper `addlock` in a private configuration whose lock file is
a copy of the effective one, so the real lock set is never written).
3. Run the native upgrade non-interactively, streaming its output, on exactly the metadata that
was reviewed: dnf `--setopt=*.metadata_expire=-1`, zypper `--no-refresh`; apt never
refreshes on upgrade. pacman: (a) `pacman -Sup --noconfirm --ignore ... --dbpath <throwaway>`
checks the reviewed transaction - if it fails (an ignored package another upgrade needs, a
conflict) its output is shown and the tool exits with its status, nothing changed; (b) the
throwaway `*.db` and `*.db.sig` files are installed into the system's sync directory under
pacman's own lock (`db.lck`, created atomically; an existing one = another pacman: nothing
changed). A system database that changed since the review and is newer than the reviewed
copy is never overwritten: the tool refuses ("package databases changed since the review
(<repos>)") with exit status 2 and changes nothing; identical ones are skipped. The install is
all-or-nothing: everything is copied to temporary names first, then all renames happen with
the signals ignored, each `.db.sig` right before its `.db`; `db.lck` is removed right before
(c) `pacman -Su --noconfirm --ignore ...` applies them, no further sync. If (c) fails or is
interrupted the system's sync database is newer than its installed packages: the tool (or its
exit handler) says so; finish with `pacman -Su` (or run the tool again) before any
`pacman -S`. With `--no-refresh` (or a failed refresh)
plain `pacman -Su` applies the existing databases. Throwaway databases carry their owner's
pid in the name (`/tmp/skeptical-updater-db.<pid>.XXXXXX`); those of runs that are gone are
removed at the start of the next root run.
4. Restore holds/locks, exit with the package manager's exit code.
Trusted packages are upgraded silently along with everything else; they are only
hidden from the questioning list.
## CLI
```
skeptical-updater [options] [command]
commands:
(none) interactive review + upgrade
list print the review list and exit (no prompt)
trusted print the statefile
trust PKG... add PKG and its installed dependency closure to the statefile
untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG)
refresh recompute dependency closures for every `user` root
info PKG same as N?
show PKG same as N??
options:
--backend=NAME force apt|dnf|pacman|zypper
--all also list trusted packages (marked with `=`)
--no-refresh skip the metadata refresh step
--dry-run print native commands instead of running mutating ones
--state-dir=DIR override /var/lib/skeptical-updater
--no-color disable ANSI colours (also honours NO_COLOR, non-tty stdout)
--version, --help
```
Exit codes: 0 ok (also `q`), 1 usage/backend error, 2 aborted (end of input),
otherwise the native package manager's exit code from the upgrade step.
When root is needed (refresh, upgrade, purge, writing a state directory the user cannot
write) and the user is not root, the tool re-runs itself as
`sudo -- /absolute/path/to/itself --sudo-reexec ARGS...` - the file itself, so a sudoers rule
for it keeps matching; only a copy that is not executable is run as
`sudo -- "$BASH" /absolute/path --sudo-reexec ARGS...`. The hidden `--sudo-reexec` (accepted
only as the first argument, never forwarded) makes a re-run that is still not root fail instead
of looping. Without sudo it fails with a clear message.
## Backend contract
Every backend is a set of bash functions with a common prefix. All output is
`LC_ALL=C`. All functions must work without a TTY.
```
<be>_refresh # update metadata; non-fatal on failure (warn)
<be>_upgradable # stdout: name<TAB>old<TAB>new<TAB>repo<TAB>flags (flags: "" | "held"; old "-" = new package)
# plus "#<note>" lines for pending work that is not a row
<be>_describe PKG # stdout: one-line summary
<be>_full_info PKG # stdout: native full dump (candidate version if available)
<be>_rdepends PKG # stdout: installed packages requiring PKG, one per line
<be>_closure PKG # stdout: installed recursive dependency closure of PKG (excluding PKG), one per line
<be>_purge_plan PKG # stdout: every package a purge of PKG and its now-unneeded dependencies removes
# (simulation, no cascade); non-zero = refused
<be>_purge PKG... # non-interactive native purge/remove of exactly the planned packages
<be>_upgrade EXCLUDED... # non-interactive native upgrade skipping EXCLUDED (handles hold/lock save+restore)
<be>_label # "apt" / "pman" / ...
```
Mutating calls go through `run_cmd` which honours `--dry-run` (prints `+ cmd` to
stdout instead of executing).
## Layout
```
skeptical-updater the applet (single bash file)
makefile install / lint / test / docker targets (no LLM needed)
docs/DESIGN.md this file
discovery/ verified per-family command notes and fixture scripts
tests/unit/*.bats bats-core tests with a fake backend (no root, no docker)
tests/e2e/*.bats bats-core tests run inside each distro container against real package managers
tests/docker/ images list, snapshot/cleanup scripts, per-distro Dockerfiles
```