Фильтры Ansible: превращаем сложное в простое
Используя Ansible в качестве инструмента автоматизации, часть приходится сталкиваться с задачей обработки и фильтрации структурированных данных. Как правило, это набор фактов, полученных с управляемых серверов, или ответ на запрос к внешним API, которые возвращают данные в виде стандартного json. Многие неопытные инженеры, используя Ansible в таких случаях, начинают прибегать к помощи привычных консольных команд и начинают городить то, что среди специалистов получило название bashsible. В общем, вспоминается известный мем:
не надо так!
В этой статье мы покажем, как легко и просто можно обрабатывать данные прямо в Ansible, используя его собственные мощные возможности. Речь идет о фильтрах Jinja2, которые представляют собой мощный, но в то же время интуитивно понятный инструмент для трансформации данных. Эти фильтры позволяют эффективно сортировать, выбирать и преобразовывать данные, устраняя необходимость в сложных внешних командах и скриптах.
В обычных языках программирования задача обработки данных обычно решается с помощью циклов (for, while, for_each и т.п.) и различных функций преобразования типов объектов (массивы, коллекции, замыкания и т.п.) Ansible использует упрощенную модель данных, используя по сути лишь два варианта объекта с данными, список (list) и словарь (dictionary). Если вы не очень хорошо понимаете, что это такое и чем они отличаются, рекомендую для начала прочесть вот эту короткую статью.
Начнем с базовых примеров использования фильтров для работы со списками и словарями. Это позволит вам видеть, насколько мощными и гибкими могут быть эти инструменты в правильных руках.
На практике часто приходится иметь дело с фактами или результатом выполнения определенных модулей, которые представляют собой структуру в виде json/yaml. Например, давайте возьмем такой факт, как ansible_mounts
и попробуем с ним поработать.
Попробуйте запустить у себя следующий плейбук, выводящий значения ansible_mounts
:
---
- name: Test ansible_mounts
hosts: localhost
gather_facts: true
connection: local
tasks:
- name: Show ansible_mounts
debug:
var: ansible_mounts
В выводе получится что-то вроде такого:
TASK [Show ansible_mounts] *********************************************
ok: [localhost] =>
ansible_mounts:
- block_available: 9906494
block_size: 4096
block_total: 16305043
block_used: 6398549
device: /dev/sda3
fstype: ext4
inode_available: 3666700
inode_total: 4161536
inode_used: 494836
mount: /
options: rw,relatime,errors=remount-ro
size_available: 40576999424
size_total: 66785456128
uuid: e24606ee-2b07-4de0-a3c3-63c605f627ff
- block_available: 0
block_size: 131072
block_total: 1
block_used: 1
device: /dev/loop0
fstype: squashfs
inode_available: 0
inode_total: 29
inode_used: 29
mount: /snap/bare/5
options: ro,nodev,relatime,errors=continue,threads=single
size_available: 0
size_total: 131072
uuid: N/A
- block_available: 0
block_size: 131072
block_total: 507
block_used: 507
device: /dev/loop1
fstype: squashfs
inode_available: 0
inode_total: 11906
inode_used: 11906
mount: /snap/core20/1822
options: ro,nodev,relatime,errors=continue,threads=single
size_available: 0
size_total: 66453504
uuid: N/A
...
Давайте попробуем отфильтровать из этого длинного списка только те значения, для которых значение ключа device
содержит /dev/sda. Это можно сделать с помощью фильтра selectattr и теста match:
---
- name: Show ansible_mounts filtered
hosts: localhost
gather_facts: true
connection: local
tasks:
- name: Show ansible_mounts
debug:
var: ansible_mounts | selectattr('device', 'match', '/dev/sda')
Получим примерно такое:
TASK [Show ansible_mounts] *********************************
ok: [localhost] =>
ansible_mounts | selectattr('device', 'match', '/dev/sda'):
- block_available: 9906486
block_size: 4096
block_total: 16305043
block_used: 6398557
device: /dev/sda3
fstype: ext4
inode_available: 3666696
inode_total: 4161536
inode_used: 494840
mount: /
options: rw,relatime,errors=remount-ro
size_available: 40576966656
size_total: 66785456128
uuid: e24606ee-2b07-4de0-a3c3-63c605f627ff
- block_available: 9906486
block_size: 4096
block_total: 16305043
block_used: 6398557
device: /dev/sda3
fstype: ext4
inode_available: 3666696
inode_total: 4161536
inode_used: 494840
mount: /var/snap/firefox/common/host-hunspell
options: ro,noexec,noatime,errors=remount-ro,bind
size_available: 40576966656
size_total: 66785456128
uuid: e24606ee-2b07-4de0-a3c3-63c605f627ff
- block_available: 129508
block_size: 4096
block_total: 131063
block_used: 1555
device: /dev/sda2
fstype: vfat
inode_available: 0
inode_total: 0
inode_used: 0
mount: /boot/efi
options: rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro
size_available: 530464768
size_total: 536834048
uuid: 7041-A883
Теперь давайте предположим, что нам из всего набора ключей каждого словаря нужно только значение ключа mount
. Как выбрать из полученного списка лишь значения определенного ключа? Тут нам на помощь придет фильтр map. Это довольно мощный фильтр, суть которого сводится к тому, что он применяет фильтр с аргументами, которые сами переданы ему в качестве аргументов, к каждому элементу списка словарей, которые приходят на вход. В простейшем случае, если нам нужно просто получить значение конкретного ключа из каждого элемента списка словарей, использование данного фильтра будет очень простым. Нужно просто указать значение нужного имени ключа в виде атрибута фильтра с соответствующей командой. В нашем случае это будет map(attribute='mount')
. В результате получим следующий код:
---
- name: Show ansible_mounts filtered
hosts: localhost
gather_facts: true
connection: local
tasks:
- name: Show mounts
debug:
var: >-
ansible_mounts
| selectattr('device', 'match', '/dev/sda')
| map(attribute='mount')
В выводе получим следующее:
TASK [Show ansible_mounts] ***************************************
ok: [localhost] =>
? |-
ansible_mounts
| selectattr('device', 'match', '/dev/sda')
| map(attribute='mount')
: - /
- /var/snap/firefox/common/host-hunspell
- /boot/efi
Как видим, получить нужный нам набор данных оказывается весьма просто даже без использования программирования и циклов. Давайте усложним задачу. Скажем, нам нужно получить в выводе значения не только ключа mount, но также значения ключей size_available
и size_total
. Идущие в комплекте фильтры так не умеют. Фильтры Ansible умеют фильтровать списки и списки словарей, но не сами ключи словаря. Выход прост: нужно превратить словарь в список и уже его отфильтровать имеющимися инструментами. К счастью, в Ansible есть подходящие фильтры для такой задачи. С их помощью можно превращать словари в списки, а списки — обратно в словари. Называются эти фильтры, соответственно, dict2items и items2dict.
Например, у нас есть следующий словарь:
server_config:
apache:
version: "2.4"
modules: ["mod_ssl", "mod_rewrite"]
php:
version: "7.4"
extensions: ["curl", "json", "pdo"]
mysql:
version: "5.7"
databases: ["db1", "db2", "db3"]
system:
os: "Ubuntu"
os_version: "20.04"
И мы хотим отфильтровать только те элементы словаря, где есть ключ version
. Сделать это мы сможем так. Сначала превращаем словать в список с помощью фильтра dict2items:
server_config | dict2items
Получим:
ok: [localhost] =>
server_config | dict2items:
- key: apache
value:
modules:
- mod_ssl
- mod_rewrite
version: '2.4'
- key: php
value:
extensions:
- curl
- json
- pdo
version: '7.4'
- key: mysql
value:
databases:
- db1
- db2
- db3
version: '5.7'
- key: system
value:
os: Ubuntu
os_version: '20.04'
Теперь отфильтруем только те элементы списка, которые содержат дочерний ключ version
:
server_config | dict2items | selectattr('value.version', 'defined')
Получим:
ok: [localhost] =>
server_config | dict2items | selectattr('value.version', 'defined'):
- key: apache
value:
modules:
- mod_ssl
- mod_rewrite
version: '2.4'
- key: php
value:
extensions:
- curl
- json
- pdo
version: '7.4'
- key: mysql
value:
databases:
- db1
- db2
- db3
version: '5.7'
Теперь превратим обратно список в словарь исходного вида. Для этого добавим в конец конвейера фильтр items2dict
. Получаем:
ok: [localhost] =>
server_config | dict2items | selectattr('value.version', 'defined') | items2dict:
apache:
modules:
- mod_ssl
- mod_rewrite
version: '2.4'
mysql:
databases:
- db1
- db2
- db3
version: '5.7'
php:
extensions:
- curl
- json
- pdo
version: '7.4'
Теперь давайте вспомним про нашу исходную задачу со списком словарей ansible_mounts
из которого мы хотим извлечь только некоторые ключи. Сам список мы уже отфильтровали по нужному нам условию, теперь нам нужно выбрать только определенные ключи из списка. Получается, что к каждому элементу списка словарей нужно применить фильтр dict2items
, потом отфильтровать этот список по списку ключей, а потом обратно превратить каждый дочерний список обратно в словарь. Сложно? На самом деле не очень. Как работать со словарем, мы уже видели на примере выше. Теперь нам нужно проделать то же самое со списком словарей. Тут нам как раз поможет упоминавшийся выше фильтр map
, только уже в более продвинутом варианте применения. Покажем сразу итоговый результат. Код:
- name: Show mounts data
debug:
var: >-
ansible_mounts
| selectattr('device', 'match', '/dev/sda')
| map('dict2items')
| map('selectattr', 'key', 'in', ['mount', 'size_available', 'size_total'])
| map('items2dict')
И результат:
ok: [localhost] =>
? |-
ansible_mounts
| selectattr('device', 'match', '/dev/sda')
| map('dict2items')
| map('selectattr', 'key', 'in', ['mount', 'size_available', 'size_total'])
| map('items2dict')
: - mount: /
size_available: 40576851968
size_total: 66785456128
- mount: /var/snap/firefox/common/host-hunspell
size_available: 40576851968
size_total: 66785456128
- mount: /boot/efi
size_available: 530464768
size_total: 536834048
Из чего состоит код в данном примере:
Выборка разделов диска:
ansible_mounts | selectattr('device', 'match', '/dev/sda')
Здесь используется фильтр
selectattr
для выбора тех элементов из спискаansible_mounts
, у которых атрибутdevice
соответствует регулярному выражению/dev/sda
. Это позволяет выбрать информацию о монтировании только для определенного устройства.
Преобразование словарей в списки элементов:
| map('dict2items')
Фильтр dict2items применяется к каждому элементу списка (каждому словарю), преобразуя его в список пар ключ-значение.
Выборка определенных ключей:
| map('selectattr', 'key', 'in', ['mount', 'size_available', 'size_total'])
Далее, для каждого списка пар ключ-значение используется
map
совместно сselectattr
, чтобы выбрать только те пары, ключи которых включают 'mount', 'size_available' или 'size_total'.
Преобразование обратно в словари:
| map('items2dict')
Наконец, после фильтрации нужных ключей, каждый список пар ключ-значение преобразуется обратно в словарь с помощью
map
и фильтраitems2dict
.
Заключение
Мы познакомились с наглядными примерами использования фильтров Ansible, и, надеюсь, теперь они кажутся вам не такими уж сложными. Ведь часто всё кажется сложным, пока не попробуешь. С фильтрами Ansible точно так же: попробуйте, поэкспериментируйте, и вы увидите, как они могут упростить вашу жизнь. А еще напишите в комментариях, хотели бы вы увидеть продолжение статьи с другими интересными примерами фильтров? Если тема будет интересна, то в следующих статьях попробую в том числе раскрыть тему написания своих собственных кастомных фильтров.