Score:1

Ansible Loop Through Variable Number of Hostvars

ec flag

I am trying to grab hostnames and IP addresses from a user-entered list of hosts and send that information to a central server. The primary issue I'm running into is that the number of hosts can vary considerably. E.g. on the first run a user may enter 1 hostname, the second run enter 30, and the next enter 5. I want to be able to use a single playbook whether a user is entering 1 or 100 hosts.

Hostnames are collected through an "extra variable" prompt when an Ansible Tower template is run:

client_hosts: 'host1,host2'

which are then referenced in the playbook:

- name: Gather client information
  hosts: 
  - "{{ client_hosts | default(omit) }}"  
  tasks:    
    - name: Grab client hostname
      shell: cat /etc/hostname
      register: client_hostname

    - name: Grab client IP address
      shell: hostname -i | sed -n '1 p'
      register: client_ip

and further down the playbook, I want to add those IPs + hostnames to a file on a specific central server (the server hostname does not change):

- name: Update server
  hosts: central.server  
  tasks:  
    - name: Update client host list
      lineinfile:
        path: /path/to/file
        line: "{{ hostvars['client_hosts']['client_ip'] }} - {{ hostvars['client_hosts']['client_hostname'] }}"

The above works fine for a single host, but how would I loop through registering variables when more than one host is specified (e.g. client_hostname[1,2,*]?) and update the server with those values when I don't know how many hosts are going to be entered ahead of time?

Score:0
br flag

There are three parts to this use case: 1) Manage the inventory, 2) Collect client_hostname and client_ip, and 3) Report to the central server.

1. Manage the inventory

There are many options on how to manage the inventory and collect client_hostname and client_ip. For example, use Adding ranges of hosts if you plan to create hundreds of hosts with simple names like host001, hosts002, ..., host999. For example, in the inventory below add central_server, localhost for simplicity, and hundred hosts in the group test

shell> cat inventory/01-hosts 
central_server ansible_host=localhost

[test]
host[001:100]

[test:vars]
ansible_connection=ssh
ansible_user=admin
ansible_become=yes
ansible_become_user=root
ansible_become_method=sudo
ansible_python_interpreter=/usr/local/bin/python3.8

Briefly test the inventory

- hosts: all
  gather_facts: false
  tasks:
    - debug:
        var: ansible_play_hosts|length
      run_once: true

give abridged

  ansible_play_hosts|length: '101'

Run the command below if you want to display the complete inventory

shell> ansible-inventory -i inventory --list --yaml

Then there is plenty of options for how to select the hosts. See Patterns: targeting hosts and groups. For example, limit the inventory to particular hosts or groups. Test it with the simple playbook below

shell> cat pb1.yml
- hosts: all
  gather_facts: false
  tasks:
    - debug:
        var: inventory_hostname

give

shell> ansible-playbook -i inventory pb1.yml -l host001,host002

PLAY [all] ***********************************************************************************

TASK [debug] *********************************************************************************
ok: [host001] => 
  inventory_hostname: host001
ok: [host002] => 
  inventory_hostname: host002

PLAY RECAP ***********************************************************************************
host001: ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
host002: ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Use the inventory plugin constructed if you want to limit the inventory to a larger group of hosts. See

shell> ansible-doc -t inventory constructed

For example, the extra variable count_hosts is used in the example below to create the group my_group comprising hosts limited by the value of this variable

shell> cat inventory/02-constructed.yml
plugin: constructed
strict: true
use_extra_vars: true
compose:
  my_group_count: count_hosts|default(0)
groups:
  my_group: inventory_hostname[-2:]|int < my_group_count|int

Test it

shell> ansible-playbook -i inventory pb.yml -e count_hosts=10 -l my_group

PLAY [all] ***********************************************************************************

TASK [debug] *********************************************************************************
ok: [host001] => 
  ansible_play_hosts|length: '11'

PLAY RECAP ***********************************************************************************
host001: ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Run the command below if you want to display the complete inventory

shell> ansible-inventory -i inventory -e count_hosts=10 --list --yaml

With the help of the constructed plugin, you can create complex conditions. For example, limit the hosts to a particular interval and create the group my_group2

shell> cat inventory/02-constructed.yml
plugin: constructed
strict: true
use_extra_vars: true
compose:
  my_group_count: count_hosts|default(0)
  my_group_start: start_hosts|default(0)
  my_group_stop: stop_hosts|default(0)
groups:
  my_group1: inventory_hostname[-2:]|int < my_group_count|int
  my_group2: inventory_hostname[-2:]|int >= my_group_start|int and
             inventory_hostname[-2:]|int < my_group_stop|int

Test it

shell> ansible-playbook -i inventory pb1.yml -e start_hosts=10 -e stop_hosts=15 -l my_group2

PLAY [all] ***********************************************************************************

TASK [debug] *********************************************************************************
ok: [host010] => 
  inventory_hostname: host010
ok: [host011] => 
  inventory_hostname: host011
ok: [host012] => 
  inventory_hostname: host012
ok: [host013] => 
  inventory_hostname: host013
ok: [host014] => 
  inventory_hostname: host014

...

2. Collect client_hostname and client_ip

You can use either the module setup or collect the facts on your own. Because of the portability setup should be preferred.

See the module setup on how to gathers facts about remote hosts. For example, the playbook below gathers facts about the machine and network and creates the variables client_hostname and client_ip

shell> cat pb2.yml
- hosts: all
  gather_facts: false
    
  tasks:

    - setup:
        gather_subset:
          - machine
          - network

    - set_fact:
        client_hostname: "{{ ansible_hostname }}"
    - debug:
        var: client_hostname

    - debug:
        var: ansible_default_ipv4
    - debug:
        var: ansible_all_ipv4_addresses

    - set_fact:
        client_ip: "{{ ansible_all_ipv4_addresses|last }}"
    - debug:
        var: client_ip

give

shell> ansible-playbook -i inventory -l host011 pb2.yml

PLAY [all] ***********************************************************************************

TASK [setup] *********************************************************************************
ok: [host011]

TASK [set_fact] ******************************************************************************
ok: [host011]

TASK [debug] *********************************************************************************
ok: [host011] => 
  client_hostname: test_11

TASK [debug] *********************************************************************************
ok: [host011] => 
  ansible_default_ipv4: {}

TASK [debug] *********************************************************************************
ok: [host011] => 
  ansible_all_ipv4_addresses:
  - 10.1.0.61

TASK [set_fact] ******************************************************************************
ok: [host011]

TASK [debug] *********************************************************************************
ok: [host011] => 
  client_ip: 10.1.0.61

PLAY RECAP ***********************************************************************************
host011: ok=7    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The structure and format of the facts may differ among the operating systems.


You can collect the facts on your own. For example, the playbook below

shell> cat pb3.yml
- hosts: all
  gather_facts: false
    
  tasks:

    - name: Grab client hostname
      command: cat /etc/hostname
      register: out
    - set_fact:
        client_hostname: "{{ out.stdout }}"
    - debug:
        var: client_hostname

    - name: Grab client IP address
      shell: hostname -i | sed -n '1 p'
      register: out
    - set_fact:
        client_ip: "{{ out.stdout|split|last }}"
    - debug:
        var: client_ip

give running on Linux

shell> ansible-playbook -i inventory -l central_server pb3.yml

PLAY [all] ***********************************************************************************

TASK [Grab client hostname] ******************************************************************
changed: [central_server]

TASK [set_fact] ******************************************************************************
ok: [central_server]

TASK [debug] *********************************************************************************
ok: [central_server] => 
  client_hostname: central_server

TASK [Grab client IP address] ****************************************************************
changed: [central_server]

TASK [set_fact] ******************************************************************************
ok: [central_server]

TASK [debug] *********************************************************************************
ok: [central_server] => 
  client_ip: 10.1.0.22

PLAY RECAP ***********************************************************************************
central_server: ok=6    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The output of the utilities may differ among the operating systems.


3) Report to the central server

Use Jinja to create the structure. Run the task once and delegate it to the central_server

shell> cat pb4.yml
- hosts: all
  gather_facts: false
    
  tasks:

    - setup:
        gather_subset:
          - machine
          - network
    - set_fact:
        client_hostname: "{{ ansible_hostname }}"
        client_ip: "{{ ansible_all_ipv4_addresses|last }}"

    - copy:
        dest: /tmp/test_host_ip.txt
        content: |
          {% for host in ansible_play_hosts %}
          {{ hostvars[host]['client_hostname'] }} - {{ hostvars[host]['client_ip'] }}
          {% endfor %}
      run_once: true
      delegate_to: central_server

give

shell> ansible-playbook -i inventory -l host011,host013 pb4.yml

PLAY [all] ***********************************************************************************

TASK [setup] *********************************************************************************
ok: [host013]
ok: [host011]

TASK [set_fact] ******************************************************************************
ok: [host011]
ok: [host013]

TASK [copy] **********************************************************************************
changed: [host011 -> central_server(localhost)]

PLAY RECAP ***********************************************************************************
host011: ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
host013: ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The playbook created the file at central_server

shell> cat /tmp/test_host_ip.txt 
test_11 - 10.1.0.61
test_13 - 10.1.0.63

Use the module lineinfile if you want to add lines to the file. The playbook below is idempotent

shell> cat pb5.yml
- hosts: all
  gather_facts: false
    
  tasks:

    - setup:
        gather_subset:
          - machine
          - network
    - set_fact:
        client_hostname: "{{ ansible_hostname }}"
        client_ip: "{{ ansible_all_ipv4_addresses|last }}"

    - lineinfile:
        path: /tmp/test_host_ip.txt
        line: |-
          {{ hostvars[item]['client_hostname'] }} - {{ hostvars[item]['client_ip'] }}
      loop: "{{ ansible_play_hosts }}"
      run_once: true
      delegate_to: central_server

There will be no changes when you run it repeatedly on the same hosts

shell> ansible-playbook -i inventory -l host011,host013 pb5.yml

PLAY [all] ***********************************************************************************

TASK [setup] *********************************************************************************
ok: [host011]
ok: [host013]

TASK [set_fact] ******************************************************************************
ok: [host011]
ok: [host013]

TASK [lineinfile] ****************************************************************************
ok: [host011 -> central_server(localhost)] => (item=host011)
ok: [host011 -> central_server(localhost)] => (item=host013)

PLAY RECAP ***********************************************************************************
host011: ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
host013: ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

A new line(s) will be added to the file if the playbook runs on new host(s)


shell> ansible-playbook -i inventory -l central_server pb5.yml

PLAY [all] ***********************************************************************************

TASK [setup] *********************************************************************************
ok: [central_server]

TASK [set_fact] ******************************************************************************
ok: [central_server]

TASK [lineinfile] ****************************************************************************
changed: [central_server] => (item=central_server)

PLAY RECAP ***********************************************************************************
central_server: ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The new line was appended to the file

shell> cat /tmp/test_host_ip.txt 
test_11 - 10.1.0.61
test_13 - 10.1.0.63
central_server - 10.1.0.184
hypen9950 avatar
ec flag
I certainly appreciate the detailed response! That last part "delegate_to" was the key I was looking for so I could iterate over the client variables and send them to the central server. Much thanks!
Score:0
br flag

You can loop through the client_hosts variable using the with_items directive in your playbook. You can then reference each individual host by using the item variable in the loop.

Here is an example of how you can modify your playbook to handle multiple hosts:

- name: Gather client information
  hosts: "{{ client_hosts | default(omit) }}"  
  tasks:
    - name: Grab client hostname and IP address
      shell: |
        hostname -i | sed -n '1 p' > /tmp/client_ip
        cat /etc/hostname > /tmp/client_hostname
      register: gather_client_info
      become: true

    - name: Set client hostname and IP address as variables
      set_fact:
        client_hostname: "{{ hostvars[item]['gather_client_info'].stdout_lines[1] }}"
        client_ip: "{{ hostvars[item]['gather_client_info'].stdout_lines[0] }}"
      with_items: "{{ client_hosts | default(omit) }}"

- name: Update server
  hosts: central.server  
  tasks:
    - name: Update client host list
      lineinfile:
        path: /path/to/file
        line: "{{ client_ip }} - {{ client_hostname }}"
      with_items: "{{ client_hosts | default(omit) }}"

This playbook will loop through each host in the client_hosts variable and gather the hostname and IP address using the shell module. It will then set these values as variables using the set_fact module. Finally, it will loop through the client_hosts variable again and use the lineinfile module to update the file on the central server with the hostname and IP address for each host.

Hope this helps.

hypen9950 avatar
ec flag
I feel like "with_items" is just what I'm looking for. However, if I run this with a single host, I get an error on the final task "Update client host list" that the variable "client_ip" is undefined. If I run this with more than one hostname, I get an error trying to set the hostname and IPs as variables: "hostvars['host1,host2']\" is undefined. It inserts a string containing the whole comma-separated server list into the hostvar[].
hypen9950 avatar
ec flag
And a small note, something like "cat /etc/hostname > /tmp/client_hostname" prevents the information from being output to stdout, so I had to remove that for the facts to be set from stdout_lines
unixoid avatar
br flag
Maybe there is an issue with the hostvars variable which contains a dictionary with keys that are host names and values that are dictionaries containing information about each host. The item variable in the with_items loop will be the host name of each host, not the client_hostname or client_ip value. To fix this, you can change the set_fact task to use the hostvars variable to access the client_hostname and client_ip values directly.
unixoid avatar
br flag
Something like this: - name: Set client hostname and IP address as variables set_fact: client_hostname: "{{ hostvars[item]['gather_client_info'].stdout_lines[1] }}" client_ip: "{{ hostvars[item]['gather_client_info'].stdout_lines[0] }}" with_items: "{{ client_hosts | default(omit) }}" This will set the client_hostname and client_ip variables for each host in the client_hosts list.
unixoid avatar
br flag
Then you can use the client_hostname and client_ip variables in the lineinfile task to update the file with the hostname and IP address of each client: - name: Update client host list lineinfile: path: /path/to/file line: "{{ client_ip }} - {{ client_hostname }}" with_items: "{{ client_hosts | default(omit) }}" This should fix the errors you are seeing and allow you to update the file with the hostname and IP address of each client host.
I sit in a Tesla and translated this thread with Ai:

mangohost

Post an answer

Most people don’t grasp that asking a lot of questions unlocks learning and improves interpersonal bonding. In Alison’s studies, for example, though people could accurately recall how many questions had been asked in their conversations, they didn’t intuit the link between questions and liking. Across four studies, in which participants were engaged in conversations themselves or read transcripts of others’ conversations, people tended not to realize that question asking would influence—or had influenced—the level of amity between the conversationalists.