Предположим, что заказчик попросил вас помочь с переносом скрипта для развертывания централизованного файла sudoers на серверах RHEL и AIX.

Что же, это вполне распространенный сценарий, и на его примере можно продемонстрировать использование расширенных возможностей Ansible, а также того, как меняется подход – от скрипта, который выполняет некую задачу, к идемпотентному (без внесения изменений) описанию и контролю за соблюдением состояния экземпляра.
Возьмем скрипт:
Здесь 212 строк кода, при этом какой-либо контроль версий в файле sudoers отсутствует. У заказчика уже имеется некий процесс, который запускается раз в неделю и проверяет контрольную сумму файла для обеспечения безопасности. Хотя в скрипте есть отсылка к Solaris, для этого заказчика нам не пришлось переносить еще и это требование.
Начнем с того, что создадим роль и поместим файл sudoers в Git для контроля версий. Помимо прочего это позволит нам избавиться от необходимости монтирования NFS томов.
С параметрами «validate» и «backup» для модулей copy и template, мы можем избавиться от необходимости написания кода для создания резервных копий и восстановления файла. При этом валидация осуществляется перед тем, как файл будет помещен в точку назначения, и если валидация не проходит, модуль выдает ошибку.
Для каждой роли нам необходимо указать задачи, шаблоны и переменные. Вот как выглядит структура соответствующего файла:

Файл со сценариями ролей (плейбук), sudoers.yml, имеет простую структуру:
Переменные ролей расположены в файле vars/main.yml. Здесь указан файл с контрольной суммой и директивы include/exclude, которые будут использоваться для создания специальной логики, чтобы пропускать хосты “Lawson” и включать файл sudoers.d только в хосты «hd».
Вот содержимое файла vars/main.yml:
Если мы используем модули copy и lineinfile, то роль не будет идемпотентной. Модуль copy установит базовый файл, и lineinfile при каждом запуске будет заново вставлять include. Поскольку эта роль будет запускаться на Ansible Tower, идемпотентность является обязательным требованием. Мы преобразуем файл в шаблон jinja2.
В первой строчке мы добавляем следующую команду для управления пробелами и отступами:
Обратите внимание, что более новые версии модуля template включают в себя параметры для trim_blocks (добавлено в Ansible 2.4).
Вот код, который вставляет строку include в конце файла:
Используем условную конструкцию ( {% if %}, {% endif %} ) для shell команды, вставляющей строку для хостов, имена которых начинаются с символов «hd». Мы используем факты Ansible и фильтр [0:2] для парсинга имени хоста.
Теперь переходим к задачам. Во-первых, необходимо установить факт для парсинга имени хоста. Мы будем использовать в условной конструкции факт «parhost».
На стоковом сервере RHEL параметр csum отсутствует. В случае необходимости мы можем использовать другой факт для условного указания имени бинарного файла с контрольной суммой. Обратите внимание, что может потребоваться дополнительный код, если эти функции отличаются в AIX, Solaris и Linux.
Кроме того, предстоит решить вопрос с различиями в группах root в AIX и RHEL.
Использование блоков (block) позволит нам задавать условие для всей задачи. Мы будем использовать условие в конце блока, чтобы исключить хосты «la».
Модуль шаблонов осуществляет валидацию и установку файла. Фиксируем результат, чтобы можно было определить, не поменялась ли задача. Использование параметра validate в этом модуле позволяет убедиться в валидности нового файла sudoer, перед тем как разместить его на хосте.
Если был установлен новый шаблон, запускаем shell скрипт для генерации файла с контрольной суммой. Условная конструкция обновляет файл с контрольной суммой при установке шаблона sudoers, или если файл с контрольной суммой отсутствует. Поскольку запущенный процесс также отслеживает и другие файлы, мы используем shell код, представленный в исходном скрипте:
Модуль file проверяет установку необходимых разрешений:
Поскольку параметр backup не предусматривает каких-либо опций для обработки предыдущих резервных копий, нам придется самим позаботиться о создании соответствующего кода. В примере ниже мы используем для этого параметр «register» и поле «stdout_lines».
Завершение блока:
Предполагаемый сценарий использования заключается в том, чтобы запускать эту роль на Ansible Tower. Оповещения Ansible Tower можно сконфигурировать таким образом, чтобы в случае сбоя в исполнении задания оповещения приходили на электронную почту, в Slack или каким-либо иным образом. Эта роль запускается в Ansible, Ansible Engine или Ansible Tower.
В результате, мы удалили из скрипта все лишнее и создали полностью идемпотентную роль, которая способна обеспечить желаемое состояние файла sudoers. Использование SCM позволяет осуществлять контроль версий, обеспечивает более эффективное управление изменениями и прозрачность. CI/CD с Jenkins или другими инструментами позволяют наладить автоматизированное тестирование кода Ansible для будущих изменений. Роль Auditor в Ansible Tower позволяет контролировать и обеспечивать соблюдение требований организаций.
Из скрипта можно было бы удалить код для работы с контрольными суммами, но для этого заказчику потребовалось бы сначала проконсультироваться со своей службой безопасности. При необходимости шаблон sudoers можно защитить с помощью Ansible Vault. Наконец, использование групп позволяет избежать написания логики с применением includes и excludes.
→ Загрузить роль можно с GitHub по этой ссылке

Что же, это вполне распространенный сценарий, и на его примере можно продемонстрировать использование расширенных возможностей Ansible, а также того, как меняется подход – от скрипта, который выполняет некую задачу, к идемпотентному (без внесения изменений) описанию и контролю за соблюдением состояния экземпляра.
Возьмем скрипт:
#!/bin/sh # Desc: Distribute unified copy of /etc/sudoers # # $Id: $ #set -x export ODMDIR=/etc/repos # # perform any cleanup actions we need to do, and then exit with the # passed status/return code # clean_exit() { cd / test -f "$tmpfile" && rm $tmpfile exit $1 } #Set variables PROG=`basename $0` PLAT=`uname -s|awk '{print $1}'` HOSTNAME=`uname -n | awk -F. '{print $1}'` HOSTPFX=$(echo $HOSTNAME |cut -c 1-2) NFSserver="nfs-server" NFSdir="/NFS/AIXSOFT_NFS" MOUNTPT="/mnt.$$" MAILTO="unix@company.com" DSTRING=$(date +%Y%m%d%H%M) LOGFILE="/tmp/${PROG}.dist_sudoers.${DSTRING}.log" BKUPFILE=/etc/sudoers.${DSTRING} SRCFILE=${MOUNTPT}/skel/sudoers-uni MD5FILE="/.sudoers.md5" echo "Starting ${PROG} on ${HOSTNAME}" >> ${LOGFILE} 2>&1 # Make sure we run as root runas=`id | awk -F'(' '{print $1}' | awk -F'=' '{print $2}'` if [ $runas -ne 0 ] ; then echo "$PROG: you must be root to run this script." >> ${LOGFILE} 2>&1 exit 1 fi case "$PLAT" in SunOS) export PINGP=" -t 7 $NFSserver " export MOUNTP=" -F nfs -o vers=3,soft " export PATH="/usr/sbin:/usr/bin" echo "SunOS" >> ${LOGFILE} 2>&1 exit 0 ;; AIX) export PINGP=" -T 7 $NFSserver 2 2" export MOUNTP=" -o vers=3,bsy,soft " export PATH="/usr/bin:/etc:/usr/sbin:/usr/ucb:/usr/bin/X11:/sbin:/usr/java5/jre/bin:/usr/java5/bin" printf "Continuing on AIX...\n\n" >> ${LOGFILE} 2>&1 ;; Linux) export PINGP=" -t 7 -c 2 $NFSserver" export MOUNTP=" -o nfsvers=3,soft " export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin" printf "Continuing on Linux...\n\n" >> ${LOGFILE} 2>&1 ;; *) echo "Unsupported Platform." >> ${LOGFILE} 2>&1 exit 1 esac ## ## Exclude Lawson Hosts ## if [ ${HOSTPFX} = "la" ] then echo "Exiting Lawson host ${HOSTNAME} with no changes." >> ${LOGFILE} 2>&1 exit 0 fi ## ## * NFS Mount Section * ## ## Check to make sure NFS host is up printf "Current PATH is..." >> ${LOGFILE} 2>&1 echo $PATH >> $LOGFILE 2>&1 ping $PINGP >> $LOGFILE 2>&1 if [ $? -ne 0 ]; then echo " NFS server is DOWN ... ABORTING SCRIPT ... Please check server..." >> $LOGFILE echo "$PROG failed on $HOSTNAME ... NFS server is DOWN ... ABORTING SCRIPT ... Please check server ... " | mailx -s "$PROG Failed on $HOSTNAME" $MAILTO exit 1 else echo " NFS server is UP ... We will continue..." >> $LOGFILE fi ## ## Mount NFS share to HOSTNAME. We do this using a soft mount in case it is lost during a backup ## mkdir $MOUNTPT mount $MOUNTP $NFSserver:${NFSdir} $MOUNTPT >> $LOGFILE 2>&1 ## ## Check to make sure mount command returned 0. If it did not odds are something else is mounted on /mnt.$$ ## if [ $? -ne 0 ]; then echo " Mount command did not work ... Please check server ... Odds are something is mounted on $MOUNTPT ..." >> $LOGFILE echo " $PROG failed on $HOSTNAME ... Mount command did not work ... Please check server ... Odds are something is mounted on $MOUNTPT ..." | mailx -s "$PROG Failed on $HOSTNAME" $MAILTO exit 1 else echo " Mount command returned a good status which means $MOUNPT was free for us to use ... We will now continue ..." >> $LOGFILE fi ## ## Now check to see if the mount worked ## if [ ! -f ${SRCFILE} ]; then echo " File ${SRCFILE} is missing... Maybe NFS mount did NOT WORK ... Please check server ..." >> $LOGFILE echo " $PROG failed on $HOSTNAME ... File ${SRCFILE} is missing... Maybe NFS mount did NOT WORK ... Please check server ..." | mailx -s "$PROG Failed on $HOSTNAME" $MA ILTO umount -f $MOUNTPT >> $LOGFILE rmdir $MOUNTPT >> $LOGFILE exit 1 else echo " NFS mount worked we are going to continue ..." >> $LOGFILE fi ## ## * Main Section * ## if [ ! -f ${BKUPFILE} ] then cp -p /etc/sudoers ${BKUPFILE} else echo "Backup file already exists$" >> ${LOGFILE} 2>&1 exit 1 fi if [ -f "$SRCFILE" ] then echo "Copying in new sudoers file from $SRCFILE." >> ${LOGFILE} 2>&1 cp -p $SRCFILE /etc/sudoers chmod 440 /etc/sudoers else echo "Source file not found" >> ${LOGFILE} 2>&1 exit 1 fi echo >> ${LOGFILE} 2>&1 visudo -c |tee -a ${LOGFILE} if [ $? -ne 0 ] then echo "sudoers syntax error on $HOSTNAME." >> ${LOGFILE} 2>&1 mailx -s "${PROG}: sudoers syntax error on $HOSTNAME" "$MAILTO" << EOF Syntax error /etc/sudoers on $HOSTNAME. Reverting changes Please investigate. EOF echo "Reverting changes." >> ${LOGFILE} 2>&1 cp -p ${BKUPFILE} /etc/sudoers else # # Update checksum file # grep -v '/etc/sudoers' ${MD5FILE} > ${MD5FILE}.tmp csum /etc/sudoers >> ${MD5FILE}.tmp mv ${MD5FILE}.tmp ${MD5FILE} chmod 600 ${MD5FILE} fi echo >> ${LOGFILE} 2>&1 if [ "${HOSTPFX}" = "hd" ] then printf "\nAppending #includedir /etc/sudoers.d at end of file.\n" >> ${LOGFILE} 2>&1 echo "" >> /etc/sudoers echo "## Read drop-in files from /etc/sudoers.d (the # here does not mean a comment)" >> /etc/sudoers echo "#includedir /etc/sudoers.d" >> /etc/sudoers fi ## ## * NFS Un-mount Section * ## ## ## Unmount /mnt.$$ directory ## umount ${MOUNTPT} >> $LOGFILE 2>&1 if [ -d ${MOUNTPT} ]; then rmdir ${MOUNTPT} >> $LOGFILE 2>&1 fi ## ## Make sure that /mnt.$$ got unmounted ## if [ -f ${SRCFILE} ]; then echo " The umount command failed to unmount ${MOUNTPT} ... We will not force the unmount ..." >> $LOGFILE umount -f ${MOUNTPT} >> $LOGFILE 2>&1 if [ -d ${MOUNTPT} ]; then rmdir ${MOUNTPT} >> $LOGFILE 2>&1 fi else echo " $MOUNTPT was unmounted ... There is no need for user intervention on $HOSTNAME ..." >> $LOGFILE fi # # as always, exit cleanly # clean_exit 0
Здесь 212 строк кода, при этом какой-либо контроль версий в файле sudoers отсутствует. У заказчика уже имеется некий процесс, который запускается раз в неделю и проверяет контрольную сумму файла для обеспечения безопасности. Хотя в скрипте есть отсылка к Solaris, для этого заказчика нам не пришлось переносить еще и это требование.
Начнем с того, что создадим роль и поместим файл sudoers в Git для контроля версий. Помимо прочего это позволит нам избавиться от необходимости монтирования NFS томов.
С параметрами «validate» и «backup» для модулей copy и template, мы можем избавиться от необходимости написания кода для создания резервных копий и восстановления файла. При этом валидация осуществляется перед тем, как файл будет помещен в точку назначения, и если валидация не проходит, модуль выдает ошибку.
Для каждой роли нам необходимо указать задачи, шаблоны и переменные. Вот как выглядит структура соответствующего файла:

Файл со сценариями ролей (плейбук), sudoers.yml, имеет простую структуру:
---
##
# Role playbook
##
- hosts: all
roles:
- sudoers
...
Переменные ролей расположены в файле vars/main.yml. Здесь указан файл с контрольной суммой и директивы include/exclude, которые будут использоваться для создания специальной логики, чтобы пропускать хосты “Lawson” и включать файл sudoers.d только в хосты «hd».
Вот содержимое файла vars/main.yml:
---
MD5FILE: /root/.sudoer.md5
EXCLUDE: la
INCLUDE: hd
...
Если мы используем модули copy и lineinfile, то роль не будет идемпотентной. Модуль copy установит базовый файл, и lineinfile при каждом запуске будет заново вставлять include. Поскольку эта роль будет запускаться на Ansible Tower, идемпотентность является обязательным требованием. Мы преобразуем файл в шаблон jinja2.
В первой строчке мы добавляем следующую команду для управления пробелами и отступами:
#jinja2: lstrip_blocks: True, trim_blocks: True
Обратите внимание, что более новые версии модуля template включают в себя параметры для trim_blocks (добавлено в Ansible 2.4).
Вот код, который вставляет строку include в конце файла:
{% if ansible_hostname[0:2] == INCLUDE %}
#includedir /etc/sudoers.d
{% endif %}
Используем условную конструкцию ( {% if %}, {% endif %} ) для shell команды, вставляющей строку для хостов, имена которых начинаются с символов «hd». Мы используем факты Ansible и фильтр [0:2] для парсинга имени хоста.
Теперь переходим к задачам. Во-первых, необходимо установить факт для парсинга имени хоста. Мы будем использовать в условной конструкции факт «parhost».
---
##
# Parse hostnames to grab 1st 2 characters
##
- name: "Parse hostname's 1st 2 characters"
set_fact: parhost={{ ansible_hostname[0:2] }}
На стоковом сервере RHEL параметр csum отсутствует. В случае необходимости мы можем использовать другой факт для условного указания имени бинарного файла с контрольной суммой. Обратите внимание, что может потребоваться дополнительный код, если эти функции отличаются в AIX, Solaris и Linux.
Кроме того, предстоит решить вопрос с различиями в группах root в AIX и RHEL.
##
# Conditionally set name of checksum binary
##
- name: "set checksum binary"
set_fact:
csbin: "{{ 'cksum' if (ansible_distribution == 'RedHat') else 'csum' }}"
##
# Conditionally set name of root group
##
- name: "set system group"
set_fact:
sysgroup: "{{ 'root' if (ansible_distribution == 'RedHat') else 'sys' }}"
Использование блоков (block) позволит нам задавать условие для всей задачи. Мы будем использовать условие в конце блока, чтобы исключить хосты «la».
##
# Enclose in block so we can use parhost to exclude hosts
##
- block:
Модуль шаблонов осуществляет валидацию и установку файла. Фиксируем результат, чтобы можно было определить, не поменялась ли задача. Использование параметра validate в этом модуле позволяет убедиться в валидности нового файла sudoer, перед тем как разместить его на хосте.
##
# Validate will prevent bad files, no need to revert
# Jinja2 template will add include line
##
- name: Ensure sudoers file
template:
src: sudoers.j2
dest: /etc/sudoers
owner: root
group: "{{ sysgroup }}"
mode: 0440
backup: yes
validate: /usr/sbin/visudo -cf %s
register: sudochg
Если был установлен новый шаблон, запускаем shell скрипт для генерации файла с контрольной суммой. Условная конструкция обновляет файл с контрольной суммой при установке шаблона sudoers, или если файл с контрольной суммой отсутствует. Поскольку запущенный процесс также отслеживает и другие файлы, мы используем shell код, представленный в исходном скрипте:
- name: sudoers checksum
shell: "grep -v '/etc/sudoers' {{ MD5FILE }} > {{ MD5FILE }}.tmp ; {{ csbin }} /etc/sudoers >> {{ MD5FILE }} ; mv {{ MD5FILE }}.tmp {{ MD5FILE }}"
when: sudochg.changed or MD5STAT.exists == false
Модуль file проверяет установку необходимых разрешений:
- name: Ensure MD5FILE permissions
file:
path: "{{ MD5FILE }}"
owner: root
group: "{{ sysgroup }}"
mode: 0600
state: file
Поскольку параметр backup не предусматривает каких-либо опций для обработки предыдущих резервных копий, нам придется самим позаботиться о создании соответствующего кода. В примере ниже мы используем для этого параметр «register» и поле «stdout_lines».
##
# List and clean up backup files. Retain 3 copies.
##
- name: List /etc/sudoers.*~ files
shell: "ls -t /etc/sudoers*~ |tail -n +4"
register: LIST_SUDOERS
changed_when: false
- name: Cleanup /etc/sudoers.*~ files
file:
path: "{{ item }}"
state: absent
loop: "{{ LIST_SUDOERS.stdout_lines }}"
when: LIST_SUDOERS.stdout_lines != ""
Завершение блока:
##
# This conditional restricts what hosts this block runs on
##
when: parhost != EXCLUDE
...
Предполагаемый сценарий использования заключается в том, чтобы запускать эту роль на Ansible Tower. Оповещения Ansible Tower можно сконфигурировать таким образом, чтобы в случае сбоя в исполнении задания оповещения приходили на электронную почту, в Slack или каким-либо иным образом. Эта роль запускается в Ansible, Ansible Engine или Ansible Tower.
В результате, мы удалили из скрипта все лишнее и создали полностью идемпотентную роль, которая способна обеспечить желаемое состояние файла sudoers. Использование SCM позволяет осуществлять контроль версий, обеспечивает более эффективное управление изменениями и прозрачность. CI/CD с Jenkins или другими инструментами позволяют наладить автоматизированное тестирование кода Ansible для будущих изменений. Роль Auditor в Ansible Tower позволяет контролировать и обеспечивать соблюдение требований организаций.
Из скрипта можно было бы удалить код для работы с контрольными суммами, но для этого заказчику потребовалось бы сначала проконсультироваться со своей службой безопасности. При необходимости шаблон sudoers можно защитить с помощью Ansible Vault. Наконец, использование групп позволяет избежать написания логики с применением includes и excludes.
→ Загрузить роль можно с GitHub по этой ссылке
