README for humans, AGENTS.md for agents and contributors, and the docs that outlive any one platform: architecture, secrets, connectivity, triggering a deploy from another repository. CLAUDE.md, GEMINI.md and .claude/skills/ are pointers rather than copies, so every agent and every human reads the same text. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
139 lines
6.9 KiB
Markdown
139 lines
6.9 KiB
Markdown
# Why it is shaped this way
|
|
|
|
Short notes on the decisions that are not obvious from the code, and what each
|
|
one is buying. Read `AGENTS.md` for the rules; this is the reasoning behind them.
|
|
|
|
## Payloads are data, not templates
|
|
|
|
A stack's compose file is copied to the host **verbatim**. Nothing is templated,
|
|
nothing is generated.
|
|
|
|
This is the decision most likely to look wrong at first, because templating is
|
|
what configuration management is *for*. The argument against it here:
|
|
|
|
- **You can read the repository and know what is on the host.** With templating
|
|
you know what the host will get after rendering, which is not the same thing
|
|
at three in the morning.
|
|
- **You can `docker compose up` it by hand.** When the deploy path is broken —
|
|
and the deploy path is what breaks — the payload is still a working compose
|
|
project you can run from an SSH session.
|
|
- **Upstream projects ship compose files.** Nextcloud, Gitea, Discourse and
|
|
most self-hosted software give you one. Keeping it verbatim means you can diff
|
|
against theirs when you upgrade. Templating it means you re-derive your changes
|
|
every time.
|
|
- **Per-host differences belong in the playbook.** Which networks exist, which
|
|
directories to create, which compose files to use: these are deployment
|
|
concerns and the role contract expresses all of them. See `metrics.yml`, which
|
|
covers two hosts from one payload without templating anything.
|
|
|
|
The cost is real: genuinely dynamic values have to come from the environment
|
|
rather than from Ansible. In practice that means a `.env` on the host, which is
|
|
where the secrets had to live anyway.
|
|
|
|
## One role, and a contract
|
|
|
|
There is one role. Every stack-specific need is a variable on it, and the list is
|
|
documented in `AGENTS.md` as a contract.
|
|
|
|
The alternative — a role per stack, or a role per *kind* of stack — is how these
|
|
repositories usually grow, and the failure mode is consistent: twelve roles that
|
|
are each 90% the same, diverging slowly, so that a fix to the sync logic has to
|
|
be applied twelve times and is applied to nine.
|
|
|
|
The rule that keeps it honest is in `AGENTS.md` rule 4: **if two playbooks need
|
|
the same logic, extend the contract, do not add a second role.** When a stack
|
|
needs something the contract cannot express, that is information — say so, rather
|
|
than working around it with loose tasks.
|
|
|
|
`banner.yml` is the deliberate exception. It is not a Compose stack at all: one
|
|
shared file dropped into another stack's directory. Forcing it through a role
|
|
built around "sync a directory, then `docker compose up`" would mean weakening
|
|
the role for one caller. Plain tasks are the right answer, and the fact that
|
|
there is exactly one such playbook is the signal that the contract is holding.
|
|
|
|
## Two entry points, not two playbooks
|
|
|
|
`compose_stack` exposes `sync` and `up` separately, so a playbook that must do
|
|
work in between — a backup, a schema migration — can interleave its own tasks.
|
|
|
|
The alternative is a second playbook, `webapp-update.yml`, and the reason to
|
|
avoid it is that the two immediately drift. The update playbook gains a network
|
|
the deploy playbook does not have; someone fixes a directory in one and not the
|
|
other. One playbook that behaves differently under a flag cannot drift from
|
|
itself.
|
|
|
|
## Deploy and update are one flag
|
|
|
|
`pull` is a single repository-wide extra-var. `policy` is the default and means
|
|
"Compose decides", so nothing is fetched for an image already present. `always`
|
|
fetches newer images **and** gates the heavier per-stack work.
|
|
|
|
Two properties follow, and both are load-bearing:
|
|
|
|
- **A default run must never destroy or migrate anything.** You should be able to
|
|
run `site.yml` at any time, against everything, without thinking about it. That
|
|
is what makes it useful for convergence after a manual change.
|
|
- **An update run must be safe to repeat.** It is not transactional. Something
|
|
will fail halfway through, and the fix is to run it again.
|
|
|
|
## The `SSH_KEY_PATH` seam
|
|
|
|
Ansible knows one thing about credentials: a path to a private key. One script
|
|
decides where that key comes from.
|
|
|
|
The payoff is that the same playbooks run unchanged from a laptop, from GitHub
|
|
Actions, from GitLab CI and from Gitea Actions, and that adding a secret backend
|
|
is a change to one
|
|
file that no playbook imports. The script prints a path and never prints key
|
|
material, so it is safe to call in a CI log.
|
|
|
|
The constraint that makes it work is worth stating: **nothing else in the
|
|
repository touches key material.** The moment a playbook learns how to read a
|
|
vault, the seam is gone.
|
|
|
|
## Nothing deploys on push
|
|
|
|
Every deploy path is manual or externally triggered. Merging changes what *would*
|
|
be deployed; a person still decides when.
|
|
|
|
This is a judgement call, not a universal truth. It is right for a handful of
|
|
long-lived stateful services where a bad deploy means restoring a database, and
|
|
where the person merging is often not the person who should be watching the
|
|
deploy. It is wrong for a fleet of stateless services with good rollback, where
|
|
continuous deployment is the whole point.
|
|
|
|
If you adopt this repository and your situation is the second one, the change is
|
|
small — add a `push` trigger to the deploy workflow — but make it deliberately.
|
|
|
|
## Bind mounts and the directories nobody creates
|
|
|
|
A surprising amount of the role contract exists because of one Docker behaviour:
|
|
**a bind mount whose host path does not exist is created by Docker, owned by
|
|
root.** The container then cannot write to it, and the failure surfaces as an
|
|
application error rather than a permissions one.
|
|
|
|
`stack_dirs` pre-creates them as the deploy user. Three details are the result of
|
|
getting this wrong:
|
|
|
|
- **Attributes apply only when set.** A directory that already exists on the host
|
|
keeps its permissions. Re-chmodding a live bind mount is how you break a
|
|
Postgres data directory, which refuses to start unless it is `0700` or `0750`.
|
|
- **Escalate only for `owner`/`group`.** Creating these as root defeats the
|
|
purpose. The role escalates only when an entry actually asks for another uid.
|
|
- **It belongs in `stack_dirs`, not `pre_tasks`.** `pre_tasks` run before the role
|
|
creates the stack directory, and `ansible.builtin.file` stamps its attributes
|
|
onto every parent it creates on the way down. An escalated chown of a child
|
|
leaves the *stack* directory owned by that uid, and the payload sync — running
|
|
as the deploy user — then cannot write into it.
|
|
|
|
## Bind-mounted config needs a reload
|
|
|
|
Compose recreates a container when the compose file changes. It does not know or
|
|
care that a file bind-mounted *into* the container changed, so a new nginx vhost
|
|
or Prometheus scrape config lands on the host and has no effect.
|
|
|
|
The role sets `compose_stack_synced` after the sync, and playbooks key a reload
|
|
off it. The `when:` is what keeps the playbook idempotent — without it every run
|
|
reports a change forever, and "changed=0 means nothing happened" stops being
|
|
true, which is the only cheap signal these deploys have.
|