--- # 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-/ # 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 '' --name '' {% for line in (item.content | b64decode).splitlines() %} {% if line | regex_search('^[A-Za-z_][A-Za-z0-9_]*=') %} {% set key = line.split('=')[0] %} {{ key }}= {% 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]