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]>
232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# AGENTS.md
|
|
|
|
Instructions for LLMs and AI agents working in this repository. Humans should
|
|
read `README.md` first; this file assumes you already know what the repo is for.
|
|
|
|
This is the one instruction file. `CLAUDE.md` and `GEMINI.md` are pointers to it,
|
|
and `.claude/skills/` points at `skills/`. Put content here or in `skills/`,
|
|
never in a pointer — the whole arrangement exists so that every agent, and every
|
|
human, reads the same text.
|
|
|
|
## What this repository is
|
|
|
|
A pattern for deploying Docker Compose stacks to a small number of Linux hosts
|
|
with Ansible, published as a working example. Every service on every host is a
|
|
Compose stack living in a directory under `/srv/stacks/` on the target. This repo
|
|
holds the files that get copied there, and the playbooks that copy them.
|
|
|
|
| Host | Address | Runs |
|
|
| ------ | ------------------ | ----------------------------------- |
|
|
| `edge` | `edge.example.com` | The reverse proxy, metrics |
|
|
| `app` | `app.example.com` | The applications behind it, metrics |
|
|
|
|
Both use SSH user `deploy` on port 22.
|
|
|
|
Everything under `server/` and every host name is an **example**. `example.com`
|
|
is reserved by RFC 2606 and can never resolve to a real service. Replace them
|
|
with yours; the framework around them is the part meant to be reused.
|
|
|
|
## Vocabulary
|
|
|
|
- **host** — one machine (`edge`, `app`). The top-level directories under
|
|
`server/` and the inventory names must always agree, because the role resolves
|
|
a payload as `server/{{ inventory_hostname }}/{{ stack_name }}`.
|
|
- **stack** — one Docker Compose deployment on one host (`metrics`, `webapp`).
|
|
- **payload** — the files under `server/<host>/<stack>/`, copied to the host
|
|
verbatim. Payloads are *data*. Do not template them, do not restructure them,
|
|
and do not lint them as Ansible content.
|
|
|
|
## Skills
|
|
|
|
Step-by-step guides for recurring jobs live in `skills/<name>/SKILL.md`, outside
|
|
any vendor directory.
|
|
|
|
| Skill | Use when |
|
|
| --- | --- |
|
|
| `adding-a-stack` | Adding a new service, moving one to a different host, or bringing a hand-deployed service under Ansible and CI |
|
|
|
|
## Playbook rules
|
|
|
|
These are the conventions. Follow them; do not invent per-stack variations.
|
|
|
|
1. **One playbook per stack**, at `ansible/playbooks/<stack>.yml`. If the same
|
|
stack runs on several hosts it is still one playbook with a host group, not
|
|
one per host. `metrics.yml` is the example.
|
|
2. **Self-contained.** Every playbook must run standalone as
|
|
`ansible-playbook playbooks/<stack>.yml` with no required extra vars — `pull`
|
|
is the one optional flag. It declares its own `hosts`, its own vars, and
|
|
everything the stack needs (directories, networks, compose files). Never rely
|
|
on another playbook having run first. Where that means two playbooks create
|
|
the same directory, that is correct and the repetition is deliberate.
|
|
3. **Tag every play** with the stack name, so `site.yml --tags metrics` works.
|
|
4. **Reuse goes in `roles/compose_stack`, not in copy-pasted tasks.** If two
|
|
playbooks need the same logic, extend the role's variable contract rather than
|
|
adding a second role. Keep the role count at one unless there is a genuinely
|
|
different shape of work.
|
|
5. **Modules, not shell.** `community.docker.docker_compose_v2` rather than
|
|
`docker compose up`; `ansible.builtin.file` rather than `mkdir -m 777`. Reach
|
|
for `command`/`shell` only when no module exists, and then set `changed_when`
|
|
explicitly.
|
|
6. **Fully-qualified collection names** everywhere (`ansible.builtin.copy`, not
|
|
`copy`).
|
|
7. **Name every task**, sentence case, imperative: `Sync the metrics payload`,
|
|
not `sync files`.
|
|
8. **Idempotent.** A second run must report zero changes, and `--check --diff`
|
|
must not error.
|
|
9. **No secrets in the repository.** `.env` files stay on the hosts. Never commit
|
|
a key, a password, or a token — including inside a compose file, where no
|
|
exclude can help you. See `docs/secrets.md`.
|
|
|
|
### The `compose_stack` role contract
|
|
|
|
Playbooks configure the role through these variables. Extend this list when a
|
|
stack needs something new; do not work around it with loose tasks.
|
|
|
|
| Variable | Default | Purpose |
|
|
| ---------------- | ----------------------------------------------------- | ------------------------------------------- |
|
|
| `stack_name` | *required* | Stack identifier, used for logs and tags |
|
|
| `stack_src` | `server/{{ inventory_hostname }}/{{ stack_name }}` | Payload directory in this repository |
|
|
| `stack_dest` | `{{ stack_root }}/{{ stack_name }}` | Destination directory on the host |
|
|
| `stack_dirs` | `[]` | Directories to pre-create (see below) |
|
|
| `stack_networks` | `[]` | External Docker networks to ensure exist |
|
|
| `stack_files` | `[docker-compose.yml]` | Compose files passed to the module |
|
|
| `stack_exclude` | `[.env]` | Paths never synced to the host |
|
|
| `stack_prune` | `false` | Delete host files absent from the payload |
|
|
| `stack_env` | `{}` | Extra env for compose; `PWD` is always set |
|
|
| `stack_pull` | `{{ pull \| default('policy') }}` | Per-invocation override of the pull flag |
|
|
| `stack_build` | `policy` | `always` for stacks built from a Dockerfile |
|
|
| `stack_state` | `present` | `present`, `absent`, or `restarted` |
|
|
|
|
Worked examples for each: `static-site.yml` (the minimum), `reverse-proxy.yml`
|
|
(`stack_dirs`, reload), `metrics.yml` (`stack_src`, per-host maps, ownership),
|
|
`webapp.yml` (two phases, `stack_files`, `stack_build`), `webapp-staging.yml`
|
|
(`stack_src` + `stack_dest` + `stack_env`), `banner.yml` (not a stack at all).
|
|
|
|
#### `stack_dirs`
|
|
|
|
Entries take a `path` plus optional `mode`, `owner`, `group` and `recurse`. All
|
|
four are applied **only when given**, so a directory that already exists on the
|
|
host is never re-chmodded — several bind mounts are created root-owned by Docker
|
|
on first start, and Postgres refuses to start if its data directory is not
|
|
`0700`/`0750`.
|
|
|
|
The role escalates **only when an entry sets `owner` or `group`.** Everything
|
|
else is created as the deploy user, which is the whole point of pre-creating a
|
|
bind mount: a root-owned directory is exactly what you are trying to avoid. A
|
|
stack that genuinely needs another uid says so with `owner`/`group` — see
|
|
`metrics.yml`, which hands `grafana-data` to `472` with `recurse: true` and no
|
|
`mode`.
|
|
|
|
Put that in `stack_dirs` rather than a `pre_tasks` chown. `pre_tasks` run before
|
|
the role creates the stack directory, and `ansible.builtin.file` stamps the
|
|
attributes it was given onto every parent directory it creates on the way down —
|
|
so an escalated chown of a child leaves the stack directory itself owned by that
|
|
uid, and the payload sync, which runs as the deploy user, cannot write into it.
|
|
|
|
#### `compose_stack_synced`
|
|
|
|
After the sync the role sets this fact. Use it to fire a reload for payloads that
|
|
are bind-mounted configuration, which Compose never recreates a container for:
|
|
|
|
```yaml
|
|
post_tasks:
|
|
- name: Reload nginx so changed vhost and conf files take effect
|
|
ansible.builtin.command:
|
|
cmd: docker exec reverse-proxy nginx -s reload
|
|
when: compose_stack_synced | default(false)
|
|
changed_when: true
|
|
```
|
|
|
|
Without the `when`, every run reports a change and the stack is never idempotent.
|
|
|
|
#### Doing maintenance between sync and up
|
|
|
|
`roles: - role: compose_stack` syncs and then starts, which is what almost every
|
|
stack wants. When something has to happen in between — a backup, a schema
|
|
migration — use the two entry points rather than inventing a second mechanism:
|
|
|
|
```yaml
|
|
tasks:
|
|
- name: Sync the payload
|
|
ansible.builtin.include_role:
|
|
name: compose_stack
|
|
tasks_from: sync
|
|
|
|
- name: Migrate the database
|
|
# ... gated on an update run
|
|
- name: Start the stack
|
|
ansible.builtin.include_role:
|
|
name: compose_stack
|
|
tasks_from: up
|
|
```
|
|
|
|
Declare the `stack_*` vars at **play** level so both phases see them; inside
|
|
`roles:` they are scoped to that invocation and the second include will not see
|
|
them. `webapp.yml` is the worked example.
|
|
|
|
### Deploy mode versus update mode
|
|
|
|
Every playbook serves two purposes and must support both without being edited:
|
|
|
|
```bash
|
|
ansible-playbook playbooks/webapp.yml # bring the stack up
|
|
ansible-playbook playbooks/webapp.yml -e pull=always # pull newer images
|
|
```
|
|
|
|
`pull` is a single repo-wide extra-var passed straight through to
|
|
`community.docker.docker_compose_v2`. Valid values are `policy` (the default —
|
|
Compose decides, so nothing is fetched for images already present), `always`,
|
|
`missing` and `never`. Read it as `pull | default('policy')` at every use site so
|
|
the playbook still runs when the var is undefined.
|
|
|
|
This is also the hook for the heavier work a stack needs only when its images
|
|
actually move. Gate those tasks on the same flag rather than creating a separate
|
|
`*-update.yml` playbook:
|
|
|
|
```yaml
|
|
- name: Back up the database before migrating
|
|
ansible.builtin.command:
|
|
cmd: docker compose -f docker-compose.yml -f docker-compose.migrate.yml run --rm backup
|
|
chdir: /srv/stacks/webapp
|
|
when: pull | default('policy') == 'always'
|
|
changed_when: true
|
|
```
|
|
|
|
Two consequences worth stating plainly: **a default run must never destroy or
|
|
migrate anything**, and **an update run must be safe to repeat.**
|
|
|
|
## Safety
|
|
|
|
- **Never run a playbook against a real host unless the user asks in that turn.**
|
|
Run `--check --diff` first and say what it would change.
|
|
- **Never read or print the contents of `.env` files, vault files, or SSH keys.**
|
|
If a task genuinely needs one, ask.
|
|
- Do not `git push`, open pull requests, or commit unless asked.
|
|
|
|
## Verifying your work
|
|
|
|
No Ansible tooling is assumed to be installed in a fresh checkout. One command
|
|
runs everything CI runs:
|
|
|
|
```bash
|
|
make check # or: scripts/check.sh
|
|
```
|
|
|
|
It runs `yamllint` from the repository root, installs the Galaxy collections,
|
|
runs `ansible-lint` at the `production` profile, and syntax-checks every
|
|
playbook. Locally it reaches for `uvx` so the repo needs no virtualenv of its
|
|
own; set `RUNNER=installed` if the tools are already on `PATH`.
|
|
|
|
Two things worth knowing if you run the commands by hand instead:
|
|
|
|
- **Run every ansible command from the `ansible/` directory.** `ansible.cfg` is
|
|
only discovered in the current directory, and it is what puts `roles/` on the
|
|
roles path. From the repository root you get
|
|
`The role 'compose_stack' was not found`.
|
|
- **Install the collections before linting**, or `community.docker` and
|
|
`ansible.posix` resolve to nothing and the fully-qualified-name checks pass
|
|
vacuously.
|
|
|
|
Lint and syntax-check are the definition of "done" for a playbook. They are
|
|
static and safe; run them yourself rather than asking the user to.
|