homelab/ansible/archive/playbooks/preflight/capture_heimdall_baseline.yml

508 lines
19 KiB
YAML

---
# playbooks/preflight/capture_heimdall_baseline.yml
#
# Purpose:
# Snapshot Heimdall's (10.0.0.151) full running configuration before
# Ansible migration begins. Captured state is the authoritative input
# for all subsequent role defaults, vars, and templates.
#
# MUST be run and artifacts reviewed before any heimdall_edge role changes.
#
# Usage:
# ansible-playbook -i inventory/hosts.ini \
# playbooks/preflight/capture_heimdall_baseline.yml
#
# Dry-run (no writes):
# ansible-playbook -i inventory/hosts.ini \
# playbooks/preflight/capture_heimdall_baseline.yml --check
#
# Output: outputs/heimdall-baseline-<ISO8601>/
# manifest.yml — What was found vs missing (machine-readable)
# host_facts.yml — OS, kernel, CPU, RAM, IP facts
# docker_info.yml — Docker daemon config and version
# containers.yml — Running container inspect data
# networks_and_volumes.yml — Docker network and volume inventory
# compose_files/ — Fetched compose files
# env_keys/ — Env KEY inventory (values REDACTED)
# firewall_rules.txt — UFW + iptables state
# systemd_units.txt — Loaded systemd service units
#
# Idempotency:
# All tasks are read-only on the remote host. No state changes to Heimdall.
# Safe to re-run at any time without side effects.
#
# Modules:
# ansible.builtin.setup — Host facts; WHY: native, structured, zero-install
# community.docker.docker_host_info — Docker daemon and container list; WHY: builtin, structured
# community.docker.docker_container_info — Full inspect per container; WHY: richer than docker ps
# ansible.builtin.find — Locate compose/env files; WHY: idempotent path discovery
# ansible.builtin.fetch — Retrieve files to controller; WHY: preserves structure
# ansible.builtin.slurp — Read remote file content; WHY: no shell, structured b64 output
- name: Capture Heimdall baseline configuration
hosts: heimdall
become: true
gather_facts: true
vars:
heimdall_stack_search_paths:
- /opt/stacks
- /opt/docker
- /home/chester/traefik
- /home/chester
pre_tasks:
- name: Freeze capture timestamp (evaluated once, used by all tasks)
ansible.builtin.set_fact:
capture_dir: "{{ playbook_dir }}/../../outputs/heimdall-baseline-{{ ansible_date_time.iso8601_basic_short }}"
tags: [always]
# WHY no delegate_to here: set_fact must store capture_dir on heimdall's variable scope.
# All subsequent tasks — even those with delegate_to: localhost — resolve variables
# in the context of their original host (heimdall). Storing on localhost's scope means
# heimdall never sees the frozen value, so {{ capture_dir }} re-evaluates
# ansible_date_time after task 1.1 (setup) refreshes it.
- name: Ensure capture output directories exist on control node
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- "{{ capture_dir }}/compose_files"
- "{{ capture_dir }}/env_keys"
- "{{ capture_dir }}/traefik_configs"
delegate_to: localhost
become: false
run_once: true
tags: [always]
tasks:
# --------------------------------------------------
# SECTION 1: Host facts
# --------------------------------------------------
- name: "1.1 Gather all system facts"
ansible.builtin.setup:
tags: [facts, always]
- name: "1.2 Compile host fact summary"
ansible.builtin.set_fact:
baseline_host_facts:
hostname: "{{ ansible_hostname }}"
fqdn: "{{ ansible_fqdn }}"
os_family: "{{ ansible_os_family }}"
distribution: "{{ ansible_distribution }}"
distribution_version: "{{ ansible_distribution_version }}"
distribution_release: "{{ ansible_distribution_release }}"
kernel: "{{ ansible_kernel }}"
architecture: "{{ ansible_architecture }}"
uptime_seconds: "{{ ansible_uptime_seconds }}"
memory_total_mb: "{{ ansible_memtotal_mb }}"
memory_free_mb: "{{ ansible_memfree_mb }}"
cpu_vcpus: "{{ ansible_processor_vcpus }}"
default_ipv4: "{{ ansible_default_ipv4 }}"
interfaces: "{{ ansible_interfaces }}"
python_version: "{{ ansible_python_version }}"
ansible_user: "{{ ansible_user_id }}"
tags: [facts]
- name: "1.3 Save host facts to control node"
ansible.builtin.copy:
content: "{{ baseline_host_facts | to_nice_yaml }}"
dest: "{{ capture_dir }}/host_facts.yml"
mode: '0640'
delegate_to: localhost
become: false
tags: [facts]
# --------------------------------------------------
# SECTION 2: Docker daemon state
# --------------------------------------------------
- name: "2.1 Verify Docker service is active"
ansible.builtin.systemd:
name: docker
register: _docker_service
failed_when: _docker_service.status.ActiveState != 'active'
changed_when: false
tags: [docker]
- name: "2.2 Query Docker daemon info"
community.docker.docker_host_info:
register: _docker_host_info
tags: [docker]
- name: "2.3 Read Docker daemon config"
ansible.builtin.slurp:
src: /etc/docker/daemon.json
register: _daemon_json
failed_when: false
tags: [docker]
- name: "2.4 Compile Docker info summary"
ansible.builtin.set_fact:
baseline_docker_info:
server_version: "{{ _docker_host_info.host_info.ServerVersion | default('unknown') }}"
storage_driver: "{{ _docker_host_info.host_info.Driver | default('unknown') }}"
logging_driver: "{{ _docker_host_info.host_info.LoggingDriver | default('unknown') }}"
cgroup_driver: "{{ _docker_host_info.host_info.CgroupDriver | default('unknown') }}"
swarm_state: "{{ _docker_host_info.host_info.Swarm.LocalNodeState | default('inactive') }}"
containers_running: "{{ _docker_host_info.host_info.ContainersRunning | default(0) }}"
containers_total: "{{ _docker_host_info.host_info.Containers | default(0) }}"
daemon_config: "{{ (_daemon_json.content | b64decode | from_json) if _daemon_json.content is defined else {} }}"
tags: [docker]
- name: "2.5 Save Docker info to control node"
ansible.builtin.copy:
content: "{{ baseline_docker_info | to_nice_yaml }}"
dest: "{{ capture_dir }}/docker_info.yml"
mode: '0640'
delegate_to: localhost
become: false
tags: [docker]
# --------------------------------------------------
# SECTION 3: Running containers
# --------------------------------------------------
- name: "3.1 Get all container summaries (running + stopped)"
community.docker.docker_host_info:
containers: true
containers_all: true
register: _all_containers
tags: [containers]
- name: "3.2 Inspect each container for full configuration"
community.docker.docker_container_info:
name: "{{ item.Names[0] | regex_replace('^/', '') }}"
loop: "{{ _all_containers.containers }}"
loop_control:
label: "{{ item.Names[0] | regex_replace('^/', '') }}"
register: _container_inspects
when: _all_containers.containers | length > 0
tags: [containers]
- name: "3.3 Save container inspections to control node"
ansible.builtin.copy:
content: "{{ _container_inspects.results | map(attribute='container') | select() | list | to_nice_yaml }}"
dest: "{{ capture_dir }}/containers.yml"
mode: '0640'
delegate_to: localhost
become: false
when: _all_containers.containers | length > 0
tags: [containers]
- name: "3.3b Save empty container file when no containers found"
ansible.builtin.copy:
content: "# No containers found on {{ inventory_hostname }}\n"
dest: "{{ capture_dir }}/containers.yml"
mode: '0640'
delegate_to: localhost
become: false
when: _all_containers.containers | length == 0
tags: [containers]
# --------------------------------------------------
# SECTION 4: Docker networks and volumes
# --------------------------------------------------
- name: "4.1 Query Docker networks"
community.docker.docker_host_info:
networks: true
register: _docker_networks
tags: [networks]
- name: "4.2 Query Docker volumes"
community.docker.docker_host_info:
volumes: true
register: _docker_volumes
tags: [networks]
- name: "4.3 Save network and volume inventory"
ansible.builtin.copy:
content: |
---
# Docker network and volume inventory
# Host: {{ inventory_hostname }} | Captured: {{ ansible_date_time.iso8601 }}
networks:
{{ _docker_networks.networks | default([]) | to_nice_yaml | indent(2) }}
volumes:
{{ _docker_volumes.volumes | default([]) | to_nice_yaml | indent(2) }}
dest: "{{ capture_dir }}/networks_and_volumes.yml"
mode: '0640'
delegate_to: localhost
become: false
tags: [networks]
# --------------------------------------------------
# SECTION 5: Compose and env files
# --------------------------------------------------
- name: "5.1 Locate docker compose files"
ansible.builtin.find:
paths: "{{ heimdall_stack_search_paths }}"
patterns:
- "docker-compose.yml"
- "docker-compose.yaml"
- "compose.yml"
- "compose.yaml"
recurse: true
register: _compose_files
tags: [compose]
- name: "5.2 Locate env files"
ansible.builtin.find:
paths: "{{ heimdall_stack_search_paths }}"
patterns:
- ".env"
- "*.env"
hidden: true
recurse: true
register: _env_files
tags: [compose]
- name: "5.3 Fetch compose files to control node"
ansible.builtin.fetch:
src: "{{ item.path }}"
dest: "{{ capture_dir }}/compose_files/{{ item.path | replace('/', '_') }}"
flat: true
loop: "{{ _compose_files.files }}"
loop_control:
label: "{{ item.path }}"
tags: [compose]
- name: "5.4 Read env files for key extraction"
ansible.builtin.slurp:
src: "{{ item.path }}"
loop: "{{ _env_files.files }}"
loop_control:
label: "{{ item.path }}"
register: _env_contents
tags: [compose]
- name: "5.5 Save redacted env key inventory to control node"
ansible.builtin.copy:
content: |
# Env key inventory — values REDACTED for security
# Source: {{ item.item.path }}
# Host: {{ inventory_hostname }} | Captured: {{ ansible_date_time.iso8601 }}
#
# To restore secrets: ansible-vault encrypt_string '<value>' --name '<KEY>'
{% for line in (item.content | b64decode).splitlines() %}
{% if line | regex_search('^[A-Za-z_][A-Za-z0-9_]*=') %}
{% set key = line.split('=')[0] %}
{{ key }}=<REDACTED>
{% elif line.startswith('#') or line | length == 0 %}
{{ line }}
{% else %}
{{ line }}
{% endif %}
{% endfor %}
dest: "{{ capture_dir }}/env_keys/{{ item.item.path | replace('/', '_') }}.redacted"
mode: '0640'
delegate_to: localhost
become: false
loop: "{{ _env_contents.results }}"
loop_control:
label: "{{ item.item.path }}"
when: item.content is defined
tags: [compose]
# --------------------------------------------------
# SECTION 5.6: Traefik-specific configuration files
# --------------------------------------------------
- name: "5.6 Stat Traefik static and dynamic config files"
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /home/chester/traefik/traefik.yml
- /home/chester/traefik/traefik-data/dynamic/middleware.yml
- /home/chester/traefik/traefik-data/dynamic/static-backends.yml
register: _traefik_config_stats
tags: [compose, traefik]
- name: "5.7 Fetch Traefik config files that exist"
ansible.builtin.fetch:
src: "{{ item.stat.path }}"
dest: "{{ capture_dir }}/traefik_configs/{{ item.stat.path | basename }}"
flat: true
loop: "{{ _traefik_config_stats.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
tags: [compose, traefik]
# --------------------------------------------------
# SECTION 6: Firewall and systemd state
# --------------------------------------------------
- name: "6.1 Capture UFW status"
ansible.builtin.command: ufw status verbose
register: _ufw_status
changed_when: false
failed_when: false
tags: [security]
- name: "6.2 Capture iptables rules"
ansible.builtin.command: iptables -L -n --line-numbers
register: _iptables_rules
changed_when: false
failed_when: false
tags: [security]
- name: "6.3 Save firewall state"
ansible.builtin.copy:
content: |
# Firewall state on {{ inventory_hostname }}
# Captured: {{ ansible_date_time.iso8601 }}
## UFW STATUS
{{ _ufw_status.stdout | default('ufw not available or not active') }}
## IPTABLES (reference)
{{ _iptables_rules.stdout | default('iptables not available') }}
dest: "{{ capture_dir }}/firewall_rules.txt"
mode: '0640'
delegate_to: localhost
become: false
tags: [security]
- name: "6.4 Query loaded systemd service units"
ansible.builtin.command: systemctl list-units --type=service --state=loaded --no-pager
register: _systemd_units
changed_when: false
tags: [systemd]
- name: "6.5 Save systemd unit list"
ansible.builtin.copy:
content: "{{ _systemd_units.stdout }}"
dest: "{{ capture_dir }}/systemd_units.txt"
mode: '0640'
delegate_to: localhost
become: false
tags: [systemd]
# --------------------------------------------------
# SECTION 7: Critical path inventory
# --------------------------------------------------
- name: "7.1 Stat critical stack paths"
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /opt/stacks/heimdall
- /opt/stacks/heimdall/docker-compose.yml
- /opt/stacks/heimdall/.env
- /opt/stacks/heimdall/traefik-certs
- /opt/stacks/heimdall/traefik-certs/acme.json
- /opt/stacks/heimdall/redis-data
- /opt/stacks/heimdall/runner-data
- /home/chester/traefik
- /home/chester/traefik/docker-compose.yml
- /home/chester/traefik/.env
- /home/chester/traefik/traefik.yml
- /home/chester/traefik/traefik-data/dynamic/middleware.yml
- /home/chester/traefik/traefik-data/dynamic/static-backends.yml
- /home/chester/traefik/traefik-data/certs/acme.json
- /etc/docker/daemon.json
register: _critical_path_stats
tags: [paths]
- name: "7.2 Build critical path presence map"
ansible.builtin.set_fact:
manifest_paths: >-
{{
dict(
_critical_path_stats.results | map(attribute='item') | list
| zip(
_critical_path_stats.results | map(attribute='stat') | map(attribute='exists') | list
)
)
}}
tags: [paths]
# --------------------------------------------------
# SECTION 8: Write manifest and validate
# --------------------------------------------------
- name: "8.1 Write machine-readable capture manifest"
ansible.builtin.copy:
content: |
---
---
# Heimdall baseline capture manifest
# Generated: {{ ansible_date_time.iso8601 }}
# Host: {{ inventory_hostname }} ({{ ansible_host }})
# Review this file before proceeding to heimdall_edge role refactor.
capture_timestamp: "{{ ansible_date_time.iso8601 }}"
capture_dir: "{{ capture_dir }}"
host:
hostname: "{{ ansible_hostname }}"
ip: "{{ ansible_host }}"
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
kernel: "{{ ansible_kernel }}"
docker:
version: "{{ baseline_docker_info.server_version }}"
storage_driver: "{{ baseline_docker_info.storage_driver }}"
swarm_state: "{{ baseline_docker_info.swarm_state }}"
containers_running: {{ baseline_docker_info.containers_running }}
containers_total: {{ baseline_docker_info.containers_total }}
inventory:
containers_found: {{ _all_containers.containers | length }}
compose_files_found: {{ _compose_files.files | length }}
env_files_found: {{ _env_files.files | length }}
critical_paths:
{{ manifest_paths | to_nice_yaml | indent(2) }}
compose_file_paths:
{{ _compose_files.files | map(attribute='path') | list | to_nice_yaml | indent(2) }}
env_file_paths:
{{ _env_files.files | map(attribute='path') | list | to_nice_yaml | indent(2) }}
containers_running:
{{ _all_containers.containers | map(attribute='Names') | map('first') | map('regex_replace', '^/', '') | list | to_nice_yaml | indent(2) }}
validation:
compose_files_present: {{ _compose_files.files | length > 0 }}
containers_present: {{ _all_containers.containers | length > 0 }}
stack_dir_present: {{ manifest_paths['/opt/stacks/heimdall'] | default(false) }}
compose_present: {{ manifest_paths['/opt/stacks/heimdall/docker-compose.yml'] | default(false) }}
env_present: {{ manifest_paths['/opt/stacks/heimdall/.env'] | default(false) }}
dest: "{{ capture_dir }}/manifest.yml"
mode: '0640'
delegate_to: localhost
become: false
tags: [manifest, always]
- name: "8.2 Assert compose file is present somewhere on host"
ansible.builtin.assert:
that:
- _compose_files.files | length > 0
fail_msg: >-
No docker-compose files found on {{ inventory_hostname }} under
{{ heimdall_stack_search_paths | join(', ') }}.
Review {{ capture_dir }}/manifest.yml for the full path inventory.
success_msg: "Compose file(s) found: {{ _compose_files.files | map(attribute='path') | list | join(', ') }}"
tags: [validate, always]
- name: "8.3 Report capture summary"
ansible.builtin.debug:
msg:
- "=========================================="
- "Heimdall baseline capture complete."
- "=========================================="
- "Output dir : {{ capture_dir }}"
- "Containers : {{ _all_containers.containers | length }} found"
- "Compose files: {{ _compose_files.files | length }} found"
- "Env files : {{ _env_files.files | length }} found"
- "------------------------------------------"
- "Next step: review artifacts, then run:"
- " ansible-playbook .../self-heal/heimdall.yml --check"
- "=========================================="
tags: [always]