Skip to content

1.4 Step | AptPackages (delta pattern)

Omisen edited this page Aug 15, 2026 · 2 revisions

Reversible installation of sets of packages. It lives in src/steps/apt_packages.rs — the name is historical: the step no longer talks to apt but to the family's package manager (apt-get or dnf), behind the PackageManager boundary. See Multi-distribution support. It covers two steps that share the same logic: BootstrapPrerequisites (common utilities) and InstallSystemDependencies (Odoo's ~30 dev packages). A port of bootstrap_prerequisites / install_dependencies from lib/checks.sh + lib/system.sh.

This is where the project's second fundamental pattern is born (after the single PreState of 1.1 PrepareOptRoot): the delta pattern.


The delta pattern

A single PreState (“does it exist, yes or no”) is not enough for a set. You have to think in subsets:

Phase Behaviour
snapshot for every package it asks the manager whether it is installed → partitions the list into already_installed (present before us) and delta (the missing ones, which we will install)
run installs the list without recommends or weak dependencies — the manager adds only the missing ones (idempotent). Empty delta → no-op
undo removes only the delta. Never the pre-existing ones, and no global autoremove
struct AptDeltaSnapshot {
    already_installed: Vec<String>,  // NEVER to be touched
    delta: Vec<String>,              // what we added → removable
}

Two rules make the pattern correct:

  1. The undo purges only the delta, never packages that were already there. Uninstalling libpq-dev from a machine that had it before us would be damage.
  2. The delta is persisted in the snapshot (invariant 4) and the undo uses it as it is — it does not recompute it from the system's current state, which run has meanwhile changed by installing the packages. Recomputing would yield an empty delta and remove nothing.

The bootstrap/deps overlap (git/curl/wget/gettext appear in both lists) is handled naturally: if the bootstrap already installed git, the deps snapshot finds it present and leaves it out of the delta.

Environment for the apt commands: DEBIAN_FRONTEND=noninteractive + NEEDRESTART_MODE=a (no tzdata/needrestart prompts), as in Bash.


The two configurations (decision D3)

One implementation (AptPackagesStep), two constructors with a different undo policy. It is firm decision D3 in CLAUDE.md about the delta's boundary:

Step List UndoPolicy Undo
BootstrapPrerequisites git, curl, wget, gettext-base KeepUnlessAggressive no-op by default; purges the delta only with --aggressive-rollback
InstallSystemDependencies ~30 dev packages PurgeDelta always purges the delta, and only the delta

Why no apt-get autoremove. autoremove acts on the whole system: it removes any auto-installed package apt considers orphaned at that moment, including ones unrelated to Odoo. That would be a removal not bounded by our delta — the opposite of the surgical principle. Dependencies pulled in by our packages stay installed: harmless noise, far better than the risk of uninstalling somebody else's things.

Why the bootstrap does not purge: these are common, low-risk utilities, probably useful to the system even after a rollback. You do not uninstall git/curl from a customer's machine over a rollback. Anyone wanting a total cleanup uses --aggressive-rollback.

The list of ~30 packages lives in one place (ODOO_DEPENDENCIES), like _apt_packages_odoo in Bash, to make overrides and tests easy.


Package names are not the same on every release (A5.1)

The same package changes name between distributions: libtiff5-dev on recent Debian is libtiff-dev, and libjpeg8-dev does not exist at all. While the list was made of bare strings, apt-get install failed on the whole group at the first unknown name → the step fails → rollback. Not a theoretical defect: the Debian job in integration.yml showed the installer not starting.

So the list is not one of names but of alternative groups, in order of preference:

// The family's catalogue: a NEED (DepId) and the names it has HERE.
CatalogEntry::new(DepId::BuildTools, &["build-essential"]),      // apt
CatalogEntry::new(DepId::Tiff,  &["libtiff5-dev", "libtiff-dev"]),
CatalogEntry::new(DepId::Freetype, &["libfreetype6-dev", "libfreetype-dev"]), // virtual → real
// …and on rpm the same need has other names:
CatalogEntry::new(DepId::Freetype, &["freetype-devel"]),         // dnf

Alternatives mean “same need, different names within the same family”: putting freetype-devel next to libfreetype6-dev would look free and would break the group, because the first rule — an already-installed alternative wins — is correct between synonyms of one distro and becomes a trap across families. The family enters one level up, in the catalogue. A test demands that every DepId be covered by both catalogues.

snapshot resolves each group down to one concrete name, and from there everything else — install, delta, purge, persistence — works on already-resolved names and does not even know alternatives existed. Two rules, in this order:

  1. if one of the alternatives is already installed, that one wins. A machine with libtiff-dev does not also get libtiff5-dev, and the delta stays honest: we did not put it there, we do not purge it;
  2. otherwise the first with a real candidate;
  3. otherwise the first the manager would install anyway — a virtual name, see below.

The three outcomes are one type (Availability::{Real, VirtualOnly, Absent}), and the policy that orders them is pure and identical for every family: the mechanism for obtaining them belongs to the manager (on apt it takes two commands — apt-cache policy with LC_ALL=C because the output is localised, and apt-get install -s), while the rule “a real name beats a virtual one” does not.

If no alternative is available the step stops in snapshot, before mutating, saying which group is empty. Degrading silently would push the error much further downstream: a missing -dev would become a compilation failure inside pip install, hard to trace back to its cause.

It does not exist, or we do not know? (A5.1-bis)

The first version of this check, in R6, blocked valid installations on Ubuntu 24.04: “no installable package for group [libfreetype6-dev]” — a standard package. A fail-closed check that produces false positives is not prudent: it blocks the good case, which is 99% of cases. Two causes, two lessons.

1. The apt index has to be refreshed, as anyone would do by hand. With stale or empty lists every query answers “not available”, and translating that into “the package does not exist on this release” is blindness dressed up as diagnosis. So BootstrapPrerequisites' run — the first package step in the sequence — runs apt-get update before installing, so that when InstallSystemDependencies queries candidates the index is fresh.

In run, never in snapshot. apt-get update writes into /var/lib/apt/lists: it is a mutation, and a snapshot never mutates (issue C4, the very one the Bash port had fixed). And it comes before the early return on an empty delta: on a machine that already has git/curl/wget/gettext, a return ahead of the update would make it unreachable — which is exactly how the bug presented.

No undo: a refreshed index does not change what is installed, it is a cache. Like a git fetch. And tolerance for unreachable repositories, because apt-get update exits non-zero over a single broken PPA: we carry on with a warning if the index stays usable, and fail only when there is no index left to query.

2. A name can be installable without having a candidate. On noble, libfreetype6-dev is no longer a real package: it exists only as a Provides of libfreetype-dev. apt-cache policy answers Candidate: (none), but apt-get install libfreetype6-dev works. The right question is not “does it have a candidate?” but “would you be able to install it?” — hence level 3 of the resolution.

And the real name beats the virtual one, not for elegance: a virtual name cannot be purged. apt-get purge libfreetype6-dev exits 0 having removed zero packages, and dpkg-query reports it not-installed. A delta containing it would have the rollback claim it purged something while libfreetype-dev stays installed: an invisible leftover, worse than a declared one. Hence the canonical list carries &["libfreetype6-dev", "libfreetype-dev"].

The diagnosis rests on evidence. SystemOps::apt_index_is_populated (apt-cache stats) distinguishes “does not exist” from “I do not know”: with an unusable index the message points at apt-get update and does not accuse the packages. For BootstrapPrerequisites alone — whose snapshot necessarily runs before the update it will perform itself — an unusable index is not an error but a “I proceed with the preferred name and let apt decide”: otherwise installing on a freshly created machine would be impossible. The fail-closed behaviour on genuinely absent names is untouched: with a populated index, a name that does not exist is an error before any mutation.

A dynamic addition: the Python interpreter. When the preflight picks an alternative interpreter (Fedora ≥ 43), its packages enter InstallSystemDependencies' list and the system Python's headers leave it — this is the step whose undo purges the delta, while BootstrapPrerequisites' leaves what it adds installed. With the system interpreter the list is unchanged, so on Debian, Ubuntu and Fedora ≤ 42 this passage does not exist.

The only exception: optional dependencies. They contain only node-less (and its rpm counterpart), the .less asset compiler: it was dropped from some Debian releases, and modern Odoo uses SCSS (compiled in-process) and does not need it to start. An optional group that is entirely unavailable is a warn! and not a stop — because installability on Debian must not hinge on a nice-to-have. Everything genuinely required lives in the mandatory list, where a missing name is an error.


The testable boundary

is_installed / availability / index_is_queryable / refresh_index / install / remove / remove_orphans are methods of PackageManager, obtained from SystemOps with ops.packages(): in production they shell out to apt-get/dpkg-query or to dnf/rpm, and in tests a mock records which packages would be installed or removed and answers “already installed?”. One boundary onto the system, one mock. So the tests verify the delta computation and the undo without touching apt and without root.


Design notes

  • Empty delta (everything already present) → run and undo are clean no-ops.
  • The undo is best-effort: a failed purge logs a warn and carries on. Every purge site goes through steps::purge_with_dpkg_recovery, because apt does not operate on a broken dpkg and the rollback always runs after a failure that may have broken it (A-RT-2).
  • Tests: the delta is computed correctly, the undo purges only the delta (never pre-existing packages), an empty delta is a no-op, the bootstrap does not purge without --aggressive-rollback, the git overlap is excluded from the delta, and the delta survives a save/load round trip of the state.

The mirror is not the request

apt-get install reaches the network, so it fails the way networks do. A debian:11 probe once lost a whole installation to Connection reset by peer while fetching one of twenty-five .debs: the machine was fine, the list was fine, the code was fine, and a mirror closed a socket. The clone has had retries since R2 for exactly this reason; this step now has them too — three attempts, linear backoff, PACKAGE_INSTALL_ATTEMPTS.

What is not retried matters as much. A name that does not exist, a dependency that cannot be satisfied, a broken dpkg: those answer the same way every time, so asking again only makes the true message arrive three times later, hidden behind a wait. The evidence has to name the fetch.

Which failures look like the mirror is decided behind the packaging boundary (PackageManager::is_transient_failure): apt says Failed to fetch, dnf says Curl error, and no step is allowed to know which family it is running on. The step decides what to do; only the manager knows what one looks like.

apt-get update is not re-run before a retry, deliberately: the index is refreshed once, in bootstrap-prerequisites, and from there it serves every step downstream. A Hash Sum mismatch will therefore not be fixed by asking again — and will fail with its own true message, which is better than deriving an exception from a decision already taken.

Clone this wiki locally