--- # playbooks/onboarding/generic_host.yml # Bootstrap non-Proxmox hosts for Ansible management # Supports both existing production hosts and net-new hosts via onboard_profile. - name: Onboard non-Proxmox host to Ansible management hosts: "{{ target_host | default('onboarding_target_undefined') }}" gather_facts: false vars: ansible_user: "{{ onboard_user | default(lab_ansible_user | default('chester')) }}" local_ssh_key: "{{ lookup('env', 'HOME') }}/.ssh/id_ed25519.pub" onboard_profile: "{{ onboarding_profile }}" onboard_enable_security_hardening: "{{ onboard_profile == 'new' }}" required_packages: - python3 - python3-apt - sudo - curl - git - vim - htop tasks: - name: Validate required runtime variables ansible.builtin.assert: that: - target_host is defined - target_host | length > 0 - onboarding_profile is defined - onboarding_profile | length > 0 - target_host not in ['all', '*', 'ubuntu_lab'] fail_msg: "Invalid onboarding scope. Use explicit non-broad target_host (for example docker_hosts) and onboarding_profile. Example: -e 'target_host=docker_hosts onboarding_profile=existing'" success_msg: "Required runtime variables provided." run_once: true delegate_to: localhost tags: ['connectivity', 'setup'] - name: Validate onboarding profile ansible.builtin.assert: that: - onboard_profile in ['new', 'existing'] fail_msg: "Invalid onboarding_profile='{{ onboard_profile }}'. Use 'new' or 'existing'." success_msg: "Onboarding profile '{{ onboard_profile }}' selected." tags: ['connectivity', 'setup'] # ======================================== # SECTION 1: Connectivity Test # ======================================== - name: Initial Connectivity Check block: - name: Test raw connection (no Python required) ansible.builtin.raw: echo "Connection successful" register: connection_test changed_when: false - name: Display connection status ansible.builtin.debug: msg: "✅ Successfully connected to {{ inventory_hostname }}" rescue: - name: Connection failed ansible.builtin.fail: msg: "❌ Cannot connect to {{ inventory_hostname }}. Check SSH credentials and network connectivity." tags: ['connectivity', 'test'] # ======================================== # SECTION 2: SSH Key Setup # ======================================== - name: Configure SSH Key Authentication block: - name: Check if .ssh directory exists ansible.builtin.raw: test -d ~/.ssh && echo "exists" || echo "missing" register: ssh_dir_check changed_when: false - name: Create .ssh directory if missing ansible.builtin.raw: mkdir -p ~/.ssh && chmod 700 ~/.ssh when: "'missing' in ssh_dir_check.stdout" - name: Read local SSH public key ansible.builtin.set_fact: ssh_public_key: "{{ lookup('file', local_ssh_key) }}" delegate_to: localhost - name: Check if SSH key is already authorized ansible.builtin.raw: grep -F "{{ ssh_public_key.split()[1][:30] }}" ~/.ssh/authorized_keys register: key_check failed_when: false changed_when: false - name: Add SSH public key to authorized_keys ansible.builtin.raw: | echo "{{ ssh_public_key }}" >> ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys when: key_check.rc != 0 - name: Verify SSH key authentication ansible.builtin.ping: vars: ansible_ssh_pass: "" ignore_errors: true register: ssh_key_test - name: Display SSH key status ansible.builtin.debug: msg: "✅ SSH key authentication configured successfully" when: ssh_key_test is succeeded tags: ['ssh', 'setup'] # ======================================== # SECTION 3: Python & Prerequisites # ======================================== - name: Install Python and Prerequisites block: - name: Check if Python3 is installed ansible.builtin.raw: which python3 || which python register: python_check failed_when: false changed_when: false - name: Install Python3 if missing (Debian/Ubuntu) ansible.builtin.raw: | export DEBIAN_FRONTEND=noninteractive sudo apt-get update -qq sudo apt-get install -y python3 python3-apt when: python_check.rc != 0 args: executable: /bin/bash - name: Gather facts (now that Python is available) ansible.builtin.setup: - name: Display system information ansible.builtin.debug: msg: - "OS: {{ ansible_distribution }} {{ ansible_distribution_version }}" - "Hostname: {{ ansible_hostname }}" - "Architecture: {{ ansible_architecture }}" - "Python: {{ ansible_python_version }}" tags: ['python', 'setup', 'prerequisites'] # ======================================== # SECTION 4: Passwordless Sudo # ======================================== - name: Configure Passwordless Sudo become: true block: - name: Check current sudo configuration ansible.builtin.command: sudo -n true register: sudo_check failed_when: false changed_when: false become: false - name: Create sudoers.d entry for passwordless sudo ansible.builtin.copy: content: "{{ ansible_user }} ALL=(ALL) NOPASSWD:ALL\n" dest: "/etc/sudoers.d/{{ ansible_user }}" mode: '0440' owner: root group: root validate: 'visudo -cf %s' when: sudo_check.rc != 0 - name: Verify passwordless sudo ansible.builtin.command: sudo -n whoami register: sudo_verify changed_when: false become: false - name: Display sudo status ansible.builtin.debug: msg: "✅ Passwordless sudo configured for {{ ansible_user }}" when: sudo_verify.stdout == "root" tags: ['sudo', 'setup'] # ======================================== # SECTION 5: System Packages # ======================================== - name: Install Essential Packages become: true block: - name: Update apt cache ansible.builtin.apt: update_cache: true cache_valid_time: 3600 when: ansible_os_family == "Debian" - name: Install required packages ansible.builtin.apt: name: "{{ required_packages }}" state: present when: ansible_os_family == "Debian" - name: Display installed packages ansible.builtin.debug: msg: "✅ Essential packages installed" tags: ['packages', 'setup'] # ======================================== # SECTION 6: Basic Security # ======================================== - name: Apply Basic Security Hardening become: true when: onboard_enable_security_hardening | bool block: - name: Disable root SSH login ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: '^#?PermitRootLogin' line: 'PermitRootLogin no' state: present validate: 'sshd -t -f %s' notify: Restart SSH - name: Disable password authentication (SSH keys only) ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: '^#?PasswordAuthentication' line: 'PasswordAuthentication no' state: present validate: 'sshd -t -f %s' notify: Restart SSH when: ssh_key_test is succeeded - name: Enable UFW firewall (allow SSH) block: - name: Check if ufw is installed ansible.builtin.command: command -v ufw register: ufw_check changed_when: false failed_when: false - name: Allow SSH via UFW when available community.general.ufw: rule: allow port: '22' proto: tcp when: ufw_check.rc == 0 - name: Skip UFW configuration when ufw is unavailable ansible.builtin.debug: msg: "UFW not installed; skipping firewall onboarding step." when: ufw_check.rc != 0 when: ansible_os_family == "Debian" tags: ['security', 'hardening'] - name: Security hardening skipped notice ansible.builtin.debug: msg: - "⚠️ Security hardening skipped (existing profile)." - " Set -e onboarding_profile=new to enforce SSH hardening controls." when: not (onboard_enable_security_hardening | bool) tags: ['security', 'hardening'] # ======================================== # SECTION 7: Final Validation # ======================================== - name: Validate Onboarding block: - name: Test passwordless authentication ansible.builtin.ping: vars: ansible_ssh_pass: "" ansible_become_pass: "" - name: Test passwordless sudo ansible.builtin.command: sudo -n whoami register: final_sudo_test changed_when: false - name: Gather final system state ansible.builtin.setup: - name: Display onboarding summary ansible.builtin.debug: msg: - "════════════════════════════════════════════════" - "✅ HOST ONBOARDING COMPLETE" - "════════════════════════════════════════════════" - "Profile: {{ onboard_profile }}" - "Host: {{ inventory_hostname }} ({{ ansible_hostname }})" - "IP: {{ ansible_default_ipv4.address }}" - "OS: {{ ansible_distribution }} {{ ansible_distribution_version }}" - "Python: {{ ansible_python_version }}" - "SSH Key Auth: ✅ Enabled" - "Passwordless Sudo: ✅ Enabled" - "Security Hardening: {{ '✅ Applied' if (onboard_enable_security_hardening | bool) else '⚠️ Skipped' }}" - "Ansible User: {{ ansible_user }}" - "════════════════════════════════════════════════" - "Next Steps:" - " • Add host to appropriate inventory groups" - " • Run role-specific playbooks" - " • Configure host-specific variables" - " • (Swarm nodes) Run: ansible-playbook playbooks/docker/bootstrap_swarm.yml --limit {{ inventory_hostname }}" - "════════════════════════════════════════════════" tags: ['validate', 'summary'] # ======================================== # SECTION 8: Disk grow (Swarm nodes only) # WHY before storage mounts: root filesystem must have space before # nfs-common installs or Docker pulls images. Cloud templates ship # with a ~2.4G root; Proxmox resizes the virtual disk via qm resize # but the in-guest partition and filesystem need explicit expansion. # WHY conditional: non-Swarm hosts manage their own disk sizing. # ======================================== - name: Grow root disk partition and filesystem (Swarm nodes only) ansible.builtin.include_role: name: disk_grow apply: become: true when: inventory_hostname in (groups['swarm_hosts'] | default([])) tags: ['disk'] # ======================================== # SECTION 9: Storage mounts (Swarm nodes only) # WHY here: NFS mounts must exist before any Swarm stack deploy runs. # Wiring this into onboarding ensures a freshly provisioned Swarm VM # is storage-ready in a single playbook run with no manual follow-up. # WHY conditional: non-Swarm hosts (Proxmox, watchtower, heimdall) do not # need /mnt/homelab or /mnt/media; skip silently for those targets. # ======================================== - name: Configure NFS storage mounts (Swarm nodes only) ansible.builtin.include_role: name: storage_mounts apply: become: true when: inventory_hostname in (groups['swarm_hosts'] | default([])) tags: ['storage'] handlers: - name: Restart SSH ansible.builtin.systemd: name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}" state: restarted become: true