Skip to content

1.16 Step | WriteControlScript + PatchBashrc

Omisen edited this page Aug 17, 2026 · 11 revisions

The last two steps: they install the odoo helper command and close the project's third critical protection (C3) — care for the user's personal files. They live in src/steps/write_control_script.rs and src/steps/patch_bashrc.rs. A port of lib/control_script.sh.

They complete the triad of protections: DB anti-drop (do not destroy) · init hard stop (do not write) · surgical bashrc (do not alter).


Ownership: SUDO_USER, neither odoo nor root

These artifacts belong to whoever ran sudo (SUDO_USER), not to the odoo user and not to root. The command is installed for that user only (~/.local/bin), never globally in /usr/local/bin — a security choice from the original Bash (reduce exposure), preserved. The home is determined with getent passwd <user>, not assumed to be /home/<user>. A missing SUDO_USER is an error.


1 — WriteControlScript (the clean one)

Our own material, in the user's directories. It follows the pattern of the earlier steps.

Phase Behaviour
snapshot do the ~/.scripts/odoo.sh script and the ~/.local/bin/odoo symlink already exist? and did the directories exist? For a named instance both carry the instance name (odoo-cliente-x), so each installation gets a helper of its own instead of overwriting the other's
run always writes odoo.sh (a wrapper over systemctl {start|stop|restart|status} plus dev), chmod +x, creates the symlink if missing, chowns to SUDO_USER. If the script was already there, it is backed up first
undo CreatedByUs → remove; Preexistingput the backup back. The directories only if we created them and they are empty

Tests: ownership == SUDO_USER (never odoo/root), no path under /usr/, a pre-existing script saved and restored.

Why the script is always rewritten. Its contents are generated by us and carry the service name inside (SERVICE_NAME=odoo18). Skipping it because “it already exists” meant that, after reinstalling a different Odoo version, the odoo helper kept driving the old service — and the user only found out when odoo restart did not do what it should.

The PreState here does not protect somebody else's content: it protects the decision to remove it in the undo. But a file with that name could still be somebody else's, and we have no way to know: hence it is set aside before being rewritten, and the undo puts it back — the same treatment as the nginx vhost and odoo.conf.


2 — PatchBashrc (the installer's most delicate mutation)

The .bashrc is the user's most intimate config file: aliases, functions, their own things. The “surgical” promise here means that, if the user rolls back, their file comes back byte for byte as it was — without our line, and without scars.

We add exactly one line, only if it is not already there:

export PATH="$HOME/.local/bin:$PATH"
Phase Behaviour
snapshot does the .bashrc exist? is the line already present? (exact line match, grep -Fqx)
run if absent: back up the file, then append the SINGLE line (never rewrite the whole file)
undo if we added it: restore from the backup (primary) → fall back to removing the exact line

Firm rules (CLAUDE.md)

  • Never rewrite or truncate the whole .bashrc. Only append/remove the single line, or restore the backup.
  • Exact match, never fuzzy: if the user had a different handwritten PATH line, the undo does not touch it.
  • Line already present (Preexisting) → it is not ours → run and undo are no-ops (no duplicates, no removals).
  • If the file did not exist and we created it → the undo removes it.

Why restoring from the backup is the primary method

It brings the file back to its exact pre-modification state, with no risk of removing the wrong line. The exact match (remove_exact_line) is the robust fallback when the backup is not available.


Design notes

  • No step-to-step channel added: sudo_user is read-only input in the Context (from the environment, like os_info), not mutable shared state. The only shared state remains db_created_by_us.
  • control_script_content and remove_exact_line are pure functions, tested separately.
  • Tests protecting C3: a round trip of the .bashrc (after run+undo, identical to the original with aliases and functions intact), line already present → no-op, .bashrc created → removed, and a different PATH line of the user's survives (exact match).

What the helper does, and what it deliberately does not

odoo {start|stop|restart|status|list|logs [N]|dev}

Every mutating verb acts on ${SERVICE_NAME} — this instance's service — and on nothing else. A machine can carry several, each with its own service, user, database and port, and a helper that started or stopped somebody else's would take one customer offline to fix another one's problem. That is the hazard the shared-artifact rule protects the rollback from, arriving through the front door instead. There is a test that reads the mutating branches and refuses any systemctl there acting on a name we did not derive.

list and status are the exception, because reading is not touching: list shows every Odoo service on the machine, marking the one this helper drives, and status prints that after this instance's own state.

That listing is also the answer to “why not a single controller taking the instance as an argument?”. It would be DRY on disk — the logic is already single-sourced in the template above, so what would be shared is the artifact, not the code — but it would put a shared file with a tombstone inside somebody's home directory, and give one helper the power to stop another instance's service. list gives the half that is actually useful (finding out what is here and what drives it) while every instance keeps a self-sufficient tool: one that still works when another instance was removed badly, or when its manifest cannot be read.

Odoo services on this machine:
   odoo-cliente-x.service           inactive
-> odoo18.service                   active   (this one: odoo)

Two commands produce that, and the reason is worth keeping. systemctl list-units was the obvious one and it is the wrong source: a service that is stopped gets unloaded, so it vanishes from that listing even with --all — which is precisely the instance somebody running status is looking for. It was written that way, and a VM showed the missing line within a minute. The unit files are the record of what is installed; is-active answers, per unit, what is up. A manifest could not answer either question: what is running now is not recorded anywhere, by design — the same distinction the preflight makes between a port that is reserved and a port that is held.

logs is read-only too, and scoped the same way: journalctl -u ${SERVICE_NAME} -f, from the last N lines (100 by default). On a machine with two customers a log that mixed both would be worse than no log. The usage says that Ctrl-C stops reading and not the service — the verb next to it is dev, which does stop it, and that is exactly the kind of neighbouring meaning that gets confused at 2am.

dev stops this service and opens a shell as the instance's user, which lands in its home. It does not stop the others: two instances share no port and no database, so there is nothing to free.

When the shell exits, dev puts the service back the way it found it. The state is read before the stop — afterwards it is no longer observable — and the restore hangs off a trap on EXIT HUP INT TERM, not off the last line of the branch: the person this was written for is the one who closes the window and forgets, and a closed window is SIGHUP. So the question is an offer, never the mechanism. With a terminal you are asked, with a bounded wait; without one, or when the wait runs out, the same rule is applied in silence.

The default is derived from the state found, not fixed on "yes", and the two cases are not symmetric:

found active →  Restart 'odoo18'? [Y/n]
found stopped →  'odoo18' was already stopped when you entered dev. Start it now? [y/N]

A fixed "yes" would mean that Enter starts an instance somebody had switched off on purpose — and Enter is precisely what a distracted person presses, which is the same distraction the behaviour exists for. Deriving it keeps the choice free in both directions while obeying the rule the rollback already follows: put back the state you found. And if the service is up when you leave — you never let it stop, or you started it again yourself from inside the shell — nothing is asked and nothing is done: that is already the state you wanted.

The restore is best-effort, and that is written into the generated script rather than left to the shell's defaults. It is an undo, and the engine's third invariant says an undo reports and carries on — but a bash script runs under set -e, where the opposite is the default. That default once cost the whole behaviour: with the window closed the pty is gone, so the restore's own first echo fails with EIO, and the shell died before it ever reached the service. The action must never be hostage to the telling of it; the one thing that still has to be noticed — the service failing to start — is checked by hand.

Previously the service was simply left down, with a line saying so. That line was only read by somebody still watching the screen.

dev <instance> — a shell in somebody else's instance, and nothing more

odoo dev cliente-x becomes that instance's user. The need is ordinary — reading and editing another instance's files — and the obstacle is deliberate: its home is 0750, its config 0640, and inside are admin_passwd, db_password and the customer's attachments. That isolation is what makes a PostgreSQL role per instance mean anything, so the way in is not a loosened permission. It is sudo, already the gate for everything else the helper does: whoever can sudo can become any user anyway, so nothing new is granted — one only stops having to remember another instance's helper name.

It does not stop that service, and that is the whole difference from the argument-less dev. A helper able to stop another instance would be a way to take one customer offline while fixing another's problem — precisely the hazard one-helper-per-instance exists to prevent. The port therefore stays busy, and dev says so on the way in instead of letting odoo-bin fail with a puzzle.

Three rules decide the rest, and each is the project's standing habit applied here:

  • the user is read, never rebuilt. odoo-<name> is only the default the installer derives; the CLI/.env cascade can override ODOO_USER. So the name is a guess and the unit is the record: systemctl show -p User --value. Same rule as who owns an artifact, and as the OS family.
  • if systemd will not say, we do not enter. An empty User= means "no answer", and the tempting fallback — root — would turn a convenience into a privilege surprise.
  • ambiguity is refused, not resolved. The unnamed instance's unit carries the Odoo version, so a machine can hold odoo17 and odoo18 at once — a migration in progress is exactly that. Rather than pick one by a precedence rule, dev lists them and asks for an exact name, the way rollback without arguments does.

The names accepted are the ones the machine makes plausible: cliente-x, odoo-cliente-x, odoo-cliente-x.service — whatever list printed — and default for the unnamed installation, the same reserved word rollback --instance uses.

The same argument on status and logs, and on nothing else

status NAME and logs NAME [N] name another instance too. The proposal on the table was larger — one shared odoo script driving every instance, with every verb taking an argument — and what was taken is the half that costs nothing: looking at somebody else's instance is not the hazard, starting it is. So start, stop and restart do not read an argument at all, and the line the header has always declared stays where it was: a mutating systemctl names ${SERVICE_NAME} and nothing else. To act on a named instance you use its own helper, which list tells you.

Two details carry more weight than their size:

  • one resolver, not three. dev, status and logs must agree on what a name means and on how a bad one is refused, so the resolution lives in a single function — three copies would be three chances to drift, which is the shape of duplication that decided the larger proposal against itself.
  • logs 500 still means five hundred lines of this journal. logs took a count long before it took a name, and both are optional. What tells them apart is not a convention invented for the occasion: an instance name must begin with a letter, so a run of digits can never be one. The rule is derived from the grammar that already exists rather than added beside it.

An unknown verb prints the usage on stderr and exits 2. It used to print it on stdout and exit 0, so a script calling the helper could not tell a typo from a success.

Clone this wiki locally