--- # Deploy a Swarm stack from a version-controlled compose file using a repeatable pipeline. - name: Build derived stack paths ansible.builtin.set_fact: stack_target_dir: "{{ stack_deploy_root }}/{{ stack_name }}" stack_target_compose: "{{ stack_deploy_root }}/{{ stack_name }}/{{ stack_compose_filename }}" stack_target_env: "{{ stack_deploy_root }}/{{ stack_name }}/{{ stack_env_filename }}" - name: Validate required role inputs ansible.builtin.assert: that: - stack_name | length > 0 - stack_compose_src | length > 0 fail_msg: "Provide stack_name and stack_compose_src for swarm_stack_deploy role." - name: Verify source compose exists on control node ansible.builtin.stat: path: "{{ stack_compose_src }}" delegate_to: localhost register: stack_compose_src_stat - name: Fail when source compose is missing ansible.builtin.assert: that: - stack_compose_src_stat.stat.exists fail_msg: "Compose source file not found on control node: {{ stack_compose_src }}" - name: Validate Docker is available on target manager ansible.builtin.command: docker --version changed_when: false - name: Collect Swarm manager state ansible.builtin.command: docker info --format '{{"{{"}} .Swarm.LocalNodeState {{"}}"}}|{{"{{"}} .Swarm.ControlAvailable {{"}}"}}' register: swarm_state changed_when: false - name: Ensure target host is an active manager ansible.builtin.assert: that: # WHY exact equality (not search): 'inactive' is a substring of 'active', so # search('active') passes on an inactive node. The format string always yields # 'active|true' for a healthy manager — match that exact string. - swarm_state.stdout == 'active|true' fail_msg: >- Target host must be an active Swarm manager. Expected 'active|true', got '{{ swarm_state.stdout }}'. - name: Ensure stack target directory exists become: true ansible.builtin.file: path: "{{ stack_target_dir }}" state: directory mode: '0755' - name: Ensure required bind-mount directories exist become: true ansible.builtin.file: path: "{{ item }}" state: directory mode: '0755' loop: "{{ stack_required_directories if stack_required_directories is not string else stack_required_directories | from_yaml }}" - name: Verify required external networks exist ansible.builtin.command: "docker network inspect {{ item }}" changed_when: false loop: "{{ stack_required_external_networks }}" - name: Render stack compose from Git source-of-truth to manager ansible.builtin.template: src: "{{ stack_compose_src }}" dest: "{{ stack_target_compose }}" mode: '0644' - name: Copy stack environment file when provided ansible.builtin.copy: src: "{{ stack_env_src }}" dest: "{{ stack_target_env }}" mode: '0600' when: stack_env_src | length > 0 - name: Validate compose YAML syntax on control node # WHY local Python parse instead of docker compose config / docker stack --dry-run: # docker compose v2 CLI plugin is not installed on Swarm nodes. # docker stack deploy --dry-run is not a valid flag in Docker Engine 29.x. # A Python YAML parse on the control node is dependency-free, fast, and # catches all syntax errors before the file is copied to any remote host. ansible.builtin.command: > python3 -c "import yaml, sys; yaml.safe_load(open('{{ stack_compose_src }}')); print('YAML syntax OK: {{ stack_compose_src }}')" delegate_to: localhost changed_when: false register: stack_syntax_check - name: Report compose syntax check result ansible.builtin.debug: msg: "{{ stack_syntax_check.stdout }}" - name: Deploy or reconcile stack desired state # WHY docker stack deploy instead of community.docker.docker_stack: # community.docker.docker_stack requires 'jsondiff' pip package on the # managed node — an unmanaged runtime dep we do not want on Swarm nodes. # 'docker stack deploy' is idempotent (declarative desired-state), always # available wherever Docker Engine is installed, and produces clear output # showing which services were Created vs Updated. ansible.builtin.command: >- docker stack deploy --compose-file {{ stack_target_compose }} {{ '--with-registry-auth' if stack_with_registry_auth else '' }} {{ '--prune' if stack_prune else '' }} {{ stack_name }} when: - not stack_validate_only - stack_state == 'present' register: stack_deploy_result changed_when: >- 'Creating service' in (stack_deploy_result.stdout | default('')) or 'Updating service' in (stack_deploy_result.stdout | default('')) - name: Collect running stack names before removal # WHY: docker stack rm is not idempotent — it errors if the stack is already gone. # Querying first lets us skip the task cleanly and report changed=false # when the desired absent state is already satisfied. ansible.builtin.command: > docker stack ls --format '{{ "{{" }}.Name{{ "}}" }}' register: _stack_list changed_when: false when: - not stack_validate_only - stack_state == 'absent' - name: Remove stack (state=absent) ansible.builtin.command: "docker stack rm {{ stack_name }}" when: - not stack_validate_only - stack_state == 'absent' - stack_name in (_stack_list.stdout_lines | default([])) changed_when: stack_name in (_stack_list.stdout_lines | default([])) - name: Show stack service status ansible.builtin.command: "docker stack services {{ stack_name }}" register: stack_services changed_when: false failed_when: false - name: Report current stack status ansible.builtin.debug: msg: - "Stack: {{ stack_name }}" - "Validate-only mode: {{ stack_validate_only }}" - "Compose source: {{ stack_compose_src }}" - "Compose target: {{ stack_target_compose }}" - "Service status output:\n{{ stack_services.stdout | default('No output yet') }}"