Managing hosts with Ansible
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 }}'