回到:Ansible系列文章
各位读者,请您:由于Ansible使用Jinja2模板,它的模板语法 {{}} 和 {%%} 和我博客系统hexo的模板使用的符号一样,在渲染时会产生冲突,尽管我尽我努力地花了大量时间做了调整,但无法保证已经全部都调整。因此,如果各位阅读时发现一些明显的诡异的错误(比如像这样的空的
行内代码),请一定要回复我修正这些渲染错误。
16.成就感源于创造:自己动手写Ansible模块
从小,书上就告诉我们”自己动手,丰衣足食”,但是在IT领域里,这句话不完全对,其实自己不动手,也可以丰衣足食,因为这个领域里提倡的是”不要重复造轮子”,别人已经把轮子造好了,直接拿来用就好,简单又高效。但自己造轮子,总是有好处的,至少收获了造轮子的过程,有些轮子,是前进道路上必造不可的。
闲话只扯一段。Ansible提供了大量已经造好的轮子,几千个模块(此刻是3387个)、很多个插件可以供用户直接使用,基本上能解决绝大多数场景的需求。但是,再多的模块也不够用,总有一些需求是只属于自己的。这时,就需要造一个只适合自己想法的轮子,根据自己的需求去扩展Ansible的功能。
Ansible允许用户自定义的方式扩展很多方面的功能,包括:
- (1).实现某功能的模块,模块以作为任务的方式被执行
- (2).各类插件,用于调整Ansible的行为。目前Ansible有12类插件
1 2 3 4 5 6 7 8 9 10 11 12 13
| $ grep 'plugins/' /etc/ansible/ansible.cfg #action_plugins = /usr/share/ansible/plugins/action #become_plugins = /usr/share/ansible/plugins/become #cache_plugins = /usr/share/ansible/plugins/cache #callback_plugins = /usr/share/ansible/plugins/callback #connection_plugins = /usr/share/ansible/plugins/connection #lookup_plugins = /usr/share/ansible/plugins/lookup #inventory_plugins = /usr/share/ansible/plugins/inventory #vars_plugins = /usr/share/ansible/plugins/vars #filter_plugins = /usr/share/ansible/plugins/filter #test_plugins = /usr/share/ansible/plugins/test #terminal_plugins = /usr/share/ansible/plugins/terminal #strategy_plugins = /usr/share/ansible/plugins/strategy
|
本文会以最简单易懂的方式介绍模块的自定义方式(不介绍自定义插件,内容太多)。考虑到有些人没有编程基础,这里我将同时使用Shell和Python来介绍,看菜吃饭即可。
16.1 自定义模块简介
可以使用任何一种语言来自定义模块,只要目标主机能执行即可。这得益于Ansible的运行方式:将模块的代码发送到目标节点上调用对应的解释器执行。
比如可以使用Shell脚本自定义模块,Ansible执行时会将Shell脚本的内容发送到目标节点上并调用目标节点的shell解释器(比如bash)去执行这些命令行。
但是绝大多数的模块都是使用Python语言编写的(Windows相关模块除外,它们使用PowerShell编写)。如果自己定义模块,按理说也建议使用Python语言,Ansible为自定义模块提供了不少非常方便的接口。但是,如果熟悉Shell脚本的话,对于逻辑不太复杂的功能,Shell脚本比Python要方便简洁,所以不要觉得用Shell写Ansible模块上不了台面。
16.1.1 自定义模块前须知:模块的存放路径
当编写好自己的模块后,需要让Ansible知道在哪里可以找到它。有三个位置可以存放模块:
- (1).playbook文件所在目录的library目录内,即pb.yml/../library目录内
- (2).roles/Role_Name/library目录内
- (3).ansible.cfg中library指令指定的目录或者环境变量ANSIBLE_LIBRARY指定的目录
显然,roles/Role_Name/library
目录内的模块只对该Role有效,而pb.yml/../library
对所有Role和pb.yml有效,ansible.cfg中library指令指定的路径对全局有效。
16.1.2 自定义模块前须知:模块的要求
从第一章节到现在,已经学习了很多模块,相信对模块的使用已经非常熟悉。我想各位在使用各个模块的过程中,应该能感受到这些模块的一些共同点:
- (1).每个模块都有changed、failed状态
- (2).绝大多数模块都可以提供各种各样的选项参数
- (3).很多模块都要求必须有某些选项参数
- (4).每个模块都有返回值,从而可以通过register注册成变量
- (5).返回值全都是json格式
- (6).有些模块具有幂等性
- (7)….
所以,这跟写一个脚本或写一个程序没什么区别,仅仅只是在写这些脚本时多了一些特殊要求。
下面将先以Shell脚本的方式解释并定义模块,稍后再演示一个简单的Python定义的模块,主要是为了体现Ansible为Python自定义模块所提供的方便的功能。
16.2 Shell脚本自定义模块(一):Hello World
先使用Shell脚本一个最简单的自定义模块,只显示”hello world”。
下面是脚本模块的内容,其路径为library/say_hello.sh:
1 2 3
| #!/bin/bash
echo '{"changed": false, "msg": "Hello World"}'
|
注意上面的false不要加引号,因为它要表示的是json的布尔假,加上引号就成了json中的字符串类型。
在library同级目录下创建一个playbook文件shell_module1.yml,在其中使用该模块:
1 2 3 4 5 6 7 8 9 10
| --- - hosts: localhost gather_facts: no tasks: - name: say hello with my module say_hello: register: res - debug: var=res - debug: var=res.msg
|
执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| $ ansible-playbook shell_module1.yml
PLAY [localhost] *****************************
TASK [say hello with my module] ************** ok: [localhost]
TASK [debug] ********************************* ok: [localhost] => { "res": { "changed": false, "failed": false, "msg": "Hello World" } }
TASK [debug] ********************************** ok: [localhost] => { "res.msg": "Hello World" }
PLAY RECAP ************************************ localhost : ok=3 changed=0 unreachable=0
|
是否发现自定义模块好简单呢?
16.3 Shell脚本自定义模块(二):简版file模块
Ansible的file模块可以创建、删除文件或目录,这里通过Shell脚本的方式来自定义精简版的file模块,称为file_by_shell
。
这个模块能够处理相关参数,包括:
- (1).识别路径和文件名,选项名称假设为path,该选项必须不能省略
- (2).识别是创建还是删除操作,选项名称假设为state,它只能是两种值:present或absent,默认是present
- (3).识别在创建操作时,创建的是文件还是目录(此处不考虑其它文件类型),如果路径不存在,这里决定递归创建缺失的目录,该选项名称为type,该选项在创建操作时必须不能省略,它只能有两种值:file或directory
- (4).识别在创建操作时,是否给定了权限参数,比如指定要创建的文件权限为0644,选项名称为mode
- (5).识别在创建操作时,是否给定了owner、group
其它的功能此处不多考虑。
所以,按照常规的脚本调用方式,这个shell脚本的用法大概如下:
例如,Ansible会先把参数以如下方式写进临时文件xxx.tmp:
1
| path=PATH state=STATE type=TYPE...
|
然后将这个临时文件名传递给模块文件:
1
| file_by_shell.sh xxx.tmp
|
所以,Shell脚本必须得能够从这个临时文件中处理来自playbook中的选项和参数。比如可以像下面这样获取path选项的值。
1
| cat xxx.tmp | tr ' ' '\n' | sed -nr 's/^path=(.*)/\1/'
|
有了这些背景知识,再来写Shell脚本file_by_shell.sh
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| #!/bin/bash
# function err_echo(){ echo "$@" >&2;exit 1; }
# args="$(cat "$@" | tr ' ' '\n')" path="$(echo "$args" | sed -nr 's/^path=(.*)/\1/p')" type="$(echo "$args" | sed -nr 's/^type=(.*)/\1/p')" state="$(echo "$args" | sed -nr 's/^state=(.*)/\1/p')" mode="$(echo "$args" | sed -nr 's/^mode=(.*)/\1/p')" owner="$(echo "$args" | sed -nr 's/^owner=(.*)/\1/p')" group="$(echo "$args" | sed -nr 's/^group=(.*)/\1/p')"
#
# path选项:必须存在 [ "$path" ] || { err_echo "'path' argument missing"; }
# state选项:如果不存在,则默认为present # 且state只能为两种值:present或absent : ${state="present"} [ "$(echo $state | sed -r 's/(present|absent)//')" ] && { err_echo "'state' argument error"; }
# type选项:在创建操作时必须存在,且只能为两种值:file或directory if [ "${state}x" == "presentx" ];then [ "${type}" ] || { err_echo "'type' argument missing"; } # type的值只能是:file或directory [ "${type}" != "file" ] && [ "${type}" != "directory" ] && { err_echo "'type' argument error"; } fi
# mode选项:如果该选项存在,必须是3位或4位数,且每位都是小于7的数值 # 如果该选项不存在,则不管,即按照对应用户的umask值决定权限 if [ "$mode" ];then echo $mode | grep -E '[0-7]?[0-7]{3}' &>/dev/null || { err_echo "'mode' argument error"; } fi
# function echo_json() { echo '{ ' $1 ' }' # 输出完后正常退出 exit }
#
# 为了实现幂等性,先判断目标是否存在, # 如果存在,不管是文件还是目录,都删除,并设置changed=true # 如果不存在,什么也不做,并设置changed=false # 如果报错(比如权限不足),无视,Ansible会自动获取报错信息 if [ $state == "absent" ];then if [ -e "$path" ];then rm -rf "$path" return_str='"changed": true' echo_json "$return_str" else return_str='"changed": false' echo_json "$return_str" fi fi
#
# 为了实现幂等性,先判断目标是否存在, # 如果存在,且类型匹配,则什么也不做,并设置changed=false # 如果存在,但类型不匹配(比如想要创建文件,但已存在同名目录),则报错 # 如果不存在,则根据类型创建文件/目录 # 如果报错(如权限不足),无视,Ansible会自动获取报错信息 if [ $state == "present" ];then if [ -e "$path" ];then # 文件已存在
# 获取已存在文件的类型 file_type=$(ls -ld "$path" | head -c 1) if [ $file_type == "-" -a "$type" == "file" ] || \ [ $file_type == "d" -a "$type" == "directory" ] then # 类型匹配 return_str='"changed": false' echo_json "$return_str" else # 类型不匹配 err_echo "target exists but filetype error"; fi else # 文件/目录不存在,在此处创建,同时创建缺失的上级目录 dir="$(dirname "$path")" [ -d "$dir" ] || mkdir -p "$dir" [ $type = "file" ] && touch "$path" [ $type = "directory" ] && mkdir "$path"
# 设置权限、owner、group,如果属性修改失败,则删除已创建的目标 [ "$mode" ] && { chmod $mode "$path" || rm -rf "$path"; } [ "$owner" ] && { chown $owner "$path" || rm -rf "$path"; } [ "$group" ] && { chgrp $group "$path" || rm -rf "$path"; }
return_str='"changed": true' echo_json "$return_str" fi fi
|
再写一个playbook文件shell_module.yml来使用该shell脚本模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| --- - hosts: localhost gather_facts: no tasks: - name: use file_by_shell module to create file file_by_shell: path: /tmp/test/file1.txt state: present type: file mode: 655 - name: use file_by_shell module to create directory file_by_shell: path: /tmp/test/dir1 state: present type: directory mode: 755
|
执行该playbook:
1 2 3 4 5 6 7 8 9 10 11 12
| $ ansible-playbook shell_module.yml
PLAY [localhost] *************************************
TASK [use file_by_shell module to create file] ******* changed: [localhost]
TASK [use file_by_shell module to create directory] ** changed: [localhost]
PLAY RECAP ******************************************* localhost : ok=2 changed=2 unreachable=0
|
再次执行,因具备幂等性,所以不会做任何操作:
1 2 3 4 5 6 7 8 9 10 11 12
| $ ansible-playbook shell_module.yml
PLAY [localhost] ******************************************
TASK [use file_by_shell module to create file] ************ ok: [localhost]
TASK [use file_by_shell module to create directory] ******* ok: [localhost]
PLAY RECAP ************************************************ localhost : ok=2 changed=0
|
执行删除操作:
1 2 3 4 5 6 7 8 9 10 11 12
| --- - hosts: localhost gather_facts: no tasks: - name: use file_by_shell module to remove file file_by_shell: path: /tmp/test/file1.txt state: absent - name: use file_by_shell module to remove directory file_by_shell: path: /tmp/test/dir1 state: absent
|
执行:
1 2 3 4 5 6 7 8 9 10
| PLAY [localhost] ****************************************
TASK [use file_by_shell module to remove file] ********** changed: [localhost]
TASK [use file_by_shell module to remove directory] ***** changed: [localhost]
PLAY RECAP ********************************************** localhost : ok=4 changed=2
|
16.4 Python自定义模块
使用Python定义模块,要简单一些,一方面是因为Python的逻辑处理能力比Shell要强,另一方面是因为Ansible提供了一些方便的Python接口,比如处理参数、处理退出时返回的json数据。
这里仍然使用Python编写一个自定义的精简版的file模块。
对于初学者来说,使用Python自定义模块的第一步,是先搭建好脚本的框架:
1 2 3 4 5 6 7 8 9
|
from ansible.module_utils.basic import *
def main(): ...to_do...
if __name__ == '__main__': main()
|
所有的处理逻辑都在main()函数中定义。
下一步是构造一个模块对象,并处理Ansible传递给脚本的参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
from ansible.module_utils.basic import *
def main(): md = AnsibleModule( argument_spec = dict( path = dict(required=True, type='str'), state = dict(choices=['present', 'absent'], type='str', default="present"), type = dict(type='str'), mode = dict(type='int',default=None), owner = dict(type='str',default=None), group = dict(type='str',default=None), ) ) params = md.params path = params['path'] state = params['state'] target_type = params['type'] mode = params['mode'] and int(str(params['mode']),8) owner = params['owner'] group = params['group']
if __name__ == '__main__': main()
|
上面使用AnsibleModule()构造了一个Ansible模块对象md,并指定处理的参数以及相关要求。比如要求path必须存在,且其数类型是str,比如state默认值为present,且只有两种值可选。关于参数处理,完整的用法参考官方手册:https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#ansiblemodule。
需要注意的是,无法直接在此对各选项之间的逻辑关系进行判断,例如创建目标时必须指定文件类型。所以,这种逻辑要么单独对选项关系做判断,要么在执行操作(比如创建文件)时在对应函数中进行判断或异常处理。
为图简单,我直接在获取完参数之后立即对它们做判断:(为节省篇幅,我省略一部分已编写的代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
from ansible.module_utils.basic import *
def main(): ...... path = params['owner'] group = params['group']
if state == 'present' and target_type is None: raise Exception('type argument missing') if __name__ == '__main__': main()
|
在之后,是定义删除、创建以及退出时的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
from ansible.module_utils.basic import *
def main(): ...... if state == 'present' and target_type is None: raise AnsibleModuleError(results={'msg': 'type argument missing'}) if state == 'absent': result = remove_target(path) elif target_type == 'file': result = create_file(path, mode, owner, group) elif target_type == 'directory': result = create_dir(path, mode, owner, group) md.exit_json(**result) if __name__ == '__main__': main()
|
最后,就是定义这三个函数:remove_target、create_file、create_dir
。因为这三个函数中都要判断目标是否存在以及文件类型,所以将判断是否存在的逻辑也定义成一个函数get_stat
。
下面是get_stat
的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
def get_stat(path): b_path = to_bytes(path) try: if os.path.lexists(b_path): if os.path.isdir(b_path): return 'directory' else: return 'file' else: return False except OSError: raise
|
下面是remove_target
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
def remove_target(path): b_path = to_bytes(path) target_stat = get_stat(b_path) result = {'path': b_path, 'target_stat': target_stat} try: if target_stat: if target_stat == 'directory': shutil.rmtree(b_path) else: os.unlink(b_path)
result.update({'changed': True}) else: result.update({'changed': False}) except Exception: raise return result
|
下面是创建普通文件的函数create_file
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| def create_file(path, mode=None, owner=None, group=None): b_path = to_bytes(path) target_stat = get_stat(b_path) result = {'path': b_path, 'target_stat': target_stat} if target_stat: if target_stat != 'file': raise Exception('target already exists, but type error') result.update({'changed': False}) else: try: if not get_stat(os.path.dirname(b_path)): os.makedirs(os.path.dirname(b_path)) open(b_path, 'wb').close() except (OSError, IOError): raise
try: if mode: os.chmod(b_path, mode) if owner: os.chown(b_path, pwd.getpwnam(owner).pw_uid, -1) if group: os.chown(b_path, -1, pwd.getpwnam(group).pw_gid) result.update({'changed': True}) except Exception: if not target_stat: os.remove(b_path) return result
|
下面是创建普通目录的函数create_dir
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| def create_dir(path, mode=None, owner=None, group=None): b_path = to_bytes(path) target_stat = get_stat(b_path) result = {'path': b_path, 'target_stat': target_stat} if target_stat: if target_stat != 'directory': raise Exception('target already exists, but type error') result.update({'changed': False}) else: try: os.makedirs(b_path) except (OSError, IOError): raise
try: if mode: os.chmod(b_path, mode) if owner: os.chown(b_path, pwd.getpwnam(owner).pw_uid, -1) if group: os.chown(b_path, -1, pwd.getpwnam(group).pw_gid) result.update({'changed': True}) except Exception: if not target_stat: shutil.rmtree(b_path) return result
|
将上面所有代码汇总,得到如下代码,并保存到文件library/file_by_python.py
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
|
import os import shutil from ansible.module_utils.basic import * from ansible.module_utils._text import to_bytes
def get_stat(path): b_path = to_bytes(path) try: if os.path.lexists(b_path): if os.path.isdir(b_path): return 'directory' else: return 'file' else: return False except OSError: raise
def remove_target(path): b_path = to_bytes(path) target_stat = get_stat(b_path) result = {'path': b_path, 'target_stat': target_stat} try: if target_stat: if target_stat == 'directory': shutil.rmtree(b_path) else: os.unlink(b_path) result.update({'changed': True}) else: result.update({'changed': False}) except Exception: raise return result
def create_file(path, mode=None, owner=None, group=None): b_path = to_bytes(path) target_stat = get_stat(b_path) result = {'path': b_path, 'target_stat': target_stat} if target_stat: if target_stat != 'file': raise Exception('target already exists, but type error') result.update({'changed': False}) else: try: if not get_stat(os.path.dirname(b_path)): os.makedirs(os.path.dirname(b_path)) open(b_path, 'wb').close() except (OSError, IOError): raise
try: if mode: os.chmod(b_path, mode) if owner: os.chown(b_path, pwd.getpwnam(owner).pw_uid, -1) if group: os.chown(b_path, -1, pwd.getpwnam(group).pw_gid) result.update({'changed': True}) except Exception: if not target_stat: os.remove(b_path) return result
def create_dir(path, mode=None, owner=None, group=None): b_path = to_bytes(path) target_stat = get_stat(b_path) result = {'path': b_path, 'target_stat': target_stat} if target_stat: if target_stat != 'directory': raise Exception('target already exists, but type error') result.update({'changed': False}) else: try: os.makedirs(b_path) except (OSError, IOError): raise
try: if mode: os.chmod(b_path, mode) if owner: os.chown(b_path, pwd.getpwnam(owner).pw_uid, -1) if group: os.chown(b_path, -1, pwd.getpwnam(group).pw_gid) result.update({'changed': True}) except Exception: if not target_stat: shutil.rmtree(b_path) return result
def main(): md = AnsibleModule( argument_spec = dict( path = dict(required=True, type='str'), state = dict(choices=['present', 'absent'], type='str', default="present"), type = dict(type='str'), mode = dict(type='int',default=None), owner = dict(type='str',default=None), group = dict(type='str',default=None), ) ) params = md.params path = params['path'] state = params['state'] target_type = params['type'] mode = params['mode'] and int(str(params['mode']),8) owner = params['owner'] group = params['group']
if state == 'present' and target_type is None: raise Exception('type argument missing')
if state == 'absent': result = remove_target(path) elif target_type == 'file': result = create_file(path, mode, owner, group) elif target_type == 'directory': result = create_dir(path, mode, owner, group) md.exit_json(**result) if __name__ == '__main__': main()
|
提供一个playbook文件python_module.yml
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| --- - hosts: localhost gather_facts: no tags: create tasks: - name: use file_by_python module to create file file_by_python: path: /tmp/test/file1.txt state: present type: file mode: 655 - name: use file_by_python module to create directory file_by_python: path: /tmp/test/dir1 state: present type: directory mode: 755
- hosts: localhost gather_facts: no tags: remove tasks: - name: use file_by_python module to remove file file_by_python: path: /tmp/test/file1.txt state: absent - name: use file_by_python module to remove directory file_by_python: path: /tmp/test/dir1 state: absent
|
执行创建操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| $ ansible-playbook --tags create python_module.yml
PLAY [localhost] **************************************
TASK [use file_by_python module to create file] ******* changed: [localhost]
TASK [use file_by_python module to create directory] ** changed: [localhost]
PLAY [localhost] **************************************
PLAY RECAP ******************************************** localhost : ok=2 changed=2 unreachable=0
|
执行删除操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| $ ansible-playbook --tags remove python_module.yml
PLAY [localhost] ***************************************
PLAY [localhost] ***************************************
TASK [use file_by_python module to remove file] ******** changed: [localhost]
TASK [use file_by_python module to remove directory] *** changed: [localhost]
PLAY RECAP ********************************************* localhost : ok=2 changed=2
|
从结果看,测试是可以通过的。
从上面分别通过Shell脚本和通过Python脚本实现精简file模块的两个示例中可以看到,完全相同的逻辑和功能,Shell脚本定义Ansible模块时不输于Python。当然,如果逻辑复杂或者遇到了Shell不好处理的逻辑,使用Python当然要优于Shell。