欢迎阅读 Nornir 中文手册!

本手册是基于 官方文档 的不完全翻译,内容相比官方文档有些增删改动,希望对想要使用 Nornir 的朋友有所帮助。
本人能力有限,文中难免会有疏漏或表意不当的地方,欢迎大家随时指正:vip@xdai.vip。
Contents
入门教程
欢迎阅读 Nornir 入门教程。
初识 Nornir
Nornir 是什么?
Nornir 是一个用 Python 编写的自动化框架。它与有一些其他自动化框架的不同之处在于,你只需要编写 Python 代码来使用 Nornir,而其他框架需要使用框架自定义的配置语言(伪语言(pseudo-language),注:需要用框架规定的语言格式来使用)。
为什么要使用纯 Python?
一般情况下,特定的配置语言可以快速上手使用。一段时间后,如果你需要使用更高级的特性,你可能必须需要另外一种编程语言来对其进行扩展(注:例如使用 SDK 进行二次封装),长此以往,二次封装后对原本的框架进行故障排除时造成很大的困难。
由于 Nornir 使用的是纯 Python 代码,所以可以像处理任何其他 Python 代码一样对它进行故障排除和调试。
Nornir 和什么比较像?
你可以将 Nornir 类比为 Flask,Flask 是一个可以创建 Web 应用的 Web 框架。它提供了非常简单的用户接口来让你无需使用特定的方式工作就能创建出强大的网站。
Nornir 提供封装了许多复杂工作的用户接口来让你完成网络设备的自动化。
需要具备的 Python 知识
为了使用 Nornir,你必须具备一些 Python 知识。如果你已经是一个比较熟练的 Python 使用者,可以直接跳过本节,到下一节查看安装指导
如果你从来没有使用 Python 写过代码,并且没有任何其他编程语言的经验,也不用害怕。你不必对 Python 非常精通,只需要学习下面列出的 Python 基础知识之后,再回来继续学习 Nornir!
你可以参考 Python 官方教程 进行学习。
为了继续学习 Nornir,你需要:
在你的电脑上配置 Python 环境
安装 Virtualenv 和 Python 相关的包
了解下面几个 Python 的基础概念
变量(Variables)
函数(Functions)
引入(Imports)
安装指导
安装 Nornir
在安装 Nornir 之前,建议你创建自己的 Python 虚拟环境(Virtualenv)。这样可以保证你的操作不会影响到系统中 Python 的环境。
本教程不提供 Python 虚拟环境的安装指导;也不提供 pip 相关的安装方法,假设你已经在系统中装好了这些。
Nornir 发布在 Pypi 上,你可以像安装其他 Python 包一样使用 pip 工具来安装 Nornir。
你可以直接运行下面的代码行来确定当前的 pip 环境。
[1]:
!pip3 --version
pip 21.2.1 from C:\Users\xdai\AppData\Roaming\Python\Python38\site-packages\pip (python 3.8)
如果你已经有了 pip 工具,安装 Nornir 非常简单。运行以下代码来进行安装。
$pip install nornir
Collecting nornir
Downloading nornir-3.0.0-py3-none-any.whl (28 kB)
Requirement already satisfied: typing_extensions<4.0,>=3.7 in /home/dbarroso/.virtualenvs/tmp-nornir/lib/python3.8/site-packages (from nornir) (3.7.4.2)
Requirement already satisfied: mypy_extensions<0.5.0,>=0.4.1 in /home/dbarroso/.virtualenvs/tmp-nornir/lib/python3.8/site-packages (from nornir) (0.4.3)
Collecting ruamel.yaml<0.17,>=0.16
Using cached ruamel.yaml-0.16.10-py2.py3-none-any.whl (111 kB)
Collecting ruamel.yaml.clib>=0.1.2; platform_python_implementation == "CPython" and python_version < "3.9"
Using cached ruamel.yaml.clib-0.2.0-cp38-cp38-manylinux1_x86_64.whl (578 kB)
Installing collected packages: colorama, ruamel.yaml.clib, ruamel.yaml, nornir
Successfully installed nornir-3.0.0 ruamel.yaml-0.16.10 ruamel.yaml.clib-0.2.0
运行完命令后,如果最后一行是 Successfully installed
,说明你已经成功安装了 Nornir。
现在可以运行以下命令来验证 Nornir 使用成功安装。
[2]:
from nornir import InitNornir
如果你能成功运行这个引入命令,证明你已经成功安装了 Nornir。
插件
Nornir 是一个插件式自动化框架,Nornir3 中只保留了最基本的插件,其他功能都通过第三方插件来扩展实现。关于插件的相关内容,请查看对应的章节;有关第三方插件的列表,请访问 nornir.tech。
初始化 Nornir
初始化 Nornir 对象的方法是使用 InitNornir
函数。
InitNornir
可以使用配置文件、代码或者两者结合起来使用来初始化一个 Nornir 对象。
先从配置文件开始看,下面是一个 Nornir 的配置文件。
[1]:
# %load files/config.yaml
---
inventory:
plugin: SimpleInventory
options:
host_file: "files/inventory/hosts.yaml"
group_file: "files/inventory/groups.yaml"
defaults_file: "files/inventory/defaults.yaml"
runner:
plugin: threaded
options:
num_workers: 100
现在你可以创建一个 Nornir 对象:
[2]:
from nornir import InitNornir
nr = InitNornir(config_file="files/config.yaml")
也可以不用配置文件,通过传参的方式来初始化 Nornir 对象,如下:
[3]:
from nornir import InitNornir
nr = InitNornir(
runner={
"plugin": "threaded",
"options": {
"num_workers": 100,
},
},
inventory={
"plugin": "SimpleInventory",
"options": {
"host_file": "files/inventory/hosts.yaml",
"group_file": "files/inventory/groups.yaml",
},
},
)
或者两种方式混合使用:
[4]:
from nornir import InitNornir
nr = InitNornir(
config_file="files/config.yaml",
runner={
"plugin": "threaded",
"options": {
"num_workers": 100,
},
},
)
Nornir 对象有一个 dict
方法,可以看到 data 和 inventory 相关的信息,执行下面代码可以查看:
[5]:
from pprint import pprint as print
print(nr.dict())
{'data': {'dry_run': False, 'failed_hosts': set()},
'inventory': {'defaults': {'connection_options': {},
'data': {'domain': 'netdevops.local'},
'hostname': None,
'password': None,
'platform': None,
'port': None,
'username': None},
'groups': {'bj': {'connection_options': {},
'data': {},
'groups': ['north', 'global'],
'hostname': None,
'name': 'bj',
'password': None,
'platform': None,
'port': None,
'username': None},
'global': {'connection_options': {},
'data': {'asn': 1,
'domain': 'global.local'},
'groups': [],
'hostname': None,
'name': 'global',
'password': None,
'platform': None,
'port': None,
'username': None},
'gz': {'connection_options': {},
'data': {'asn': 65000,
'vlans': {100: 'wired',
200: 'wireless'}},
'groups': [],
'hostname': None,
'name': 'gz',
'password': None,
'platform': None,
'port': None,
'username': None},
'north': {'connection_options': {},
'data': {'asn': 65100},
'groups': [],
'hostname': None,
'name': 'north',
'password': None,
'platform': None,
'port': None,
'username': None}},
'hosts': {'host00': {'connection_options': {},
'data': {},
'groups': ['gz', 'bj'],
'hostname': None,
'name': 'host00',
'password': None,
'platform': None,
'port': None,
'username': None},
'host01': {'connection_options': {},
'data': {},
'groups': ['bj', 'gz'],
'hostname': None,
'name': 'host01',
'password': None,
'platform': None,
'port': None,
'username': None},
'host01.bj': {'connection_options': {},
'data': {'nested_data': {'a_dict': {'a': 1,
'b': 2},
'a_list': [1,
2],
'a_string': 'this '
'is '
'a '
'web '
'server'},
'role': 'host',
'site': 'bj',
'type': 'host'},
'groups': ['bj'],
'hostname': '127.0.0.1',
'name': 'host01.bj',
'password': 'netdevops',
'platform': 'linux',
'port': 2201,
'username': 'netdevops'},
'host01.gz': {'connection_options': {},
'data': {'role': 'host',
'site': 'gz',
'type': 'host'},
'groups': ['gz'],
'hostname': None,
'name': 'host01.gz',
'password': None,
'platform': 'linux',
'port': None,
'username': None},
'leaf00.bj': {'connection_options': {},
'data': {'asn': 65100,
'role': 'leaf',
'site': 'bj',
'type': 'network_device'},
'groups': ['bj'],
'hostname': '127.0.0.1',
'name': 'leaf00.bj',
'password': 'netdevops',
'platform': 'hp_comware',
'port': 12443,
'username': 'netdevops'},
'leaf01.bj': {'connection_options': {},
'data': {'asn': 65101,
'role': 'leaf',
'site': 'bj',
'type': 'network_device'},
'groups': ['bj'],
'hostname': '127.0.0.1',
'name': 'leaf01.bj',
'password': '',
'platform': 'huawei',
'port': 12203,
'username': 'netdevops'},
'leaf01.gz': {'connection_options': {},
'data': {'role': 'leaf',
'site': 'gz',
'type': 'network_device'},
'groups': ['gz'],
'hostname': '127.0.0.1',
'name': 'leaf01.gz',
'password': 'netdevops',
'platform': 'eos',
'port': 12443,
'username': 'netdevops'},
'spine00.bj': {'connection_options': {},
'data': {'role': 'spine',
'site': 'bj',
'type': 'network_device'},
'groups': ['bj'],
'hostname': '127.0.0.1',
'name': 'spine00.bj',
'password': 'netdevops',
'platform': 'ios',
'port': 12444,
'username': 'netdevops'},
'spine01.bj': {'connection_options': {},
'data': {'role': 'spine',
'site': 'bj',
'type': 'network_device'},
'groups': ['bj'],
'hostname': '127.0.0.1',
'name': 'spine01.bj',
'password': '',
'platform': 'junos',
'port': 12204,
'username': 'netdevops'},
'spine01.gz': {'connection_options': {},
'data': {'role': 'spine',
'site': 'gz',
'type': 'network_device'},
'groups': ['gz'],
'hostname': '127.0.0.1',
'name': 'spine01.gz',
'password': 'netdevops',
'platform': 'eos',
'port': 12444,
'username': 'netdevops'}}}}
这里看到的是运行时指定的 data 参数和所有的主机信息,配置相关的信息则存储在 config 的 dict
方法里,这里可以看到包括默认配置在内的所有配置:
[6]:
print(nr.config.dict())
{'core': {'raise_on_error': False},
'inventory': {'options': {'defaults_file': 'files/inventory/defaults.yaml',
'group_file': 'files/inventory/groups.yaml',
'host_file': 'files/inventory/hosts.yaml'},
'plugin': 'SimpleInventory',
'transform_function': '',
'transform_function_options': {}},
'logging': {'enabled': True,
'format': '%(asctime)s - %(name)12s - %(levelname)8s - '
'%(funcName)10s() - %(message)s',
'level': 'INFO',
'log_file': 'nornir.log',
'loggers': ['nornir'],
'to_console': False},
'runner': {'options': {'num_workers': 100}, 'plugin': 'threaded'},
'ssh': {'config_file': 'C:\\Users\\xdai\\.ssh\\config'},
'user_defined': {}}
从这两个例子可以看出,Nornir 数据相关的字段都是封装成字典的格式来返回给用户。如果想要取某个部分的值,就可以直接使用字典的方式来取,比如查看配置中的并发数量(注:Nornir 默认的线程池并发是 20):
[7]:
print(nr.config.runner.options["num_workers"])
100
主机清单
主机清单(Inventory) 是 nornir 最重要的部分,它由 hosts、groups、defaults 三部分组成。它还支持多种插件,默认情况下使用 SimpleInventory
插件。在之前的版本中,nornir 还支持 Ansible、Netbox 等主机格式的插件,3.0 版本之后,除了最核心的功能外,其他的功能都需要手动导入插件来使用。
在本教程中使用 SimpleInventory
插件来了解主机清单相关的内容。
可以在 nornir.tech 中获取当前已经公开发布的插件。
在 SimpleInventory
插件中,需要 hosts、groups、defaults 三个文件来存储信息,其中 groups、defaults 文件不是必需的。
主机相关的文件都使用 YAML 格式来保存数据,YAML 是一种可读性较好的标记语言,有关 YAML 的内容,可以查看 YAML 入门教程或者 YAML 官方手册。
现在来看一个 hosts 的示例文件:
[1]:
# %load files/inventory/hosts.yaml
---
host01.bj:
hostname: 127.0.0.1
port: 2201
username: netdevops
password: netdevops
platform: linux
groups:
- bj
data:
site: bj
role: host
type: host
nested_data:
a_dict:
a: 1
b: 2
a_list: [1, 2]
a_string: "this is a web server"
spine00.bj:
hostname: 127.0.0.1
username: netdevops
password: netdevops
port: 12444
platform: ios
groups:
- bj
data:
site: bj
role: spine
type: network_device
spine01.bj:
hostname: 127.0.0.1
username: netdevops
password: ""
platform: junos
port: 12204
groups:
- bj
data:
site: bj
role: spine
type: network_device
leaf00.bj:
hostname: 127.0.0.1
username: netdevops
password: netdevops
port: 12443
platform: hp_comware
groups:
- bj
data:
site: bj
role: leaf
type: network_device
asn: 65100
leaf01.bj:
hostname: 127.0.0.1
username: netdevops
password: ""
port: 12203
platform: huawei
groups:
- bj
data:
site: bj
role: leaf
type: network_device
asn: 65101
host01.gz:
groups:
- gz
platform: linux
data:
site: gz
role: host
type: host
spine01.gz:
hostname: 127.0.0.1
username: netdevops
password: netdevops
port: 12444
platform: eos
groups:
- gz
data:
site: gz
role: spine
type: network_device
leaf01.gz:
hostname: 127.0.0.1
username: netdevops
password: netdevops
port: 12443
platform: eos
groups:
- gz
data:
site: gz
role: leaf
type: network_device
host00:
groups:
- gz
- bj
host01:
groups:
- bj
- gz
主机文件是由键值对组成的映射表,其中最外层的是主机名,第二层是主机的一些基本信息,第三层、第四层是主机的其他相关信息。可以通过以下代码来查看一个主机对象的数据模型:
[2]:
from nornir.core.inventory import Host
import json
print(json.dumps(Host.schema(), indent=4))
{
"name": "str",
"connection_options": {
"$connection_type": {
"extras": {
"$key": "$value"
},
"hostname": "str",
"port": "int",
"username": "str",
"password": "str",
"platform": "str"
}
},
"groups": [
"$group_name"
],
"data": {
"$key": "$value"
},
"hostname": "str",
"port": "int",
"username": "str",
"password": "str",
"platform": "str"
}
通过这段代码可以看到一个主机对象可以包含的所有信息。
如果需要登录设备,那么 connection_options
里面的 5 个参数 hostname、port、username、password、platform 是必须包含的(注:默认情况下,connection_options
会从第二层进行取值,如果设备的登录地址和资产管理地址不一样,可以在该选项里面单独指定),如果有额外的连接参数需要传递(如 enable password 、指定连接方式等),则需要在 extras
里面进行添加;其他字段都是可以选的,其中用户可以将所需的任意信息定义到 data
字段中。
当然,如果主机信息只做资产管理的作用,没有登录设备的需求,除了最外层的主机名以外,其他字段都是可选的。
groups 文件和 hosts 文件一样,也是由键值对映射组成,来看一个示例:
[3]:
# %load files/inventory/groups.yaml
---
global:
data:
domain: global.local
asn: 1
north:
data:
asn: 65100
bj:
groups:
- north
- global
gz:
data:
asn: 65000
vlans:
100: wired
200: wireless
最后,defaults 文件与之前描述的 Host 对象架构一样,但是它只有 data
字段,没有其他外层键值对。
[4]:
# %load files/inventory/defaults.yaml
---
data:
domain: netdevops.local
访问主机清单
可以通过 nornir 对象的 inventory
属性来访问主机清单。
[5]:
from nornir import InitNornir
nr = InitNornir(config_file="files/config.yaml")
主机清单有两个类字典(dict-like)的属性:hosts
和 groups
,通过访问该属性,可以获取到当前有哪些主机和组。
查看加载的配置文件中包含哪些主机:
[6]:
nr.inventory.hosts
[6]:
{'host01.bj': Host: host01.bj,
'spine00.bj': Host: spine00.bj,
'spine01.bj': Host: spine01.bj,
'leaf00.bj': Host: leaf00.bj,
'leaf01.bj': Host: leaf01.bj,
'host01.gz': Host: host01.gz,
'spine01.gz': Host: spine01.gz,
'leaf01.gz': Host: leaf01.gz,
'host00': Host: host00,
'host01': Host: host01}
查看加载的配置文件中包含哪些组:
[7]:
nr.inventory.groups
[7]:
{'global': Group: global,
'north': Group: north,
'bj': Group: bj,
'gz': Group: gz}
主机和组都是类字典(dict-like)形式的对象,可以通过 [$values]
来访问它们的属性,以主机 host01.bj
为例,来查看一下这个主包含哪些属性:
[8]:
host = nr.inventory.hosts["host01.bj"]
host.keys()
[8]:
dict_keys(['site', 'role', 'type', 'nested_data', 'asn', 'domain'])
查看这个主机位于哪个站点:
[9]:
host["site"]
[9]:
'bj'
继承模型
Nornir 中,hosts、groups、defaults 数据之间有继承关系,下面来看一下继承是如何工作的。
[10]:
# %load files/inventory/groups.yaml
---
global:
data:
domain: global.local
asn: 1
north:
data:
asn: 65100
bj:
groups:
- north
- global
gz:
data:
asn: 65000
vlans:
100: wired
200: wireless
在 hosts.yaml
中,可以看到 host01.bj
属于 bj
组,bj
组又属于 north
和 global
组;主机 host01.gz
属于 gz
组。
在这里,nornir 的数据解析方式是:递归遍历所属的父组,并查看任意父组中是否包含相应的数据。
[11]:
host01_bj = nr.inventory.hosts["host01.bj"]
host01_bj["domain"] # 继承自 `global` 组
[11]:
'global.local'
[12]:
host01_bj["asn"] # 继承自 `north` 组
[12]:
65100
如果主机有数据,那么优先使用主机具有的数据,而不是从父组继承:
[13]:
leaf01_bj = nr.inventory.hosts["leaf01.bj"]
leaf01_bj["asn"] # 主机的 asn 为 65101,父组 `bj` 的 asn 为 65100
[13]:
65101
如果主机、父组都没有数据,那么会从 defaults
中继承:
[14]:
host01_gz = nr.inventory.hosts["host01.gz"]
host01_gz["domain"] # 从 `defaults` 中继承数据
[14]:
'netdevops.local'
如果 nornir 遍历了所有的父组,而且 defaults
中也没有数据,则会返回 KeyError
:
[15]:
try:
host01_gz["non_existent"]
except KeyError as e:
print(f"无法找到数据:{e}")
无法找到数据:'non_existent'
如果不想遍历父组的话,可以直接使用主机的 data
属性来访问。例如从上面的示例中 host01_bj
的 asn 是继承自父组 north
,直接通过 data
来访问这个属性的话,不会遍历父组,而是返回 KeyError
的错误。
父组之间数据的优先级关系
Nornir 通过遍历所有父组来查找数据,那么如果多个父组里面有相同的数据,会如何取值?通过一个不恰当的例子来看一下,host00
和 host01
都属于 bj
和 gz
组,但是配置文件中的顺序有所差异:
[16]:
host00 = nr.inventory.hosts["host00"]
print(host00.groups) # `gz` 的 asn 为 65000
host00["asn"]
[Group: gz, Group: bj]
[16]:
65000
[17]:
host01 = nr.inventory.hosts["host01"]
print(host01.groups) # `bj` 的 asn 为 65100,继承自 `north`
host01["asn"]
[Group: bj, Group: gz]
[17]:
65100
可以看到如果主机属于多个组,数据解析是按照列表的先后顺序进行迭代,源码实现中是对数据的 key
做了判断,如果遍历已经找到了对应的 key
,之后不会再更新数据。
主机清单的过滤方法
到目前已经看到 nr.inventory.hosts
和 nr.inventory.groups
是类字典(dict-like)的对象,可以使用它们来遍历所有主机和组或直接访问任何特定的主机和组。现在来看看如何进行一些更高级的过滤:根据主机的属性对来对一组主机进行操作。
过滤主机最简单的方法是通过 filter
传入键值对()参数,例如筛选站点是 bj
的机器:
[18]:
nr.filter(site='bj').inventory.hosts
[18]:
{'host01.bj': Host: host01.bj,
'spine00.bj': Host: spine00.bj,
'spine01.bj': Host: spine01.bj,
'leaf00.bj': Host: leaf00.bj,
'leaf01.bj': Host: leaf01.bj}
也可以使用多个键值对来进行过滤,例如筛选站点是 bj
而且角色为 spine
的设备:
[19]:
nr.filter(site='bj', role='spine').inventory.hosts
[19]:
{'spine00.bj': Host: spine00.bj, 'spine01.bj': Host: spine01.bj}
filter
方法也可以进行叠加使用:
[20]:
nr.filter(site='bj').filter(role='spine').inventory.hosts
[20]:
{'spine00.bj': Host: spine00.bj, 'spine01.bj': Host: spine01.bj}
或者赋值给对象,进行再次过滤:
[21]:
bj = nr.filter(site='bj')
[22]:
bj.filter(role='spine').inventory.hosts
[22]:
{'spine00.bj': Host: spine00.bj, 'spine01.bj': Host: spine01.bj}
[23]:
bj.filter(role='leaf').inventory.hosts
[23]:
{'leaf00.bj': Host: leaf00.bj, 'leaf01.bj': Host: leaf01.bj}
还可以根据组进行过滤,例如查找所有属于 bj
组的主机:
[24]:
nr.inventory.children_of_group('bj')
[24]:
{Host: host00,
Host: host01,
Host: host01.bj,
Host: leaf00.bj,
Host: leaf01.bj,
Host: spine00.bj,
Host: spine01.bj}
高级过滤方法
有时候使用键值对无法满足过滤需求,还可以使用更高级的过滤方式:
过滤函数(filter function)
过滤对象(filter object)
过滤函数(filter functions)
Filter 方法里面的 filter_func
参数可以通过传入自定义代码来进行主机过滤。过滤函数的格式应该是 my_func(host)
,其中参数是一个主机对象(Host)并且返回值必须是 True
或 False
来确定过滤结果是否是需要的主机。
[25]:
# 过滤名字主机名长度为 10 的主机
def has_long_name(host):
return len(host.name) == 10
nr.filter(filter_func=has_long_name).inventory.hosts
[25]:
{'spine00.bj': Host: spine00.bj,
'spine01.bj': Host: spine01.bj,
'spine01.gz': Host: spine01.gz}
[26]:
# 或者使用 lambda 函数
nr.filter(filter_func=lambda h: len(h.name)==6).inventory.hosts
[26]:
{'host00': Host: host00, 'host01': Host: host01}
过滤对象(filter object)
使用过滤对象 F
来叠加创建复杂查询对象。
F
对象作为 filter
方法的参数,也接受键值对传参,可以使用叠加的双下划线来访问到任意数据(类似于字典的 []
取值),也可以使用 __contains
来检查一个元素中是否包含指定字符。同时还支持将多个 F
对象进行位运算(&
、|
、~
)来返回查询对象。
注:
__contains__
一般情况下是 Python 容器对象的方法,在 nornir 中,groups 是一个列表,所以对组进行过滤时,应该使用__contains
。
来看几个例子:
[27]:
# 首先引入 F 对象
from nornir.core.filter import F
[28]:
# 查看属于 `bj` 组的设备
bj = nr.filter(F(groups__contains='bj'))
bj.inventory.hosts
[28]:
{'host01.bj': Host: host01.bj,
'spine00.bj': Host: spine00.bj,
'spine01.bj': Host: spine01.bj,
'leaf00.bj': Host: leaf00.bj,
'leaf01.bj': Host: leaf01.bj,
'host00': Host: host00,
'host01': Host: host01}
[29]:
# 查看 `bj` 组中,系统是 `linux` 的设备
bj_linux = nr.filter(F(groups__contains='bj') & F(platform='linux'))
bj_linux.inventory.hosts
[29]:
{'host01.bj': Host: host01.bj}
[30]:
# 查看系统是 `ios` 或者 `eos` 的设备
ios_or_eos = nr.filter(F(platform='ios') | F(platform='eos'))
ios_or_eos.inventory.hosts
[30]:
{'spine00.bj': Host: spine00.bj,
'spine01.gz': Host: spine01.gz,
'leaf01.gz': Host: leaf01.gz}
[31]:
# 查看 `gz` 组中,角色不是 `spine` 的设备
gz_not_spine = nr.filter(F(groups__contains='gz') & ~F(role='spine'))
[32]:
gz_not_spine.inventory.hosts
[32]:
{'host01.gz': Host: host01.gz,
'leaf01.gz': Host: leaf01.gz,
'host00': Host: host00,
'host01': Host: host01}
[33]:
# 使用 `__` 来查看用户自定义的数据,并检查 dicts/lists/strings 是否包含元素
nested_dict = nr.filter(F(nested_data__a_dict__a=1))
nested_dict.inventory.hosts
[33]:
{'host01.bj': Host: host01.bj}
[34]:
nested_list = nr.filter(F(nested_data__a_list__contains=1))
nested_list.inventory.hosts
[34]:
{'host01.bj': Host: host01.bj}
[35]:
nested_string = nr.filter(F(nested_data__a_string__contains='web'))
nested_string.inventory.hosts
[35]:
{'host01.bj': Host: host01.bj}
[36]:
# 也可以对键值对的数据进行 `__contains` 查找
host_os = nr.filter(F(platform__contains='os'))
host_os.inventory.hosts
[36]:
{'spine00.bj': Host: spine00.bj,
'spine01.bj': Host: spine01.bj,
'spine01.gz': Host: spine01.gz,
'leaf01.gz': Host: leaf01.gz}
任务
之前的内容中已经了解了如何初始化 nornir 对象并查看其主机清单和主机组信息,这节内容说明了如何在主机或主机组中执行任务。
任务是针对单台主机实现某种功能的一段可以重复使用的代码,例如收集信息等。
在 nornir 中, 任务(Tasks) 是一个将 Task
对象作为第一个参数并且返回值是 Result
对象的函数。
在旧版本中,nornir 提供了一些内置的任务可以直接使用。从 3.0 版本开始,为了保持框架的纯粹性,剔除了除核心功能外的插件代码,现在需要自己来编写 Task 或者使用其他人贡献出来的插件。
可以在 nornir.tech 中获取当前已经公开发布的插件。。
现在来看一个 Task
的示例:
[1]:
# 初始化一个 nornir 对象
# 导入 print_result 模块来处理 Result 对象
from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
nr = InitNornir(config_file="files/config.yaml")
# 为了保持内容简洁,只针对一些主机进行操作
nr = nr.filter(site='bj',role='spine')
[2]:
# 首先导入 Task 、Result 模块
from nornir.core.task import Task, Result
# 定义一个 task,作用是让主机输出 hello world。
def hello_world(task: Task) -> Result:
return Result(
host=task.host,
result=f"{task.host.name} says hello world!"
)
要运行这个 task,需要使用 nornir 对象的 run
方法,将 task 函数作为参数传递给 run
,要获取到任务执行的结果,需要使用 print_result
方法打印出来:
[3]:
result = nr.run(task=hello_world)
print_result(result)
hello_world*********************************************************************
* spine00.bj ** changed : False ************************************************
vvvv hello_world ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj says hello world!
^^^^ END hello_world ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* spine01.bj ** changed : False ************************************************
vvvv hello_world ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine01.bj says hello world!
^^^^ END hello_world ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
定义 Task 函数时,支持 **kwargs
来传参,这样可以扩展 task 的功能性,例如:
[4]:
def say(task: Task, text: str) -> Result:
return Result(
host=task.host,
result=f"{task.host.name} says {text}"
)
然后可以像之前一样使用 nornir 对象的 run
方法来运行 task,这次需要指定额外的参数 text
:
[5]:
result = nr.run(
name="再见~",
task=say,
text="byebye!"
)
print_result(result)
再见~*****************************************************************************
* spine00.bj ** changed : False ************************************************
vvvv 再见~ ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj says byebye!
^^^^ END 再见~ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* spine01.bj ** changed : False ************************************************
vvvv 再见~ ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine01.bj says byebye!
^^^^ END 再见~ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
需要注意的是,在这个例子中传入了 name
参数来作为这个 task 的描述性名字,这个参数会在结果中显示出来。如果没有指定这个参数的话,则会使用 task 函数的名字。
任务组
一个任务(Tasks)可以调用其他的任务,这样就可以使用多个功能来组成更复杂的功能,这称为任务组(Grouping tasks)。
来定义一个新的 task:
[6]:
def count(task: Task, number: int) -> Result:
return Result(
host=task.host,
result=f"{[n for n in range(0, number)]}"
)
然后将这个新的 task count
和之前的 say
结合起来,形成任务组,实现更复杂的工作流:
[7]:
def greet_and_count(task: Task, number: int) -> Result:
task.run(
name="你好~",
task=say,
text="Hi~",
)
task.run(
name="计数",
task=count,
number=number,
)
task.run(
name="再见",
task=say,
text="byebye."
)
# 计算打招呼打了奇数次还是偶数次
even_or_odds = "even" if number % 2 == 1 else "odd"
return Result(
host=task.host,
result = f"{task.host} counted {even_or_odds} times!",
)
来简单分析一个这个 task: 1. 首先调用了 say
任务并传入了文本 “Hi~”; 2. 之后调用了 count
任务,它接收中在父任务 greet_and_count
也定义的参数 number
,这样可以在执行父任务时动态调整这部分参数; 3. 然后再次调用了 say
任务,这次传入了文本 “byebye”; 4. 之后 if
来判断计数情况; 5. 最后返回了 Result
对象,将需要的信息返回。
现在可以像调用一个普通的 task 一样来调用新定义的任务组:
[8]:
result = nr.run(
name="对打招呼次数进行计数",
task=greet_and_count,
number=5,
)
print_result(result)
对打招呼次数进行计数**********************************************************************
* spine00.bj ** changed : False ************************************************
vvvv 对打招呼次数进行计数 ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj counted even times!
---- 你好~ ** changed : False ---------------------------------------------------- INFO
spine00.bj says Hi~
---- 计数 ** changed : False ----------------------------------------------------- INFO
[0, 1, 2, 3, 4]
---- 再见 ** changed : False ----------------------------------------------------- INFO
spine00.bj says byebye.
^^^^ END 对打招呼次数进行计数 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* spine01.bj ** changed : False ************************************************
vvvv 对打招呼次数进行计数 ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine01.bj counted even times!
---- 你好~ ** changed : False ---------------------------------------------------- INFO
spine01.bj says Hi~
---- 计数 ** changed : False ----------------------------------------------------- INFO
[0, 1, 2, 3, 4]
---- 再见 ** changed : False ----------------------------------------------------- INFO
spine01.bj says byebye.
^^^^ END 对打招呼次数进行计数 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
处理任务结果
在这节中一起来看一下如何处理任务(Tasks)的运行结果。
先看下面的示例:
[1]:
import logging
from nornir import InitNornir
from nornir.core.task import Task, Result
nr = InitNornir(config_file="files/config.yaml")
spine_bj = nr.filter(site="bj", role="spine")
def count(task: Task, number: int) -> Result:
return Result(
host=task.host,
result=f"{[n for n in range(0, number)]}"
)
def say(task: Task, text: str) -> Result:
if task.host.name == "spine01.bj":
raise Exception(f"{task.host.name} 不能输出信息")
return Result(
host=task.host,
result=f"{task.host.name} says {text}"
)
这个示例与之前示例的区别是:通过 if 判断让主机 spine01.bj
强制抛出了一个错误信息。
再继续编写任务组:
[2]:
def greet_and_count(task: Task, number: int) -> Result:
task.run(
name="你好~",
severity_level=logging.DEBUG,
task=say,
text="Hi~",
)
task.run(
name="计数",
task=count,
number=number,
)
task.run(
name="再见",
severity_level=logging.DEBUG,
task=say,
text="byebye."
)
# 计算打招呼打了奇数次还是偶数次
even_or_odds = "even" if number % 2 == 1 else "odd"
return Result(
host=task.host,
result = f"{task.host} counted {even_or_odds} times!",
)
这个任务组与之前编写的任务组一样,不同的地方是添加了 severity_level=logging.DEBUG
来输出任务执行的日志。现在来运行一下任务组,并把运行结果赋值给 result
:
[3]:
result = spine_bj.run(
task=greet_and_count,
number=5
)
简单的任务处理方法
大多数情况下,如果只想知道任务的执行结果,可以使用 nornir_utils
里面的 print_result
函数,之前的示例中已经在使用它来查看结果了。
[4]:
from nornir_utils.plugins.functions import print_result
print_result(result)
greet_and_count*****************************************************************
* spine00.bj ** changed : False ************************************************
vvvv greet_and_count ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj counted even times!
---- 计数 ** changed : False ----------------------------------------------------- INFO
[0, 1, 2, 3, 4]
^^^^ END greet_and_count ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* spine01.bj ** changed : False ************************************************
vvvv greet_and_count ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Subtask: 你好~ (failed)
---- 你好~ ** changed : False ---------------------------------------------------- ERROR
Traceback (most recent call last):
File "c:\program files\python38\lib\site-packages\nornir\core\task.py", line 99, in start
r = self.task(self, **self.params)
File "C:\Users\xdai\AppData\Local\Temp/ipykernel_16088/2400762698.py", line 17, in say
raise Exception(f"{task.host.name} 不能输出信息")
Exception: spine01.bj 不能输出信息
^^^^ END greet_and_count ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
从结果中可以看到两台 spine
设备的执行结果,显示出来了两台主机上 count
任务的执行结果及第二台主机 say
任务的结果,仍然有一些其他的结果没有显示出来,下文将说明原因。
现在来通过字典取值方式单独查看一下某台设备的任务执行结果:
[5]:
print_result(result["spine00.bj"])
vvvv spine00.bj: greet_and_count ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj counted even times!
---- 计数 ** changed : False ----------------------------------------------------- INFO
[0, 1, 2, 3, 4]
^^^^ END greet_and_count ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
或者查看某一个任务的执行结果:
[6]:
print_result(result["spine00.bj"][2])
---- spine00.bj: 计数 ** changed : False ----------------------------------------- INFO
[0, 1, 2, 3, 4]
从上面的几个处理结果的示例中可以看到,并不是所有的处理结果都显示出来了,这是因为指定了 severity_level
参数,可以用指定的日志级别来记录任务的执行结果。
print_result
可以按照日志规则打印结果,默认情况下,它只打印严重级别大于 INFO
的任务(如果任务中没有指定日志级别,默认值也是INFO
)。
如果任务执行失败的话,它的严重级别是 ERROR
,比 INFO
大,所以可以显示出来。上面的 spine02.bj
的第一个任务就是显示出来的错误信息。
日志级别排序:CRITICAL > ERROR > WARNING > INFO > DEBUG
可以通过设置 print_result
的参数来调整输出:
[7]:
print_result(result, severity_level=logging.DEBUG)
greet_and_count*****************************************************************
* spine00.bj ** changed : False ************************************************
vvvv greet_and_count ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj counted even times!
---- 你好~ ** changed : False ---------------------------------------------------- DEBUG
spine00.bj says Hi~
---- 计数 ** changed : False ----------------------------------------------------- INFO
[0, 1, 2, 3, 4]
---- 再见 ** changed : False ----------------------------------------------------- DEBUG
spine00.bj says byebye.
^^^^ END greet_and_count ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* spine01.bj ** changed : False ************************************************
vvvv greet_and_count ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Subtask: 你好~ (failed)
---- 你好~ ** changed : False ---------------------------------------------------- ERROR
Traceback (most recent call last):
File "c:\program files\python38\lib\site-packages\nornir\core\task.py", line 99, in start
r = self.task(self, **self.params)
File "C:\Users\xdai\AppData\Local\Temp/ipykernel_16088/2400762698.py", line 17, in say
raise Exception(f"{task.host.name} 不能输出信息")
Exception: spine01.bj 不能输出信息
^^^^ END greet_and_count ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
现在通过给 print_result
传递参数,已经可以看到所有任务的执行结果了,从显示任务名那一行的内容最后可以看到日志级别。
更详细的任务处理方法
从上一小节的示例中,已经说明了如果处理任务的结果,现在详细说明一下。任务组(Task Groups)的返回结果是 AggregatedResult
对象,它是个类字典(dict-like)对象,所以可以像操作字典一样进行迭代或者访问。
[8]:
result
[8]:
AggregatedResult (greet_and_count): {'spine00.bj': MultiResult: [Result: "greet_and_count", Result: "你好~", Result: "计数", Result: "再见"], 'spine01.bj': MultiResult: [Result: "greet_and_count", Result: "你好~"]}
[9]:
result.keys()
[9]:
dict_keys(['spine00.bj', 'spine01.bj'])
[10]:
result["spine00.bj"]
[10]:
MultiResult: [Result: "greet_and_count", Result: "你好~", Result: "计数", Result: "再见"]
从上面的示例输出中可以看到,AggregatedResult
中的每个键都有一个MultiResult
对象。这个对象是一个类列表(list-like)的对象,里面存放着 Result
对象,可以使用列表的操作方式来迭代或访问 Result
对象:
[11]:
result["spine00.bj"][0]
[11]:
Result: "greet_and_count"
从 MultiResult
和 Result
中可以看到执行对象中是否有错误或变化:
[12]:
print(f'changed: {result["spine00.bj"].changed}')
print(f'failed: {result["spine00.bj"].failed}')
changed: False
failed: False
[13]:
print(f'changed: {result["spine01.bj"].changed}')
print(f'failed: {result["spine01.bj"].failed}')
changed: False
failed: True
如果运行前后对目标系统造成了改变,可以通过 diff
显示出来,当前示例中执行的任务组没有产生变化,所以输出为空:
[14]:
print(f'diff: {result["spine01.bj"].diff}')
diff:
处理失败任务
任务执行失败是不可避免的,现在接着上一节的示例来看下如何处理失败的任务。
上一节中的示例代码:
[1]:
import logging
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result
nr = InitNornir(config_file="files/config.yaml")
spine_bj = nr.filter(site="bj", role="spine")
def count(task: Task, number: int) -> Result:
return Result(
host=task.host,
result=f"{[n for n in range(0, number)]}"
)
def say(task: Task, text: str) -> Result:
if task.host.name == "spine01.bj":
raise Exception(f"{task.host.name} 不能输出信息")
return Result(
host=task.host,
result=f"{task.host.name} says {text}"
)
def greet_and_count(task: Task, number: int) -> Result:
task.run(
name="你好~",
severity_level=logging.DEBUG,
task=say,
text="Hi~",
)
task.run(
name="计数",
task=count,
number=number,
)
task.run(
name="再见",
severity_level=logging.DEBUG,
task=say,
text="byebye."
)
# 计算打招呼打了奇数次还是偶数次
even_or_odds = "even" if number % 2 == 1 else "odd"
return Result(
host=task.host,
result = f"{task.host} counted {even_or_odds} times!",
)
result = spine_bj.run(
task=greet_and_count,
number=5
)
在这段示例代码中,任务 say
针对 spine01.bj
主机抛出了一个异常,这导致整个任务的执行结果是失败的:
[2]:
result.failed
[2]:
True
[3]:
# 查看是哪些主机导致了失败
result.failed_hosts
[3]:
{'spine01.bj': MultiResult: [Result: "greet_and_count", Result: "你好~"]}
如果任务发生了失败,可以通过 exception
显示异常信息:
[4]:
result["spine01.bj"].exception
[4]:
nornir.core.exceptions.NornirSubTaskError()
上一条命令显示结果是子任务错误,可以通过列表取值来查看错误信息:
[5]:
result["spine01.bj"][1].exception
[5]:
Exception('spine01.bj 不能输出信息')
想要查看更具体的信息,可以使用 print_result
查看具体的异常信息:
[6]:
print_result(result["spine01.bj"])
vvvv spine01.bj: greet_and_count ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Subtask: 你好~ (failed)
---- 你好~ ** changed : False ---------------------------------------------------- ERROR
Traceback (most recent call last):
File "c:\program files\python38\lib\site-packages\nornir\core\task.py", line 99, in start
r = self.task(self, **self.params)
File "C:\Users\xdai\AppData\Local\Temp/ipykernel_35768/1441132238.py", line 18, in say
raise Exception(f"{task.host.name} 不能输出信息")
Exception: spine01.bj 不能输出信息
^^^^ END greet_and_count ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
在处理任务执行结果的过程中,如果有执行出错的话,还会抛出 NornirExecutionError
异常,可以使用 raise_on_error
方法来引出这个异常,然后使用 try
子句进行处理:
[7]:
from nornir.core.exceptions import NornirExecutionError
try:
result.raise_on_error()
except NornirExecutionError:
print("ERROR!!!")
ERROR!!!
跳过失败的主机
Nornir 会跟踪记录任务执行失败的主机,然后不在该主机上运行其他新的任务。
现在定义一个新的任务,并使用之前示例筛选出来的主机组 spine_bj
来执行该任务。
这里需要注意一下: spine_bj
中有两个主机,但是之前示例中,spine01.bj
在执行任务组 greet_and_count
中失败了。
[8]:
spine_bj.inventory.hosts
[8]:
{'spine00.bj': Host: spine00.bj, 'spine01.bj': Host: spine01.bj}
[9]:
from nornir.core.task import Result
def hi(task: Task) -> Result:
return Result(
host=task.host,
result=f"{task.host.name}: Hi, I am still here!"
)
result = spine_bj.run(hi)
[10]:
print_result(result)
hi******************************************************************************
* spine00.bj ** changed : False ************************************************
vvvv hi ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj: Hi, I am still here!
^^^^ END hi ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
查看执行出来的结果,只有第一台主机 spine00.bj
成功执行了新的任务。
如果需要新任务在失败的主机上执行,需要在执行调用时添加 on_failed=True
:
[11]:
result = spine_bj.run(task=hi, on_failed=True)
print_result(result)
hi******************************************************************************
* spine00.bj ** changed : False ************************************************
vvvv hi ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine00.bj: Hi, I am still here!
^^^^ END hi ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* spine01.bj ** changed : False ************************************************
vvvv hi ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine01.bj: Hi, I am still here!
^^^^ END hi ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
如果只想在失败的主机上执行新任务,可以使用 on_good
参数: >上一个代码框中使用了 on_failed=True
,导致两个主机都执行成功了。如果想要验证 on_good
,需要再执行一下之前导致错误的任务组来看到这次的结果
[12]:
# 这是上一节示例中执行失败的任务组,再次执行一下,来验证 `on_good`
result = spine_bj.run(
task=greet_and_count,
number=5
)
[13]:
result = spine_bj.run(task=hi, on_failed=True, on_good=False)
print_result(result)
hi******************************************************************************
* spine01.bj ** changed : False ************************************************
vvvv hi ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
spine01.bj: Hi, I am still here!
^^^^ END hi ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
可以看到只在失败的主机上执行了新任务。
如何实现的呢?
为了实现这种效果,nornir 是通过在 data
对象中添加了 failed_hosts
字段来让任务之间共享失败的主机(有关 data
对象,可以回顾一下初始化 Nornir):
[14]:
nr.data.failed_hosts
[14]:
{'spine01.bj'}
如果要将某些主机标记为成功并让它们重新符合执行新任务的资格,可以使用函数 recovery_host
为某个主机单独执行此操作,或者使用 reset_failed_hosts
完全重置失败列表:
[15]:
nr.data.recover_host('spine01.bj')
nr.data.failed_hosts
[15]:
set()
[16]:
nr.data.reset_failed_hosts()
nr.data.failed_hosts
[16]:
set()
自动抛出异常
一般情况下,如果任务执行出错,只能在最终打印任务结果时看到错误信息,如果需要及时反馈或者处理失败的失误,可以在初始化 nornir 对象时添加 raise_on_error
来让任务出错时自动引发异常:
[17]:
nr = InitNornir(
config_file="files/config.yaml",
core = {"raise_on_error": True}
)
spine_bj = nr.filter(site='bj', role='spine')
try:
result = spine_bj.run(
task=greet_and_count,
number=5,
)
except NornirExecutionError:
print("ERROR!!!")
ERROR!!!
工作流
由任务组组成的工作流(Workflow)适用于大多数使用场景,因为它可以跳过出错的主机,并且 print_result
也提供了足够的信息来了解任务执行的结果。
对于更复杂的工作流,也可以通过 nornir 来实现,因为这个框架足够灵活,接下来就来看看强大的处理器。
处理器
在 Nornir 中 处理器(Processors) 是一种可以通过自定义代码处理某些事件的插件,它就是一个可以处理任务的装饰器,它在不改变任务结果的前提下,让用户可以自己编写代码对任务结果进行加工,为处理任务提供了更多的扩展性。它有一些优点:
由于处理器是基于事件(event-based)的,所以可以异步处理事件,例如在某台主机完成任务后马上处理该主机的结果,不用需等待其它主机完成任务。
基于事件编写的代码更简洁,更容易理解。
来通过几个例子来看看处理器是如何工作的,先初始化一个 nornir 对象:
[1]:
from typing import Dict
from nornir import InitNornir
nr = InitNornir(config_file="files/config.yaml")
编写一个处理器,它的作用是打印一些有关任务执行的信息:
[2]:
from nornir.core import Nornir
from nornir.core.inventory import Host
from nornir.core.task import Task, AggregatedResult, MultiResult, Result, Task
class PrintResult:
# 任务开始运行时执行的动作
def task_started(self, task: Task) -> None:
print(f" 任务[{task.name}] 开始执行 ".center(79, "="))
# 任务运行结束后执行的动作
def task_completed(self, task: Task, result: AggregatedResult) -> None:
print(f" 任务[{task.name}]执行结束 ".center(79, "="))
# 任务分配给单台主机运行时执行的动作
def task_instance_started(self, task: Task, host: Host) -> None:
print(f"任务[{task.name}]分配给主机[{host.name}]开始执行.\n")
# 任务分配给单台主机运行完成后执行的动作
def task_instance_completed(
self, task: Task, host: Host, result: MultiResult
) -> None:
print(f"任务[{task.name}]分配给主机[{host.name}]执行完成,执行结果:{result.result} \n")
# 子任务开始运行时执行的动作
def subtask_instance_started(self, task: Task, host: Host) -> None:
pass
# 子任务结束运行时执行的动作
def subtask_instance_completed(
self, task: Task, host: Host, result: MultiResult
) -> None:
pass
编写一个简单的任务,让自定义的处理器 PrintResult
来处理结果:
[3]:
def greeter(task: Task, greet: str) -> Result:
return Result(
host=task.host,
result=f"{greet}! My name is {task.host.name}!"
)
要使用自定义的处理器,需要用到 nornir 对象的 with_processors
方法,这个方法需要传递一个 Processer
的列表对象 Processers
,然后返回一个带有 Processers
的 nornir 对象:
[4]:
# 为了保持简洁,这里使用过滤器过滤所有角色是 `spine` 的主机来执行任务
nr = nr.filter(role="spine")
nr_with_processors = nr.with_processors([PrintResult()])
nr_with_processors.run(
task=greeter,
greet="Hi",
name="Hi",
)
nr_with_processors.run(
task=greeter,
greet="Bye",
name="Bye",
)
================================= 任务[Hi] 开始执行 =================================
任务[Hi]分配给主机[spine00.bj]开始执行.
任务[Hi]分配给主机[spine00.bj]执行完成,执行结果:Hi! My name is spine00.bj!
任务[Hi]分配给主机[spine01.bj]开始执行.
任务[Hi]分配给主机[spine01.gz]开始执行.
任务[Hi]分配给主机[spine01.bj]执行完成,执行结果:Hi! My name is spine01.bj!
任务[Hi]分配给主机[spine01.gz]执行完成,执行结果:Hi! My name is spine01.gz!
================================== 任务[Hi]执行结束 =================================
================================= 任务[Bye] 开始执行 ================================
任务[Bye]分配给主机[spine00.bj]开始执行.
任务[Bye]分配给主机[spine01.bj]开始执行.
任务[Bye]分配给主机[spine01.bj]执行完成,执行结果:Bye! My name is spine01.bj!
任务[Bye]分配给主机[spine01.gz]开始执行.
任务[Bye]分配给主机[spine01.gz]执行完成,执行结果:Bye! My name is spine01.gz!
任务[Bye]分配给主机[spine00.bj]执行完成,执行结果:Bye! My name is spine00.bj!
================================= 任务[Bye]执行结束 =================================
[4]:
AggregatedResult (Bye): {'spine00.bj': MultiResult: [Result: "Bye"], 'spine01.bj': MultiResult: [Result: "Bye"], 'spine01.gz': MultiResult: [Result: "Bye"]}
可以看到,任务执行完成后,它的过程都被打印出来了,这是由自定义的处理器 PrintResult
来完成的。
打印结果是无序的,因为默认情况下 nornir 的任务是多线程异步执行的。
前面说到 with_processors
方法需要传递一个 Processers
对象,这个对象是由 Processer
组成的列表。
现在来再定义一个处理器,它的任务是将任务的信息保存在字典中。
[5]:
class SaveResultToDict:
def __init__(self, data: Dict[str, None]) -> None:
self.data = data
def task_started(self, task: Task) -> None:
self.data[task.name] = {}
self.data[task.name]["started"] = True
print(f"任务开始信息已经保存到 {self.data.keys()}!")
def task_completed(self, task: Task, result: AggregatedResult) -> None:
self.data[task.name]["completed"] = True
print(f"任务完成信息已经保存到 {self.data.keys()}!")
def task_instance_started(self, task: Task, host: Host) -> None:
self.data[task.name][host.name] = {"started": True}
print(f"主机[{host.name}]任务开始信息已经保存到 {self.data.keys()}!")
def task_instance_completed(
self, task: Task, host: Host, result: MultiResult
) -> None:
self.data[task.name][host.name] = {
"completed": True,
"result": result.result,
}
print(f"主机[{host.name}]任务完成信息已经保存到 {self.data.keys()}!")
def subtask_instance_started(self, task: Task, host: Host) -> None:
pass
def subtask_instance_completed(
self, task: Task, host: Host, result: MultiResult
) -> None:
pass
现在来再次执行任务 greeter
,这次使用两个处理器 SaveResultToDict
和 PrintResult
来对任务进行处理:
[6]:
data = {}
nr_with_processors = nr.with_processors([PrintResult(),SaveResultToDict(data)])
nr_with_processors.run(
task=greeter,
greet="Hi",
name="Hi",
)
nr_with_processors.run(
task=greeter,
greet="Bye",
name="Bye",
)
================================= 任务[Hi] 开始执行 =================================
任务开始信息已经保存到 dict_keys(['Hi'])!
任务[Hi]分配给主机[spine00.bj]开始执行.
主机[spine00.bj]任务开始信息已经保存到 dict_keys(['Hi'])!任务[Hi]分配给主机[spine01.bj]开始执行.
任务[Hi]分配给主机[spine00.bj]执行完成,执行结果:Hi! My name is spine00.bj!
主机[spine00.bj]任务完成信息已经保存到 dict_keys(['Hi'])!
任务[Hi]分配给主机[spine01.gz]开始执行.
主机[spine01.gz]任务开始信息已经保存到 dict_keys(['Hi'])!
任务[Hi]分配给主机[spine01.gz]执行完成,执行结果:Hi! My name is spine01.gz!
主机[spine01.gz]任务完成信息已经保存到 dict_keys(['Hi'])!
主机[spine01.bj]任务开始信息已经保存到 dict_keys(['Hi'])!
任务[Hi]分配给主机[spine01.bj]执行完成,执行结果:Hi! My name is spine01.bj!
主机[spine01.bj]任务完成信息已经保存到 dict_keys(['Hi'])!
================================== 任务[Hi]执行结束 =================================
任务完成信息已经保存到 dict_keys(['Hi'])!
================================= 任务[Bye] 开始执行 ================================
任务开始信息已经保存到 dict_keys(['Hi', 'Bye'])!
任务[Bye]分配给主机[spine00.bj]开始执行.
主机[spine00.bj]任务开始信息已经保存到 dict_keys(['Hi', 'Bye'])!
任务[Bye]分配给主机[spine00.bj]执行完成,执行结果:Bye! My name is spine00.bj!
主机[spine00.bj]任务完成信息已经保存到 dict_keys(['Hi', 'Bye'])!
任务[Bye]分配给主机[spine01.bj]开始执行.
任务[Bye]分配给主机[spine01.gz]开始执行.
主机[spine01.gz]任务开始信息已经保存到 dict_keys(['Hi', 'Bye'])!
任务[Bye]分配给主机[spine01.gz]执行完成,执行结果:Bye! My name is spine01.gz!
主机[spine01.gz]任务完成信息已经保存到 dict_keys(['Hi', 'Bye'])!
主机[spine01.bj]任务开始信息已经保存到 dict_keys(['Hi', 'Bye'])!
任务[Bye]分配给主机[spine01.bj]执行完成,执行结果:Bye! My name is spine01.bj!
主机[spine01.bj]任务完成信息已经保存到 dict_keys(['Hi', 'Bye'])!
================================= 任务[Bye]执行结束 =================================
任务完成信息已经保存到 dict_keys(['Hi', 'Bye'])!
[6]:
AggregatedResult (Bye): {'spine00.bj': MultiResult: [Result: "Bye"], 'spine01.bj': MultiResult: [Result: "Bye"], 'spine01.gz': MultiResult: [Result: "Bye"]}
任务已经成功执行,并且两个处理器都按照预期进行工作,任务执行的最后也打印出了最后的结果: AggregatedResult
对象,事实上如果处理器里面已经对结果进行除了,这个对象也不需要再给它赋值然后再使用 print_result
打印出来了。
这里注意一点,因为
Processers
是一个列表,所以它里面Processer
的执行顺序是按照列表的顺序来运行的。
接下来看一下处理器 SaveResultToDict
对 data
做的操作:
[7]:
import json
print(json.dumps(data, indent=4))
{
"Hi": {
"started": true,
"spine00.bj": {
"completed": true,
"result": "Hi! My name is spine00.bj!"
},
"spine01.gz": {
"completed": true,
"result": "Hi! My name is spine01.gz!"
},
"spine01.bj": {
"completed": true,
"result": "Hi! My name is spine01.bj!"
},
"completed": true
},
"Bye": {
"started": true,
"spine00.bj": {
"completed": true,
"result": "Bye! My name is spine00.bj!"
},
"spine01.gz": {
"completed": true,
"result": "Bye! My name is spine01.gz!"
},
"spine01.bj": {
"completed": true,
"result": "Bye! My name is spine01.bj!"
},
"completed": true
}
}
通过以上两个示例,可以看到 处理器(Processers) 处理器的强大功能,通过它来操作处理任务结果更加简单,也无需通过 print_result
来查看任务结果。
一些想法
借助处理器还可以做哪些其他事情?
将任务执行事件发送到 slack/IRC/logging_system
让使用者可以关注到正在的执行的任务情况而无需等待所有主机的任务执行完成(尤其是当设备数量很多时)
如果某些任务失败,及时通知/发出警报
根据业务场景尽情发挥吧!
Nornir 基础教程到这里就结束了,如果想要更加深入的了解,请继续阅读进阶部分。
HowTo指南
HowTo 指南主要是根据特定场景来举例说明 Nornir 的基本/进阶用法,以及 Nornir 与其他模块相结合的扩展用法。
高级过滤方法
这节内容主要介绍使用 F
对象来做高级过滤。如果你已经足够了解 F
对象的操作方法,可以直接到本节最后翻看两个新的列表过滤方法:__any
和 __all
。
先从初始化 nornir
对象开始,查看现在的主机清单和组:
[1]:
from nornir import InitNornir
from nornir.core.filter import F
nr = InitNornir(config_file="advanced_filtering/config.yaml")
[15]:
# %load advanced_filtering/inventory/hosts.yaml
---
cat:
groups:
- terrestrial
- mammal
data:
domestic: true
diet: omnivore
additional_data:
lifespan: 17
famous_members:
- garfield
- felix
- grumpy
bat:
groups:
- terrestrial
- mammal
data:
domestic: false
fly: true
diet: carnivore
additional_data:
lifespan: 15
famous_members:
- batman
- count chocula
- nosferatu
eagle:
groups:
- terrestrial
- bird
data:
domestic: false
diet: carnivore
additional_data:
lifespan: 50
famous_members:
- thorondor
- sam
canary:
groups:
- terrestrial
- bird
data:
domestic: true
diet: herbivore
additional_data:
lifespan: 15
famous_members:
- tweetie
caterpillaer:
groups:
- terrestrial
- invertebrate
data:
domestic: false
diet: herbivore
additional_data:
lifespan: 1
famous_members:
- Hookah-Smoking
octopus:
groups:
- marine
- invertebrate
data:
domestic: false
diet: carnivore
additional_data:
lifespan: 1
famous_members:
- sharktopus
[4]:
# %load advanced_filtering/inventory/groups.yaml
---
mammal:
data:
reproduction: birth
fly: false
bird:
data:
reproduction: eggs
fly: true
invertebrate:
data:
reproduction: mitosis
fly: false
terrestrial: {}
marine: {}
在上面的主机及主机组文件中,建立了具有不同属性的动物分类。F
对象可以只需在前面加上两个下划线和魔术方法的名称即可访问每种类型的魔术方法。例如,如果想检查一个列表是否包含一个特定的元素,你可以在前面加上 __contains
。现在来查找属于鸟类(bird
)的所有动物:
[2]:
birds = nr.filter(F(groups__contains="bird"))
print(birds.inventory.hosts.keys())
# dict_keys(['鹰', '金丝雀'])
dict_keys(['eagle', 'canary'])
还可以通过添加 ~
来对 F
对象进行取反:
[3]:
not_birds = nr.filter(~F(groups__contains="bird"))
print(not_birds.inventory.hosts.keys())
# dict_keys(['猫', '蝙蝠', '毛毛虫', '章鱼'])
dict_keys(['cat', 'bat', 'caterpillaer', 'octopus'])
还可以组合 F
对象并使用符号 &
和 |
执行 AND 和 OR 运算:
[4]:
# 筛选鸟类(bird)或者家养动物(domestic)
domestic_or_bird = nr.filter(F(groups__contains="bird") | F(domestic=True))
print(domestic_or_bird.inventory.hosts.keys())
# dict_keys(['猫', '鹰', '金丝雀'])
dict_keys(['cat', 'eagle', 'canary'])
[5]:
# 筛选哺乳动物(mammal)并且是家养动物(domestic)
domestic_mammals = nr.filter(F(groups__contains="mammal") & F(domestic=True))
print(domestic_mammals.inventory.hosts.keys())
# dict_keys(['猫'])
dict_keys(['cat'])
也可以将所有符号进行组合:
[6]:
# 筛选会飞的动物(fly)并且不是食肉动物(cannivore)
flying_not_carnivore = nr.filter(F(fly=True) & ~F(diet="carnivore"))
print(flying_not_carnivore.inventory.hosts.keys())
# dict_keys(['金丝雀'])
dict_keys(['canary'])
可以像访问魔法方法一样访问嵌套数据,方法是在要访问的数据前面添加双下划线;在数据筛选之后,还能继续添加双下划线来访问最终数据的魔法方法。 例如在示例数据中,筛选寿命(lifespan)最终的结果是一个整数,整数可以进行比较运算,因为它具有双下划线魔术方法,所以可以对最终的数据进行再次调用魔术方法。 来筛选一下寿命(lifespan)大于 15 的动物:
[7]:
long_lived = nr.filter(F(additional_data__lifespan__ge=15)) # 调用了整数的 __ge__
print(long_lived.inventory.hosts.keys())
# dict_keys(['猫', '蝙蝠', '鹰', '金丝雀'])
dict_keys(['cat', 'bat', 'eagle', 'canary'])
[8]:
# 结合这个例子,增加对上一个代码框的理解
# 使用整数的魔术方法进行比较大小
# 定义 a = 1,b = 2
a = 1
b = 2
# 调用 a 的 魔术方法,将 b 作为参数传入,等价于 a >= b
a.__ge__(b)
[8]:
False
除了 __contains
外,还有两个选项可以对列表进行处理:any
和 all
。 any
代表列表中的元素是 OR 的关系,满足一个条件就可以;all
代表列表中的元素是 AND 的关系,必须满足所有的条件才行:
[9]:
# 筛选鸟类(bird)或者无脊椎动物(invertebrates)
bird_or_invertebrates = nr.filter(F(groups__any=["bird", "invertebrate"]))
print(bird_or_invertebrates.inventory.hosts.keys())
# dict_keys(['鹰', '金丝雀', '毛毛虫', '章鱼'])
dict_keys(['eagle', 'canary', 'caterpillaer', 'octopus'])
[10]:
# 筛选海生动物(marine)并且是无脊椎动物(invertebrates)
marine_and_invertebrates = nr.filter(F(groups__all=["marine", "invertebrate"]))
print(marine_and_invertebrates.inventory.hosts.keys())
# dict_keys(['章鱼'])
dict_keys(['octopus'])
从示例中可以看出,如果需要对多个组进行过滤操作的话,某些情况下使用 any
和 all
比使用 __contains
和多级运算 &
、~
、|
更为方便。
下一节中,将以网络设备作为对象,深入了解过滤功能在网络自动化中的使用方法。
处理设备连接
自动处理
默认情况下,Nornir 会自动处理设备的连接。这里指的意思是 Nornir 会自动连接到设备,执行完成任务后再退出设备。
[1]:
from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_napalm.plugins.tasks import napalm_get
[2]:
nr = InitNornir(config_file="handling_connections/config.yaml")
r1 = nr.filter(name="rt01")
r = r1.run(
task=napalm_get,
getters=["facts"]
)
[3]:
print_result(r)
napalm_get**********************************************************************
* rt01 ** changed : False ******************************************************
vvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
{ 'facts': { 'fqdn': 'Unknown',
'hostname': 'R1',
'interface_list': [],
'model': 'Unknown',
'os_version': 'Unknown',
'serial_number': [],
'uptime': -1,
'vendor': 'Huawei'}}
^^^^ END napalm_get ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
手动处理
在某些情况下,可能需要手动管理设备的连接,让用户来决定什么时候连接到设备上,什么时候和设备断开连接。
这时候可以使用 open_connection
、close_connection
、close_connections
和 Nornir.close_connections
这几个方法来实现:
[4]:
def task_manages_connection_manually(task):
print(f"开始连接:{task.host.name}")
task.host.open_connection("napalm", configuration=task.nornir.config)
r = task.run(
task=napalm_get,
getters=["facts"]
)
print(f"连接成功: {not r[0].failed}")
task.host.close_connection("napalm")
nr = InitNornir(config_file="handling_connections/config.yaml")
rtr = nr.filter(name="rt01")
r = rtr.run(
task=task_manages_connection_manually,
)
开始连接:rt01
连接成功: True
[5]:
print_result(r)
task_manages_connection_manually************************************************
* rt01 ** changed : False ******************************************************
vvvv task_manages_connection_manually ** changed : False vvvvvvvvvvvvvvvvvvvvvvv INFO
---- napalm_get ** changed : False --------------------------------------------- INFO
{ 'facts': { 'fqdn': 'Unknown',
'hostname': 'R1',
'interface_list': [],
'model': 'Unknown',
'os_version': 'Unknown',
'serial_number': [],
'uptime': -1,
'vendor': 'Huawei'}}
^^^^ END task_manages_connection_manually ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
指定连接参数
使用 open_connection
时,可以指定所需要的任何参数,如果没有指定或者如果让 nornir 自动打开设备连接,nornir 将会从主机清单中读取这些连接参数。
在主机清单中的 connection_options
中指定设备的连接方式,然后在 extras
-> optional_args
中添加连接插件的额外参数,连接参数通常是下面这种格式:
[6]:
# %load handling_connections/inventory/test-hosts.yaml
dev1.group_1:
port: 22
hostname: 192.168.56.20
username: username
password: password
platform: ios
connection_options:
netmiko:
port: 22
hostname:
username: user
password: pass
platform: ios
extras:
optional_args:
secret: secret
session_log: path/to/save_log
napalm:
platform: ios
extras:
optional_args:
path: path/to/save_log
dummy:
hostname: dummy_from_host
port:
username:
password:
platform:
extras:
blah: from_host
配置文件
本节主要介绍如何加载 Nornir 的配置文件及各个配置项的详细信息。
配置文件
初始化 Nornir 对象时需要加载配置,配置信息由一些配置块及其包含的参数组成,Nornir 默认情况下有 core
、 runner
、inventory
、ssh
、logging
五个部分的默认配置,如果有额外的配置需要指定,可以直接添加配置块,并在代码里面进行调用。
Nornir 提供三种加载配置的方式:
在代码中以字典类型配置
使用系统环境变量
使用 YAML 配置文件
可以使用任意一种方式或者三种方式混合的方式提供配置信息,三种方式的优先级从高到低依次为:代码、系统环境变量、YAML 配置文件。
使用代码
nr = InitNornir(
# 使用字典的格式来进行配置
runner={"plugin": "threaded", "options": {"num_workers": 20}},
logging={"log_file": "mylogs", "level": "DEBUG"}
)
使用环境变量
每个配置项都有对应的环境变量键值,可以在下一节查看具体的值,Nornir 初始化时如果相关配置信息没有从代码的字典中找到,则从系统环境变量中查找,下面示例使用 os
模块的相关配置来代替已经存在的环境变量,具体使用中应根据对应的系统进行配置。
# 已经存在的系统环境变量
import os
os.environ.setdefault("NORNIR_RUNNER_OPTIONS","{'num_workers': 100}")
os.environ.setdefault("NORNIR_INVENTORY_OPTIONS","{'host_file':'./hosts.yaml',}")
# 初始化 Nornir,没有传递参数,环境变量中可以读取到相关配置
from nornir import InitNornir
nr=InitNornir()
nr.config.runner.options # 查看线程数
nr.inventory.hosts # 查看主机
使用配置文件
默认情况下 Nornir 会从程序运行的当前目录读取 hosts.yaml
文件,如果不存在则会报错;如果 hosts.yaml
中有关于 groups
的配置,还会继续加载 groups.yaml
文件。
---
inventory:
plugin: SimpleInventory
options:
host_file: "advanced_filtering/inventory/hosts.yaml"
group_file: "advanced_filtering/inventory/groups.yaml"
runner:
plugin: threaded
options:
num_workers: 20
配置参数详解
Nornir 的五个配置块及其对应参数的默认值和环境变量值。
core
raise_on_error
描述 |
如果配置为 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
runner
plugin
描述 |
任务运行的线程插件,分为两种: |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
options
描述 |
需要给插件传递的参数,默认为空字典 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
inventory
plugin
描述 |
要使用的主机清单插件名;必须注册该插件 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
options
描述 |
需要给插件传递的参数,默认为空字典 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
transform_function
描述 |
要使用的转换函数插件名;必须注册该插件 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
transform_function_options
描述 |
需要给插件传递的参数,默认为空字典 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
ssh
config_file
描述 |
指定 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
logging
默认情况下,当调用 InitNornir 时,Nornir 会自动配置日志记录。
日志记录的配置可以根据以下选项进行修改。
如果想使用 Python 的 logging 模块配置日志,需要确保此配置中 enable
参数值为 False,以免发生冲突(Python 中日志配置为一次性的配置,只有第一次调用的配置会生效,随后的调用不会产生生效)。
enabled
描述 |
是否启用日志记录功能 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
level
描述 |
日志记录的级别(CRITICAL > ERROR > WARNING > INFO > DEBUG) |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
log_file
描述 |
保存到日志文件的名称 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
format
描述 |
日志信息的格式 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
to_console
描述 |
日志是否输出到控制台 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
loggers
描述 |
默认使用的 |
数据类型 |
|
默认值 |
|
是否需要该配置 |
|
系统环境变量 |
|
user_defined
用户可以自行配置需要的 <k, v>
键值对, 使用时必须在 Config
对象下才能调用到该配置,例如: nr.config.user_defined.my_app_option
。
插件
插件
Nornir 是一个插件式系统,nornir3 中只包含了非常基础的插件,可以访问 nornir.tech 来查看已经公开发布的第三方插件,本篇介绍 nornir 自带的基础插件。
注册插件
从 nornir3 开始,插件需要注册之后才能使用,包括:
主机清单插件(inventory plugins)
转换函数(transform functions)
连接插件(connection plugins)
运行插件(runners)
插件注册有两种方式:
在打包发布插件时添加 entry point 来让 nornir 自动注册
在脚本中编写代码手动注册
自动注册
如果使用 setuptools
打包发布,需要在 setup.py
中添加 entry_points
:
setup(
# ...
entry_points={
"PATH": "NAME = path.to:Plugin",
}
)
如果使用 peotry
打包发布,需要在 pyproject.toml 中添加配置:
[tool.poetry.plugins."PATH"]
"NAME" = "path.to:Plugin"
其中:
PATH
是需要配置的插件类型,有以下几个值:
nornir.plugins.inventory
- for inventory pluginsnornir.plugins.transform_function
- for transform functionsnornir.plugins.runners
- for runnersnornir.plugins.connections
- for connection plugins
NAME
是要引用插件的的名称, path.to:Plugin
是导入路径,例如定义一个名为 inventory-name
的主机清单插件:
[tool.poetry.plugins."nornir.plugins.inventory"]
"inventory-name" = "path.to:InventoryPlugin"
使用带有 entry point
打包发布的插件,使用时 nornir 会自动进行注册。
手动注册
如果需要使用本地编写的插件,或者发布时没有添加 entry point
的插件,需要以代码的方式来注册,例如注册一个名为 inventory-name
的主机清单插件:
from nornir.core.plugins.inventory import InventoryPluginRegister
from path.to import InventoryPlugin
InventoryPluginRegister.register("inventory-name", InventoryPlugin)
连接插件(Connections)
是一种让 nornir 管理与设备的连接的插件,常见的有 netmiko、paramiko、napalm 等。
主机清单插件(Inventory)
是一种让 nornir 从外部源(如 YAML、CSV、DB 等)创建一个 Inventory
对象的插件,常见的有 SimpleInventory、ansible、netbox、table 等。
SimpleInventory
Nornir 默认包含 SimpleInventory 插件,它从 YAML 文件读取信息,源码定义如下:
class nornir.plugins.inventory.__init__.SimpleInventory(
host_file: str = 'hosts.yaml',
group_file: str = 'groups.yaml',
defaults_file: str = 'defaults.yaml',
encoding: str = 'utf-8'
):
def load()-> nornir.core.inventory.Inventory:...
转换函数(Transform function)
是一种独立于已使用的主机清单插件来操作主机的插件,可以帮助用户使用环境变量、加密存储或者类似的方式来对主机的数据进行扩展。
运行插件(Runners)
是一种定义如何在主机上执行任务的插件,分为单线程和多线程。
SerialRunner
SerialRunner 单线程插件,在每个主机上顺序执行任务,源码定义如下:
class nornir.plugins.runners.__init__.SerialRunner():
def run(
task: nornir.core.task.Task,
hosts: List[nornir.core.inventory.Host]
) -> nornir.core.task.AggregatedResult: ...
ThreadedRunner
ThreadedRunner 使用多线程在每个主机上异步执行任务,源码定义如下:
class nornir.plugins.runners.__init__.ThreadedRunner(num_workers: int = 20):
"""Parameters:
num_workers – number of threads to use"""
def run(
task: nornir.core.task.Task,
hosts: List[nornir.core.inventory.Host]
) -> nornir.core.task.AggregatedResult: ...
有关 ThreadedRunner 的更多详细信息,请阅读 任务执行模型。
处理器(Processors)
是一种让用户编写任意代码来控制任务执行事件的插件,具体使用方法见入门教程中的 处理器。
线程插件执行模型
Nornir 优点之一是它可以并行执行任务,工作方式如下:
你可以通过带有参数
num_workers > 1
(默认值是20
) 的nornir.core.Nornir.run
对象运行任务以达到并行的目的;如果
num_workers == 1
,nornir 通过简单的循环一个接一个地在所有主机上运行任务,通常用于故障排除/调试、写入磁盘/数据库或仅在屏幕上打印内容;并行执行任务时,nornir 会为每个主机使用不同的线程。
下面这个图说明了工作流程:

Nornir 也支持创建包含其他任务的任务,即 任务组(Grouping tasks) 。当运行任务组时,同一个子任务在所有主机上并行执行,子任务之间按顺序执行,这样就可以按照特定的需求来控制执行流程。
例如,可以编写如下工作流:

为什么要编写这样的工作流?大多数情况下,我们会尽可能多的将任务拆分进而形成不同的任务组,这样就可以保证脚本运行的更快,尤其是有很多主机的时候。 但是,某些任务可能需要在确保其他一些任务完成后才能运行。例如如下场景:
并行进行多台主机的配置
并行进行配置验证及测试
按照顺序启动服务
Nornir 一览图
下图包含了 Nornir 的大部分概念及简单的交互逻辑,希望可以帮助刚开始使用 Nornir 的朋友稍微加深一些理解。
如有纰漏之处,还请指正。