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,20 @@
|
|||||||
|
# Ansible only discovers an ansible.cfg in the current working directory, so
|
||||||
|
# every command in this repository is run from ansible/. See "Getting started"
|
||||||
|
# in the README, and ANSIBLE_CONFIG for the CI case.
|
||||||
|
[defaults]
|
||||||
|
inventory = inventory/hosts.yml
|
||||||
|
roles_path = roles
|
||||||
|
retry_files_enabled = False
|
||||||
|
stdout_callback = default
|
||||||
|
callback_result_format = yaml
|
||||||
|
display_skipped_hosts = False
|
||||||
|
interpreter_python = auto_silent
|
||||||
|
|
||||||
|
# CI runners are ephemeral, so a known_hosts file never survives to be checked
|
||||||
|
# against. Turn this back on if you deploy from a durable control node or put a
|
||||||
|
# bastion in front of the hosts — it is the one setting here worth revisiting.
|
||||||
|
host_key_checking = False
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
|
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ServerAliveInterval=30
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
ansible_user: deploy
|
||||||
|
# Override per host in inventory/host_vars/<host>.yml if sshd moves off 22.
|
||||||
|
ansible_port: 22
|
||||||
|
|
||||||
|
# Path to the deploy private key, produced by scripts/load-ssh-key.sh. The key
|
||||||
|
# itself is never stored in this repository — see docs/secrets.md.
|
||||||
|
ansible_ssh_private_key_file: "{{ lookup('ansible.builtin.env', 'SSH_KEY_PATH') }}"
|
||||||
|
|
||||||
|
# Where stacks live on the hosts. A stack deploys to {{ stack_root }}/<stack>
|
||||||
|
# unless its playbook overrides stack_dest.
|
||||||
|
stack_root: /srv/stacks
|
||||||
|
|
||||||
|
# Image pull policy, overridden per run with `-e pull=always` to update a stack.
|
||||||
|
# See "Deploy versus update" in the README.
|
||||||
|
pull: policy
|
||||||
|
|
||||||
|
# Repository root, so playbooks and the role can find payloads under server/
|
||||||
|
# regardless of the directory ansible-playbook was invoked from.
|
||||||
|
repo_root: "{{ (inventory_dir ~ '/../..') | realpath }}"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
# Two example hosts. Connection settings live in group_vars/all.yml, and key
|
||||||
|
# material lives nowhere in this repository — see docs/secrets.md.
|
||||||
|
#
|
||||||
|
# The names here must match the directory names under server/: the compose_stack
|
||||||
|
# role resolves a payload as server/{{ inventory_hostname }}/{{ stack_name }}.
|
||||||
|
all:
|
||||||
|
hosts:
|
||||||
|
# Public entry point: the reverse proxy and its certificates.
|
||||||
|
edge:
|
||||||
|
ansible_host: edge.example.com
|
||||||
|
# Everything behind it.
|
||||||
|
app:
|
||||||
|
ansible_host: app.example.com
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
collections:
|
||||||
|
# docker_compose_v2 — bringing stacks up.
|
||||||
|
- name: community.docker
|
||||||
|
version: ">=3.10.0"
|
||||||
|
# synchronize — rsyncing stack payloads to the hosts.
|
||||||
|
- name: ansible.posix
|
||||||
|
version: ">=1.5.0"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
# The variable contract. Playbooks set these; nothing else should. Every entry is
|
||||||
|
# documented in AGENTS.md — keep the two in step.
|
||||||
|
|
||||||
|
# Required. Names the stack, and by default its payload and destination too.
|
||||||
|
stack_name: ""
|
||||||
|
|
||||||
|
# Payload directory in this repository, and where it lands on the host. Override
|
||||||
|
# stack_src to share one payload between hosts or between two instances of the
|
||||||
|
# same application; override stack_dest when the remote directory cannot be
|
||||||
|
# named after the stack.
|
||||||
|
stack_src: "{{ repo_root }}/server/{{ inventory_hostname }}/{{ stack_name }}"
|
||||||
|
stack_dest: "{{ stack_root | default('/srv/stacks') }}/{{ stack_name }}"
|
||||||
|
|
||||||
|
# Extra directories to create before the stack starts. Each entry needs a `path`;
|
||||||
|
# `mode`, `owner`, `group` and `recurse` are optional and applied only when
|
||||||
|
# given, so a directory that already exists on the host is never re-chmodded or
|
||||||
|
# re-chowned.
|
||||||
|
stack_dirs: []
|
||||||
|
|
||||||
|
# Extra environment for the compose invocation. PWD is always set to stack_dest;
|
||||||
|
# anything here is merged on top.
|
||||||
|
stack_env: {}
|
||||||
|
|
||||||
|
# External Docker networks the compose file expects to already exist.
|
||||||
|
stack_networks: []
|
||||||
|
|
||||||
|
# Compose files, relative to stack_dest, in the order `docker compose -f` wants.
|
||||||
|
stack_files:
|
||||||
|
- docker-compose.yml
|
||||||
|
|
||||||
|
# Never ship secrets to the host through the payload. Extend this list; do not
|
||||||
|
# replace it without understanding what you are letting through.
|
||||||
|
stack_exclude:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# Whether the sync removes host files that are no longer in the payload. Off by
|
||||||
|
# default: a stack's directory usually also holds data the payload knows nothing
|
||||||
|
# about (bind mounts, certificates, logs).
|
||||||
|
stack_prune: false
|
||||||
|
|
||||||
|
# present, absent, or restarted.
|
||||||
|
stack_state: present
|
||||||
|
|
||||||
|
# Image pull policy for this invocation. Defaults to the repo-wide `pull` flag so
|
||||||
|
# `-e pull=always` keeps working, but because it is a role variable a playbook
|
||||||
|
# can override it for a single call — for example to sync with the old images,
|
||||||
|
# run a database upgrade, and only then bring the stack up on the new ones.
|
||||||
|
stack_pull: "{{ pull | default('policy') }}"
|
||||||
|
|
||||||
|
# Compose build policy. `policy` builds only when the image is missing; stacks
|
||||||
|
# that build from a Dockerfile in their payload want `always` on an update run.
|
||||||
|
stack_build: policy
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
galaxy_info:
|
||||||
|
role_name: compose_stack
|
||||||
|
namespace: example
|
||||||
|
author: The contributors
|
||||||
|
description: Sync a Docker Compose stack payload to a host and bring the stack up.
|
||||||
|
license: MIT
|
||||||
|
min_ansible_version: "2.15"
|
||||||
|
platforms:
|
||||||
|
- name: EL
|
||||||
|
versions:
|
||||||
|
- "9"
|
||||||
|
- name: Debian
|
||||||
|
versions:
|
||||||
|
- bookworm
|
||||||
|
- name: Ubuntu
|
||||||
|
versions:
|
||||||
|
- jammy
|
||||||
|
- noble
|
||||||
|
galaxy_tags:
|
||||||
|
- docker
|
||||||
|
- compose
|
||||||
|
- deployment
|
||||||
|
|
||||||
|
dependencies: []
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
# Two entry points, so a playbook that must do maintenance between the payload
|
||||||
|
# landing and the stack starting can interleave its own tasks:
|
||||||
|
#
|
||||||
|
# - ansible.builtin.include_role:
|
||||||
|
# name: compose_stack
|
||||||
|
# tasks_from: sync
|
||||||
|
# - name: Run the database migration
|
||||||
|
# ...
|
||||||
|
# - ansible.builtin.include_role:
|
||||||
|
# name: compose_stack
|
||||||
|
# tasks_from: up
|
||||||
|
#
|
||||||
|
# Using the role normally (roles: - role: compose_stack) runs both, in order.
|
||||||
|
# playbooks/webapp.yml is the worked example.
|
||||||
|
- name: Sync the payload to the host
|
||||||
|
ansible.builtin.include_tasks: sync.yml
|
||||||
|
|
||||||
|
- name: Start the stack
|
||||||
|
ansible.builtin.include_tasks: up.yml
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
- name: Verify the caller named a stack
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- stack_name | length > 0
|
||||||
|
fail_msg: compose_stack requires stack_name
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Look for the payload on the control node
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ stack_src }}"
|
||||||
|
register: compose_stack_payload
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
# A missing payload otherwise surfaces as an empty sync that reports success and
|
||||||
|
# leaves the host running whatever was there before.
|
||||||
|
- name: Verify the payload directory exists
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- compose_stack_payload.stat.isdir | default(false)
|
||||||
|
fail_msg: "no payload directory at {{ stack_src }}"
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
- name: Create the stack directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ stack_dest }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Create the extra directories the stack needs
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
# All four default to omit: create the directory if it is absent, and leave
|
||||||
|
# whatever is already on the host alone. Bind mounts are routinely created
|
||||||
|
# root-owned by Docker on first start, and re-chmodding those breaks them.
|
||||||
|
mode: "{{ item.mode | default(omit) }}"
|
||||||
|
owner: "{{ item.owner | default(omit) }}"
|
||||||
|
group: "{{ item.group | default(omit) }}"
|
||||||
|
# For a bind mount whose existing tree is owned by the wrong uid. Set it with
|
||||||
|
# `owner`/`group` and no `mode`: recursing a mode over live data rewrites the
|
||||||
|
# permissions of everything the container has already written there.
|
||||||
|
recurse: "{{ item.recurse | default(omit) }}"
|
||||||
|
# Escalate only to set an owner or group. Creating these as root would defeat
|
||||||
|
# the point of pre-creating them at all — callers do it precisely so the bind
|
||||||
|
# mount is *not* root-owned when Docker first starts the stack. Unescalated
|
||||||
|
# they land as the deploy user, which is the user Compose runs as.
|
||||||
|
become: "{{ item.owner is defined or item.group is defined }}"
|
||||||
|
loop: "{{ stack_dirs }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item.path }}"
|
||||||
|
|
||||||
|
# Deliberately the docker CLI rather than community.docker.docker_network: that
|
||||||
|
# module needs the Docker SDK for Python installed on every host, and creating a
|
||||||
|
# network is the only thing this repository would need it for.
|
||||||
|
- name: Create the external Docker networks
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker network create {{ item }}
|
||||||
|
register: compose_stack_network
|
||||||
|
changed_when: compose_stack_network.rc == 0
|
||||||
|
failed_when:
|
||||||
|
- compose_stack_network.rc != 0
|
||||||
|
- '"already exists" not in compose_stack_network.stderr'
|
||||||
|
loop: "{{ stack_networks }}"
|
||||||
|
|
||||||
|
- name: Sync the payload to the host
|
||||||
|
ansible.posix.synchronize:
|
||||||
|
src: "{{ stack_src }}/"
|
||||||
|
dest: "{{ stack_dest }}/"
|
||||||
|
delete: "{{ stack_prune }}"
|
||||||
|
rsync_opts: "{{ stack_exclude | map('regex_replace', '^', '--exclude=') | list }}"
|
||||||
|
register: compose_stack_sync
|
||||||
|
|
||||||
|
# Bind-mounted configuration changes without the compose file changing, so
|
||||||
|
# docker_compose_v2 will not recreate the container and the new file has no
|
||||||
|
# effect. Playbooks key a reload task off this fact — see
|
||||||
|
# playbooks/reverse-proxy.yml.
|
||||||
|
- name: Record whether the payload changed
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
compose_stack_synced: "{{ compose_stack_sync.changed }}"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
- name: Bring the stack up
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ stack_dest }}"
|
||||||
|
files: "{{ stack_files }}"
|
||||||
|
state: "{{ stack_state }}"
|
||||||
|
pull: "{{ stack_pull }}"
|
||||||
|
build: "{{ stack_build }}"
|
||||||
|
remove_orphans: true
|
||||||
|
# A compose file that interpolates ${PWD} — a common way to hand a host path to
|
||||||
|
# a sibling container — resolves it against the *invoking shell*, not the
|
||||||
|
# project directory. The module sets the process working directory but not PWD,
|
||||||
|
# so ${PWD} would silently become the SSH login directory. Set it explicitly.
|
||||||
|
environment: "{{ {'PWD': stack_dest} | combine(stack_env) }}"
|
||||||
Reference in New Issue
Block a user