344 lines
13 KiB
YAML

---
# 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