-
Notifications
You must be signed in to change notification settings - Fork 0
1.16 Step | WriteControlScript + PatchBashrc
The last two steps: they install the
odoohelper command and close the project's third critical protection (C3) — care for the user's personal files. They live insrc/steps/write_control_script.rsandsrc/steps/patch_bashrc.rs. A port oflib/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).
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.
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; Preexisting → put 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, theodoohelper kept driving the old service — and the user only found out whenodoo restartdid not do what it should.The
PreStatehere 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 andodoo.conf.
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 |
-
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
PATHline, 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.
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.
-
No step-to-step channel added:
sudo_useris read-only input in the Context (from the environment, likeos_info), not mutable shared state. The only shared state remainsdb_created_by_us. -
control_script_contentandremove_exact_lineare 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,.bashrccreated → removed, and a different PATH line of the user's survives (exact match).
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.
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/.envcascade can overrideODOO_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
odoo17andodoo18at once — a migration in progress is exactly that. Rather than pick one by a precedence rule,devlists them and asks for an exact name, the wayrollbackwithout 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.
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,statusandlogsmust 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 500still means five hundred lines of this journal.logstook 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.
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: