--- # playbooks/onboarding/watchtower_baseline.yml # Idempotent baseline enforcement for the Ansible control node (Watchtower). # # ───────────────────────────────────────────────────────────────────────────── # PURPOSE: Ongoing drift enforcement — safe to run any time, safe to schedule. # Does NOT upgrade packages. Does NOT reboot. # For OS updates: use playbooks/onboarding/watchtower_update.yml # For control node audit: use playbooks/onboarding/watchtower_audit.yml # ───────────────────────────────────────────────────────────────────────────── # # What this enforces (all idempotent): # 0. Packages: Required system packages present (git, curl, python3, python3-venv, etc.) # 1. Storage: Swap disabled (swapoff -a + fstab commented) # 2. Sysctl: vm.swappiness=0, bridge netfilter, ip_forward (Docker on control node) # 3. Docker: /etc/docker/daemon.json with log rotation # 4. Toolchain: Python venv exists, Ansible Galaxy collections installed # 5. SSH: Ansible SSH key present on control node # # NOTE: Watchtower connects to itself via ansible_connection=local. # Tasks that would SSH to a remote host are not needed here. # # Usage: # ansible-playbook -i inventory/hosts.ini playbooks/onboarding/watchtower_baseline.yml # # # Dry-run: # ansible-playbook -i inventory/hosts.ini playbooks/onboarding/watchtower_baseline.yml --check --diff # # # Target a specific section only: # ansible-playbook -i inventory/hosts.ini playbooks/onboarding/watchtower_baseline.yml --tags toolchain # ansible-playbook -i inventory/hosts.ini playbooks/onboarding/watchtower_baseline.yml --tags docker - name: Watchtower control node baseline enforcement hosts: watchtower become: true vars: lab_user: "{{ lab_ansible_user | default('chester') }}" homelab_root: "/home/{{ lab_user }}/homelab" venv_path: "{{ homelab_root }}/.venv" ssh_key_path: "/home/{{ lab_user }}/.ssh/id_ed25519" galaxy_requirements: "{{ homelab_root }}/ansible/requirements.yml" handlers: - name: Restart Docker ansible.builtin.service: name: docker state: restarted tasks: - name: "0. Packages: ensure required system packages are present" tags: [packages, baseline] ansible.builtin.apt: name: - git - curl - htop - python3 - python3-pip - python3-venv - python3-apt - nfs-common - ca-certificates - docker-ce - docker-ce-cli - containerd.io state: present update_cache: true - name: "1. Storage: disable swap" tags: [storage, baseline] block: - name: Disable swap immediately (covers traditional + zram) ansible.builtin.command: swapoff -a when: ansible_swaptotal_mb > 0 changed_when: ansible_swaptotal_mb > 0 - name: Comment out swap entries in /etc/fstab ansible.builtin.replace: path: /etc/fstab regexp: '^([^#].*\s+swap\s+.*)$' replace: '# \1' - name: Remove zram-generator config to prevent zram swap at boot ansible.builtin.copy: dest: /etc/systemd/zram-generator.conf owner: root group: root mode: '0644' content: | # Managed by Ansible — watchtower_baseline.yml # Empty config disables zram swap on Ubuntu 24.04. - name: Stop and mask systemd-zram-generator service if present ansible.builtin.systemd: name: systemd-zram-generator state: stopped enabled: false masked: true failed_when: false - name: Swapoff zram devices explicitly ansible.builtin.shell: | for dev in $(ls /dev/zram* 2>/dev/null); do swapoff "$dev" 2>/dev/null || true done changed_when: false - name: "2. Sysctl: Docker networking parameters" tags: [sysctl, baseline] block: - name: Ensure br_netfilter module is loaded community.general.modprobe: name: br_netfilter state: present - name: Persist br_netfilter module load at boot ansible.builtin.copy: dest: /etc/modules-load.d/br_netfilter.conf content: "br_netfilter\n" owner: root group: root mode: '0644' - name: Apply and persist sysctl parameters ansible.posix.sysctl: name: "{{ item.key }}" value: "{{ item.value }}" sysctl_file: /etc/sysctl.d/90-watchtower.conf state: present reload: true loop: - { key: vm.swappiness, value: "0" } - { key: net.bridge.bridge-nf-call-iptables, value: "1" } - { key: net.bridge.bridge-nf-call-ip6tables, value: "1" } - { key: net.ipv4.ip_forward, value: "1" } - name: "3. Docker: daemon configuration and log rotation" tags: [docker, baseline] block: - name: Ensure /etc/docker directory exists ansible.builtin.file: path: /etc/docker state: directory owner: root group: root mode: '0755' - name: Deploy Docker daemon.json with log rotation ansible.builtin.copy: dest: /etc/docker/daemon.json owner: root group: root mode: '0644' content: | { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } notify: Restart Docker - name: Ensure '{{ lab_user }}' is in the docker group ansible.builtin.user: name: "{{ lab_user }}" groups: docker append: true - name: "4. Toolchain: Python venv and Ansible Galaxy collections" tags: [toolchain, baseline] become: false block: - name: Ensure Python venv exists at {{ venv_path }} ansible.builtin.command: "python3 -m venv {{ venv_path }}" args: creates: "{{ venv_path }}/bin/activate" - name: Ensure pip is up to date in venv ansible.builtin.pip: name: pip state: latest virtualenv: "{{ venv_path }}" - name: Ensure Ansible is installed in venv ansible.builtin.pip: name: ansible state: present virtualenv: "{{ venv_path }}" - name: Install Ansible Galaxy collections from requirements.yml ansible.builtin.command: > {{ venv_path }}/bin/ansible-galaxy collection install -r {{ galaxy_requirements }} --upgrade register: galaxy_install changed_when: "'Installing' in galaxy_install.stdout" - name: "5. SSH: verify Ansible SSH key is present" tags: [ssh, baseline] become: false block: - name: Check SSH private key exists ansible.builtin.stat: path: "{{ ssh_key_path }}" register: ssh_key_stat - name: Fail if SSH private key is missing ansible.builtin.fail: msg: >- SSH private key not found at {{ ssh_key_path }}. Generate one with: ssh-keygen -t ed25519 -f {{ ssh_key_path }} then run: ssh-copy-id -i {{ ssh_key_path }}.pub chester@ when: not ssh_key_stat.stat.exists