An Advanced Guide to Salt

System Administration ?? Comments 30/jan/2019 qua

Introduction

In this post we will be talking about more advanced scenarios using Salt, specifically how to write your own execution and state modules.

Like we saw before, we configure all of the salt subsystem in files within /srv/salt/.

The location for our custom execution and state modules is in the same place we place our SLS files, however, within two specific directories: _modules and _states, for execution and state modules, respectively.

Execution Module

Let us take the example of creating a custom execution module that can manage users in a system, in a way that we can:

  • see which home directories have no users associated with
  • disable a user from login
  • enable a user to login
  • force a user to change its password

Let us call the module myuser, so it does not clash with any existing module name. For info on all of salt's existing modules check this list.

First we create the module file in the proper location:

root@master:~# tree /srv/salt
/srv/salt
└── states
    └── base
        │  ...
        ├── _modules
        │   └── myuser.py
        ...

Now we start writing our module. The first thing we should do, after imports, is tell the salt system the proper module name to use:

'''
set the module name
'''
__virtualname__ = 'myuser'
def __virtual__():
    return __virtualname__

Now we can implement our first function, let us call it get_orphan_home_dirs:

'''
returns a directory list of directories, which the users that they belong to no longer exist

CLI Example::

    salt '*' myuser.get_orphan_home_dirs
'''
def get_orphan_home_dirs():
    ret = dict()
    root='/home'
    dirs = os.listdir(root)
    for dir_ in [d for d in dirs if os.path.isdir(os.path.join(root, d))]:
        found = False
        for p in [u for u in pwd.getpwall() if (u.pw_uid >= 1000)]: # here we skip system users
            if p.pw_dir == os.path.join(root, dir_):
                found = True
                break
        if not found:  # we got a culprit
            ret[root+dir_] = 'User {} Not Found!'.format(dir_)
    return ret

Before we can test this execution module we need to update the cache on the minion:

root@master:~$ salt 'minion-1' saltutil.sync_modules
minion-1:
  ----------
  - modules.myuser
root@master:~$

Now we can test it on the salt-master:

root@master:~$ salt 'minion-1' myuser.get_orphan_home_dirs
minion-1:
    ----------
    /home/does-not-exist:
        User does-not-exist Not Found!
root@master:~$

And everything looks good, so we can move on to the next functions, disable/enable user accounts:

'''
disables a user from being able to login to the minion

CLI Example::

    salt '*' myuser.disable_login username
'''
def disable_login(name):
    ret = dict()

    minion_id = __salt__['grains.get']('id')

    try:
        pwd.getpwnam(name)
        __salt__['shadow.set_expire'](name, 0)

        ret['result'] = True
        ret['comment'] = None
        ret['changes'] = {'disable_login': {'old': '',
                                            'new': name + '@' + minion_id}}
    except KeyError:
        ret['result'] = True
        ret['changes'] = dict()
        ret['comment'] = 'User not Present ' + name + ' @ ' + minion_id

    return ret

'''
enables a user to login to the minion

CLI Example::

    salt '*' myuser.enable_login username
'''
def enable_login(name):
    ret = dict()
    minion_id = __salt__['grains.get']('id')

    try:
        pwd.getpwnam(name)
        __salt__['shadow.set_expire'](name, -1)

        ret['result'] = True
        ret['comment'] = None
        ret['changes'] = {'enable_login': {'old': '',
                                           'new': name + '@' + minion_id}}
    except KeyError:
        ret['result'] = True
        ret['changes'] = dict()
        ret['comment'] = 'User not Present ' + name + ' @ ' + minion_id

    return ret

All of the Salt execution modules are available to each other and modules can call functions available in other execution modules. [1]

The variable __salt__ is packed into the modules after they are loaded into the Salt minion. [1]

The __salt__ variable is a Python dictionary containing all of the Salt functions. Dictionary keys are strings representing the names of the modules and the values are the functions themselves. [1]

Here we make use of the shadow module, you can see details about it here

Let us test these with the user user temp:

root@master:~#  salt 'minion-1' myuser.disable_login temp
minion-1:
    ----------
    changes:
        ----------
        disable_login:
            ----------
            new:
                temp@minion-1
            old:
    comment:
        None
    result:
        True

Check that it is disabled:

temp@local:~$ ssh minion-1
Your account has expired; please contact your system administrator
Connection to minion-1 closed by remote host.
Connection to minion-1 closed.

Now we enable it back.

root@master:~#  salt 'minion-1' myuser.enable_login temp
minion-1:
    ----------
    changes:
        ----------
        enable_login:
            ----------
            new:
                temp@minion-1
            old:
    comment:
        None
    result:
        True

And check that it works.

temp@local:~$ ssh minion-1
Welcome to Ubuntu 16.04.5 LTS (GNU/Linux 4.4.0-131-generic x86_64)
temp@minion-1:~$

Nice, now we just have one task left, make a function that forces a user to change its password:

'''
Expire the password validity for a user, forcing the user to change the password at next successful login

CLI Example::

    salt '*' myuser.expire_passwd username
'''


def expire_passwd(name):
    ret = dict()
    minion_id = __salt__['grains.get']('id')

    try:
        old = spwd.getspnam(name)
        __salt__['shadow.set_date'](name, 0)

        ret['result'] = True
        ret['changes'] = {'expire_passwd': {'old': old.sp_lstchg,
                                            'new': spwd.getspnam(name).sp_lstchg}}
        ret['comment'] = None
    except KeyError:
        ret['result'] = True
        ret['changes'] = dict()
        ret['comment'] = 'User not Present ' + name + ' @ ' + minion_id

    return ret

Let us test it, then.

root@master:~#  salt 'minion-1' myuser.expire_passwd temp
minion-1:
    ----------
    changes:
        ----------
        expire_passwd:
            ----------
            new:
                0
            old:
                -1
    comment:
        None
    result:
        True

If we did everything right, this will trigger a password change on the user's next successful login. And the user is forced to change to a different password.

temp@local:~$ ssh minion-1
You are required to change your password immediately (root enforced)
WARNING: Your password has expired.
You must change your password now and login again!
Changing password for temp.
(current) UNIX password:
Enter new UNIX password:
Retype new UNIX password:
Password unchanged
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully
temp@minion-1:~$ logout
Connection to minion-1 closed.

Looks good =)

State Module

Like we saw in the previous post, to apply a state we use the state execution module, and its function apply. One key difference between using execution modules and state modules is the possibility of using the keyword test that, when True, will not effectively apply the state but rather it will show what it would do and/or which changes it would apply. We will see how to define this behaviour.

Just like for the execution module we should first create the state file in the proper location.

root@master:~# tree /srv/salt
/srv/salt
└── states
    └── base
        │  ...
        ├── _states
        │   └── myuser.py
        ...

And we can start writing our state module. Like the execution module let us call it myuser. As the get_orphan_home_dirs function is informational only, we will be implementing the other three disable_login, enable_login and expire_passwd.

def disable_login(name):
    ret = dict()

    if __opts__['test']:
        ret['name'] = 'login'
        ret['changes'] = dict()
        ret['result'] = None
        ret['comment'] = 'Would disable login for user {0}'.format(name)
        return ret

    ret = __salt__['myuser.disable_login'](name)

    ret['name'] = 'login'

    return ret


def enable_login(name):

    ret = dict()

    if __opts__['test']:
        ret['name'] = 'login'
        ret['changes'] = dict()
        ret['result'] = None
        ret['comment'] = 'Would enable login for user {0}'.format(name)
        return ret

    ret = __salt__['myuser.enable_login'](name)

    ret['name'] = 'login'

    return ret


def expire_passwd(name):

    ret = dict()

    if __opts__['test']:
        ret['name'] = 'login'
        ret['changes'] = dict()
        ret['result'] = None
        ret['comment'] = 'Would enable login for user {0}'.format(name)
        return ret

    ret = __salt__['myuser.expire_passwd'](name)

    ret['name'] = 'login'

    return ret

And now we can use these in SLS files, i.e:

{%- set shell = '/bin/bash' -%}
{%- set home_root = '/home' -%}

{% for username in ['alice', 'bob'] %}
add user {{ username }} and ensure the login is enabled:
  user.present:
    - name: {{ username }}
    - shell: {{ shell }}
    - home: {{ home_root }}/{{ username }}
    - createhome: True
    - password: <some hash previously created with mkpasswd -m sha-256>
  myuser.enable_login:
    - name: {{ username }}
    - require:
      - user: add user {{ username }} and ensure the login is enabled
force user {{ username }} to change its password:
  myuser.expire_passwd:
    - name: {{ username }}
    - require:
      - myuser: add user {{ username }} and ensure the login is enabled
{% endfor %}

{% for username in ['charlie', 'lucy'] %}
add user {{ username }} and disable its login:
  user.present:
    - name: {{ username }}
    - shell: {{ shell }}
    - home: {{ home_root }}/{{ username }}
    - createhome: True
    - password: <some hash previously created with mkpasswd -m sha-256>
  myuser.disable_login:
    - name: {{ username }}
    - require:
      - user: add user {{ username }} and disable its login
{% endfor %}

Before testing our newly created state module we have to update the minion's cache.

root@master:~$ salt minion-1 saltutil.sync_states
minion-1:
  ----------
  - states.myuser
root@master:~$

Now let us test it.

root@master:~$ salt minion-1 state.apply users test=True
minion-1:
----------
          ID: add user alice and ensure the login is enabled
    Function: user.present
        Name: alice
      Result: None
     Comment: User alice set to be added
     Started: 14:23:49.860660
    Duration: 0.967 ms
     Changes:
----------
          ID: add user bob and ensure the login is enabled
    Function: user.present
        Name: bob
      Result: None
     Comment: User bob set to be added
     Started: 14:23:49.860660
    Duration: 0.967 ms
     Changes:
----------
          ID: add user alice and ensure the login is enabled
    Function: myuser.enable_login
        Name: alice
      Result: None
     Comment: Would enable login for user alice
     Started: 14:23:48.895331
    Duration: 0.473 ms
     Changes:
----------
          ID: add user bob and ensure the login is enabled
    Function: myuser.enable_login
        Name: bob
      Result: None
     Comment: Would enable login for user bob
     Started: 14:23:48.895431
    Duration: 0.473 ms
     Changes:
----------
          ID: force user alice to change its password
    Function: myuser.expire_passwd
        Name: alice
      Result: None
     Comment: Would force password change for user alice
     Started: 14:23:48.895531
    Duration: 0.473 ms
     Changes:
----------
          ID: add user charlie and ensure the login is enabled
    Function: user.present
        Name: charlie
      Result: None
     Comment: User charlie set to be added
     Started: 14:23:49.860660
    Duration: 0.967 ms
     Changes:
    ----------
          ID: add user lucy and ensure the login is enabled
    Function: user.present
        Name: lucy
      Result: None
     Comment: User lucy set to be added
     Started: 14:23:49.860660
    Duration: 0.967 ms
     Changes:
----------
          ID: force user bob to change its password
    Function: myuser.expire_passwd
        Name: bob
      Result: None
     Comment: Would force password change for user bob
     Started: 14:23:48.896661
    Duration: 0.473 ms
     Changes:
----------
          ID: add user charlie and disable its login
    Function: myuser.disable_login
        Name: charlie
      Result: None
     Comment: Would force password change for user charlie
     Started: 14:23:49.896761
    Duration: 0.473 ms
     Changes:
----------
          ID: add user lucy and disable its login
    Function: myuser.disable_login
        Name: lucy
      Result: None
     Comment: Would force password change for user lucy
     Started: 14:23:49.896771
    Duration: 0.473 ms
     Changes:
----------
Summary for minion-1
--------------
Succeeded: 10 (unchanged=10)
Failed:      0
--------------
Total states run:      10
Total run time:    9.325 ms

The test looks good so now we can apply it.

root@master:~$ salt minion-1 state.apply users
minion-1:
----------

.....
.....

Summary for minion-1
--------------
Succeeded: 10 (changed=10)
Failed:      0
--------------
Total states run:      10
Total run time:    9.325 ms
root@master:~$

Looks good =)

Now we have 4 new users added to minion-1, alice and bob set with a forced password change, and charlie and lucy which exist but cannot login.

Conclusion

In this post we saw how to write both an execution module and a state module for the Saltstack Salt configuration management tool. We also saw how to test these modules and use them in a SLS file.

The files described in this post are available here:

If you have any questions feel free to comment below or contact me directly through any of the channels listed in the footer.


Manuel Torrinha is an information systems engineer, with more than 10 years of experience in managing GNU/Linux environments. Has an MSc in Information Systems and Computer Engineering. Work interests include High Performance Computing, Data Analysis, and IT management and Administration. Knows diverse programming, scripting and markup languages. Speaks Portuguese and English.

Related content