202 lines
9.3 KiB
YAML

---
# playbooks/docker/swarm_audit.yml
# Read-only cross-node consistency audit for the Docker Swarm cluster.
# Safe to schedule. Makes no changes to any host.
#
# What this does:
# Play 1 — Gathers key state from all swarm_hosts nodes (kernel, distro,
# swap, sysctl, daemon.json, Docker Swarm role)
# Play 2 — Asserts consistency across all 6 nodes and writes a markdown
# drift report to outputs/swarm_audit_<timestamp>.md
#
# Usage:
# ansible-playbook -i inventory/hosts.ini playbooks/docker/swarm_audit.yml
#
# Output:
# outputs/swarm_audit_<timestamp>.md (repo root)
- name: "Play 1: Gather Swarm node state"
hosts: swarm_hosts
become: true
gather_facts: true
tasks:
- name: Read sysctl values for audit
ansible.builtin.shell: "sysctl -n {{ item }} 2>/dev/null || echo 0"
register: sysctl_raw
loop:
- vm.swappiness
- net.bridge.bridge-nf-call-iptables
- net.bridge.bridge-nf-call-ip6tables
- net.ipv4.ip_forward
changed_when: false
check_mode: false
- name: Read Docker daemon.json
ansible.builtin.command: cat /etc/docker/daemon.json
register: daemon_json_content
changed_when: false
failed_when: false
check_mode: false
- name: Get Docker Swarm node role
ansible.builtin.shell: >
docker info --format '{{ '{{' }}.Swarm.LocalNodeState{{ '}}' }}:{{ '{{' }}.Swarm.ControlAvailable{{ '}}' }}'
register: docker_swarm_info
changed_when: false
failed_when: false
check_mode: false
- name: Stash per-node audit facts
ansible.builtin.set_fact:
swarm_audit:
kernel: "{{ ansible_kernel }}"
distro: "{{ ansible_distribution }}"
distro_version: "{{ ansible_distribution_version }}"
swap_mb: "{{ ansible_swaptotal_mb }}"
swappiness: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'vm.swappiness') | first).stdout | trim }}"
bridge_iptables: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'net.bridge.bridge-nf-call-iptables') | first).stdout | trim }}"
bridge_ip6tables: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'net.bridge.bridge-nf-call-ip6tables') | first).stdout | trim }}"
ip_forward: "{{ (sysctl_raw.results | selectattr('item', 'equalto', 'net.ipv4.ip_forward') | first).stdout | trim }}"
daemon_json: "{{ daemon_json_content.stdout | default('{}') }}"
log_rotation_configured: "{{ 'max-size' in (daemon_json_content.stdout | default('{}')) }}"
swarm_local_state: "{{ docker_swarm_info.stdout.split(':')[0] | trim }}"
swarm_is_manager: "{{ docker_swarm_info.stdout.split(':')[1] | trim | lower == 'true' }}"
- name: "Play 2: Cross-node consistency assertions and drift report"
hosts: localhost
gather_facts: false
vars:
swarm_nodes: "{{ groups['swarm_hosts'] }}"
managers: "{{ groups['swarm_managers'] }}"
workers: "{{ groups['swarm_workers'] }}"
audit_timestamp: "{{ lookup('pipe', 'date +%Y%m%dT%H%M%S') }}"
report_path: "{{ playbook_dir }}/../../../outputs/swarm_audit_{{ audit_timestamp }}.md"
tasks:
- name: Ensure outputs directory exists
ansible.builtin.file:
path: "{{ playbook_dir }}/../../../outputs"
state: directory
mode: '0755'
- name: Write drift report
ansible.builtin.copy:
dest: "{{ report_path }}"
mode: '0644'
content: |
# Swarm Cluster Audit Report
Generated: {{ audit_timestamp }}
Nodes audited: {{ swarm_nodes | join(', ') }}
## Node Summary
| Node | Role | Kernel | Distro | Swap | Swappiness | Bridge IPTables | IP Forward | Log Rotation |
|------|------|--------|--------|------|------------|-----------------|------------|--------------|
{% for node in swarm_nodes %}
| {{ node }} | {{ 'Manager' if hostvars[node]['swarm_audit']['swarm_is_manager'] | bool else 'Worker' }} | `{{ hostvars[node]['swarm_audit']['kernel'] }}` | {{ hostvars[node]['swarm_audit']['distro'] }} {{ hostvars[node]['swarm_audit']['distro_version'] }} | {{ hostvars[node]['swarm_audit']['swap_mb'] }}MB | {{ hostvars[node]['swarm_audit']['swappiness'] }} | {{ hostvars[node]['swarm_audit']['bridge_iptables'] }} | {{ hostvars[node]['swarm_audit']['ip_forward'] }} | {{ '✅' if hostvars[node]['swarm_audit']['log_rotation_configured'] | bool else '❌' }} |
{% endfor %}
## Swarm Role Mapping
| Node | Inventory Role | Docker ControlAvailable |
|------|----------------|------------------------|
{% for node in managers %}
| {{ node }} | Manager | {{ '✅ true' if hostvars[node]['swarm_audit']['swarm_is_manager'] | bool else '❌ false (DRIFT!)' }} |
{% endfor %}
{% for node in workers %}
| {{ node }} | Worker | {{ '❌ true (UNEXPECTED!)' if hostvars[node]['swarm_audit']['swarm_is_manager'] | bool else '✅ false' }} |
{% endfor %}
## Docker Swarm State
| Node | LocalNodeState |
|------|----------------|
{% for node in swarm_nodes %}
| {{ node }} | {{ hostvars[node]['swarm_audit']['swarm_local_state'] }} |
{% endfor %}
- name: Assert kernel consistency across all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['kernel'] == hostvars[swarm_nodes[0]]['swarm_audit']['kernel']
fail_msg: >-
❌ Kernel drift: {{ item }} has {{ hostvars[item]['swarm_audit']['kernel'] }}
but {{ swarm_nodes[0] }} has {{ hostvars[swarm_nodes[0]]['swarm_audit']['kernel'] }}
success_msg: "✅ {{ item }}: kernel {{ hostvars[item]['swarm_audit']['kernel'] }}"
loop: "{{ swarm_nodes }}"
- name: Assert distro version consistency across all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['distro_version'] == hostvars[swarm_nodes[0]]['swarm_audit']['distro_version']
fail_msg: >-
❌ Distro version drift: {{ item }} has {{ hostvars[item]['swarm_audit']['distro_version'] }}
but {{ swarm_nodes[0] }} has {{ hostvars[swarm_nodes[0]]['swarm_audit']['distro_version'] }}
success_msg: "✅ {{ item }}: distro {{ hostvars[item]['swarm_audit']['distro'] }} {{ hostvars[item]['swarm_audit']['distro_version'] }}"
loop: "{{ swarm_nodes }}"
- name: Assert swap is disabled on all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['swap_mb'] | int == 0
fail_msg: "❌ Swap is enabled on {{ item }}: {{ hostvars[item]['swarm_audit']['swap_mb'] }}MB — run swarm_baseline.yml --tags storage"
success_msg: "✅ {{ item }}: swap disabled"
loop: "{{ swarm_nodes }}"
- name: Assert vm.swappiness=0 on all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['swappiness'] | int == 0
fail_msg: "❌ vm.swappiness={{ hostvars[item]['swarm_audit']['swappiness'] }} on {{ item }} — run swarm_baseline.yml --tags sysctl"
success_msg: "✅ {{ item }}: vm.swappiness=0"
loop: "{{ swarm_nodes }}"
- name: Assert bridge netfilter is enabled on all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['bridge_iptables'] | int == 1
- hostvars[item]['swarm_audit']['bridge_ip6tables'] | int == 1
fail_msg: >-
❌ Bridge netfilter not fully enabled on {{ item }}:
bridge-nf-call-iptables={{ hostvars[item]['swarm_audit']['bridge_iptables'] }}
bridge-nf-call-ip6tables={{ hostvars[item]['swarm_audit']['bridge_ip6tables'] }}
Run swarm_baseline.yml --tags sysctl to fix.
success_msg: "✅ {{ item }}: bridge netfilter enabled"
loop: "{{ swarm_nodes }}"
- name: Assert ip_forward is enabled on all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['ip_forward'] | int == 1
fail_msg: "❌ net.ipv4.ip_forward={{ hostvars[item]['swarm_audit']['ip_forward'] }} on {{ item }} — run swarm_baseline.yml --tags sysctl"
success_msg: "✅ {{ item }}: ip_forward=1"
loop: "{{ swarm_nodes }}"
- name: Assert Docker log rotation configured on all nodes
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['log_rotation_configured'] | bool
fail_msg: "❌ Docker log rotation not configured on {{ item }} — run swarm_baseline.yml --tags docker"
success_msg: "✅ {{ item }}: Docker log rotation configured"
loop: "{{ swarm_nodes }}"
- name: Assert swarm_managers are Docker managers
ansible.builtin.assert:
that:
- hostvars[item]['swarm_audit']['swarm_is_manager'] | bool
fail_msg: "❌ {{ item }} is in swarm_managers inventory group but Docker reports it is NOT a manager"
success_msg: "✅ {{ item }}: confirmed Docker manager"
loop: "{{ managers }}"
- name: Assert swarm_workers are not Docker managers
ansible.builtin.assert:
that:
- not (hostvars[item]['swarm_audit']['swarm_is_manager'] | bool)
fail_msg: "❌ {{ item }} is in swarm_workers inventory group but Docker reports it is a Manager"
success_msg: "✅ {{ item }}: confirmed Docker worker"
loop: "{{ workers }}"