Skip to content

0. Engine | Step trait, PreState, rollback

Omisen edited this page Aug 15, 2026 · 2 revisions

The installer's engine: the frame that orchestrates the individual steps while guaranteeing the surgical rollback. It does not exist in the original Bash — it is the very reason for the Rust port. It lives in src/engine.rs, src/step.rs, src/state.rs. No system step is wired in here: the engine knows only the Step contract.


The 4 invariants (non-negotiable)

# Invariant Where
1 snapshot always before run — before mutating, every step records whether what it is about to create already existed (PreState). It is the only source of truth for the undo. Installer::execute
2 Undo in reverse order — the rollback runs the undo of the completed steps from last to first. Installer::rollback
3 Undo is best-effort and idempotent — an undo does not fail if the artifact is already gone; if it does fail, it logs a warn and carries on with the others, so the cleanup is never blocked. Installer::rollback
4 State persisted to disk, and read backcompleted + snapshots + configuration go to /var/lib/invok/state.json (root, 0600). InstallState::save / load

Invariant 4 has three consumers

The state is not a scratch file: it is the uninstall manifest, and three different things depend on it.

  1. invok rollback reads it back to undo an installation it did not run — after a kill -9, a power cut, or months later to uninstall. It rebuilds the steps by name (steps::step_by_name), rehydrates them from their snapshot and runs their undo in reverse order.
  2. Re-running the installer reads it to decide: first installation, resume of an interrupted one, or refusal if a completed one is already registered.
  3. Uninstalling later. This is why, after a successful installation, the state is not deleted: it is marked (finished = true) and kept. Deleting it would make the instance no longer removable automatically.

The manifest says what is STILL on the system, not what was done at some point. After a successful undo the record is removed: if it kept listing an undone step, a re-run would skip it believing it done, and the installation would continue on artifacts that do not exist. A failed undo leaves the record instead — there the artifact may still be present, and that record is the only trace of the leftover to retry.

Why the state does not live inside /opt/odoo. That directory is the perimeter the rollback must be able to remove whole: a manifest inside it would keep it occupied at the very last undo. Same reason for the lock (/run/invok.lock) and the log (/var/log/invok.log), which are opened before the engine and would quietly bring that directory into existence.


The PreState pattern

Every mutation falls into one of three states, decided during snapshot:

enum PreState {
    Untracked,    // run() not executed → no undo
    Preexisting,  // it was there before us → undo is a NO-OP (not ours to destroy)
    CreatedByUs,  // we created it → undo removes it
}

Rule: undo acts only if the step is completed and PreState == CreatedByUs.

This is the project's critical protection: a database, a user or a home that already existed is never destroyed by a rollback. Steps with several independent mutations (PostgreSQL, say: installed / enabled / active) use several PreState fields, one per mutation.


The Step trait

The contract every installer step implements. The engine orchestrates it without knowing its details, and the trait does not change when new steps are added.

Method Signature Role
name fn name(&self) -> &str Stable, unique name (logs + persisted state)
snapshot fn snapshot(&mut self, &Context) -> Result<(), StepError> Detect and record the PreState before mutating
run fn run(&mut self, &Context) -> Result<(), StepError> Perform the mutation (honours dry_run)
undo fn undo(&self, &Context) -> Result<(), StepError> Undo, best-effort, only on CreatedByUs
snapshot_value fn snapshot_value(&self) -> serde_json::Value Serialisable snapshot for persistence (default null)
rehydrate fn rehydrate(&mut self, &serde_json::Value) -> Result<(), StepError> Rebuild the snapshot from the manifest, for rollback from disk

snapshot_value and rehydrate must be exact inverses: that is the property that makes rollback-from-disk trustworthy, and tests/rehydrate.rs checks it step by step (JSON identity plus equivalence of the undo). Rollback from disk deliberately does not re-run snapshot(): it would photograph the system after our mutations, and the database we created would come out as Preexisting, or the other way round. An unreadable snapshot makes the rehydration fail and the undo is skipped — fail-closed: better a leftover to remove by hand than a resource destroyed on a wrong inference.

Steps read the configuration only from the Context, never from the UI: that is what makes the same installer runnable interactively or non-interactively.


Installer: execute + rollback

execute

For each step, in sequence:

snapshot()  ──▶  run()  ──▶  record + persist state (except in dry-run)
   │               │
   │ err           │ err
   ▼               ▼
roll back the previous steps, then return the error
  • If snapshot fails: the step mutated nothing, but without a reliable snapshot it is not safe to carry on → roll back the previous steps.
  • If run fails: roll back the previous steps and the failing one. A run that stops halfway has usually already created something — the clone makes its directories before going to the network, the user step runs useradd before the chown — and leaving it out left that on disk, which then kept /opt/odoo non-empty and therefore alive. It is safe because an undo acts only on CreatedByUs, and each step claims that verdict the moment its artifact comes into existence, not once it is tidy.
  • If persistence fails: the on-disk state would be inconsistent with the system → roll back.
  • Every completed step is persisted immediately after it succeeds (in dry_run persistence is skipped).

rollback

It walks the steps to undo in reverse order and calls their undo — the failing one first, being the last to have run. An undo that returns an error is logged at warn and the cleanup carries on (best-effort). The rollback drops the DB before the role, stops the service before removing the unit, and so on.


State persistence

InstallState { completed: Vec<StepRecord> }, where StepRecord { name, snapshot } holds the step's name and its snapshot (JSON, opaque to the engine).

Function Behaviour
save(path) Creates the file 0600 from creation (no window at wider permissions) and re-enforces them after writing
load(path) Missing file → empty state (first run); that is not an error
clear(path) Idempotent removal (already gone → ok)

The path is configurable (Context::state_path): in production /var/lib/invok/state.json, in tests a temporary directory — so the tests run without root and without touching the system. The historical path /opt/odoo/.installer-state.json is still read when it is the only one present: an instance installed by an earlier version must stay uninstallable.

Before being consumed for a real rollback, the manifest is validated: it must declare the expected perimeter (/opt/odoo, a constant that cannot be overridden) and belong to root, in a directory not writable by third parties. It drives rm -rf, dropdb and userdel: you do not take it from a source somebody else can rewrite.

Interruption

A SIGINT (Ctrl-C) or a SIGTERM no longer kills the process: they raise a flag that the engine watches between one step and the next, and the run rolls back as if a step had failed.

Between steps, not inside one: a step is the unit that is either complete or not started, and it is the only safe boundary — truncating an apt would leave dpkg inconsistent. In practice the wait is short, because the signal reaches the whole process group and the external command in flight dies by itself.

A second signal exits at once with code 130: someone insisting wants to leave, and a rollback can take minutes. From a script, signal the installer alone (pkill -INT -x invok): a pkill -f would also hit the sudo wrapping it, and two signals count as “second Ctrl-C”.


The rollback's perimeter

The rollback restores only the Odoo artifacts the installer added (user, /opt/odoo, role, DB, sources, venv, config, unit, helper, package delta, filestore, Nginx if any). It does not touch the base system beyond the delta it introduced.

A corollary learned in the field (A-R5-3): whatever the installer brings into existence must be born inside the perimeter, not chased outside it. Pip's cache ends up in the venv (--cache-dir) rather than in /opt/odoo/.cache, and the filestore is created by a step (1.12b SetupDataDir) instead of being left to appear on Odoo's first start. An artifact born with nobody recording it cannot be undone, and no after-the-fact cleanup inside a customer's home is anywhere near as safe. The rollback also restores the services' runtime state as the snapshot found it (service was stopped and we started it → stop it again; it was running → leave it).

Firm decisions:

  • PostgreSQL: if we created it, by default stop + disable, not purge. Purge only with --aggressive-rollback.
  • Package delta: the common bootstrap utilities (git/curl/wget/gettext) are left; only the heavy dev delta is purged, and only the packages that were not present before.
  • Init of a pre-existing DB: hard stop (the installer refuses to initialise a database it did not create).
  • ~/.bashrc: the PATH patch is a single targeted line, with a backup; the whole file is never rewritten.

Design notes

  • The crate is lib + bin: the logic lives in src/lib.rs (testable through the public API), and src/main.rs is a thin binary.
  • No .unwrap()/.expect() in the steps' production code: every failure is a Result with a typed error (StepError, thiserror).
  • The engine and the trait must not change to add a step: if the trait looks insufficient, stop and raise it instead of forcing it.
  • The run → undo round trip is covered by tests wherever feasible (reverse order, best-effort, no-op on Preexisting, 0600 persistence).

Clone this wiki locally