Наделяем ansible состоянием, делая похожим на terraform
(Читать с толикой сарказма…) Все, кто работал с ansible, знают, что он не хранит состояние результата своей работы. Это нелепое поведение ansible, нельзя взять и просто удалить из git объекты конфигурации, чтобы они исчезли с управляемых систем, фу. При этом сразу вспоминается его величество terraform с tfstate. Всех, кого раздражает подобное положение дел, прошу в подкат.
*
Предназначено для опытных пользователей ansible (но можете рискнуть), знающих, например, как определяется сетевой интерфейс по умолчанию.
Да они просто роутят наудачу!
Проблематика
Теперь серьёзнее. Мы в РТЛабс любим автоматизацию, хороший DevOps — ленивый изучающий DevOps. Активно используем ansible для управления инфраструктурой и продуктовыми сервисами. Он очень крутой и закрывает большинство наших «болей», а если не позволяет сделать что-то из коробки, всегда можно дописать, спасибо Python.
Но что с ansible не так? Почему иногда глаз дёргается в сторону других схожих инструментов?
Причин может быть множество*, в этот раз рассмотрим отсутствие внутреннего состояния — такой области хранения данных, которая запоминает последние применённые настройки к серверу.
*Хотя бы вспомнить порядок наследования переменных, а ты и не помнишь:-), интерполяцию переменных и приведение их к определённому типу, слабые возможности по управлению ошибками и т. д.
Ну вот серьёзно, создаёшь файлы, БД, пользователей или другие объекты, а чтобы их удалить, надо сначала явно прописать статус удаления (обычно absent), прогнать код и только потом удалить из git. Хочется, как у terraform: удалил из git, пушнул и всё — состояние в git соответствует состоянию управляемых систем, спасибо CI/CD за это.
Предлагаемое решение
Раз у terraform есть tfstate, который хранит последнее успешное применённое состояние (это и позволяет terraform понимать, что создать, изменить или удалить), то почему бы не реализовать подобную логику для ansible?
Что в арсенале возможностей ansible можно задействовать для решения подобной задачи?
Изучая данный вопрос, пришли к выводу, что наиболее подходящим решением будет функционал локальных фактов ansible и правильный алгоритм работы с ними.
Тому, кто не знает или не помнит о чём речь, стоит прервать чтение статьи и ознакомиться с документацией.
Как в целом выглядит алгоритм
Ansible создаёт/изменяет объекты, определённые в inventory
При успешном завершении сохраняет актуальный список объектов в локальные факты о системе
При последующих запусках происходит сверка с локальными фактами, недостающие объекты удаляются, а существующие также продолжают находиться под контролем ansible
Схематичное представление алгоритма
Пример реализации предложенного решения
Реализуем предложенный подход на практике. В качестве примера предлагаю взять случай использования локальных учётных записей (УЗ).
У нас за каждой командой закреплены свои сервера, которыми они управляют. Но иногда бывают случаи, когда временно или на постоянной основе надо предоставить доступ сотрудникам из других команд.
Есть роль, которая создаёт локальные УЗ с правами sudo, далее админов, и использует переменную со списком всех пользователей
admins:
- name: test.test
- name: test.test2
Чтобы удалить пользователя, необходимо указать состояние
admins:
- name: test.test
- name: test.test2
state: absent
И прогнать роль по всем управляемым объектам. Когда их сотни, задача немного усложняется.
Какие тут подводные камни
Можно просто удалить пользователя из списка
Не прокатить роль по всем серверам
Могут быть случаи, когда временно создают пользователя и вообще забывают отправить изменения в git
Всё это приводит к неуправляемым объектам в инфраструктуре. Пример с админами взят как самый критичный — он может привести к несанкционированному доступу.
Практический код
Теория — это хорошо, но предлагаю закрепить реализацию предложенного решения с использованием локальных фактов. Подготовил роль, далее предлагаю разобрать её по основным моментам, по ходу комментируя, почему она написана так, а не иначе.
Defaults
Используются два списка переменных: admins и admins_extra. Использование двух переменных облегчает использование роли.
Обычно admins — определяется на уровне all в inventory, и тут же определяются все сотрудники команды.
Admins_extra — задаётся на уровне отдельных групп или хостов, в неё включаются УЗ из других команд.
Такое распределение позволяет нивелировать возможные сложности по управлению УЗ.
Tasks
- name: MAIN | Assert to check list of admin users
ansible.builtin.assert:
that: admins | length > 1
Проверяем, что админов задано более 2. Это перестраховка на тот случай, если роль будет запущена без указания переменной admins, тогда возможно удаление созданных ранее пользователей.
- name: MAIN | Assert admins and admins_extra lists are unicle
ansible.builtin.assert:
that: (admins + admins_extra) | map(attribute='name') | list | unique | length == (admins + admins_extra) | map(attribute='name') | list | length
fail_msg: admins and admins_extra must be used different username
Убеждаемся, что списки админов содержат только уникальных пользователей, а то могут быть проблемы.
- name: MAIN | Update local facts
ansible.builtin.setup:
gather_subset:
- "!all"
- local
Обязательно собираем минимально необходимые факты, т. к. может сложиться ситуация, что роль запущена через playbook без сбора фактов.
Далее всё просто: создаём указанную группу и правила sudo.
Создаём пользователей, итерируясь по двум спискам админов.
- name: USERS | LOOP | create admins
ansible.builtin.include_tasks: "user.yml"
loop: "{{ admins }}"
loop_control:
loop_var: user
- name: USERS | LOOP | create admins extra
ansible.builtin.include_tasks: "user.yml"
loop: "{{ admins_extra }}"
loop_control:
loop_var: user
По факту вызывается один и тот же набор задач.
Отдельные особенности из user
Пароль задаётся пустым — такое требование ИБ, вход только по ключам.
Пароль всегда обновляется.
Директория пользователя удаляется, если иное не задано в соответствующей переменной.
SSH-ключ тоже всегда обновляем, обратите внимание, что это действие по условию.
Далее начинается самое интересное:
- name: MAIN | Compare users from facts
ansible.builtin.include_tasks: state.yml
when:
- (admins + admins_extra) | list != (ansible_local['admins_'+admins_team] | default([])) | list
Набор задач state включается только в случае, если текущий список суммарных админов (admins
, admins_extra
) не совпадает с ранее сохранённым в локальных фактах. Если таких фактов нет и первый раз роль запускается по хосту, то возвращается пустой список. Обратите внимание, что сравнение происходит по всему листу словарей. Т. е. если любой атрибут будет изменён, будет вызван state.yml. Приводить к list необязательно, просто так будет нагляднее и исключит неправильное использование роли.
Задачи из state.yml
- name: "State | Delete not existed admin users: {{ user }}"
ansible.builtin.user:
name: "{{ user }}"
state: absent
remove: "{{ ansible_local['admins_'+admins_team] | selectattr('name', 'equalto', user) | map(attribute='remove') | join | default(True)}}"
loop: "{{ (ansible_local['admins_'+admins_team] | default([]) | map(attribute='name')) | difference((admins + admins_extra) | map(attribute='name') | list) }}"
loop_control:
loop_var: user
Наверное, это самый интересный вызов модуля, ради него вся статья.
Что тут происходит.
Мы итерируемся по разнице между текущим суммарным списком админов и ранее сохранённым списком в локальные факты. Тут нужно обратить внимание, что мы берём только атрибуты name. Это значит, что итоговый лист, по которому будет выполняться цикл, будет состоять только из имён. Именно поэтому нужно такое хитрое условие на удаление «хомяка» пользователя.
Итерироваться только по атрибуту name — жизненно необходимо. Если сравнивать словари полностью как элементы списка администраторов, то могут быть ложные срабатывания.
Далее мы сохраняем текущий список админов и ещё раз обновляем локальные факты (обновление проходит очень быстро).
В итоге получаем нужную логику
Users.yml управляет пользователями, полученными как переменные для хоста.
State.yml сравнивает текущий список с ранее применённым, удаляет разницу.
Важно
Хочется обратить внимание на отдельные положения:
— запись текущих переменных в факты должна проходить в самом конце выполнения роли. В случае успешного прохождения получаем поведение — обновление фактов.
— внимательный читатель заметил, что имя переменной в фактах завязано на переменную is_support_team
.
❗️ Уникальность переменной
is_support_team
Нужна для уникальности фактов. Как и говорил, у каждой команды есть свои сервера, но бывают исключения, когда одни и те же сервера могут администрировать несколько команд и каждая хочет создавать своих админов. Никто не исключает, что общие сервера будут появляться и в дальнейшем. Благодаря уникальному имени факта каждая команда сможет вести свой список локальных админов да и в принципе становится понятно, кто создал УЗ.
Иначе бы УЗ пересоздавались каждый раз, когда разные команды прокатывают роль по одним и тем же серверам. Переменная
is_support_team
— это наша базовая переменная, которая определяется всегда и однозначно указывает команду. Без такого подхода нет гарантий того, что пользователи не будут удалены. На самом деле кто-то может указать не ту группу, но такие сотрудники могут и rm — rf в корне вызвать, базу прода дропнуть или tfstate руками поменять, не обновить или удалить.*Против лома нет приёма
Заключение
Не знаю, насколько «стильным» (TERRAFORM!) получилось решение. Другого для минимизации потеряшек не нашёл. Наверное, можно было бы обмазаться кодом Python, но зачем?
Что мы получили по итогу
Реализация логики terraform — что в git, то и в инфре, только для отдельных элементов, для которых это действительно нужно.
Неплохое место для размещения state — локальные факты. Можно использовать любое другое, например s3, consul. Главное, чтобы комбинация размещения и имени переменной была уникальна в вашей инфраструктуре.
Да, terraform честно видит все изменения и работает по ним, тут получается эмуляция такой деятельности, но результат схожий. Оценивая предложенную реализацию, кто-то может сказать, что мы изобрели велосипед, но как вы решаете схожую проблему?
Хочется подкинуть ещё пару сценариев использования локальных переменных:
— передача данных между playbook или inventory. К примеру, указание принадлежности сервера к команде или системе. Другие playbook будут понимать, что это за система/команда, и реализовывать свою логику в обработке полученных значений
— локальный факт можно получить из результата выполнения исполняемого файла. Положите в /etc/ansible/facts.d исполняемый файл или ссылку на него. Всё, что он вернёт на STDOUT в формате JSON или INI, будет считаться фактами. Вот хороший пример.
*RTFM себе под нос
Надеюсь, читатель найдёт применение предложенному подходу и получит положительный результат.
Всееееем успехов!