-
Notifications
You must be signed in to change notification settings - Fork 0
1.4 Step | AptPackages (delta pattern)
Reversible installation of sets of packages. It lives in
src/steps/apt_packages.rs— the name is historical: the step no longer talks toaptbut to the family's package manager (apt-getordnf), behind thePackageManagerboundary. 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 ofbootstrap_prerequisites/install_dependenciesfromlib/checks.sh+lib/system.sh.This is where the project's second fundamental pattern is born (after the single
PreStateof 1.1 PrepareOptRoot): 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:
-
The undo purges only the delta, never packages that were already there. Uninstalling
libpq-devfrom a machine that had it before us would be damage. -
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
runhas 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.
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.autoremoveacts 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.
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"]), // dnfAlternatives 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:
- if one of the alternatives is already installed, that one wins. A machine with
libtiff-devdoes not also getlibtiff5-dev, and the delta stays honest: we did not put it there, we do not purge it; - otherwise the first with a real candidate;
- 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.
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 insnapshot.apt-get updatewrites 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, areturnahead 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, becauseapt-get updateexits 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.
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.
-
Empty delta (everything already present) →
runandundoare clean no-ops. - The undo is best-effort: a failed purge logs a
warnand carries on. Every purge site goes throughsteps::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.
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.
Start here
Key concepts
References
For developers
Technical detail — how it works inside
Steps:
- 1.1 PrepareOptRoot
- 1.2 CreateOdooUser
- 1.3 SetupLogDir
- 1.3b SetupCacheDir
- 1.4 AptPackages (delta)
- 1.5 InstallWkhtmltopdf
- 1.6 SetupPostgres
- 1.7 CreateDbRole
- 1.8 CreateDatabase
- 1.9 CloneOdooRepo
- 1.10 CreateVirtualenv
- 1.11 InstallPythonRequirements
- 1.12 GenerateConfig
- 1.12b SetupDataDir
- 1.13 InitializeOdooDatabase
- 1.14 SetupSystemd
- 1.15 Nginx (6 sub-steps)
- 1.16 WriteControlScript + PatchBashrc
Cross-cutting: