Extending SaltStack
上QQ阅读APP看书,第一时间看更新

Writing Salt modules

There are a few items that are consistent across all Salt modules. These pieces generally work the same way across all module types, though there are a handful of places where you can expect at least a little deviation. We'll cover those in other chapters as we get to them. For now, let's talk about the things that are generally the same.

Hidden objects

It has long been common for programmers to preface functions, variables, and the like with an underscore, if they are only intended to be used internally in the same module. In many languages, objects that are used like this are said to be private objects.

Some environments enforce private behavior by not allowing external code to reference those things directly. Other environments allow it, but its use is discouraged. Salt modules fall into the list of environments that enforce private function behavior; if a function inside a Salt module begins with an underscore, it will not even be exposed to other modules that try to call it.

In Python, there is a special type of object whose name begins and ends with two underscores. These "magic methods" are nicknamed dunder (meaning double underscore). How Python normally treats them is beyond the scope of this book, but it is important to know that Salt adds some of its own. Some are built-ins, which are generally available in (almost) all module types, whereas others are user-defined objects that Salt will apply special treatment to.

The __virtual__() function

This is a function that can appear in any module. If there is no __virtual__() function, then the module will always be available on every system. If that module is present, then its job is to determine whether the requirements for that module are met. These requirements could be any number of things from configuration settings to package dependencies.

If the requirements are not met, then the __virtual__() function will return False. In more recent versions of Salt, it is possible to instead return a tuple containing both the False value and a reason why the module cannot be loaded. If they are met, then there are two types of value that it can return. This is where things get just a tad tricky.

Let's say that the module that we are working on is located at salt/modules/mymodule.py. If the requirements are met, and the module is to be referred to as mymodule, then the __virtual__() function will return True. Assuming there is also a function in that module called test(), it would be called using the following command:

#salt-call mymodule.test

If the requirements are met, but this module is to be referred to as testmodule, then the __virtual__() function will return the string testmodule instead. However, instead of returning that string directly, you should define it before all of the functions using the __virtualname__ variable.

Let's go ahead and start writing a module, using the __virtual__() function and __virtualname__ variable. We won't check for any requirements yet:

'''
This module should be saved as salt/modules/mysqltest.py
'''
__virtualname__ = 'mysqltest'


def __virtual__():
    '''
    For now, just return the __virtualname__
    '''
    return __virtualname__


def ping():
    '''
    Returns True

    CLI Example:
        salt '*' mysqltest.ping
    '''
    return True

Formatting your code

Before we get any further, I want to point out some important things that you should be aware of now, so that you don't get into any bad habits that need to be fixed later.

The module starts off with a special kind of comment called a docstring. In Salt, this begins and ends with three single quotes, all on one line, by themselves. Do not use double quotes. Do not put text on the same line as the quotes. All public functions must also include a docstring, with the same rules. These docstrings are used internally by Salt, to provide help text to functions such as sys.doc.

Note

Keep in mind that these guidelines are specific to Salt; Python itself follows a different style. Check Understanding the Salt style guide in Appendix B for more information.

Take note that the docstring for the ping() function includes a CLI Example. You should always include just enough information to make it clear what the function is meant to do, and at least one (or more, as warranted) command-line examples that demonstrate how to use that function. Private functions do not include a CLI Example.

You should always include two blank lines between any imports and variable declarations at the top and the functions below, and between all functions. There should be no whitespace at the end of the file.

Virtual modules

The primary motivation behind the __virtual__() function is not just to rename modules. Using this function allows Salt to not only detect certain pieces of information about the system but also use them to appropriately load specific modules to make certain tasks more generic.

Chapter 1, Starting with the Basics, mentioned some of these examples. salt/modules/aptpkg.py contains a number of tests to determine whether it is running on a Debian-like operating system that uses the apt suite of tools to perform package management. There are similar tests in salt/modules/yumpkg.py, salt/modules/pacman.py, salt/modules/solarispkg.py, and a number of others. If all of the tests pass for any of those modules, then it will be loaded as the pkg module.

If you are building a set of modules like this, it is important to remember that they should all perform as similarly as possible. For instance, all of the pkg modules contain a function called install(). Every single install() function accepts the same arguments, performs the same task (as appropriate for that platform), and then returns data in exactly the same format.

There may be situations where one function is appropriate for one platform, but not another. For example, salt/modules/aptpkg.py contains a function called autoremove(), which calls out to apt-get autoremove. There is no such functionality in yum, so that function does not exist in salt/modules/yumpkg.py. If there were, then that function would be expected to behave the same way between both files.

Using the salt.utils library

The preceding module will always run, because it doesn't check for requirements on the system. Let's go ahead and add some checking now.

There is an extensive set of tools available to import inside the salt/utils/ directory. A large number of them live directly under the salt.utils namespace, including a very commonly used function called salt.utils.which(). When given the name of a command, this function will report the location of that command, if it exists on the system. If it does not exist, then it will return False.

Let's go ahead and rework the __virtual__() function to look for a command called mysql:

'''
This module should be saved as salt/modules/mysqltest.py
'''
import salt.utils

__virtualname__ = 'mysqltest'


def __virtual__():
    '''
    Check for MySQL
    '''
    if not salt.utils.which('mysql'):
        return False
    return __virtualname__


def ping():
    '''
    Returns True

    CLI Example:
        salt '*' mysqltest.ping
    '''
    return True

The salt.utils libraries ship with Salt, but you need to explicitly import them. It is common for Python coders to import only parts of functions. You may find it tempting to use the following import line instead:

from salt.utils import which

And then use the following line:

if which('myprogram'):

Although not expressly forbidden in Salt, this is discouraged except when necessary. Although it may require more typing, especially if you use a particular function several times in a particular module, doing so makes it easier to tell at a glance which module a particular function came from.

Cross-calling with the __salt__ dictionary

There are times when it is helpful to be able to call out to another function in another module. For instance, calling external shell commands is a pretty important part of Salt. It's so important in fact that it was standardized in the cmd module. The most common command for issuing shell commands is cmd.run. The following Salt command demonstrates using cmd.run on a Windows Minion:

#salt winminon cmd.run 'dir C:\'

If you had a need for your execution module to obtain the output from such a command, you would use the following Python:

__salt__['cmd.run']('dir C:\')

The __salt__ object is a dictionary, which contains references to all of the available functions on that Minion. If a module exists, but its __virtual__() function returns False, then it will not appear in this list. As a function reference, it requires parentheses at the end, with any arguments inside.

Let's go ahead and create a function that tells us whether or not the sshd daemon is running on a Linux system, and listening to a port:

def check_mysqld():
    '''
    Check to see if sshd is running and listening

    CLI Example:
        salt '*' testmodule.check_mysqld
    '''
    output = __salt__['cmd.run']('netstat -tulpn | grep mysqld', python_shell=True)
    if 'tcp' not in output:
        return False
    return True

If sshd is running and listening on a port, the output of the netstat -tulpn | grep sshd command should look like this:

tcp        0      0 0.0.0.0:3306              0.0.0.0:*               LISTEN      426/mysqld
tcp6       0      0 :::3306                   :::*                    LISTEN      426/mysqld

If mysqld is running, and listening either on IPv4 or IPv6 (or both), then this function will return True.

This function is far from perfect. There are a number of factors that may cause this command to return a false positive. For instance, let's say you were looking for sshd instead of mysqld. And say you were a fan of American football, and had written your own high-definition football video-streaming service that you called passhd. This may be unlikely, but it's certainly not impossible. And it brings up an important point: when dealing with data received either from users or from computers, trust but verify. In fact, you should always assume that somebody is going to try to do something bad, and you should watch for ways to keep them from doing so.

Getting configuration parameters

Whereas some software can be accessed without any special configuration, there is plenty that does require some information to be set up. There are four places that an execution module can get its configuration from: the Minion configuration file, grain data, pillar data, and the master configuration file.

Note

This is one of those places where Salt built-ins behave differently. Grain and pillar data are available to execution and state modules, but not to other types of module. This is because grain and pillar data is specific to the Minion running the module. Runners, for instance, cannot access this data, because runners are used on the Master; not directly on Minions.

The first place we can look for configuration is from the __opts__ dictionary. When working in modules that execute on a Minion, this dictionary will contain a copy of the data from the Minion configuration file. It may also contain some information that Salt generates on its own during runtime. When accessed from modules that execute on the Master, this data will come from the master configuration file.

It is also possible to set configuration values inside grain or pillar data. This information is accessed using the __grains__ and __pillar__ dictionaries, respectively. The following example shows different configuration values being pulled from each of these locations:

username = __opts__['username']
hostname = __grains__['host']
password = __pillar__['password']

Since those values may not actually exist, it is better to use Python's dict.get() method, and supply a default:

username = __opts__.get('username', 'salt')
hostname = __grains__.get('host', 'localhost')
password = __pillar__.get('password', None)

The last place we can store configuration data is inside the master configuration file. All of the Master's configuration can be stored inside a pillar dictionary called master. By default, this is not made available to Minions. However, it can be turned on by setting pillar_opts to True in the master configuration file.

Once pillar_opts is turned on, you can use commands like this to access a value in the master configuration:

master_interface = __pillar__['master']['interface']
master_sock_dir = __pillar__.get('master', {}).get('sock_dir', None)

Finally, it is possible to ask Salt to search each of these locations, in turn, for a specific variable. This can be very valuable when you don't care which component carries the information that you need, so long as you can get it from somewhere.

In order to search each of these areas, cross-call to the config.get() function:

username = __salt__['config.get']('username')

This will search for the configuration parameter in the following order:

  1. __opts__ (on the Minion).
  2. __grains__.
  3. __pillar__.
  4. __opts__ (on the Master).

Keep in mind that when using config.get(), the first value found will be used. If the value that you are looking for is defined in both __grains__ and __pillar__, then the value in __grains__ will be used.

Another advantage of using config.get() is that this function will automatically resolve data that is referred to using sdb:// URIs. When accessing those dictionaries directly, any sdb:// URIs will need to be handled manually. Writing and using SDB modules will be covered in Chapter 3, Extending Salt Configuration.

Let's go ahead and set up a module that obtains configuration data and uses it to make a connection to a service:

'''
This module should be saved as salt/modules/mysqltest.py
'''
import MySQLdb


def version():
    '''
    Returns MySQL Version

    CLI Example:
        salt '*' mysqltest.version
    '''
    user = __salt__['config.get']('mysql_user', 'root')
    passwd = __salt__['config.get']('mysql_pass', '')
    host = __salt__['config.get']('mysql_host', 'localhost')
    port = __salt__['config.get']('mysql_port', 3306)
    db_ = __salt__['config.get']('mysql_db', 'mysql')
    dbc = MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )
    cur = dbc.cursor()
    return cur.execute('SELECT VERSION()')

This execution module will run on the Minion, but it can connect to any MySQL database using configuration defined in any of the four configuration areas. However, this function is pretty limited. If the MySQLdb driver is not installed, then errors will appear in the Minion's log files when it starts up. If you need to perform other types of query, you will need to grab the configuration values each time. Let's solve each of these problems in turn.

Tip

Did you notice that we used a variable called db_ instead of db? In Python, it is considered better practice to use variable names that are at least three characters long. Salt also considers this to be a requirement. A very common means of accomplishing this for variables that would normally be shorter is to append one or two underscores to the end of the variable name.

Handling imports

A number of Salt modules require third-party Python libraries to be installed. If any of those libraries aren't installed, then the __virtual__() function should return False. But how do you know beforehand whether or not the libraries can be imported?

A very common trick in a Salt module involves attempting to import libraries, and then recording whether or not the import succeeded. This is often accomplished using a variable with a name like HAS_LIBS:

try:
    import MySQLdb
    HAS_LIBS = True
except ImportError:
    HAS_LIBS = False


def __virtual__():
    '''
    Check dependencies
    '''
    return HAS_LIBS

In this case, Python will attempt to import MySQLdb. If it succeeds, then it will set HAS_LIBS to True. Otherwise, it will set it to False. And because this directly correlates to the value that needs to be returned from the __virtual__() function, we can just return it as it is, so long as we're not changing __virtualname__. If we were, then the function would look like this:

def __virtual__():
    '''
    Check dependencies
    '''
    if HAS_LIBS:
        return __virtualname__
    return False

Reusing code

There's still the matter of eliminating redundant code between different functions in the same module. In the case of modules that use connection objects (such as a database cursor, or a cloud provider authentication) throughout the code, specific functions are often set aside to gather configuration, and establish a connection.

A very common name for these in-cloud modules is _get_conn(), so let's go with that in our example:

def _get_conn():
    '''
    Get a database connection object
    '''
    user = __salt__['config.get']('mysql_user', 'root')
    passwd = __salt__['config.get']('mysql_pass', '')
    host = __salt__['config.get']('mysql_host', 'localhost')
    port = __salt__['config.get']('mysql_port', 3306)
    db_ = __salt__['config.get']('mysql_db', 'mysql')
    return MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )


def version():
    '''
    Returns MySQL Version

    CLI Example:
        salt '*' mysqltest.version
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    return cur.execute('SELECT VERSION()')

This greatly simplifies our code, by turning a large chunk of lines in every function into a single line. Of course, this can be taken quite a bit further. The actual salt/modules/mysql.py module that ships with Salt uses a function called _connect() instead of _get_conn(), and it also has cur.execute() abstracted out into its own _execute() function. You can see these at Salt's GitHub page:

https://github.com/saltstack/salt

Logging messages

Very often, you will perform an operation that requires some kind of message to be logged somewhere. This is especially common when writing new code; it's nice to be able to log debugging information.

Salt has a logging system built in, based on Python's own logging library. To turn it on, there are two lines that you'll need to add toward the top of your module:

import logging
log = logging.getLogger(__name__)

With these in place, you can log messages using a command like this:

log.debug('This is a log message')

There are five levels of logging that are typically used in Salt:

  1. log.info(): Information at this level is something that is considered to be important to all users. It doesn't mean anything is wrong, but like all log messages, its output will be sent to STDERR instead of STDOUT (so long as Salt is running in the foreground, and not configured to log elsewhere).
  2. log.warn(): A message logged from here should indicate to the user that something is not happening as it should be. However, it is not so broken as to stop the code from running.
  3. log.error(): This denotes that something has gone wrong, and Salt is unable to continue until it is fixed.
  4. log.debug(): This is not only information that is useful for determining what the program is thinking but is also intended to be useful to regular users of the program for things like troubleshooting.
  5. log.trace(): This is similar to a debug message, but the information here is more likely to be useful only to developers.

For now, we'll add a log.trace() to our _get_conn() function, which lets us know when we successfully connect to the database:

def _get_conn():
    '''
    Get a database connection object
    '''
    user = __salt__['config.get']('mysql_user', 'root')
    passwd = __salt__['config.get']('mysql_pass', '')
    host = __salt__['config.get']('mysql_host', 'localhost')
    port = __salt__['config.get']('mysql_port', 3306)
    db_ = __salt__['config.get']('mysql_db', 'mysql')
    dbc = MySQLdb.connect(
        connection_user=user,
        connection_pass=passwd,
        connection_host=host,
        connection_port=port,
        connection_db=db_,
    )
    log.trace('Connected to the database')
    return dbc
Tip

There are certain places where it is tempting to use log messages, but they should be avoided. Specifically, log messages may be used in any function, except for __virtual__(). Log messages used outside of functions, and in the __virtual__() function, make for messy log files that are a pain to read and navigate.

Using the __func_alias__ dictionary

There are a handful of words that are reserved in Python. Unfortunately, some of these words are also very useful for things like function names. For instance, many modules have a function whose job is to list data relevant to that module, and it seems natural to call such a function list(). But that would conflict with Python's list built-in. This poses a problem, since function names are directly exposed to the Salt command.

A workaround is available for this. A __func_alias__ dictionary may be declared at the top of a module, which creates a map between aliases used from the command line and the actual name of the function. For instance:

__func_alias__ = {
    'list_': 'list'
}

def list_(type_):
    '''
    List different resources in MySQL
    CLI Examples:
        salt '*' mysqltest.list tables
        salt '*' mysqltest.list databases
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    return cur.execute('SHOW {0}()'.format(type_))

With this in place, the list_ function will be called as mysqltest.list (as in the CLI Example) instead of mysqltest.list_.

Tip

Why did we call the variable type_ instead of type? Because type is a Python built-in. But since this function only has one argument, it's not expected that users will need to use type_=<something> as part of their Salt command.

Validating data

From that last piece of code, a number of readers at this point probably have warning bells going off in their heads. It allows for a very common type of security vulnerability called an injection attack. Because the function does not perform any sort of validation on the type_ variable, it is possible for users to pass in code that can cause destruction, or obtain data that they shouldn't have.

One might think that this isn't necessarily a problem in Salt, because in a number of environments, only trusted users should have access. However, because Salt can be used by a wide range of user types, who may be intended to only have limited access, there are a number of scenarios where an injection attack can be devastating. Imagine a user running the following Salt command:

#salt myminion mysqltest.list 'tables; drop table users;'

This is often easy to fix, by adding some simple checking to any user input (remember: trust but verify):

from salt.exceptions import CommandExecutionError


def list_(type_):
    '''
    List different resources in MySQL
    CLI Examples:
        salt '*' mysqltest.list tables
        salt '*' mysqltest.list databases
    '''
    dbc = _get_conn()
    cur = dbc.cursor()
    valid_types = ['tables', 'databases']
    if type_ not in valid_types:
        err_msg = 'A valid type was not specified'
        log.error(err_msg)
        raise CommandExecutionError(err_msg)
    return cur.execute('SHOW {0}()'.format(type_))

In this case, we've declared which types are valid before allowing them to be passed in to the SQL query. Even a single bad character will cause Salt to refuse to complete the command. This kind of data validation is often better, because it doesn't try to modify the input data to make it safe to run. Doing so is referred to as validating user input.

We've added in another piece of code as well: a Salt exception. There are a number of these available in the salt.exceptions library, but CommandExecutionError is one that you may find yourself using quite a bit when validating data.

Formatting strings

A quick note on string formatting: Older Python developers may have noticed that we opted to use str.format() instead of the older printf-style string handling. The following two lines of code do the same thing in Python:

'The variable's value is {0}'.format(myvar)
'The variable's value is %s' % myvar

String formatting using str.format() is just a little faster in Python, and is required in Salt except for in places where it doesn't make sense.

Don't be tempted to use the following shortcut available in Python 2.7.x:

'The variable's value is {}'.format(myvar)

Because Salt still needs to run on Python 2.6, which doesn't support using {} instead of {0}, this will cause problems for users on older platforms.