first commit
This commit is contained in:
commit
1354db6c39
90 files changed
+13123
No files matched your search
+226
@@ -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
|
||||
```
|
||||
Reference in new issue
Block a user