508 lines
19 KiB
YAML
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]
|