Skip to content

Development and contributing

Omisen edited this page Aug 14, 2026 · 1 revision

For whoever works on the code: build, tests, architecture, and how to add a step.

Build & test

cargo build                              # build
cargo test                               # tests WITHOUT root: the system is mocked
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check

The tests never touch the real system: privileged operations go through a boundary (SystemOps) that is mocked in tests. The CI (.github/workflows/test.yml) runs the same checks on every push/PR.

And a CI that really installs

Mock tests prove the logic, not the integration with a real package manager, PostgreSQL and systemd. .github/workflows/integration.yml installs Odoo on ephemeral runners and containers and checks that invok rollback leaves the system clean. It runs on demand and on the main branches.

The scenarios are chosen to cover what mocks cannot see:

Scenario What it checks
Ubuntu 22.04 / 24.04 full life cycle, second installation refused, system clean after the rollback
Debian 11 / 12 (container) portability of the apt names and of the wkhtmltopdf pin. Not the service start: in a container systemd is not PID 1
Fedora 41 (container, systemd as PID 1) the whole rpm family: dnf names, wkhtmltopdf .rpm, PostgreSQL cluster init
Fedora 41 with Nginx firewalld and SELinux — the only place they really run
Fedora 44 the other branch of the interpreter choice: system Python 3.14, venv on python3.13
With Nginx, ufw active the six Nginx steps, in a matrix over the two natures of the default site (symlink and regular file)
Pre-existing odoo user the home handed over to a user who is already there, and the refusal when /opt/odoo pre-exists owned by root
Real Ctrl-C a SIGINT sent mid-installation: the installer must roll back on its own
MODE=full bash scripts/ci/integration-test.sh   # runnable by hand — DESTRUCTIVE: throwaway VMs only

Nearly every real defect in this project was found by a real machine, not by re-reading the code. When you add an area of behaviour, ask yourself whether any real execution crosses it: if the answer is no, that is where defects survive.

Architecture in two minutes

  • trait Step — every step exposes snapshot (detect the pre-existing state), run (mutate) and undo. Detail: 0. Engine.
  • Installer (the engine) — runs the steps in sequence; if one fails it calls the previous ones' undo in reverse order. It knows nothing about the individual steps.
  • PreStateUntracked / Preexisting / CreatedByUs: the source of truth for the undo. Only what is CreatedByUs is ever undone.
  • SystemOps — the boundary over system commands (systemctl, useradd, psql, files…), which also yields packages() and distro(), the two multi-distro boundaries (Multi-distribution support): real in production, mocked in tests. It is what makes everything testable without root.
  • Context — the resolved config the steps read; the UI (inquire/indicatif) lives outside the steps (see 2. UI + dry-run).

Where things are

invok/
├── src/
│   ├── main.rs          entry point: install (parse → prompt → checks → lock → execute) | rollback
│   ├── lib.rs           the library the tests use: main.rs is only the shell
│   ├── cli.rs           CLI arguments (clap) + the `rollback`/`uninstall` subcommand
│   ├── config.rs        CLI/.env/default cascade + declarative .env parser + validators
│   ├── context.rs       the resolved config the steps read
│   ├── engine.rs        the engine: execute + rollback (reverse order) + dry-run plan
│   ├── step.rs          the Step trait (snapshot/run/undo)
│   ├── state.rs         PreState + state persistence
│   ├── rollback.rs      rollback from persisted state (step rehydration + leftovers report)
│   ├── system_ops.rs    boundary over system commands (mockable in tests)
│   ├── checks.rs        non-mutating preflight checks
│   ├── secret.rs        redacted password (never in the logs)
│   ├── error.rs         domain errors (thiserror), per step
│   ├── logging.rs       tracing to TTY + file
│   ├── lockfile.rs      concurrency lock (RAII)
│   ├── interrupt.rs     Ctrl-C/SIGTERM: raises a flag, the engine watches it
│   ├── progress.rs      ProgressReporter (indicatif/log/noop)
│   ├── prompt.rs        interactive input (inquire)
│   ├── packaging/       FIRST BOUNDARY: which commands install, and what it is called here
│   │                      mod.rs (PackageManager + per-family alternative groups) · apt.rs · dnf.rs
│   ├── distro/          SECOND BOUNDARY: where files live, who governs the firewall
│   │                      mod.rs (Distro trait + OsFamily) · debian.rs · fedora.rs
│   │                      ufw.rs (deb-family firewall) · firewalld.rs (rpm-family firewall)
│   └── steps/           the real steps, one per file
├── templates/           odoo.conf.tpl · odoo.service.tpl · nginx.conf.tpl (embedded in the binary)
├── configs/             the CI presets (ci.env, ci-nginx.env) — you write your own .env
├── debian/ · rpm/       postinst/postrm and post/postun: they create and remove the `vok` alias
├── scripts/ci/          integration-test.sh · journal.sh (reads the journal from the log)
│                        selftest-journal.sh (checks that journal.sh can read it)
├── .github/workflows/   test.yml (fast, mock) · integration.yml (real) · release.yml
└── tests/               per-step tests + coordination + end-to-end rollback

The two boundaries (packaging/ and distro/) are why none of the 25 steps contains a match on the distribution. Adding a family means adding a backend to each of them, not touching the steps — and the Step trait does not change.

Adding a step

  1. Create src/steps/<name>.rs implementing Step:
    • snapshot → determine the PreState (what already existed?);
    • run → mutate only when needed; honour ctx.dry_run;
    • undo → act only on CreatedByUs, best-effort and idempotent;
    • snapshot_value / rehydrate → serialise and deserialise the snapshot (persistence). They must be exact inverses: that is what makes rollback-from-disk trustworthy, and tests/rehydrate.rs checks it step by step.
  2. Use SystemOps for every system operation (no direct std::process::Command inside a step, or it stops being mockable).
  3. Register the step in both functions of src/steps/mod.rsbuild_steps (the canonical order; order matters, because the rollback is its reverse) and step_by_name (rebuilding by name, for rollback from disk). A parity test demands they cover the same set.
  4. Write the tests in tests/<name>.rs against the mock, including the run → undo round trip.
  5. Do not modify the Step trait or the engine to fit a new step: if it does not fit, that is a signal to discuss, not to widen the trait.

Every step also has its own detail page (1.11.16) in this wiki.

Conventions

  • No .unwrap()/.expect() in production code: every failure is a Result.
  • cargo fmt and clippy -D warnings clean before committing.
  • The password is never logged: use the Secret type.

Code history

See History: the installer was Bash up to the v1.x tags, then a complete rewrite in Rust.

Clone this wiki locally