homelab/ansible/archive/playbooks/docker/deploy_example_stack.yml

179 lines
7.8 KiB
YAML

---
# =============================================================================
# FUTURE-STACK DEPLOYMENT BLUEPRINT — copy, rename, and fill in TODO items.
# This playbook is the minimum viable deploy playbook for any new Swarm stack.
#
# COPY CHECKLIST:
# 1. Rename this file to deploy_<service>.yml
# 2. Search for TODO and fill in every occurrence
# 3. Run validate-only first:
# ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_<service>.yml \
# -e "stack_validate_only=true"
# 4. Run full deploy and verify convergence
# 5. Run deploy a second time and confirm "changed=0" (idempotency proof)
# =============================================================================
#
# IDEMPOTENCY CONTRACT (required for all new stacks):
# - All required secrets MUST be asserted before any Swarm state is touched.
# - All required bind-mount paths MUST be statted and asserted before deploy.
# - All command/shell tasks MUST declare changed_when.
# - validate-only mode MUST work without any Swarm mutations.
# - Deploy MUST be replay-safe: running twice produces no unintended changes.
#
# Usage:
# Normal deploy:
# ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_<service>.yml
#
# Validate only (no Swarm changes):
# ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_<service>.yml \
# -e "stack_validate_only=true"
#
# Tear down:
# ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_<service>.yml \
# -e "<service>_deploy_state=absent"
# TODO: set the play name and stack name.
- name: Deploy <service> Swarm stack
hosts: swarm_managers
become: true
gather_facts: false
vars_files:
- ../../group_vars/all.yml
vars:
# TODO: set the deploy target. Default: first Swarm manager.
_deploy_target: "{{ groups['swarm_managers'][0] }}"
tasks:
# --------------------------------------------------
# STEP 0: Assert required secrets are present
# WHY: Fail before any Swarm state is touched. An empty/placeholder secret
# causes a silent misconfiguration that is hard to diagnose at runtime.
# --------------------------------------------------
# TODO: add one assert block per required vault variable.
# Remove this block entirely if the stack has no secrets.
- name: Assert vault_<service>_secret is defined and non-empty
ansible.builtin.assert:
that:
- vault_example_secret is defined
- vault_example_secret | trim | length > 0
- vault_example_secret not in ['change-me', 'changeme', 'TODO']
fail_msg: >-
vault_example_secret is not defined, empty, or still a placeholder.
Encrypt a real value with:
ansible-vault encrypt_string 'value' --name 'vault_example_secret'
then add it to group_vars/vault/all.yml.
when: inventory_hostname == _deploy_target
# --------------------------------------------------
# STEP 1: Assert Swarm manager is active
# WHY: Exact equality check prevents 'inactive' passing as a substring of
# 'active' via regex. Docker format yields 'active|true' for a healthy
# manager and nothing else valid.
# --------------------------------------------------
- name: Collect Swarm manager state
ansible.builtin.command: >
docker info --format '{{ "{{" }}.Swarm.LocalNodeState{{ "}}" }}|{{ "{{" }}.Swarm.ControlAvailable{{ "}}" }}'
register: _swarm_info
changed_when: false
when: inventory_hostname == _deploy_target
- name: Assert target is an active Swarm manager
ansible.builtin.assert:
that:
- _swarm_info.stdout == 'active|true'
fail_msg: >-
{{ inventory_hostname }} must be an active Swarm manager.
Expected 'active|true', got '{{ _swarm_info.stdout | default('unknown') }}'.
when: inventory_hostname == _deploy_target
# --------------------------------------------------
# STEP 2: Validate required bind-mount paths
# WHY: A missing path causes the service to start against an empty/wrong
# directory. Pre-existence assertion protects against accidental fresh
# bootstrap over existing data.
# TODO: add/remove paths to match the stacks volume mounts.
# IMPORTANT: do NOT create missing paths here; require the operator to
# provision or restore them first (data safety).
# --------------------------------------------------
- name: Stat required bind-mount paths
ansible.builtin.stat:
path: "{{ item }}"
register: _path_stat
loop:
- /mnt/homelab/apps/example/data # TODO: adjust per service
when: inventory_hostname == _deploy_target
- name: Assert required paths exist before deploy
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isdir
fail_msg: >-
Required path '{{ item.item }}' is missing on {{ inventory_hostname }}.
Create or restore this directory before deploying.
loop: "{{ _path_stat.results }}"
when: inventory_hostname == _deploy_target
# --------------------------------------------------
# STEP 3: Deploy stack via shared role
# WHY swarm_stack_deploy: handles template render, YAML syntax validation,
# external-network pre-check, bind-mount directory creation, and
# idempotent docker stack deploy with correct changed semantics.
# --------------------------------------------------
- name: Deploy <service> stack
ansible.builtin.include_role:
name: swarm_stack_deploy
vars:
stack_name: "example" # TODO: change to service name
stack_compose_src: "{{ playbook_dir }}/../../templates/stacks/example.service.stack.yml" # TODO: change path
# WHY <service>_deploy_state (not stack_state): using stack_state here
# creates a Jinja2 self-reference loop inside the role. Use a
# service-specific var that defaults cleanly.
stack_state: "{{ example_deploy_state | default('present') }}" # TODO: rename var
stack_required_external_networks:
- proxy-net
# OPTIONAL: directories the role should CREATE if absent (non-data dirs).
# Do NOT list data directories here — assert their existence in STEP 2.
stack_required_directories: []
when: inventory_hostname == _deploy_target
# --------------------------------------------------
# STEP 4: Wait for service convergence
# WHY: Confirms the scheduler placed and started the task successfully.
# changed_when: false — querying replica count is read-only.
# TODO: adjust filter name and replica count to match stack_name.
# --------------------------------------------------
- name: Wait for <service> to converge
ansible.builtin.command: >
docker service ls --filter name=example_example-app --format '{{ "{{" }}.Replicas{{ "}}" }}'
register: _replicas
retries: 12
delay: 10
until: _replicas.stdout is search('1/1')
changed_when: false
when:
- inventory_hostname == _deploy_target
- example_deploy_state | default('present') == 'present'
- not ansible_check_mode
tags: [verify]
- name: Report deployment result
ansible.builtin.debug:
msg:
- "================================================"
- "<service> deployment complete." # TODO: rename
- "================================================"
- "Stack : example" # TODO: rename
- "Manager : {{ inventory_hostname }} ({{ ansible_host | default('') }})"
- "URL : https://example.castaldifamily.com" # TODO: change
- "Data : /mnt/homelab/apps/example" # TODO: change
- "================================================"
when: inventory_hostname == _deploy_target
tags: [always]