Skip to content

Reference

Paul Asadoorian edited this page Aug 27, 2026 · 4 revisions

Reference

The flags, the exit codes, how fettle becomes root, how the code is laid out, and how to work on it.

Common options

Option Effect
-a, --all run the default action set
--dry-run print what would run; execute nothing (read-only queries still run)
--no-sync dry-run preview: use cached repo data instead of a fresh sync
--full-preview with --dry-run: elevate so the preview resolves new deps + removals (rhel)
--only ACTION / --skip ACTION restrict / exclude actions (repeatable)
--yes assume yes to all prompts (non-interactive)
--no-aur-precheck skip the pre-upgrade AUR IoC gate (arch)
--force-aur with --yes, install AUR pkgs despite a CRITICAL pre-check finding (arch)
-R, --auto-rebuild offer to rebuild instead of only listing (with -r/-y)
-v / -q / --no-color verbose / quiet / disable color (also honors NO_COLOR)
--distro NAME override distro detection
--print-config / --version print config or version and exit

Exit codes — what fettle's status actually means

Short version: 0 means the run completed, not that the machine is clean. What counts as "completed" depends on how you invoked it, deliberately.

0 1 2
a single action (fettle -V, -S, -P, …) nothing wrong, nothing missed the action failed, or a check could not look, or it looked and found something
fettle -M (compromise-check) it looked, and found nothing above Medium a High or Critical finding, or it examined nothing at all
--everything the run completed an action could not do its job
fettle report the dashboard was rebuilt it could not be written
fettle remote <host> the remote's own status the remote's own status
fettle remote <group> every host succeeded any host failed or was unreachable
any invocation bad arguments (argparse)

compromise-check is the one action whose status turns on severity rather than on findings existing. Every real machine has some — the reference desktop has four — and exiting non-zero on all of them would make -M red forever and teach people to ignore it, the same trap -H avoids. High and Critical are the two bands that also print the preservation banner, so the status and the banner agree about what "stop and look at this" means. Partial blindness (a missing bpftool, an unreadable /sys/fs/bpf) is reported loudly and does not set the status; only a run that examined nothing fails on it. See Compromise indicators. | a single host inside a group | — | — | 255 = unreachable, distinct from a failed run |

Gate automation on a single action, not on --everything. fettle -V is a tripwire: it goes red when a packaged file's contents have changed, when the integrity database could not be opened, or when the tool is missing — any of which means you should look. That is precisely what a cron job or CI step wants.

--everything answers a different question on purpose. Fourteen checks on a real machine will essentially always find something — advisory-check alone reports 142 packages with fixes available on the machine this was developed on — so a status that failed on findings would be red every single time and would stop being read. It also does not fail on blindness, which is the deliberate part: on that same machine chipsec cannot run at all, so two checks report "could not run" on every run, and failing on a condition nobody can fix is how an exit code becomes noise.

So read the summary, not just the status. Findings are in it, and everything that could not be examined is listed under Not checked at the end of every run, with the command to fix it where a missing tool is the cause:

▸ Not checked
  these were not examined, so nothing above speaks for them
  ? storage device firmware — smartctl is not installed
      install: sudo apt install smartmontools

That block is how fettle keeps its central promise — a check that cannot look must never render identically to a clean result — without spending the exit code on it.

How elevation works

fettle elevates lazily and by itself — you never need to type sudo fettle.

  • Maintenance actions re-exec under sudo only when a selected action will actually change the system. Read-only work such as pkg-audit (-P), aur-audit (-A) and config-drift (-d) runs unprivileged and never prompts. --dry-run never elevates.
  • hardening-audit (-H) elevates since v1.17.0. Its AppArmor axis reads state that is root-only: unprivileged, aa-status reports "You do not have enough privilege to read the profile set" and exits 0, so the answer would be the reassuring half on its own. Pass --user to keep it unprivileged, the same way sys-audit --user works. Measured on Debian 13: identical findings either way, 28 s as root against 27 s with --user.
  • pkg-integrity (-V) is the exception: read-only, but it does elevate, because unprivileged it cannot read a large share of the files it must hash. "Read-only" and "needs no root" are different questions.
  • sys-audit elevates itself too (most checks need root); pass --user to stay unprivileged. --list and remote don't elevate.

Being in the read-only set does not mean an action executes unprivileged. It means that action does not elevate on its own. Run it inside -a or --everything and the process has already re-exec'd under sudo for the mutating half — so every action after that point is root, including the ones that never asked to be.

That distinction is not academic: it hid three bugs for months. pkg-audit reads GNOME extensions, rootless podman images and --user flatpak apps, all of which live in your account. Run alone it saw them; run inside -a it was root, and root's copies are empty or unreachable. Since v1.13.0-1.15.0 those three are asked as the invoking user regardless of how the run started (see Package supply-chain). Any new check should ask whose data it is reading before assuming the answer is "the machine's".

Because elevation re-execs the full python3 -m fettle path (not the fettle name), it works even though the launcher in ~/.local/bin isn't on root's PATH — which is why sudo fettle … is unnecessary (and fails with command not found unless you also install to a system path).

Your config path is carried across the re-exec, so your keep_orphans, exclude_foreign, and [updaters] settings are honored on elevated runs too (sudo resets HOME to /root, so without this the elevated process would quietly fall back to built-in defaults).

Architecture

  • One backend per distro family (fettle/backends/*.py) implementing a shared PackageBackend ABC; a backend advertises the actions it supports, and the CLI hides the rest. Adding a distro is one subclass + one registry line — never a new script.
  • Curated command allowlist per backend: config tunes behavior (skip flatpak, pick nala), it never discovers new commands to run.
  • Normalized supply-chain model (fettle/supplychain/): one Finding format and one seven-question set; each source provider answers what its ecosystem can and states its coverage.
  • Mockable seams: all command execution goes through one run() wrapper, and the sys-audit checks read /sys·/proc·/dev through an injectable root — so the whole thing is unit-tested with no root and no real hardware.
  • Everything routes through one output layer (fettle/output.py) for a single color / verbosity / summary language.

Development

python -m venv venv && source venv/bin/activate
pip install -e '.[dev]'      # pytest + ruff (dev-only; runtime stays pure-stdlib)
pytest -q                    # full unit suite
ruff check fettle/ tests/    # lint

Tests mock external commands via unittest.mock.patch("subprocess.run", …) and fake /sys·/proc trees with a tmp_path root, so they need neither root nor special hardware. Runtime code never imports pytest — the shipped tool is pure standard library.

Testing against real distros

Unit tests mock the package managers, which proves the parsing but not the assumptions behind it — several bugs here were formats and exit codes that documentation described incorrectly. Two harnesses cover that:

  • Containers for most things: fast, disposable, and reproducible in a way a borrowed host is not ("no findings" on a real box might mean clean, unentitled, or empty).
  • tests/lab/ for what containers cannot reach — systemd timers actually firing, snapd, fwupd, real reboots, the privilege model, and fettle remote over ssh. It builds small cloud-image VMs on a KVM/libvirt host, snapshots each one, and reverts before every run so the pending-update set is identical every time. See tests/lab/README.md; host specifics go in a gitignored lab.conf.

Both are stdlib/shell only. dependencies = [] is load-bearing — the remote zipapp has to run under any bare python3.

Clone this wiki locally