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:
Lucas Winther
2026-09-16 03:58:01 +02:00
co-authored by Claude Opus 5
parent 7cdf9b854b
commit 7b52e9af9e
16 changed files with 606 additions and 0 deletions
+111
View File
@@ -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