Skip to main content

An Ansible playbook for solving a new problem from scratch

Automation means spending time upfront to save a lot more time in the future.
Image
Golden gears

Image by Pavlofox from Pixabay

Imagine you're in the middle of a cloud migration or a penetration test, and you have to enable an existing account on over 400 hosts as quickly as possible. It sounds like a big problem, but it can be easy with automation.

First things first. You must define the exact requirements you have for the task. This is the step that will help and guide your code for automation. Don't do this in code yet.

Here are your requirements:

  • Do not create an account on a server if it does not already exist there.
  • Enable this existing account for a period of six months, no more.

Ansible has a module for this.

Write your first draft

A quick trip to your favorite editor plus a couple of internet searches, and you have an idea where to start.

To begin your development, write some YAML and test it against localhost. Add this text to a file called addauser.yml:

---
- name: add a user
  hosts: localhost
  gather_facts: false
  become: yes
  tasks:
  - name: Add a user with no expiry
    user:
      name: pentest
      expires: -1

Test it out:

$ ansible-playbook addauser.yml

$ sudo chage -l pentest
[sudo] password for stuart:            
chage: user 'pentest' does not exist in /etc/passwd

$ ansible-playbook 1-addauser.yml --ask-become
BECOME password: 
[WARNING]: No inventory parsed, implicit localhost available
[WARNING]: hosts list is empty, localhost available.
Note that the implicit localhost does not match 'all'
PLAY [add a user] ***************************
TASK [Add a user with no expiry] ************
changed: [localhost]
PLAY RECAP **********************************
localhost: ok=1    changed=1    unreachable=0
failed=0    skipped=0    rescued=0    ignored=0   

$ sudo chage -l pentest
Last password change                              : Apr 26, 2022
Password expires                                  : never
Password inactive                                 : never
Account expires                                   : never
Minimum number of days between password change    : 0
Maximum number of days between password change    : 99999
Number of days of warning before password expires : 7

Here's what happened:

  1. User pentest did not exist, which you proved with chage -l pentest.
  2. The playbook ran and created a new user with no account expiry.
  3. To keep a privileged password safe, Ansible prompted for the privileged password with the --ask-become option.
  4. You saved some time by not gathering facts.

That's a great first try, but the requirements were not met. The playbook created an account when one did not exist. So how can you prevent a user from being created when it already exists?

[ Get started with IT automation by downloading Red Hat Ansible Automation Platform: A beginner's guide. ]

You need some way to check whether the user is already in a database of existing users.

Use Ansible getent

A quick search of the internet suggests that you can use getent:

$ getent passwd not-user

$ echo $?
2

The command returns no output, but it appears to work successfully. Notice that the return code is 2, and the getent(1) man page says:

Exit Status
2 One or more supplied key could not be found in the database.

Ansible has a built-in module that supports the getent command, and you can find the documentation at ansible.builtin.getent. Using it, write a task for the playbook that checks for the existence of a specific user:

  - name: GETENT pentest info
    getent:
      database: passwd
      key: pentest
    ignore_errors: yes

In this code example, I used ignore_errors because, by default, the getent module fails when the key (pentest in this example) does not exist in the database (passwd in this case) being searched. This is a temporary fix for testing purposes, and you'll modify it later.

Hint: Read the entire manual page and understand the options for making future improvements.

The getent module returns a Fact stored in ansible_facts.genent_DBNAME[key]. In the example above, this is ansible_facts.getent_passwd[pentest]. Combining this task with the first draft of the code, you can now determine whether a user exists. If it does, you can update the account expiry time with a value of -1 to set it never to expire.

Here's the complete playbook so far:

---
- name: add a user
  hosts: localhost
  gather_facts: false
  become: yes
  tasks:
  - name: GETENT pentest info
    getent:
      database: passwd
      key: pentest
    ignore_errors: yes 
  - name: Add user pentest with no expiry
    user:
      name: pentest
      expires: -1
    when: ansible_facts.getent_passwd[pentest] is defined

Did you meet your goal to enable only an existing account and for a period of six months? Close, but you're not there yet.

Set the expiry date

There are several ways to get or set the expires date. Your first thought might be to have a task register a variable or execute set_fact. That would have to be executed for each host, though, which would lengthen the execution time of the playbook.

Instead, set a variable to be used by all hosts. Better still, set another variable with the user name, making your code reusable for any user account, not just the sample user pentest. You can always pass in variables from the command line to change which user is configured and set the desired expiry time:

---
- name: update users
  hosts: localhost
  gather_facts: false
  become: yes
  vars:
    s_user: 'pentest'
    s_expires: "{{ lookup('pipe','date +%s -d now+6months') }}"
  tasks:
  - name: GETENT {{s_user}} info
    getent:
      database: passwd
      key: "{{ s_user }}"
    ignore_errors: yes
  - name: Change expiry date for user to today + 6 months
    user:
      name: "{{ s_user }}"
      # expires: -1
      expires: "{{ s_expires }}"
    when: ansible_facts.getent_passwd["{{s_user}}"] is defined

There are many best practices for selecting variable names. Here are a couple of good ones: Don't make them cryptic and do make them easy to identify (don't make them look like module names, for instance).

What happens when you run your playbook now?

$ ansible-playbook adduser.yml --ask-become
BECOME password: 
[WARNING]: No inventory parsed, implicit localhost available
[WARNING]: Provided hosts list is empty, localhost is available.
Note that the implicit localhost does not match 'all'
PLAY [update users] *********************************
TASK [GETENT pentest info] **************************
fatal: [localhost]: FAILED! => 
{"changed": false, "msg": 
"One or more supplied key could not be found in the database."}
...ignoring
TASK [Change expiry date for user to today + 6 months] ********
[WARNING]: conditional statements should not include jinja2 
templating delimiters such as {{ }} or {% %}. 
Found: ansible_facts.getent_passwd["{{s_user}}"] is defined
skipping: [localhost]
PLAY RECAP ****************************************************
localhost: ok=1    changed=0    unreachable=0
failed=0    skipped=1    rescued=0    ignored=1

As expected, the getent task fails because it cannot find the relevant key in the passwd database. You explicitly use ignore_errors so that the playbook continues anyway. You don't want to fail on a single error when you have multiple hosts.

It skips the user task because the when conditional is not met because the variable ansible_facts.getend_passwd[pentest] is not defined. According to the requirements at the start of this article, this is the expected and desired behavior.

Quick confirmation:

$ sudo chage -l pentest
chage: user 'pentest' does not exist in /etc/passwd

You can add the user either from the terminal or by creating a quick playbook for testing purposes—your choice. Expire the account by setting the expiry for some time in the near future:

$ sudo usermod -e 2022-01-01 pentest

$ sudo chage -l pentest
Last password change                      : Apr 27, 2022
Password expires                          : never
Password inactive                         : never
Account expires                           : Jan 01, 2022
Minimum number of days between password ch : 0
Maximum number of days between password ch : 99999
Number of days of warning before password  : 7

With the expired user present, run the playbook again:

$ ansible-playbook adduser.yml --ask-become
BECOME password: 
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
PLAY [update users] **************************************************************************************************
TASK [GETENT pentest info] **************************************************************************************************
ok: [localhost]
TASK [Change expiry date for user to today + 6 months] **************************************************************************************************
[WARNING]: conditional statements should not include jinja2 templating delimiters such as {{ }} or {% %}. Found: ansible_facts.getent_passwd["{{s_user}}"] is defined
changed: [localhost]
PLAY RECAP **************************************************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

$ sudo chage -l pentest
Last password change                      : Apr 27, 2022
Password expires                          : never
Password inactive                         : never
Account expires                           : Oct 27, 2022
Minimum number of days between password ch: 0
Maximum number of days between password ch: 99999
Days of warning before password expires   : 7

At this point, it looks like you've fulfilled the requirements. You enabled an account only if it existed previously and set its expiry for six months from now.

In addition, you created a reusable forward-thinking playbook because you can override the variables s_user and s_expires with any user name and any expiry date.

Now do it without ignoring errors

There's one thing left to do. Ignoring errors is a useful trick while you're getting the logic right, but if you ignore all errors, you'll miss all errors, even the important ones. As a colleague reviewing my initial code advised: Be positive, don't override a negative condition.

The fail_key attribute in the getent module is set to yes by default. That's why I had to set ignore_errors to yes. In other words, getent was detected as an error only because fail_key was set to yes.

Usually, a return code of 0 means success, and any other return code signifies an error. However, you can use the getent module parameter fail_key: no so that a non-zero return code is not seen as an error. This creates a variable ansible_facts.getent_passwd[pentest] with a null value for the dictionary.

Here's the complete and revised playbook:

---
- name: update users
  hosts: all
  gather_facts: false
  become: yes
  vars:
    s_user: 'pentest'
    s_expires: "{{ lookup('pipe','date +%s -d now+6months') }}"
  tasks:
  - name: GETENT {{s_user}} info
    getent:
      database: passwd
      key: "{{ s_user }}"
    fail_key: no
  - name: Change expiry date for user to today + 6 months
    user:
      name: "{{ s_user }}"
      # expires: -1
      expires: "{{ s_expires }}"
    when: (ansible_facts.getent_passwd[s_user] | type_debug) != 'NoneType'

The user task now updates the user only when the getent_passwd variable for the user is not a NoneType or null. (I know, I said to be positive, and this looks like a negative conditional, but technically it's a double negative.)

Of course, before you can use this against multiple hosts, you must update the hosts: line to operate against all hosts:

hosts: all

Successful automation

Automation means spending time upfront to save a lot more time in the future. You should expect iteration when composing an automation script, and you should thoroughly test your playbooks before running them against your actual targets. Use the principles of open source, and show your scripts to your colleagues to make improvements.

  1. Define your problem, know what you want to achieve, and define what success means for this task.
  2. Solve one issue at a time. Be iterative.
  3. You will encounter issues you didn't expect. That in itself is expected.
  4. Keep checking against your criteria for success.
  5. Ask for multiple viewpoints. Is there a better way to achieve your goal?

Everybody loves homework, so here's a bonus puzzle: What could you do to expire the account after three months?

Check out these related articles on Enable Sysadmin

Topics:   Ansible   Automation   Linux administration  
Author’s photo

Stuart Hayes

Stuart Hayes is an infrastructure and automation architect and a Red Hat customer. He prefers Linux and preventing medium-aged laptops from reaching the recycling bins by giving them to people who wouldn't otherwise afford technology. More about me

Red Hat Summit 2022: On Demand

Get the latest on Ansible, Red Hat Enterprise Linux, OpenShift, and more from our virtual event on demand.

Related Content

OUR BEST CONTENT, DELIVERED TO YOUR INBOX