Shell-скрипты в Ansible

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



    Что же, это вполне распространенный сценарий, и на его примере можно продемонстрировать использование расширенных возможностей 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 по этой ссылке
    • +10
    • 6,4k
    • 6
    Red Hat
    64,00
    Программные решения с открытым исходным кодом
    Поделиться публикацией

    Комментарии 6

      0

      Спасибо огромное за статью, но фитать портянки кода без форматирование очень не удобно

        +3

        Спасибо большое за интересный и очень практический пример использования ansible.
        Но пока читал, меня не отпускало ощущение, что можно все сделать красивее и лаконичнее.

          +9

          Ребята, Вам не стыдно такое переводить?)


          Структура папок:


          ├── README.md
          ├── roles
          │   └── sudoers
          │        ├── tasks
          │        │   └── main.yml
          │        ├── templates
          │        │   └── sudoers.j2
          │        └── vars
          │             └── main.yml
          └── sudoers.yml

          Красиво, понятно? А у вас так же, как в источнике.
          Серьезно, дали бы какому-то стажеру запустить ваш неработающий плейбук (роль).
          Вместо MD5STAT.exists надо MD5STAT.stat.exists.
          Халтура, одним словом.


          А теперь про культуру.


          shell: "grep -v '/etc/sudoers' {{ MD5FILE }} > {{ MD5FILE }}.tmp; {{ csbin }} /etc/sudoers >> {{ MD5FILE }}; mv {{ MD5FILE }}.tmp {{ MD5FILE }}"

          Какой смысл переходить на декларативный язык описания, и при этом продолжать тянуть за собой вот такие паровозы?
          Как такое поддерживать? Вспоминаются однострочники на перле.


          Сравните:


          - 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

          и мой вариант:


           - name: sudoers getcksum
             command: "{{ csbin }} /etc/sudoers"
             register: sudoers_crc
          
           - name: sudoers writecksum
             lineinfile:
               dest: "{{ MD5FILE }}"
               state: present
               regexp: " /etc/sudoers$"
               line: "{{ sudoers_crc.stdout }}"
               create: yes

          И мы идем дальше.
          Заметьте, там нет " when: sudochg.changed or MD5STAT.stat.exists == false". Не нужно больше.
          Целых два блока, нужены лишь для одного, не допустить ошибку типа grep:: No such file or directory
          И они нам теперь тоже не нужны:


             - name: "Check for checksum file"
               stat:
                 path: "{{ MD5FILE }}"
               register: MD5STAT
          
             - name: Ensure MD5FILE 
               file:
                 path: "{{ MD5FILE }}"
                 owner: root
                 group: "{{ sysgroup }}"
                 mode: 0600
                 state: touch
               when: MD5STAT.stat.exists == false

          Далее, поговорим про MD5, и зачем его пишут файл.
          Это делают для простоты проверки контрольных сумм. Скорее всего "некий процесс, который запускается раз в неделю и проверяет контрольную сумму файла " выглядит так:


          md5sum -c ~/.sudoer.md5
          exit $?

          Тогда файл надо генерировать так:


          md5sum /etc/sudoers >> ~/.sudoer.md5

          А в плейбуке будет так:


             - name: sudoers getcksum
               stat:
                 path: "/etc/sudoers"
                 checksum_algorithm: md5
               register: sudoers_crc
          
             - name: update MD5FILE
               lineinfile:
                 dest: "{{ MD5FILE }}"
                 state: present
                 regexp: "  /etc/sudoers$"
                 line: "{{ sudoers_crc.stat.checksum }}  /etc/sudoers"
                 create: yes

          И два блока set_fact: теперь тоже можно удалить.

            0

            К своему стыду, я тоже склонен ансибль использовать как "продвинутый Баш" и Ваш комментарий не в бровь, а в глаз (и соответствует моим ощущениям описанным выше, что автор "мог сделать роль красивее и лаконичнее).
            И коли уж улучшения внедрять, то я очень топлю за SaltStack, в котором такой ужас писать не надо


            #
            # 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

            А можно тупо сделать https://docs.saltstack.com/en/latest/ref/states/all/salt.states.file.html
            Тут тебе и бекап, и retention

              +3

              Да, этот кусок, тоже, пример плохого тона, и вспоминается вот этот заголовок:


              - 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 != ""

              Но, и это можно красиво переписать ансиблом:


                  - find: path="/etc" patterns="sudoers*"
                    register: files
              
                  - file:
                      path: "{{ item }}"
                      state: absent
                    with_items: "{{ (files.files | sort(attribute='ctime', reverse=True) | map(attribute='path')| list)[3:] }}"

              Как видите, все читается, и нет ничего сложного.
              Если что-то "сложное", то берем в руки питона и делаем filter_plugins.

              0
              - name: sudoers getcksum
                 command: "{{ csbin }} /etc/sudoers"
                 register: sudoers_crc

              Я бы ещё добавил changed_when: False, чтобы при проверке плейбуки в check-mode таска не отмечалась как changed.

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

            Самое читаемое