-
Notifications
You must be signed in to change notification settings - Fork 0
0. Engine | Step trait, PreState, rollback
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 theStepcontract.
| # | 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 back — completed + snapshots + configuration go to /var/lib/invok/state.json (root, 0600). |
InstallState::save / load
|
The state is not a scratch file: it is the uninstall manifest, and three different things depend on it.
-
invok rollbackreads it back to undo an installation it did not run — after akill -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 theirundoin reverse order. - Re-running the installer reads it to decide: first installation, resume of an interrupted one, or refusal if a completed one is already registered.
-
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
undothe 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 failedundoleaves 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.
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 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.
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
snapshotfails: the step mutated nothing, but without a reliable snapshot it is not safe to carry on → roll back the previous steps. - If
runfails: roll back the previous steps and the failing one. Arunthat stops halfway has usually already created something — the clone makes its directories before going to the network, the user step runsuseraddbefore thechown— and leaving it out left that on disk, which then kept/opt/odoonon-empty and therefore alive. It is safe because an undo acts only onCreatedByUs, 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_runpersistence is skipped).
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.
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.
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 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.
- The crate is lib + bin: the logic lives in
src/lib.rs(testable through the public API), andsrc/main.rsis a thin binary. -
No
.unwrap()/.expect()in the steps' production code: every failure is aResultwith 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 → undoround trip is covered by tests wherever feasible (reverse order, best-effort, no-op onPreexisting,0600persistence).
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: