179 lines
7.8 KiB
YAML
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]
|