Add the compose_stack role, inventory and playbooks
One role that syncs a payload and brings the stack up, configured entirely through stack_* variables, plus six playbooks that each demonstrate one part of that contract. Every playbook runs standalone and is tagged with its stack name, so site.yml --tags <stack> works. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cdf9b854b
commit
7b52e9af9e
@@ -0,0 +1,50 @@
|
||||
---
|
||||
# Not a Compose stack, and deliberately not forced into the role.
|
||||
#
|
||||
# banner.html is a single shared file that belongs to the *reverse-proxy* stack's
|
||||
# directory. There is no payload directory of its own and no compose file to
|
||||
# bring up, and compose_stack syncs a whole directory and then calls
|
||||
# `docker compose up`, so it cannot express this. Plain tasks instead.
|
||||
#
|
||||
# This is the escape hatch, and it is a small one. If you find yourself writing a
|
||||
# third playbook like this, that is a sign the role's contract should grow
|
||||
# instead — see AGENTS.md.
|
||||
- name: Deploy the shared default-vhost banner
|
||||
hosts:
|
||||
- edge
|
||||
- app
|
||||
gather_facts: false
|
||||
tags: [banner]
|
||||
|
||||
vars:
|
||||
# One file, shared by both hosts, unlike the per-host stack payloads.
|
||||
banner_src: "{{ repo_root }}/server/shared/components/banner.html"
|
||||
banner_html_dir: "{{ stack_root }}/reverse-proxy/proxy-data/html"
|
||||
|
||||
tasks:
|
||||
- name: Ensure the reverse proxy's html directory exists
|
||||
# reverse-proxy.yml creates this too. Repeated here so this playbook runs
|
||||
# standalone on a host where the proxy payload has never been synced —
|
||||
# every playbook in this repository stands on its own (AGENTS.md, rule 2).
|
||||
ansible.builtin.file:
|
||||
path: "{{ banner_html_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Install the shared banner
|
||||
ansible.builtin.copy:
|
||||
src: "{{ banner_src }}"
|
||||
dest: "{{ banner_html_dir }}/index.html"
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
handlers:
|
||||
# html/ is a bind mount, so the container is never recreated for this file;
|
||||
# the reload is the only thing that applies it. It fires only when the copy
|
||||
# above reported a change, so a repeat run is a genuine no-op. It fails if
|
||||
# the reverse proxy is not running, which means reverse-proxy.yml has never
|
||||
# run on this host.
|
||||
- name: Reload nginx
|
||||
ansible.builtin.command:
|
||||
cmd: docker exec reverse-proxy nginx -s reload
|
||||
changed_when: true
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
# One stack, both hosts, one payload.
|
||||
#
|
||||
# Demonstrates three things:
|
||||
# - stack_src pointing outside the per-host convention, so two hosts share a
|
||||
# single payload instead of keeping duplicate copies in step by hand;
|
||||
# - a per-host variable map, which is how a play covers hosts that differ
|
||||
# without growing a second playbook or a `when:` on every task;
|
||||
# - stack_dirs entries that set ownership, and ones that only set a mode.
|
||||
- name: Deploy metrics
|
||||
hosts:
|
||||
- edge
|
||||
- app
|
||||
gather_facts: false
|
||||
tags: [metrics]
|
||||
|
||||
vars:
|
||||
# Only the app host runs anything worth proxying a dashboard to, so only it
|
||||
# joins the private backend network. Keyed by inventory_hostname and looked
|
||||
# up below — adding a host means adding a line here, not a branch.
|
||||
metrics_networks:
|
||||
edge:
|
||||
- proxy-net
|
||||
app:
|
||||
- proxy-net
|
||||
- backend
|
||||
|
||||
metrics_dirs:
|
||||
# The Grafana image runs as uid 472 and this is a bind mount, so the
|
||||
# directory has to be owned by that uid on the host. `recurse: true` with
|
||||
# no `mode`: recursing a mode here would rewrite the permissions of
|
||||
# Grafana's SQLite database, which holds hashed credentials and datasource
|
||||
# secrets.
|
||||
#
|
||||
# This belongs in stack_dirs rather than a pre_tasks chown. pre_tasks run
|
||||
# before the role creates the stack directory, and an escalated
|
||||
# ansible.builtin.file on a child stamps its owner onto every parent it
|
||||
# creates on the way down — which would leave the payload sync, running as
|
||||
# the deploy user, unable to write into the stack's own directory on a host
|
||||
# where metrics has never been deployed.
|
||||
- path: "{{ stack_root }}/metrics/grafana-data"
|
||||
owner: "472"
|
||||
group: "472"
|
||||
recurse: true
|
||||
# Written by the prometheus container, which runs as nobody (65534). A mode
|
||||
# rather than an owner because the uid is not stable across image versions.
|
||||
# Narrow this to an owner once you have confirmed the uid on your hosts.
|
||||
- path: "{{ stack_root }}/metrics/prometheus-data"
|
||||
mode: "0777"
|
||||
|
||||
roles:
|
||||
- role: compose_stack
|
||||
vars:
|
||||
stack_name: metrics
|
||||
# Both hosts run the same thing, so the payload lives under server/shared/
|
||||
# rather than being copied into server/edge/ and server/app/.
|
||||
stack_src: "{{ repo_root }}/server/shared/metrics"
|
||||
stack_networks: "{{ metrics_networks[inventory_hostname] }}"
|
||||
stack_dirs: "{{ metrics_dirs }}"
|
||||
|
||||
post_tasks:
|
||||
- name: Reload prometheus so a changed scrape configuration takes effect
|
||||
# prometheus.yml is bind-mounted, so Compose never recreates the container
|
||||
# for a change to it. Prometheus re-reads its configuration on SIGHUP.
|
||||
ansible.builtin.command:
|
||||
cmd: docker kill --signal=SIGHUP prometheus
|
||||
when: compose_stack_synced | default(false)
|
||||
changed_when: true
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# The public entry point, and the stack every other stack depends on: it owns
|
||||
# proxy-net and the generated vhost configuration, so site.yml runs it first.
|
||||
#
|
||||
# Demonstrates the two things a payload made of plain configuration files needs:
|
||||
# bind-mount directories created before the container starts, and a reload after
|
||||
# the payload changes.
|
||||
- name: Deploy the reverse proxy
|
||||
hosts: edge
|
||||
gather_facts: false
|
||||
tags: [reverse-proxy]
|
||||
|
||||
roles:
|
||||
- role: compose_stack
|
||||
vars:
|
||||
stack_name: reverse-proxy
|
||||
stack_networks:
|
||||
- proxy-net
|
||||
stack_dirs:
|
||||
# Bind mounts the compose file declares but no payload provides. Docker
|
||||
# would otherwise create them root-owned on first start, and html/ in
|
||||
# particular has to be writable by the deploy user before banner.yml
|
||||
# can put a file in it.
|
||||
#
|
||||
# No `mode` on any of them on purpose: the role only applies the
|
||||
# attributes an entry actually sets, so a directory that already exists
|
||||
# on the host keeps whatever permissions it has.
|
||||
- path: "{{ stack_root }}/reverse-proxy/proxy-data/conf"
|
||||
- path: "{{ stack_root }}/reverse-proxy/proxy-data/vhost"
|
||||
- path: "{{ stack_root }}/reverse-proxy/proxy-data/html"
|
||||
- path: "{{ stack_root }}/reverse-proxy/proxy-data/dhparam"
|
||||
# certs/ and acme/ are deliberately absent. They hold live private keys
|
||||
# and are managed by acme-companion; nothing here should touch their
|
||||
# ownership or permissions.
|
||||
|
||||
post_tasks:
|
||||
- name: Reload nginx so changed vhost and conf files take effect
|
||||
# conf/ and vhost/ are bind mounts, so editing a file in them never causes
|
||||
# Compose to recreate the container — this reload is the only thing that
|
||||
# applies the change. compose_stack_synced is set by the role from the
|
||||
# payload sync, so a run that changed nothing stays a genuine no-op.
|
||||
ansible.builtin.command:
|
||||
cmd: docker exec reverse-proxy nginx -s reload
|
||||
when: compose_stack_synced | default(false)
|
||||
changed_when: true
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
# Every stack, in dependency order. Each imported playbook also runs on its own
|
||||
# — see AGENTS.md, rule 2.
|
||||
#
|
||||
# ansible-playbook playbooks/site.yml # converge everything
|
||||
# ansible-playbook playbooks/site.yml --tags metrics # one stack
|
||||
# ansible-playbook playbooks/site.yml -e pull=always # update everything
|
||||
#
|
||||
# Order matters. The reverse proxy owns proxy-net and the vhost configuration
|
||||
# that every other stack registers against, so it goes first; banner.yml writes
|
||||
# into the proxy's html directory, so it goes second.
|
||||
|
||||
- name: Reverse proxy
|
||||
ansible.builtin.import_playbook: reverse-proxy.yml
|
||||
- name: Default vhost banner
|
||||
ansible.builtin.import_playbook: banner.yml
|
||||
- name: Metrics
|
||||
ansible.builtin.import_playbook: metrics.yml
|
||||
|
||||
- name: Static site
|
||||
ansible.builtin.import_playbook: static-site.yml
|
||||
- name: Webapp
|
||||
ansible.builtin.import_playbook: webapp.yml
|
||||
- name: Webapp staging
|
||||
ansible.builtin.import_playbook: webapp-staging.yml
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# The reference playbook. Most stacks look exactly like this: a name, the
|
||||
# networks the compose file declares external, and nothing else. Copy this file
|
||||
# when you add a service, and only reach for the rest of the contract when the
|
||||
# stack genuinely needs it.
|
||||
- name: Deploy the static site
|
||||
hosts: app
|
||||
gather_facts: false
|
||||
tags: [static-site]
|
||||
|
||||
roles:
|
||||
- role: compose_stack
|
||||
vars:
|
||||
stack_name: static-site
|
||||
stack_networks:
|
||||
- proxy-net
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# A second instance of webapp, from the same payload.
|
||||
#
|
||||
# Demonstrates stack_src, stack_dest and stack_env together: one set of compose
|
||||
# files, deployed twice to the same host under different directories with
|
||||
# different settings. This is much better than a forked copy of the payload,
|
||||
# which drifts the first time someone fixes a bug in only one of them.
|
||||
#
|
||||
# Staging deliberately has no backup-and-migrate phase. It is rebuilt from the
|
||||
# same images and its data is disposable, so the whole two-phase apparatus in
|
||||
# webapp.yml would be ceremony here.
|
||||
- name: Deploy webapp staging
|
||||
hosts: app
|
||||
gather_facts: false
|
||||
tags: [webapp-staging]
|
||||
|
||||
roles:
|
||||
- role: compose_stack
|
||||
vars:
|
||||
stack_name: webapp-staging
|
||||
# The same payload as the production instance.
|
||||
stack_src: "{{ repo_root }}/server/app/webapp"
|
||||
# ...deployed somewhere else. Without this, stack_dest would default to
|
||||
# {{ stack_root }}/webapp-staging, which happens to be right here, but
|
||||
# spelling it out is what makes the pairing with stack_src obvious.
|
||||
stack_dest: "{{ stack_root }}/webapp-staging"
|
||||
stack_networks:
|
||||
- proxy-net
|
||||
stack_files:
|
||||
- docker-compose.yml
|
||||
# Per-instance settings, merged into the compose environment on top of
|
||||
# PWD. The compose file reads these; nothing in the payload knows which
|
||||
# instance it is.
|
||||
stack_env:
|
||||
COMPOSE_PROJECT_NAME: webapp-staging
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
# The stack that needs more than "sync, then up".
|
||||
#
|
||||
# Demonstrates the whole heavy end of the contract: an assertion that a host-side
|
||||
# .env exists, a payload built from a Dockerfile, a second compose file holding
|
||||
# maintenance-only services, and work that has to happen *between* the payload
|
||||
# landing and the stack starting.
|
||||
#
|
||||
# The shape to copy is the two entry points. `roles: - role: compose_stack` runs
|
||||
# sync and then up, which is what almost every stack wants. When something has to
|
||||
# happen in between, include the role twice with tasks_from — do not invent a
|
||||
# second mechanism, and do not write a separate *-update.yml playbook.
|
||||
- name: Deploy webapp
|
||||
hosts: app
|
||||
gather_facts: false
|
||||
tags: [webapp]
|
||||
|
||||
vars:
|
||||
webapp_dir: "{{ stack_root }}/webapp"
|
||||
# Both compose files, in the order `docker compose -f` wants. Only the
|
||||
# maintenance runs below use this; see the note on stack_files.
|
||||
webapp_maintenance_compose: >-
|
||||
-f docker-compose.yml -f docker-compose.migrate.yml
|
||||
# The single flag that separates a deploy from an update. Read it through
|
||||
# `default` at every use site so the playbook still runs when `pull` is
|
||||
# undefined.
|
||||
webapp_updating: "{{ (pull | default('policy')) == 'always' }}"
|
||||
|
||||
# The compose_stack contract, declared at play level so that both the sync
|
||||
# phase and the up phase below see the same values. Inside `roles:` they
|
||||
# would be scoped to the role invocation and the second include would not
|
||||
# see them.
|
||||
stack_name: webapp
|
||||
stack_dest: "{{ webapp_dir }}"
|
||||
stack_networks:
|
||||
- proxy-net
|
||||
# Deliberately only docker-compose.yml. docker-compose.migrate.yml adds
|
||||
# one-shot `run --rm` targets; bringing the stack up with both files would
|
||||
# start them as long-running services.
|
||||
stack_files:
|
||||
- docker-compose.yml
|
||||
# Rebuild the image only on an update run. Rebuilding on every deploy would
|
||||
# report a change every time and quietly break idempotency.
|
||||
stack_build: "{{ 'always' if webapp_updating else 'policy' }}"
|
||||
stack_dirs:
|
||||
# Bind mounts Docker would otherwise create root-owned. ./pgdata is
|
||||
# deliberately absent: Postgres refuses to start unless its data directory
|
||||
# is 0700 or 0750, so it must be left to the image.
|
||||
- path: "{{ stack_root }}/webapp/uploads"
|
||||
- path: "{{ stack_root }}/webapp/backups"
|
||||
|
||||
pre_tasks:
|
||||
# Every variable in this stack's compose files comes from .env with no
|
||||
# defaults, and the role never ships .env to a host. Fail loudly and say what
|
||||
# to do, rather than starting a stack with empty credentials.
|
||||
- name: Look for the webapp .env on the host
|
||||
ansible.builtin.stat:
|
||||
path: "{{ webapp_dir }}/.env"
|
||||
register: webapp_env
|
||||
changed_when: false
|
||||
|
||||
- name: Verify the webapp .env is present on the host
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- webapp_env.stat.exists
|
||||
fail_msg: >-
|
||||
{{ webapp_dir }}/.env is missing on {{ inventory_hostname }}. webapp
|
||||
reads every credential from it and it is never synced from this
|
||||
repository; copy .env.example on the host and fill it in, or restore
|
||||
it from your secret store.
|
||||
quiet: true
|
||||
|
||||
tasks:
|
||||
# Phase 1: deliver the payload, including the current Dockerfile and
|
||||
# migration script, without starting anything.
|
||||
- name: Sync the webapp payload
|
||||
ansible.builtin.include_role:
|
||||
name: compose_stack
|
||||
tasks_from: sync
|
||||
|
||||
# Phase 2: maintenance, against the payload that was just delivered and only
|
||||
# on an update run. An ordinary deploy must never back up or migrate
|
||||
# anything — see "Deploy versus update" in the README.
|
||||
- name: Build the webapp maintenance images
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose {{ webapp_maintenance_compose }} build
|
||||
chdir: "{{ webapp_dir }}"
|
||||
changed_when: true
|
||||
when: webapp_updating
|
||||
|
||||
- name: Back up the webapp database before migrating
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose {{ webapp_maintenance_compose }} run --rm backup
|
||||
chdir: "{{ webapp_dir }}"
|
||||
changed_when: true
|
||||
when: webapp_updating
|
||||
|
||||
- name: Migrate the webapp database
|
||||
# app/migrate.sh is idempotent by design, so repeating an update run after
|
||||
# a failure elsewhere is safe.
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose {{ webapp_maintenance_compose }} run --rm migrate
|
||||
chdir: "{{ webapp_dir }}"
|
||||
changed_when: true
|
||||
when: webapp_updating
|
||||
|
||||
# Phase 3: start the stack, on the new images if any were pulled or built.
|
||||
- name: Start webapp
|
||||
ansible.builtin.include_role:
|
||||
name: compose_stack
|
||||
tasks_from: up
|
||||
Reference in New Issue
Block a user