Фильтры 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

Из чего состоит код в данном примере:

  1. Выборка разделов диска:

    • ansible_mounts | selectattr('device', 'match', '/dev/sda')

    • Здесь используется фильтр selectattr для выбора тех элементов из списка ansible_mounts, у которых атрибут device соответствует регулярному выражению /dev/sda. Это позволяет выбрать информацию о монтировании только для определенного устройства.

  2. Преобразование словарей в списки элементов:

    • | map('dict2items')

    • Фильтр dict2items применяется к каждому элементу списка (каждому словарю), преобразуя его в список пар ключ-значение.

  3. Выборка определенных ключей:

    • | map('selectattr', 'key', 'in', ['mount', 'size_available', 'size_total'])

    • Далее, для каждого списка пар ключ-значение используется map совместно с selectattr, чтобы выбрать только те пары, ключи которых включают 'mount', 'size_available' или 'size_total'.

  4. Преобразование обратно в словари:

    • | map('items2dict')

    • Наконец, после фильтрации нужных ключей, каждый список пар ключ-значение преобразуется обратно в словарь с помощью map и фильтра items2dict.

Заключение

Мы познакомились с наглядными примерами использования фильтров Ansible, и, надеюсь, теперь они кажутся вам не такими уж сложными. Ведь часто всё кажется сложным, пока не попробуешь. С фильтрами Ansible точно так же: попробуйте, поэкспериментируйте, и вы увидите, как они могут упростить вашу жизнь. А еще напишите в комментариях, хотели бы вы увидеть продолжение статьи с другими интересными примерами фильтров? Если тема будет интересна, то в следующих статьях попробую в том числе раскрыть тему написания своих собственных кастомных фильтров.

© Habrahabr.ru