В посте рассматриваются следующие Ansible модули loop: with_items, with_nested, with_subelements, with_dict.


Все эти with* уже deprecated, и рекомендуется использовать loop.


Исходный код


Одна из моих ролей в Chromatic — член команды DevOps. Помимо прочего, это включает в себя работу с нашими серверами и серверами наших клиентов. Это, в свою очередь, означает, что я трачу много времени на работу с Ansible, популярным инструментом для инициализации, настройки и развертывания серверов и приложений.


Проще говоря, машина, на которой запущен Ansible, запускает команды на другом компьютере через SSH. Эти команды указываются декларативно (не обязательно) с использованием небольших участков YAML, называемых задачами. Эти TASKS вызывают модули Ansible, которые специализируются на выполнении опций с определенными компонентами, такими как файлы, базы данных и т. д.


Например, следующая задача использует модуль File (документация, код) для создания определенного каталога, если он еще не существует, и изменяет его атрибуты, если они еще не установлены правильно:


- file:
    path: /home/jenkins/.ssh
    state: directory
    owner: jenkins
    group: jenkins
    mode: 700

Несколько задач, относящихся к одной задаче, сгруппированы в роли, а несколько ролей могут быть сгруппированы в playbooks. Затем можно использовать playbook для выполнения точно таких же шагов конфигурации на любом количестве серверов одновременно.


Ansible декларативный?


TASKS Ansible записываются декларативно, то есть мы не указываем, какая базовая реализация должна использоваться для выполнения TASKS. Это полезно, поскольку обеспечивает высокий уровень абстракции, очень читаемый и относительно простой для написания кода, а в некоторых случаях позволяет нам использовать одну и ту же задачу на разных платформах. Например, есть модуль Ansible Copy, который используется для копирования файлов на конечный компьютер. В следующей задаче Ansible копирует файл конфигурации в правильный каталог на удаленном компьютере и устанавливает владельца, группу и права доступа к файлу:


- name: Copy SSH config file into Alice’s .ssh directory.
  copy:
    src: files/config
    dest: /home/alice/.ssh/config
    owner: alice
    group: alice
    mode: 0600

Для достижения того же результата мы могли бы, например, написать серию команд или функцию в bash, используя scp, chown и chmod. С Ansible мы можем сосредоточиться на желаемой конфигурации, не слишком заботясь о деталях.


С другой стороны, это также означает, что доступные инструменты иногда кажутся странными или необычными — в основном потому, что разработчики обычно имеют доступ к императивным инструментам в тех случаях, когда декларативный вариант не подходит.


Одно место, где я заметил это в Ansible, — это многократное выполнение одной и той же TASKS с набором разных элементов. В частности, я нашел инструменты циклов Ansible немного странными, не в последнюю очередь потому, что их шестнадцать — по сравнению с PHP, который имеет четыре вида циклов.


На самом деле для этого есть причина, если вас интересует внутреннее устройство Ansible. На странице Loops в документации указано, что «loops на самом деле представляют собой комбинацию вещей с _ + lookup(), поэтому любой плагин поиска можно использовать в качестве источника для цикла». Поиск (Lookups) — это тип плагина Ansible, который используется для «доступа к данным в Ansible из внешних источников», и если вы сравните документацию Loops и каталог плагинов Ansible на Github, вы увидите многие из них с одинаковыми именами.


Однако в документации Ansible поисковые запросы рассматриваются как «расширенная тема», и нет необходимости углубляться в исходный код, чтобы научиться использовать сами циклы. В оставшейся части этого поста описаны несколько наиболее часто используемых циклов Ansible, а также некоторые вещи, которые я узнал о том, как их использовать.


Циклы Ansible


TASKS, выполняемые в следующих примерах, являются более или менее произвольными примерами, связанными с созданием пользователей и их каталогов, но они тесно связаны с реальными задачами, которые могут потребоваться на производственных серверах (но обратите внимание: данные, с которыми мы должны работать, и количество задач, которые мы используем для достижения правильной конфигурации, явно нереально!)


Примеры основываются друг на друге для выполнения следующих простых задач на гипотетическом сервере:


  1. Убедитесь, что присутствуют четыре пользователя: alice, bob, carol и dan.


  2. Убедитесь, что домашний каталог каждого пользователя содержит два каталога: .ssh/ и loops.


  3. Убедитесь, что каждый из четырех домашних каталогов пользователей содержит по одному каталогу для каждого другого пользователя. Например, домашний каталог пользователя alice по завершении должен выглядеть так:



/home/alice/
+-- .ssh/
+-- bob/
+-- carol/
+-- dan/
L-- loops/

Цикл 1. Создание пользователей WITH_ITEMS


Типичная задача в Ansible может выглядеть примерно так, когда пользователь удаляет пользователя chuck из системы, в которой выполняется задача:


- name: Remove user ‘Chuck’ from the system.
  user:
    name: chuck
    state: absent
    remove: yes

Чтобы повторить эту задачу для нескольких пользователей — скажем, нам нужно удалить пользователей Chuck и Craig — мы просто добавляем в задачу параметр with_items. with_items принимает либо список (показанный здесь), либо переменную (как в остальных следующих примерах):


- name: Remove users ‘Chuck’ and ‘Craig’ from the system.
  user:
    name: "{{ item }}"
    state: absent
    remove: yes
  with_items:
    - chuck
    - craig

Возвращаясь к нашему первому примеру цикла, мы можем использовать with_items для создания первых пользователей в нашем списке, alice и bob:


Переменные

users_with_items:
  - name: "alice"
    personal_directories:
      - "bob"
      - "carol"
      - "dan"
  - name: "bob"
    personal_directories:
      - "alice"
      - "carol"
      - "dan"

TASKS

- name: "Loop 1: create users using 'with_items'."
  user:
    name: "{{ item.name }}"
  with_items: "{{ users_with_items }}"

Здесь мы используем модуль Ansible User для перебора переменной с именем users_with_items. Эта переменная содержит имена и информацию о двух пользователях, но задача только гарантирует, что пользователи существуют в системе, она не создает каталоги, содержащиеся в списке personal_directories каждого пользователя (обратите внимание, что personal_directories — это просто произвольный ключ в массиве данных для нашего примера).


Это примечательная особенность циклов Ansible (и Ansible в целом): поскольку TASKS вызывают определенные модули с определенной проблемной областью, обычно в задаче невозможно выполнять более одного вида вещей. В данном конкретном случае это означает, что мы не можем убедиться, что personal_directories пользователя существуют из этой TASKS (т. е. Потому что мы используем модуль User, а не модуль File).


Цикл with_items работает примерно так же, как этот цикл PHP:


<?php

foreach ($users_with_items as $user) {
  // Do something with $user...
}

Мы писали задачу как обычно, за исключением того, что:


• Мы заменили имя переменной item.name на имя пользователя.


• Мы добавили строку with_items, определяющую переменную для перебора.


Также стоит отметить, что внутри цикла Ansible текущая итерация всегда является item, и доступ к любому заданному свойству осуществляется с помощью item.property.


РЕЗУЛЬТАТЫ

/home/
+-- alice/
L-- bob/

Цикл 2: Создавайте каталоги общих пользователей, используя WITH_NESTED


Примечание: Для цикла 2 нужны созданные юзеры, например с помощью цикла 1. Иначе будет ошибка chown failed: failed to look up user


В этом примере мы используем две переменные, users_with_items из цикла 1, и новую, common_directories, которая представляет собой список всех каталогов, которые должны присутствовать в каталоге каждого пользователя. Это означает, что (снова возвращаясь к PHP), нам нужно что-то, что работает примерно так:


<?php

foreach ($users_with_items as $user) {
  foreach ($common_directories as $directory) {
    // Create $directory for $user...
  }
}

В Ansible мы можем использовать цикл with_nested. Циклы with_nested принимают два списка, второй из которых повторяется на каждой итерации первого:


Переменные

users_with_items:
  - name: "alice"
    personal_directories:
      - "bob"
      - "carol"
      - "dan"
  - name: "bob"
    personal_directories:
      - "alice"
      - "carol"
      - "dan"

common_directories:
  - ".ssh"
  - "loops

TASKS

# Note that this does not set correct permissions on /home/{{ item.x.name }}/.ssh!
- name: "Loop 2: create common users' directories using 'with_nested'."
  file:
    dest: "/home/{{ item.0.name }}/{{ item.1 }}"
    owner: "{{ item.0.name }}"
    group: "{{ item.0.name }}"
    state: directory
  with_nested:
    - "{{ users_with_items }}"
    - "{{ common_directories }}"

Как показано в приведенной выше задаче, к двум спискам в with_nested можно получить доступ, используя item.0 (для users_with_items) и item.1 (для common_directories) соответственно. Это позволяет нам, например, создайте каталог /home/alice/.ssh на самой первой итерации.


Результаты

/home/
+-- alice/
¦   +-- .ssh/
¦   L-- loops/
L-- bob/
    +-- .ssh/
    L-- loops/

Цикл 3: Создавайте личные каталоги пользователей, используя WITH_SUBELEMENTS


Примечание: Для цикла 3 нужны созданные юзеры, например с помощью цикла 1. Иначе будет ошибка chown failed: failed to look up user


В этом примере мы используем другой вид вложенного цикла with_subelements для создания каталогов, перечисленных в переменной users_with_items из цикла 1. В PHP цикл может выглядеть примерно так:


<?php

foreach ($users_with_items as $user) {
  foreach ($user['personal_directories'] as $directory) {
    // Create $directory for $user...
  }
}

Обратите внимание, что мы перебираем массив $users_with_items и $user['personal_directories'] для каждого пользователя.


Переменные

users_with_items:
  - name: "alice"
    personal_directories:
      - "bob"
      - "carol"
      - "dan"
  - name: "bob"
    personal_directories:
      - "alice"
      - "carol"
      - "dan"

TASKS

- name: "Loop 3: create personal users' directories using 'with_subelements'."
  file:
    dest: "/home/{{ item.0.name }}/{{ item.1 }}"
    owner: "{{ item.0.name }}"
    group: "{{ item.0.name }}"
    state: directory
  with_subelements:
    - "{{ users_with_items }}"
    - personal_directories

Цикл with_subelements работает почти так же, как with_nested, за исключением того, что вместо второй переменной он принимает переменную и ключ другого списка, содержащегося в этой переменной — в данном случае personal_directories. Как и в цикле 2, первая итерация этого цикла создает (или проверяет существование) /home/alice/bob.


Результаты

/home/
+-- alice/
¦   +-- .ssh/
¦   +-- bob/
¦   +-- carol/
¦   +-- dan/
¦   L-- loops/
L-- bob/
    +-- .ssh/
    +-- alice/
    +-- carol/
    +-- dan/
    L-- loops/

Цикл 4: Создавайте пользователей с использованием WITH_DICT


Цикл 3 завершил настройку домашних каталогов, принадлежащих alice и bob, но есть еще два выдающихся пользователя, которые нужно создать, carol и dan. В этом примере этих пользователей создаются с помощью новой переменной users_with_dict и цикла Ansible with_dict.


Обратите внимание, что структура данных здесь содержит значимые ключи (dict или dictionary — это имя Python для ассоциативного массива); with_dict может быть лучшим вариантом, если вы вынуждены использовать данные с таким типом структуры. Цикл, который мы создаем здесь в Ansible, в PHP примерно такой:


<?php

foreach ($users_with_dict as $user => $properties) {
  // Create a user named $user...
}

ПЕРЕМЕННЫЕ

users_with_dict:
  carol:
    common_directories: "{{ common_directories }}"
  dan:
    common_directories: "{{ common_directories }}"

TASKS

- name: "Loop 4: create users using 'with_dict'."
  user:
    name: "{{ item.key }}"
  with_dict: "{{ users_with_dict }}"

Тип цикла with_dict довольно краток и позволяет получить доступ к ключам переменной и соответствующим значениям. К сожалению, у него есть один практический недостаток, а именно то, что невозможно перебрать подэлементы dict с помощью with_dict (так, например, мы не можем использовать with_dict для создания общих каталогов каждого пользователя).


Результаты

/home/
+-- alice/
¦   +-- .ssh/
¦   +-- bob/
¦   +-- carol/
¦   +-- dan/
¦   L-- loops/
+-- bob/
¦   +-- .ssh/
¦   +-- alice/
¦   +-- carol/
¦   +-- dan/
¦   L-- loops/
+-- carol/
L-- dan/

Цикл 5: Создавайте личные каталоги, если они не существуют


Поскольку мы не можем легко использовать users_with_dict, нам нужно использовать доступные инструменты Ansible, чтобы сделать это по-другому. Поскольку теперь мы создали необходимых пользователей alice, bob, carol и dan, мы можем повторно использовать цикл with_nested вместе с содержимым каталога /home/. В этом примере используется несколько новых функций, не связанных с циклами, чтобы показать, как циклы могут быть интегрированы в относительно сложные TASKS:


  • Регистрируемые переменные Ansible
    • Ansible условные выражения
    • Jinja2 (переменные)
    • Jinja2 (фильтры)

Переменные

common_directories:
  - ".ssh"
  - "loops"

TASKS

- name: "Get list of extant users."
  shell: "find * -type d -prune | sort"
  args:
    chdir: "/home"
  register: "home_directories"
  changed_when: false

- name: "Loop 5: create personal user directories if they don't exist."
  file:
    dest: "/home/{{ item.0 }}/{{ item.1 }}"
    owner: "{{ item.0 }}"
    group: "{{ item.0 }}"
    state: directory
  with_nested:
    - "{{ home_directories.stdout_lines }}"
    - "{{ home_directories.stdout_lines | union(common_directories) }}"
  when: "'{{ item.0 }}' != '{{ item.1 }}'"

Здесь у нас есть две TASKS: одна использует модуль shell для выполнения команды find на сервере, а другая использует file для создания каталогов.


При выполнении в каталоге /home команда find \ -type d -prune | sort (выполняется модулем shell) вернет только имена каталогов, найденных внутри /home, другими словами, имена всех пользователей, каталоги которых необходимо подготовить.


Вывод этой команды сохраняется в переменной home_directories строкой register: "home_directories" в задаче. Важная часть этой переменной, которую мы будем использовать в следующей задаче, выглядит так:


"stdout_lines": [
  "alice",
  "bob",
  "carol",
  "dan",
],

Вторая задача в этом примере (фактический цикл) почти полностью совпадает с циклом with_nested во втором примере, но следует отметить два отличия:


  1. Вторая строка в разделе with_nested выглядит несколько необычно:

- "{{ home_directories.stdout_lines | union(common_directories) }}"

  1. Есть еще одна строка, начинающаяся с when в конце TASKS:


    when: "'{{ item.0 }}' != '{{ item.1 }}'"


Давайте пройдемся по ним по очереди. Нечетная строка под with_nested применяет фильтр Jinja2 к новому списку каталогов из первой TASKS выше (это часть home_directories.stdout_lines). Базовый синтаксис фильтров Jinja:


  • объект для фильтрации (home_directories.stdout_lines)
  • применить фильтр (|)
  • имя фильтра плюс аргументы, если есть (union (common_directories))

Другими словами, мы используем фильтр для объединения home_directories.stdout_lines и переменной common_directories из начала этого примера в единый массив:


item:
  - .ssh
  - alice
  - bob
  - carol
  - dan
  - loops

Это означает, что наш цикл with_nested будет перебирать каждый из home_directories.stdout_lines (первая строка with_nested) и гарантировать, что каждый из каталогов во второй строке существует в домашнем каталоге каждого пользователя.


К сожалению, это дало бы нам неверный результат — если бы мы полагались только на цикл, мы бы обнаружили, что домашний каталог каждого пользователя будет содержать каталог с тем же именем, что и домашний каталог! (например, /home/alice/alice, /home/bob/bob и т. д.) Вот где появляются условные выражения Ansible — when — приходят:


when: "'{{ item.0 }}' != '{{ item.1 }}'"

Эта строка не позволяет задаче создать каталог, когда текущий элемент в home_directories.stdout_lines и текущий элемент в нашем объединении home_directories.stdout_lines идентичны (как указано в документации Ansible Loops, «… при объединении when с with_items (или любой другой оператор цикла), оператор when обрабатывается отдельно для каждого элемента »). В PHP то, что мы делаем во второй задаче, будет выглядеть примерно так:


<?php

$users = ['alice', 'bob', 'carol', 'dan'];
$common_directories = ['.ssh', 'loops'];
$directories = $user + $common_directories;

foreach ($users as $user) {
  foreach ($directories as $directory) {
    if ($directory != $user) {
      // Create the directory…
    }
  }
}

Это дает нам набор результатов, показанных ниже, и завершает подготовку нашего тестового примера.


Результаты

/home/
+-- alice/
¦   +-- .ssh/
¦   +-- bob/
¦   +-- carol/
¦   +-- dan/
¦   L-- loops/
+-- bob/
¦   +-- .ssh/
¦   +-- alice/
¦   +-- carol/
¦   +-- dan/
¦   L-- loops/
+-- carol/
¦   +-- .ssh/
¦   +-- alice/
¦   +-- bob/
¦   +-- dan/
¦   L-- loops/
L-- dan/
    +-- .ssh/
    +-- alice/
    +-- bob/
    +-- carol/
    L-- loops/

Выводы


Циклы Ansible довольно странные. Они не только декларативны (как и все остальное в Ansible), но и имеют много разных типов, некоторые из имен которых (with_nested? with_subitems?) Трудно распутать.


С другой стороны, они достаточно мощны, чтобы выполнять TASKS, хотя это может потребовать небольшого сдвига в мышлении (во многом подобно языковым функциям, таким как array_filter, array_reduce, array_map и другим подобным функциям, когда вы впервые сталкиваетесь с ними). Прошло некоторое время, прежде чем я действительно начал понимать, что необходимо присоединить цикл к задаче — даже если это иногда означает повторение одних и тех же данных более одного раза — вместо выполнения одной или нескольких задач внутри цикла.


Надеюсь, этот пост поможет вам избавиться от моего первоначального затруднения. С этой целью я собрал виртуальную машину Vagrant (Vagrant изначально поддерживает использование Ansible для подготовки) и Ansible playbook, который я использовал для создания и тестирования этих примеров). Просто следуйте инструкциям в README, чтобы запустить примеры из этого сообщения или попробовать свои собственные. Если у вас есть какие-либо вопросы или комментарии, напишите нам в Twitter по адресу @chromaticHQ!