В посте рассматриваются следующие 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/
└── 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/
└── 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/
│   └── loops/
└── bob/
    ├── .ssh/
    └── 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/
│   └── loops/
└── bob/
    ├── .ssh/
    ├── alice/
    ├── carol/
    ├── dan/
    └── 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/
│   └── loops/
├── bob/
│   ├── .ssh/
│   ├── alice/
│   ├── carol/
│   ├── dan/
│   └── loops/
├── carol/
└── 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/
│   └── loops/
├── bob/
│   ├── .ssh/
│   ├── alice/
│   ├── carol/
│   ├── dan/
│   └── loops/
├── carol/
│   ├── .ssh/
│   ├── alice/
│   ├── bob/
│   ├── dan/
│   └── loops/
└── dan/
    ├── .ssh/
    ├── alice/
    ├── bob/
    ├── carol/
    └── loops/

Выводы


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


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


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