Ansible源码分析之command模块

Ansible源码分析之command模块

command模块是ansible中执行命令的模块,但不限于command,还有raw,shell,win_command等模块,只是使用场景不同。根据command的文档描述:

  • C(command)模块采用命令名称,后跟由空格分隔的参数列表。
  • 不会通过外壳程序处理命令,因此像C($ HOSTNAME)这样的变量和像C(“ *”),C(“ <”),C(“>”),C(“ | “),C(”;“)和C(”&“)无效。如果需要这些功能,请使用shell模块。
  • 对于Windows目标,请改用win_command模块。

command模块的代码并不多,直接分析。

import datetime
import glob
import os
import shlex

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native, to_bytes, to_text
from ansible.module_utils.common.collections import is_iterable

从导入的模块来看,都是些常用的。其中 shlex 模块可能用的少一些。shkex 模块最常见的用法就是其中的split 函数,split 函数提供了和shell 处理命令行参数时一致的分隔方式。代码示例:

>>>shlex.split("python -u a.py -a A    -b   B     -o test")
['python', '-u', 'a.py', '-a', 'A', '-b', 'B', '-o', 'test']

在shell 中,对于选项和对应的值之间可以有多个空格,而shlex.split 保持了和sell 一致的处理方式。

由于command模块只有check_command和main函数,我们就先看check_command的实现。

def check_command(module, commandline):
    arguments = {'chown': 'owner', 'chmod': 'mode', 'chgrp': 'group',
                 'ln': 'state=link', 'mkdir': 'state=directory',
                 'rmdir': 'state=absent', 'rm': 'state=absent', 'touch': 'state=touch'}
    commands = {'curl': 'get_url or uri', 'wget': 'get_url or uri',
                'svn': 'subversion', 'service': 'service',
                'mount': 'mount', 'rpm': 'yum, dnf or zypper', 'yum': 'yum', 'apt-get': 'apt',
                'tar': 'unarchive', 'unzip': 'unarchive', 'sed': 'replace, lineinfile or template',
                'dnf': 'dnf', 'zypper': 'zypper'}
    become = ['sudo', 'su', 'pbrun', 'pfexec', 'runas', 'pmrun', 'machinectl']
    if isinstance(commandline, list):
        command = commandline[0]
    else:
        command = commandline.split()[0]
    command = os.path.basename(command)

    disable_suffix = "If you need to use command because {mod} is insufficient you can add" \
                     " 'warn: false' to this command task or set 'command_warnings=False' in" \
                     " ansible.cfg to get rid of this message."

check_command接收两个参数,为模块和命令行。定义了arguments和commands两个字典,都是包含了常见的一些命令。

  • arguments出现的命令都是可以用file模块以及对应的参数来代替的。
  • commands中出现的命令,是ansible中对应的一个或多个模块来代替的。
  • become这个列表都是不同系统中所用于提权的命令。

判断commandline的类型为列表,则取第一个元素作为命令。为字符串用split分割成列表后取第一个。因为传入的命令可能是带路径的,比如/usr/bin/whoami就会转化为whoami命令本身。

然后是提示信息,“如果由于{mod}不足而需要使用命令,可以在此命令任务中添加’warn:false’或在ansible.cfg中设置’command_warnings = False’来摆脱此消息。”

    substitutions = {'mod': None, 'cmd': command}

    if command in arguments:
        msg = "Consider using the {mod} module with {subcmd} rather than running '{cmd}'.  " + disable_suffix
        substitutions['mod'] = 'file'
        substitutions['subcmd'] = arguments[command]
        module.warn(msg.format(**substitutions))

    if command in commands:
        msg = "Consider using the {mod} module rather than running '{cmd}'.  " + disable_suffix
        substitutions['mod'] = commands[command]
        module.warn(msg.format(**substitutions))

    if command in become:
        module.warn("Consider using 'become', 'become_method', and 'become_user' rather than running %s" % (command,))

这里就是判断命令是否在对应的字典/列表里,分别返回不同的提示信息。值得注意的是格式化字符串的使用。

Consider using the {mod} module rather than running ‘{cmd}

乍一看这个f-string不是在Python3.6及以后才有的语法吗,ansible可是用Python2开发的。其实在2中有字符串格式化format(**dict)的用法,而这里就是这样用的。

再看main函数的代码。

def main():
    module = AnsibleModule(
        argument_spec=dict(
            _raw_params=dict(),
            _uses_shell=dict(type='bool', default=False),
            argv=dict(type='list', elements='str'),
            chdir=dict(type='path'),
            executable=dict(),
            creates=dict(type='path'),
            removes=dict(type='path'),
            warn=dict(type='bool', default=False, removed_in_version='2.14', removed_from_collection='ansible.builtin'),
            stdin=dict(required=False),
            stdin_add_newline=dict(type='bool', default=True),
            strip_empty_ends=dict(type='bool', default=True),
        ),
        supports_check_mode=True,
    )
    shell = module.params['_uses_shell']
    chdir = module.params['chdir']
    executable = module.params['executable']
    args = module.params['_raw_params']
    argv = module.params['argv']
    creates = module.params['creates']
    removes = module.params['removes']
    warn = module.params['warn']
    stdin = module.params['stdin']
    stdin_add_newline = module.params['stdin_add_newline']
    strip = module.params['strip_empty_ends']

同样是实例化ansible模块对象,然后将各个参数的值赋值给变量。并且这里的注释提到:command模块是一个不带key = value args的ansible模块。因此,如果您要构建其他版本,请不要复制此版本!
不过这个写是给开发者看的。

    if not shell and executable:
        module.warn("As of Ansible 2.4, the parameter 'executable' is no longer supported with the 'command' module. Not using '%s'." % executable)
        executable = None

    if (not args or args.strip() == '') and not argv:
        module.fail_json(rc=256, msg="no command given")

    if args and argv:
        module.fail_json(rc=256, msg="only command or argv can be given, not both")

    if not shell and args:
        args = shlex.split(args)

    args = args or argv

    if is_iterable(args, include_strings=False):
        args = [to_native(arg, errors='surrogate_or_strict', nonstring='simplerepr') for arg in args]

如果没有指定所使用的shell,但指定了executable。会提示command模块已经不支持executable选项了,算是兼容考虑吧。接着判断args或argv是否有值,两者冲突,只能选其一。保证所有参数必须是字符串,用is_iterable判断参数是否可迭代,并排除字符串(因为字符串也是可迭代的),最终将参数转换成列表。

    if chdir:
        try:
            chdir = to_bytes(os.path.abspath(chdir), errors='surrogate_or_strict')
        except ValueError as e:
            module.fail_json(msg='Unable to use supplied chdir: %s' % to_text(e))

        try:
            os.chdir(chdir)
        except (IOError, OSError) as e:
            module.fail_json(msg='Unable to change directory before execution: %s' % to_text(e))

    if creates:
        if glob.glob(creates):
            module.exit_json(
                cmd=args,
                stdout="skipped, since %s exists" % creates,
                changed=False,
                rc=0
            )

    if removes:
        if not glob.glob(removes):
            module.exit_json(
                cmd=args,
                stdout="skipped, since %s does not exist" % removes,
                changed=False,
                rc=0
            )

这里有三个判断,切换路径,创建/删除文件。其中的注释提到:如果该行包含creates = filename并且文件名已经存在,请不要运行该命令。这使得命令执行具有幂等性。幂等性的意思是无论多次执行,其结果都是一样的。

    if warn:
        check_command(module, args)

    startd = datetime.datetime.now()

    if not module.check_mode:
        rc, out, err = module.run_command(args, executable=executable, use_unsafe_shell=shell, encoding=None, data=stdin, binary_data=(not stdin_add_newline))
    elif creates or removes:
        rc = 0
        out = err = b'Command would have run if not in check mode'
    else:
        module.exit_json(msg="skipped, running in check mode", skipped=True)

    endd = datetime.datetime.now()
    delta = endd - startd

    if strip:
        out = out.rstrip(b"\r\n")
        err = err.rstrip(b"\r\n")

    result = dict(
        cmd=args,
        stdout=out,
        stderr=err,
        rc=rc,
        start=str(startd),
        end=str(endd),
        delta=str(delta),
        changed=True,
    )

    if rc != 0:
        module.fail_json(msg='non-zero return code', **result)

    module.exit_json(**result)

当warn为真就去检查命令,同时delta = endd – startd这种方式去记录命令的执行耗时。这里是用的 module.run_command 来执行的命令。而我们追踪run_command 的代码还是用的subprocess实现的。

        try:
            if self._debug:
                self.log('Executing: ' + self._clean_args(args))
            cmd = subprocess.Popen(args, **kwargs)
            if before_communicate_callback:
                before_communicate_callback(cmd)

所以这也能说明为什么command模块不会通过外壳程序处理命令的原因了,而是推荐使用shell模块。那么问题来了?既然shell模块可以完全代替command模块,为什么还要用command模块呢?

command模块的代码分析完了,可以说是非常简单的。主要就是对参数的一些处理,然后调用到底层的subprocess来完成命令执行。

赞赏

微信赞赏支付宝赞赏

Zgao

愿有一日,安全圈的师傅们都能用上Zgao写的工具。