Many tasks for a single loop?

I want to write a playbook which check if a file exist for all users including root and if this file exists make some changes to this file. I have a script getUsers.fact which will get all users with uid 1000 and above.

I came out with the playbook below but of course it won’t work because of the first task which check if the file exist for all users before going to the second task which will do something with the file if it exists.

How can I loop all the users and check if the file exists, modify the file and go to the next user?

---
- name: make changes to file for all users
  hosts: all
  tasks:
  - name: Check state of file .bashrc
    ansible.builtin.stat:
      path: /home/{{ item }}/.bashrc
    register: fileStat
    with_items:
      - "{{ ansible_facts.ansible_local.getUsers.user }}"
      - root

  - name: List file
    ansible.builtin.command:
      cmd: excute.changes.on.files
    with_items:
      - "{{ ansible_facts.ansible_local.getUsers.user }}"
      - root
    when: fileStat.stat.exists
...

By the way, I am new to Ansible and presently reading the doc but need to fix things now.

Maybe something like:

---
- name: make changes to file for all users
  hosts: all
  tasks:
    - name: Check state of file .bashrc for each user and modify if exists
      block:
        - name: Check state of file .bashrc for {{ item }}
          ansible.builtin.stat:
            path: "{{ '/root' if item == 'root' else '/home/' + item }}/.bashrc"
          register: fileStat

        - name: Modify file for {{ item }} if it exists
          ansible.builtin.command:
            cmd: excute.changes.on.files
          when: fileStat.stat.exists
      loop: 
        - "{{ ansible_facts.ansible_local.getUsers.user }}"
        - root

Hey @bern . Welcome to the Forum.

I’m trying to reproduce your issue, and I’m stuck coming up with a /etc/ansible/facts.d/getUsers.fact that works with your expression {{ ansible_facts.ansible_local.getUsers.user }}. I’m curious what your getUsers.fact looks like. Here’s mine:

#!/bin/bash
# /etc/ansible/facts.d/getUsers.fact
# Returns JSON of local users with id>999 and without a 'nologin' shell.
pending=''
echo -n '['
getent passwd | while IFS=: read -r user password uid gid gecos home shell; do
  if [ "$uid" -gt 999 ] && [[ ! "$shell" =~ nologin ]] ; then
     printf "%s{" "$pending"
     printf '"user": "%s", ' "$user"
     printf '"uid": "%s", ' "$uid"
     printf '"home": "%s", ' "$home"
     printf '"shell": "%s"}' "$shell"
     pending=$',\n'
  fi
done
echo ']'

Having typed all that up and reading over it, it dawned on me that maybe your ansible_facts.ansible_local.getUsers.user expression isn’t working for you either.

Is that the problem?

1 Like

This would have been too easy. loop is not usable in block according to the documentation.

Ansible doc about Block

Grouping tasks with blocks
All tasks in a block inherit directives applied at the block level. Most of what you can apply to a single task (with the exception of loops) can be applied at the block level, so blocks make it much easier to set data or directives common to the tasks.

Here the output If I try to run it:

$ ansible-playbook playbooks.d/playbook_bashrc.yaml
ERROR! 'loop' is not a valid attribute for a Block

But we might be closer to something.

Thanks,

1 Like

Here is my very simple script to get users:

echo {

for u in `grep 10[0-9][0-9] /etc/passwd | awk -F: '{print $1}'` ; do
  echo "  \"user\": \"${u}\"";
done;

echo }

If I go your way, I would modify your script a bit:

echo {
getent passwd | while IFS=: read -r user password uid gid gecos home shell ; do
  if (( ${uid} > 999 )) && [[ ! ${shell} =~ nologin ]]  ; then
      printf "    \"user\": ${user}\n"
  fi
done
echo }

And actually I prefer the modified version from yours than mine. :smiley: Thanks!!

My problem is really in the logic of playbook where I have to pass item to more than one tasks.

oh just loop on an include_tasks, sorry about that.

You can put the two task in another file, then loop on that file

2 Likes

In fact, your first method finds my wife’s id (tplewis), but misses mine (utoddl) because my uid doesn’t start with (or contain) 10. Behold:

[utoddl@tango ansible]$ for u in `grep 10[0-9][0-9] /etc/passwd | awk -F: '{print $1}'` ; do   echo "  \"user\": \"${u}\""; done;
  "user": "tplewis"
[utoddl@tango ansible]$ getent passwd tplewis utoddl
tplewis:x:1001:1001::/home/tplewis:/bin/bash
utoddl:x:12428:12428:Todd Lewis:/home/utoddl:/bin/bash

But besides that, the expression ansible_facts.ansible_local.getUsers.user is only going to work if there’s a single user. If that’s a list of users, it’s going to require more jinja.

Well that’s a good point about the uid above 10xx. But I know in my environment all uid start with 10.

For the other point, the script get all users with uid > 999 without any problem on my side.

Okay. What output do you get for this:

    - name: Show getUsers
      ansible.builtin.debug:
        msg: "getUsers: {{ ansible_facts.ansible_local.getUsers }}"
    - name: Show getUsers.user
      ansible.builtin.debug:
        msg: "getUsers.user: {{ ansible_facts.ansible_local.getUsers.user }}"

Your modified getUsers.fact is generating invalid json for me.

Going back to my version from the 3rd msg in this thread, these expressions are working for me:

    - name: Show getUsers
      ansible.builtin.debug:
        msg: "{{ ansible_facts.ansible_local.getUsers }}"

    - name: Show each user returned by getUsers
      ansible.builtin.debug:
        msg: "{{ item }}"
      loop:
        - "{{ ansible_facts.ansible_local.getUsers | map(attribute='user') }}"
        
    - name: Show each user returned by getUsers plus 'root'
      ansible.builtin.debug:
        msg: "{{ item }}"
      loop:
        - "{{ ansible_facts.ansible_local.getUsers | map(attribute='user') + ['root'] }}"

So to loop over “those users” plus “root”, you could use that last expression:

    loop:
      - "{{ ansible_facts.ansible_local.getUsers | map(attribute='user') + ['root'] }}"

to loop over - as @IPvSean suggested - an ansible.builtin.include_task step.

(Only, I probably wouldn’t, for reasons I’ll state in a follow-up. Trying to stick to one salient point per post is hard.)

That’s one level too deep. It needs to be written like this:

    loop: "{{ ansible_facts.ansible_local.getUsers | map(attribute='user') + ['root'] }}"

Otherwise, you get one item which itself is a list. This way, you get one user per item.

There’s an aesthetic balance, a trade-off to be made, between complexity (i.e. number of pieces) and complications (i.e. how intricate individual pieces are). Adding another file to have something to loop over increases complexity with no particular gain — except that it’s the only way to get Ansible to do the thing, which is frankly a design flaw in Ansible so I’m glossing over that point for this argument.

“Don’t do in shell scripts what you can do in tasks” is a guideline, not a rule. Choosing when to follow it depends on lots of factors, including who’s going to run and maintain the playbook, overall comfort with and understanding of various failure modes, comfort level with shell scripting generally, etc.

For me, looking at your original ask, I’d be happier with one Ansible task that uses a shell script, something like this:

    - name: Do a thing at/with/to these users' .bashrc files
      ansible.builtin.shell: |
        IFS=: read -r user password uid gid gecos home shell < <(getent passwd '{{ item }}')
        if [ -f "$home/.bashrc" ] ; then
           echo "do a thing with '$home/.bashrc'"
           # This is where your 'excute.changes.on.files' command goes
           exit $?
        else
           echo "no '$home/.bashrc' or can't access it." >&2
           exit 0
        fi
      args:
        executable: /bin/bash
      loop: "{{ ansible_facts.ansible_local.getUsers | map(attribute='user') + ['root'] }}"
1 Like

Thanks! Now I got it.

- name: make changes to file for all users
  hosts: all
  tasks:
  - name: Block of tasks
    ansible.builtin.include_tasks: looping_ansible_facts_tasks.yaml
    loop:

This has been very enlightening for me. This make me get some new concepts about Ansible.

Thanks a lot

2 Likes

Hello Todd,
I found a little problem with script getUsers. Here is the corrected version:

declare -x -a UARR

# Build array of users
while IFS=: read -r user password uid gid gecos home shell ; do
    if (( ${uid} > 999 )) && [[ ! ${shell} =~ nologin ]]  ; then
        UARR=( ${UARR[*]} ${user} )
    fi
done < <( getent passwd )

# Build json output
printf "{ \"users\": ["
x=0
while (( x < ${#UARR[*]} )); do
    (( ${x} == ${#UARR[*]}-1 )) && printf "\"${UARR[${x}]}\"" || printf "\"${UARR[${x}]}\", "
    (( x++ ))
done
printf " ] }\n"

I found it is a good idea to check the output of the script above with jq to validate if it is a proper json format. Also, we can see it is properly working with the module setup and listing the facts about a host.

Now I have to found a way to iterate a json array. My script create a json array of users.

I like this better. It’s shorter, doesn’t double your last entry, nor assume there is one.

printf '{ "users": ['
sep=''
while IFS=: read -r user password uid gid gecos home shell ; do
    if (( "${uid}" > 999 )) && [[ ! "${shell}" =~ nologin ]]  ; then
        printf '%s"%s"' "${sep}" "${user//\"/\\\"}"
        sep=', '
    fi
done < <( getent passwd )
printf "] }\n"

It also escapes double-quotes in the $user which is required in the generated json. Probably not going to be an issue here, but the technique is instructive. I learned something anyway, so thanks for that.

Finally, get in the habit of linting any shell scripts you write with the most excellent shellcheck program. It can elevate a pretty good shell scripter to the next level. Likewise for ansible-lint and Ansible. Nobody has enough time to skip linting.

That’s the beauty of “facts”. Your playbook doesn’t need to care what form your data arrived in when it was ingested. It’s just a list, like any other. So now you can put your tasks in a separate file — let’s call it bash_rc_wrangler.yml — and you can iterate over it like this:

    - name: Run the bash_rc_wrangler tasks on each local user and "root"
      ansible.builtin.include_tasks:
        file: bash_rc_wrangler.yml
      loop: "{{ ansible_facts.ansible_local.getUsers + ['root'] }}"
      loop_control:
        loop_var: wrangle_user

Those last two lines create the variable wrangle_user and sets it to each of your users’ userids. The tasks inside bash_rc_wrangler.yml will use that variable to know what user they are working on. The other thing this buys you is, within that task file, if you loop over some other list, you can let it set the default loop_var item without wiping out your wrangle_user variable. Without those lines, your users’ userids would be in item, which is overwritten by the next loop that comes along.