155 lines
5.8 KiB
YAML
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') }}"
|