Ansible does not seem to have any built-in modules for managing hosts files, unlike SaltStack which has such a module out of the box. Fortunately the file is a plain text, line orientated, format and so easily managed by ansible.builtin.lineinfile. My need to manage this file came to the fore while I was building my live Proxmox virtual environment cluster which requires the local hostname resolves to its real IP via /etc/hosts and I decided to add entries for all Proxmox virtual environment hosts so that they are not dependent on DNS (which allows DNS to be virtualised within the virtualisation environment).

The hosts-file role

To manage the hosts file, I created a new role called hosts-file. This role takes a list of dictionaries, hosts_file_hosts, that contain the keys hostname (name of the host to map, the shortname will also be added if the hostname contains a . unless add_short_name is set to false), add_short_name (boolean to control whether or not to add the shortname if hostname is qualified, defaults to true) and ipv4 and/or ipv6 (the IPv4 and IPv6 information - address and any version-specific aliases - this hostname should map to). It also takes hosts_file_path (the path to the hosts file) that defaults to a setting that uses the detected system type, from ansible_facts, to select the operating system’s default path. The argument specification (for the role’s meta/argument_specs.yaml) is:

---
argument_specs:
  main:
    short_description: Configures host file entries
    author: Laurence Alexander Hurst
    options:
      hosts_file_path:
        description: The path of the hosts file to manage
        type: str
        default: >-
          {%- if ansible_facts.system == 'Linux' -%}
            /etc/hosts
          {%- elif ansible_facts.system == 'Win32NT' -%}
            {{ ansible_facts.SystemRoot }}\\System32\drivers\etc\hosts
          {%- elif ansible_facts.system == 'Darwin' -%}
            /private/etc/hosts
          {%- endif -%}
      hosts_file_hosts:
        description: List of entries for the hosts file
        type: list
        required: true
        elements: dict
        required_one_of:
          - ['ipv4', 'ipv6']
        options:
          hostname:
            description: Hostname to map to these IP addresses
            required: true
            type: str
          add_short_name:
            description: If `hostname` is qualified, whether to add the short name as an alias
            type: bool
            default: true
          ipv4:
            description: IP version 4 specific options
            type: dict
            options:
              address:
                description: IPv4 address for this hostname to resolve to
                type: str
                required: true
              aliases:
                description: Aliases specific to the IPv4 address
                type: list
                required: false
                elements: str
          ipv6:
            description: IP version 6 specific options
            type: dict
            options:
              address:
                description: IPv6 address for this hostname to resolve to
                type: str
                required: true
              aliases:
                description: Aliases specific to the IPv6 address
                type: list
                required: false
                elements: str
...

The corresponding defaults/main.yaml is straightforward:

---
hosts_file_path: >-
  {%- if ansible_facts.system == 'Linux' -%}
    /etc/hosts
  {%- elif ansible_facts.system == 'Win32NT' -%}
    {{ ansible_facts.SystemRoot }}\\System32\drivers\etc\hosts
  {%- elif ansible_facts.system == 'Darwin' -%}
    /private/etc/hosts
  {%- endif -%}
...

The actual tasks file (tasks/main.yaml) is a fairly simple set of directives to add any IPv4 entries, IPv6 entries then remove any incorrect entries for the hostnames configured:

---
- name: Hostfile ipv4 entries are correct for hosts
  become: true
  ansible.builtin.lineinfile:
    path: '{{ hosts_file_path }}'
    line: "{{ item.ipv4.address }}\t{{ item.hostname }}{% if item.add_short_name | default(true) and '.' in item.hostname %}\t{{ item.hostname.split('.', 1) | first }}{% endif %}{% if 'aliases' in item.ipv4 %}\t{{ item.ipv4.aliases | join('\t') }}{% endif %}"
    regexp: '^{{ item.ipv4.address }}\s'
  loop: '{{ hosts_file_hosts }}'
  when: "'ipv4' in item"
- name: Hostfile ipv6 entries are correct for hosts
  become: true
  ansible.builtin.lineinfile:
    path: '{{ hosts_file_path }}'
    line: "{{ item.ipv6.address }}\t{{ item.hostname }}{% if item.add_short_name | default(true) and '.' in item.hostname %}\t{{ item.hostname.split('.', 1) | first }}{% endif %}{% if 'aliases' in item.ipv6 %}\t{{ item.ipv6.aliases | join('\t') }}{% endif %}"
    regexp: '^{{ item.ipv6.address }}\s'
  loop: '{{ hosts_file_hosts }}'
  when: "'ipv6' in item"
- name: No incorrect hosts for configured hostnames
  become: true
  ansible.builtin.lineinfile:
    path: '{{ hosts_file_path }}'
    regexp: '^(?!{{ valid_addresses | join("|") }})[^\s]+\s(?:.*\s)*(?:{{ item.hostname }}{% if item.add_short_name | default(true) and "." in item.hostname %}|{{ item.hostname.split(".", 1) | first }}{% endif %}{% if aliases | length > 0 %}|{{ aliases | join("|") }}{% endif %})(\s|$)'
    state: absent
  loop: '{{ hosts_file_hosts }}'
  vars:
    valid_addresses: >-
      {{
        item
        | dict2items
        | selectattr('key', 'in', ['ipv4', 'ipv6'])
        | map(attribute='value')
        | map(attribute='address')
      }}
    aliases: >-
      {{
        item
        | dict2items
        | selectattr('key', 'in', ['ipv4', 'ipv6'])
        | map(attribute='value')
        | selectattr('aliases', 'defined')
        | map(attribute='aliases')
        | flatten
      }}
...

Migrating hostname role to use hosts-file

My existing hostname role configured the 127.0.1.1 entry to be the local hostname (so the hostname resolves to a loopback address by default), as is the default on Debian:

# XXX what about ipv6?
- name: Set localhost entry in /etc/hosts correctly
  become: yes
  # Ensure that 127.0.0.1 is only "localhost" and not (an old) hostname
  ansible.builtin.lineinfile:
    path: /etc/hosts
    line: "127.0.0.1\tlocalhost"
    regexp: '^127\.0\.0\.1'
    owner: root
    group: root
    mode: 00644
- name: Set hostname entry in /etc/hosts correctly
  become: yes
  # XXX Check if 127.0.1.1 is Debian specific or is also default on EL (e.g. Rocky)?
  # Not that it matters if 127.0.0.1 is correctly set to just
  # `localhost`.
  ansible.builtin.lineinfile:
    path: /etc/hosts
    line: "127.0.1.1\t{{ hostname }}\t{{ hostname | split('.') | first }}"
    regexp: '^127\.0\.1\.1'
    owner: root
    group: root
    mode: 00644

I simply replaced this, adding a new argument hostname_set_to_loopback_address (defaults to true) that disables the default behaviour of setting the real hostname to resolve to a loopback address (e.g. on the Proxmox VE hosts I mentioned above):

- name: Localhost entry in hosts file is correct
  ansible.builtin.include_role:
    name: hosts-file
  vars:
    hosts_file_hosts:
      - hostname: localhost
        ipv4:
          address: '127.0.0.1'
        ipv6:
          address: '::1'
          aliases:
            - ip6-localhost
            - ip6-loopback
# XXX what about ipv6?
- name: Set hostname entry in hosts file is correct
  ansible.builtin.include_role:
    name: hosts-file
  vars:
    # XXX Check if 127.0.1.1 is Debian specific or is also default on EL (e.g. Rocky)?
    # Not that it matters if 127.0.0.1 is correctly set to just
    # `localhost`.
    hosts_file_hosts:
      - hostname: '{{ hostname }}'
        ipv4:
          address: '127.0.1.1'
  when: hostname_set_to_loopback_address

Proxmox VE hosts

For these hosts, I created a group level variables file (group_vars/proxmox_virtual_environment_hosts.yaml) to set hostname_set_to_loopback_address to false for all Proxmox VE hosts:

---
hostname_set_to_loopback_address: false
...

The tasks loop over all of the hosts in the proxmox_virtual_environment_hosts group and looks up their IP address from the networks variable to build a list for the hosts-file role to manage:

# /etc/hosts must have the local machine's IP configured before install
# see: https://forum.proxmox.com/threads/proxmox-ve-installation-8-0-errors-were-encountered-while-processing-pve-manager-proxmox-ve.134808/
- name: host -> IP mapping is known for all PVE hosts
  ansible.builtin.set_fact:
    pve_hosts_ips: >-
      {{
        pve_hosts_ips
        +
        [{
          'hostname': this_host_fqdn,
          'ipv4': {
            'address':
              networks.values()
              | map(attribute='ip4_assignments')
              | map('dict2items')
              | flatten
              | selectattr('value', 'eq', item)
              | map(attribute='key')
              | first
          },
        }]
      }}
  vars:
    pve_hosts_ips: []  # Fact will take precedence once set
    this_host_fqdn: >-
      {%- if '.' in item -%}
        {{ item }}
      {%- else -%}
        {{ item }}.{{ ansible_facts.domain }}
      {%- endif -%}
  loop: '{{ groups.proxmox_virtual_environment_hosts }}'
- name: Hosts file is configured for Proxmox VE hosts
  ansible.builtin.include_role:
    name: hosts-file
  vars:
    hosts_file_hosts: '{{ pve_hosts_ips }}'