Skip to content

1. Checks | Preflight checks (sudo, OS, ports, disk)

Omisen edited this page Aug 15, 2026 · 2 revisions

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 in src/checks.rs. They are not Steps: they have no undo because they do not mutate.

A port of lib/checks.sh, with one structural correction: creating /opt/odoo left the checks and is now the reversible step 1.1 Step | PrepareOptRoot.


The C4 correction (why this page changed)

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.


Two groups, and the order between them matters

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.


Public functions

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

Pure helpers (testable without root)

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

Function detail

check_os / check_os_from

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.

It returns:

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.

plan_python — which interpreter will build the virtualenv

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

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.

check_ports

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.

check_commands

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.


Errors (CheckError)

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 }.


Design notes

  • No check mutates. They have no undo because there is nothing to undo.
  • Injectable paths (os-release, disk target): the tests touch neither /etc nor /opt/odoo and need no root.
  • The disk measurement uses statvfs through nix — no shelling out to df.
  • The checks run before the engine: the first effect on the system is the PrepareOptRoot step, which is reversible.

Clone this wiki locally