--- # ============================================================================= # 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_.yml # 2. Search for TODO and fill in every occurrence # 3. Run validate-only first: # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_.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_.yml # # Validate only (no Swarm changes): # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_.yml \ # -e "stack_validate_only=true" # # Tear down: # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_.yml \ # -e "_deploy_state=absent" # TODO: set the play name and stack name. - name: Deploy 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__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 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 _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 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: - "================================================" - " 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]