155 lines
5.8 KiB
YAML

---
# 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') }}"