-
Notifications
You must be signed in to change notification settings - Fork 0
1. Checks | Preflight checks (sudo, OS, ports, disk)
Non-mutating preflight checks: the preconditions verified before any step. They run in
main's flow, before the engine — if one fails, the installer stops with a clear message and without having touched the system. They live insrc/checks.rs. They are notSteps: they have noundobecause they do not mutate.A port of
lib/checks.sh, with one structural correction: creating/opt/odooleft the checks and is now the reversible step 1.1 Step | PrepareOptRoot.
In Bash, check_disk ran mkdir -p "$target" so it could measure the free space: a check that
mutates. If a later check failed, that directory stayed on the system — untracked dirty state.
Here the principle is sharp: a check measures, it does not create. check_disk measures the first
existing ancestor of the target without creating it; creating /opt/odoo is a reversible mutation, so
it is a Step with its own undo, not a hidden side effect. C4 is eliminated at the root.
The general lesson: if a check “needs” to create something in order to work, that something belongs in a step, not in the check.
The checks answer two different questions, and A-R9-1 showed that running them as one block gives the wrong diagnosis:
checks::check_caller who are you? (root + sudo)
↓
state::start_decision should this run happen at all? (manifest: install / resume / refuse)
↓
run_environment_checks can this machine host it? (OS, disk, ports, commands)
Reinstalling over a live instance means Odoo is listening on the port, so with a single block
check_ports failed first and sent the user off to stop Odoo — while the thing to do was rollback
or --force. A busy port is a consequence of the existing installation, not its cause. The same
ordering also broke resume: an installation interrupted after setup-systemd leaves our service
on the port, so the re-run was rejected by the service it had just installed. Hence the precise
exception: InstallState::owns_the_http_port (true when the manifest records setup-systemd) skips
the port check during a resume. The verdict is read from the manifest, not inferred from the
system: “who holds the port” is not observable, “who opened it” is.
Each returns Result<(), CheckError> (or Result<OsInfo, CheckError>), never a panic.
| Function | Verifies |
|---|---|
check_root |
EUID == 0 (running as root) |
check_sudo_user |
SUDO_USER present and non-empty (started via sudo from a normal user, not sudo -i/su -) |
check_os |
supported OS and minimum version; returns OsInfo
|
check_disk |
free space ≥ threshold on the target's filesystem — without creating the target |
check_ports |
odoo_port and the gevent port (and 80/443 with Nginx) free, in the system and in the other manifests |
check_commands |
the family's package manager (apt-get or dnf) and systemctl are present |
plan_python |
picks the interpreter the virtualenv will be built on, and says so |
The sensitive logic is extracted into pure functions, so the tests run unprivileged and without
touching the system — injectable paths instead of hardcoded /etc/os-release and /opt/odoo.
| Helper | Role |
|---|---|
ensure_root_euid(euid) |
Pure EUID comparison (testable without being root) |
ensure_sudo_user(Option<&str>) |
SUDO_USER validation |
check_os_from(path) |
check_os with an injectable os-release path |
validate_os(&OsInfo) |
Applies the version thresholds |
ports_to_check(...) |
Decides which ports are worth checking — the interesting case (Nginx already serving) cannot be reproduced in a test, so the decision is a return value |
Reads os-release (injectable path), extracts ID, VERSION_ID, VERSION_CODENAME, lowercases ID
and applies the thresholds:
| OS | Minimum | Newest exercised in CI |
|---|---|---|
| Ubuntu | ≥ 22.04 | 24.04 |
| Debian | ≥ 11 | 12 |
| Fedora | ≥ 40 | 44 |
ID also yields the family (OsFamily), which is what decides the backends the steps will be
built with → Multi-distribution support. A distribution whose family
we do not know is rejected here, before anything else.
Unsupported distro → UnsupportedOs; version too old → UnsupportedVersion; missing file →
OsReleaseNotFound.
The thresholds are open upwards, and must stay that way. A release newer than the ones we exercise is accepted with a warning, not refused: a refusal without evidence blocks the good case, and a blocked installation is a certain harm while the avoided one is hypothetical. But “we accept” does not mean “we keep quiet” — whoever installs on Ubuntu 26.04 or Debian 13 deserves to know that release is not among the ones our CI runs on, because that is the information they need when something goes wrong.
The constants defining “newest exercised” are tied to the real CI matrix by a test that reads the workflow: if they diverged, the warning would lie in one direction or the other.
struct OsInfo { id: String, version: String, codename: Option<String> }OsInfo is propagated into the Context
(ctx.os_info) and is used, for instance, to pick the wkhtmltopdf package. The family travels in
ctx.os_family instead, which is not optional: the undos use it too, and in a rollback from disk the
manifest supplies it.
Odoo pins gevent and greenlet per Python version: on an interpreter newer than its pins there is no
ready wheel, pip tries to compile, and the build fails with three hundred lines of gcc in which the
cause never appears. The preflight chooses:
- the system
python3, if the pins cover it → nothing gets installed; - otherwise the newest interpreter packaged by the distribution that they do cover (on Fedora ≥ 43,
where the system is on 3.14, that is
python3.13) → it enters the package delta and the rollback removes it; - if there is no alternative, it goes ahead anyway with the system one, but with a warning that says what will break and where. A refusal here would be a hardcoded threshold, and a hardcoded threshold ages into blocking the good case.
It does not return a Result: it is not a precondition, it is a decision plus a warning. Detail in
Multi-distribution support.
check_disk(target: &Path, required_gb: u64) -> Result<(), CheckError>Walks up to the first existing ancestor of target (/opt/odoo → /opt → /) and measures its
space with statvfs (blocks_available * fragment_size). It creates nothing. Below the threshold
→ InsufficientDisk. Default threshold 5 GB, overridable with MIN_DISK_GB.
Checks both of the instance's ports — the HTTP one and the gevent/longpolling one — and, with
with_nginx, 80 and 443 as well — unless Nginx itself is what holds them.
The gevent port is not a detail: it is the one nobody names in a .env, so it is the one a second
instance takes without noticing. And with the default single-worker configuration Odoo never binds
it, so a conflict there does not fail the installation — it fails the service, later, on somebody
else's machine.
That is the supported scenario: adding an Odoo vhost to an existing reverse proxy. On such a machine port 80 is held by the very program we are about to configure, and treating that as a conflict made the normal use case impossible. If Nginx is not serving and 80 is taken (Apache, another proxy), the conflict is real and the refusal stands.
The probe cascades:
ss → netstat → lsof
If no tool is available → Unknown = a non-blocking warning (the port is assumed free), as in
Bash. Busy port → PortInUse. Detecting whether the commands exist scans PATH without running
them.
This probe is only half the question. It sees who is listening now, so an instance that is
merely stopped — for maintenance, or never started — holds no socket and its ports look free. They
would be handed out twice and the collision would surface at the first simultaneous start, naming
neither instance. So before the system is asked at all, the candidate ports are compared with those
recorded in the other manifests, crosswise: my HTTP against their longpolling too, because
--port 8072 looks perfectly free and is nobody's HTTP port. Same rule as everywhere else here —
what is recorded is re-read; only what cannot be recorded is observed.
Checks only the OS prerequisites the installer cannot install itself: the family's package manager
(apt-get or dnf) and systemctl — missing → MissingCommand. nginx/certbot are optional, so
they are info only. git/python3/psql/… are installed by later steps and are therefore not here.
Typed with thiserror, each carrying context for the post-mortem:
NotRoot { euid } · NoSudoUser · OsReleaseNotFound(path) · OsReleaseParse { path, reason } ·
UnsupportedOs { id } · UnsupportedVersion { id, version } ·
InsufficientDisk { target, available_gb, required_gb } · DiskProbe { path, reason } ·
PortInUse { port } · MissingCommand { command }.
-
No check mutates. They have no
undobecause there is nothing to undo. -
Injectable paths (
os-release, disk target): the tests touch neither/etcnor/opt/odooand need no root. - The disk measurement uses
statvfsthroughnix— no shelling out todf. - The checks run before the engine: the first effect on the system is the
PrepareOptRootstep, which is reversible.
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: