Puppet — это система управления конфигурацией. Он используется для приведения хостов к нужному состоянию и поддержания этого состояния.


Я работаю с Puppet уже больше пяти лет. Этот текст — по сути переведённая и переупорядоченная компиляция ключевых моментов из официальной документации, которая позволит новичкам быстро вникнуть в суть Puppet.



Базовая информация


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


Используется pull-модель работы: по умолчанию раз в полчаса клиенты обращаются к серверу за конфигурацией и применяют её. Если вы работали с Ansible, то там используется другая, push-модель: администратор инициирует процесс применения конфигурации, сами по себе клиенты ничего применять не будут.


При сетевом взаимодействии используется двустороннее TLS-шифрование: у сервера и клиента есть свои закрытые ключи и соответствующие им сертификаты. Обычно сервер выпускает сертификаты для клиентов, но в принципе возможно использование и внешнего CA.


Знакомство с манифестами


В терминологии Puppet к паппет-серверу подключаются ноды (nodes). Конфигурация для нод пишется в манифестах на специальном языке программирования — Puppet DSL.


Puppet DSL — декларативный язык. На нём описывается желаемое состояние ноды в виде объявления отдельных ресурсов, например:


  • Файл существует, и у него определённое содержимое.
  • Пакет установлен.
  • Сервис запущен.

Ресурсы могут быть взаимосвязаны:


  • Есть зависимости, они влияют на порядок применения ресурсов.
    Например, «сначала установи пакет, затем поправь конфигурационный файл, после этого запусти сервис».
  • Есть уведомления — если ресурс изменился, он отправляет уведомления подписанным на него ресурсам.
    Например, если изменяется конфигурационный файл, можно автоматически перезапускать сервис.

Кроме того, в Puppet DSL есть функции и переменные, а также условные операторы и селекторы. Также поддерживаются различные механизмы шаблонизации — EPP и ERB.


Puppet написан на Ruby, поэтому многие конструкции и термины взяты оттуда. Ruby позволяет расширять Puppet — дописывать сложную логику, новые типы ресурсов, функции.


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


Синтаксис и кодстайл


Вот разделы официальной документации, которые помогут разобраться с синтаксисом, если приведённых примеров будет недостаточно:



Вот пример того, как выглядит манифест:


# Комментарии пишутся, как и много где, после решётки.
#
# Описание конфигурации ноды начинается с ключевого слова node,
# за которым следует селектор ноды — хостнейм (с доменом или без)
# или регулярное выражение для хостнеймов, или ключевое слово default.
#
# После этого в фигурных скобках описывается собственно конфигурация ноды.
#
# Одна и та же нода может попасть под несколько селекторов. Про приоритет
# селекторов написано в статье про синтаксис описания нод.
node 'hostname', 'f.q.d.n', /regexp/ {
  # Конфигурация по сути является перечислением ресурсов и их параметров.
  #
  # У каждого ресурса есть тип и название.
  #
  # Внимание: не может быть двух ресурсов одного типа с одинаковыми названиями!
  #
  # Описание ресурса начинается с его типа. Тип пишется в нижнем регистре.
  # Про разные типы ресурсов написано ниже.
  #
  # После типа в фигурных скобках пишется название ресурса, потом двоеточие,
  # дальше идёт опциональное перечисление параметров ресурса и их значений.
  # Значения параметров указываются через т.н. hash rocket (=>).
  resource { 'title':
    param1 => value1,
    param2 => value2,
    param3 => value3,
  }
}

Отступы и переводы строк не являются обязательной частью манифеста, однако есть рекомендованный style guide. Краткое изложение:


  • Двухпробельные отступы, табы не используются.
  • Фигурные скобки отделяются пробелом, двоеточие пробелом не отделяется.
  • Запятые после каждого параметра, в том числе последнего. Каждый параметр — на отдельной строке. Исключение делается для случая без параметров и одного параметра: можно писать на одной строке и без запятой (т.е. resource { 'title': } и resource { 'title': param => value }).
  • Стрелки у параметров должны быть на одном уровне.
  • Стрелки взаимосвязи ресурсов пишутся перед ними.

Расположение файлов на паппетсервере


Для дальнейших объяснений я введу понятие «корневая директория». Корневая директория — это директория, в которой находится Puppet-конфигурация для конкретной ноды.


Корневая директория различается в зависимости от версии Puppet и использования окружений. Окружения — это независимые наборы конфигурации, которые хранятся в отдельных директориях. Обычно используются в  сочетании с гитом, в таком случае окружения создаются из веток гита. Соответственно, каждая нода находится в том или ином окружении. Это настраивается на самой ноде, либо в ENC, про что я расскажу в следующей статье.


  • В третьей версии («старый Паппет») базовой директорией была /etc/puppet. Использование окружений опциональное — мы, например, их не используем со старым Паппетом. Если окружения используются, то они обычно хранятся в /etc/puppet/environments, корневой директорией будет директория окружения. Если окружения не используются, корневой директорией будет базовая.
  • Начиная с четвёртой версии («новый Паппет») использование окружений стало обязательным, а базовую директорию перенесли в /etc/puppetlabs/code. Соответственно, окружения хранятся в /etc/puppetlabs/code/environments, корневая директория — директория окружения.

В корневой директории должна быть поддиректория manifests, в которой лежит один или несколько манифестов с описанием нод. Кроме того, там должна быть поддиректория modules, в которой лежат модули. Что такое модули, я расскажу чуть позже. Кроме того, в старом Паппете также может быть поддиректория files, в которой лежат различные файлы, которые мы копируем на ноды. В новом Паппете же все файлы вынесены в модули.


Файлы манифестов имеют расширение .pp.


Пара боевых примеров


Описание ноды и ресурса на ней


На ноде server1.testdomain должен быть создан файл /etc/issue с содержимым Debian GNU/Linux \n \l. Файл должен принадлежать пользователю и группе root, права доступа должны быть 644.


Пишем манифест:


node 'server1.testdomain' {   # блок конфигурации, относящийся к ноде server1.testdomain
    file { '/etc/issue':   # описываем файл /etc/issue
        ensure  => present,   # этот файл должен существовать
        content => 'Debian GNU/Linux \n \l',   # у него должно быть такое содержимое
        owner   => root,   # пользователь-владелец
        group   => root,   # группа-владелец
        mode    => '0644',   # права на файл. Они заданы в виде строки (в кавычках), потому что иначе число с 0 в начале будет воспринято как записанное в восьмеричной системе, и всё пойдёт не так, как задумано
    }
}

Взаимосвязи ресурсов на ноде


На ноде server2.testdomain должен быть запущен nginx, работающий с подготовленной заранее конфигурацией.


Декомпозируем задачу:


  • Нужно, чтобы был установлен пакет nginx.
  • Нужно, чтобы была скопированы конфигурационные файлы с сервера.
  • Нужно, чтобы был запущен сервис nginx.
  • В случае обновления конфигурации нужно перезапускать сервис.

Пишем манифест:


node 'server2.testdomain' {   # блок конфигурации, относящийся к ноде server2.testdomain
    package { 'nginx':   # описываем пакет nginx
        ensure => installed,   # он должен быть установлен
    }
  # Прямая стрелка (->) говорит о том, что ресурс ниже должен
  # создаваться после ресурса, описанного выше.
  # Такие зависимости транзитивны.
    -> file { '/etc/nginx':   # описываем файл /etc/nginx
        ensure  => directory,   # это должна быть директория
        source  => 'puppet:///modules/example/nginx-conf',   # её содержимое нужно брать с паппет-сервера по указанному адресу
        recurse => true,   # копировать файлы рекурсивно
        purge   => true,   # нужно удалять лишние файлы (те, которых нет в источнике)
        force   => true,   # удалять лишние директории
    }
  # Волнистая стрелка (~>) говорит о том, что ресурс ниже должен
  # подписаться на изменения ресурса, описанного выше.
  # Волнистая стрелка включает в себя прямую (->).
    ~> service { 'nginx':   # описываем сервис nginx
        ensure => running,   # он должен быть запущен
        enable => true,   # его нужно запускать автоматически при старте системы
    }
  # Когда ресурс типа service получает уведомление,
  # соответствующий сервис перезапускается.
}

Чтобы это работало, нужно примерно такое расположение файлов на паппет-сервере:


/etc/puppetlabs/code/environments/production/ # (это для нового Паппета, для старого корневой директорией будет /etc/puppet)
+-- manifests/
¦   L-- site.pp
L-- modules/
    L-- example/
        L-- files/
            L-- nginx-conf/
                +-- nginx.conf
                +-- mime.types
                L-- conf.d/
                    L-- some.conf

Типы ресурсов


Полный список поддерживаемых типов ресурсов находится в документации, здесь же я опишу пять базовых типов, которых в моей практике хватает для решения большинства задач.


file


Управляет файлами, директориями, симлинками, их содержимым, правами доступа.


Параметры:


  • название ресурса — путь к файлу (опционально)
  • path — путь к файлу (если он не задан в названии)
  • ensure — тип файла:
    • absent — удалить файл
    • present — должен быть файл любого типа (если файла нет — будет создан обычный файл)
    • file — обычный файл
    • directory — директория
    • link — симлинк
  • content — содержимое файла (подходит только для обычных файлов, нельзя использовать вместе с source или target)
  • source — ссылка на путь, из которого нужно копировать содержимое файла (нельзя использовать вместе с content или target). Может быть задана как в виде URI со схемой puppet: (тогда будут использованы файлы с паппет-сервера), так и со схемой http: (надеюсь, понятно, что будет в этом случае), и даже со схемой file: или в виде абсолютного пути без схемы (тогда будет использован файл с локальной ФС на ноде)
  • target — куда должен указывать симлинк (нельзя использовать вместе с content или source)
  • owner — пользователь, которому должен принадлежать файл
  • group — группа, которой должен принадлежать файл
  • mode — права на файл (в виде строки)
  • recurse — включает рекурсивную обработку директорий
  • purge — включает удаление файлов, которые не описаны в Puppet
  • force — включает удаление директорий, которые не описаны в Puppet

package


Устанавливает и удаляет пакеты. Умеет обрабатывать уведомления — переустанавливает пакет, если задан параметр reinstall_on_refresh.


Параметры:


  • название ресурса — название пакета (опционально)
  • name — название пакета (если не задано в названии)
  • provider — пакетный менеджер, который нужно использовать
  • ensure — желаемое состояние пакета:
    • present, installed — установлена любая версия
    • latest — установлена последняя версия
    • absent — удалён (apt-get remove)
    • purged — удалён вместе с конфигурационными файлами (apt-get purge)
    • held — версия пакета заблокирована (apt-mark hold)
    • любая другая строка — установлена указанная версия
  • reinstall_on_refresh — если true, то при получении уведомления пакет будет переустановлен. Полезно для source-based дистрибутивов, где пересборка пакетов может быть необходима при изменении параметров сборки. По умолчанию false.

service


Управляет сервисами. Умеет обрабатывать уведомления — перезапускает сервис.


Параметры:


  • название ресурса — сервис, которым нужно управлять (опционально)
  • name — сервис, которым нужно управлять (если не задано в названии)
  • ensure — желаемое состояние сервиса:
    • running — запущен
    • stopped — остановлен
  • enable — управляет возможностью запуска сервиса:
    • true — включен автозапуск (systemctl enable)
    • mask — замаскирован (systemctl mask)
    • false — выключен автозапуск (systemctl disable)
  • restart — команда для перезапуска сервиса
  • status — команда для проверки статуса сервиса
  • hasrestart — указать, поддерживает ли инитскрипт сервиса перезапуск. Если false и указан параметр restart — используется значение этого параметра. Если false и параметр restart не указан — сервис останавливается и запускается для перезапуска (но в systemd используется команда systemctl restart).
  • hasstatus — указать, поддерживает ли инитскрипт сервиса команду status. Если false, то используется значение параметра status. По умолчанию true.

exec


Запускает внешние команды. Если не указывать параметры creates, onlyif, unless или refreshonly, команда будет запускаться при каждом прогоне Паппета. Умеет обрабатывать уведомления — запускает команду.


Параметры:


  • название ресурса — команда, которую нужно выполнить (опционально)
  • command — команда, которую нужно выполнить (если она не задана в названии)
  • path — пути, в которых искать исполняемый файл
  • onlyif — если указанная в этом параметре команда завершилась с нулевым кодом возврата, основная команда будет выполнена
  • unless — если указанная в этом параметре команда завершилась с ненулевым кодом возврата, основная команда будет выполнена
  • creates — если указанный в этом параметре файл не существует, основная команда будет выполнена
  • refreshonly — если true, то команда будет запущена только в том случае, когда этот exec получает уведомление от других ресурсов
  • cwd — директория, из которой запускать команду
  • user — пользователь, от которого запускать команду
  • provider — с помощью чего запускать команду:
    • posix — просто создаётся дочерний процесс, обязательно указывать path
    • shell — команда запускается в шелле /bin/sh, можно не указывать path, можно использовать глоббинг, пайпы и прочие фичи шелла. Обычно определяется автоматически, если есть всякие спецсимволы (|, ;, &&, || и так далее).

cron


Управляет кронджобами.


Параметры:


  • название ресурса — просто какой-то идентификатор
  • ensure — состояние кронджоба:
    • present — создать, если не существует
    • absent — удалить, если существует
  • command — какую команду запускать
  • environment — в каком окружении запускать команду (список переменных окружения и их значений через =)
  • user — от какого пользователя запускать команду
  • minute, hour, weekday, month, monthday — когда запускать крон. Если какой-то из этих аттрибутов не указан, его значением в кронтабе будет *.

В Puppet 6.0 cron как бы удалили из коробки в puppetserver, поэтому нет документации на общем сайте. Но он есть в коробке в puppet-agent, поэтому ставить его отдельно не надо. Документацию по нему можно посмотреть в документации к пятой версии Паппета, либо на Гитхабе.


Про ресурсы в общем


Требования к уникальности ресурсов


Самая частая ошибка, с которой мы встречаемся — Duplicate declaration. Эта ошибка возникает, когда в каталог попадают два и более ресурса одинакового типа с одинаковым названием.


Поэтому ещё раз напишу: в манифестах для одной ноды не должно быть ресурсов одинакового типа с одинаковым названием (title)!


Иногда есть необходимость поставить пакеты с одинаковым названием, но разными пакетными менеджерами. В таком случае нужно пользоваться параметром name, чтобы избежать ошибки:


package { 'ruby-mysql':
  ensure   => installed,
  name     => 'mysql',
  provider => 'gem',
}
package { 'python-mysql':
  ensure   => installed,
  name     => 'mysql',
  provider => 'pip',
}

В других типах ресурсов есть аналогичные параметры, помогающие избежать дубликации, — name у service, command у exec, и так далее.


Метапараметры


Некоторые специальные параметры есть у каждого типа ресурса, независимо от его сущности.


Полный список метапараметров в документации Puppet.


Краткий список:


  • require — в этом параметре указывается, от каких ресурсов зависит данный ресурс.
  • before — в этом параметре указывается, какие ресурсы зависят от данного ресурса.
  • subscribe — в этом параметре указывается, от каких ресурсов получает уведомления данный ресурс.
  • notify — в этом параметре указывается, какие ресурсы получают уведомления от данного ресурса.

Все перечисленные метапараметры принимают либо одну ссылку на ресурс, либо массив ссылок в квадратных скобках.


Ссылки на ресурсы


Ссылка на ресурс — это просто упоминание ресурса. Используются они в основном для указания зависимостей. Ссылка на несуществующий ресурс вызовет ошибку компиляции.


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


Пример:


file { '/file1': ensure => present }
file { '/file2':
  ensure => directory,
  before => File['/file1'],
}
file { '/file3': ensure => absent }
File['/file1'] -> File['/file3']

Зависимости и уведомления


Документация здесь.


Как уже было сказано ранее, простые зависимости между ресурсами транзитивны. Кстати, будьте внимательны при проставлении зависимостей — можно сделать циклические зависимости, что вызовет ошибку компиляции.


В отличие от зависимостей, уведомления не транзитивны. Для уведомлений действуют следующие правила:


  • Если ресурс получает уведомление, он обновляется. Действия при обновлении зависят от типа ресурса — exec запускает команду, service перезапускает сервис, package переустанавливает пакет. Если для ресурса не определено действие при обновлении, то ничего не происходит.
  • За один прогон Паппета ресурс обновляется не больше одного раза. Это возможно, так как уведомления включают в себя зависимости, а граф зависимостей не содержит циклов.
  • Если Паппет меняет состояние ресурса, то ресурс отправляет уведомления всем подписанным на него ресурсам.
  • Если ресурс обновляется, то он отправляет уведомления всем подписанным на него ресурсам.

Обработка неуказанных параметров


Как правило, если у какого-то параметра ресурса нет значения по умолчанию и этот параметр не указан в манифесте, то Паппет не будет менять это свойство у соответствующего ресурса на ноде. Например, если у ресурса типа file не указан параметр owner, то Паппет не будет менять владельца у соответствующего файла.


Знакомство с классами, переменными и дефайнами


Предположим, у нас несколько нод, на которых есть одинаковая часть конфигурации, но есть и различия — иначе мы могли бы описать это всё в одном блоке node {}. Конечно, можно просто скопировать одинаковые части конфигурации, но в общем случае это плохое решение — конфигурация разрастается, при изменении общей части конфигурации придётся править одно и то же во множестве мест. При этом легко ошибиться, ну и вообще принцип DRY (don’t repeat yourself) не просто так придумали.


Для решения такой проблемы есть такая конструкция, как класс.


Классы


Класс — это именованный блок паппет-кода. Классы нужны для переиспользования кода.


Сначала класс нужно описать. Само по себе описание не добавляет никуда никакие ресурсы. Класс описывается в манифестах:


# Описание класса начинается с ключевого слова class и его названия.
# Дальше идёт тело класса в фигурных скобках.
class example_class {
    ...
}

После этого класс можно использовать:


# первый вариант использования — в стиле ресурса с типом class
class { 'example_class': }
# второй вариант использования — с помощью функции include
include example_class
# про отличие этих двух вариантов будет рассказано дальше

Пример из предыдущей задачи — вынесем установку и настройку nginx в класс:


class nginx_example {
    package { 'nginx':
        ensure => installed,
    }
    -> file { '/etc/nginx':
        ensure => directory,
        source => 'puppet:///modules/example/nginx-conf',
        recure => true,
        purge  => true,
        force  => true,
    }
    ~> service { 'nginx':
        ensure => running,
        enable => true,
    }
}

node 'server2.testdomain' {
    include nginx_example
}

Переменные


Класс из предыдущего примера совсем не гибок, потому что он всегда приносит одну и ту же конфигурацию nginx. Давайте сделаем так, чтобы путь к конфигурации стал переменным, тогда этот класс можно будет использовать для установки nginx с любой конфигурацией.


Это можно сделать с помощью переменных.


Внимание: переменные в Puppet неизменяемые!


Кроме того, обращаться к переменной можно только после того, как её объявили, иначе значением переменной окажется undef.


Пример работы с переменными:


# создание переменных
$variable = 'value'
$var2 = 1
$var3 = true
$var4 = undef
# использование переменных
$var5 = $var6
file { '/tmp/text': content => $variable }
# интерполяция переменных — раскрытие значения переменных в строках. Работает только в двойных кавычках!
$var6 = "Variable with name variable has value ${variable}"

В Puppet есть пространства имён, а у переменных, соответственно, есть область видимости: переменная с одним и тем же именем может быть определена в разных пространствах имён. При разрешении значения переменной переменная ищется в текущем неймспейсе, потом в объемлющем, и так далее.


Примеры пространства имён:


  • глобальное — туда попадают переменные вне описания класса или ноды;
  • пространство имён ноды в описании ноды;
  • пространство имён класса в описании класса.

Чтобы избежать неоднозначности при обращении к переменной, можно указывать пространство имён в имени переменной:


# переменная без пространства имён
$var
# переменная в глобальном пространстве имён
$::var
# переменная в пространстве имён класса
$classname::var
$::classname::var

Договоримся, что путь к конфигурации nginx лежит в переменной $nginx_conf_source. Тогда класс будет выглядеть следующим образом:


class nginx_example {
    package { 'nginx':
        ensure => installed,
    }
    -> file { '/etc/nginx':
        ensure => directory,
        source => $nginx_conf_source,   # здесь используем переменную вместо фиксированной строки
        recure => true,
        purge  => true,
        force  => true,
    }
    ~> service { 'nginx':
        ensure => running,
        enable => true,
    }
}

node 'server2.testdomain' {
    $nginx_conf_source = 'puppet:///modules/example/nginx-conf'
    include nginx_example
}

Однако приведённый пример плох тем, что есть некое «тайное знание» о том, что где-то внутри класса использует переменная с таким-то именем. Гораздо более правильно сделать это знание общим — у классов могут быть параметры.


Параметры класса — это переменные в пространстве имён класса, они задаются в заголовке класса и могут быть использованы как обычные переменные в теле класса. Значения параметров указывается при использовании класса в манифесте.


Параметру можно задать значение по умолчанию. Если у параметра нет значения по умолчанию и значение не задано при использовании, это вызовет ошибку компиляции.


Давайте параметризуем класс из примера выше и добавим два параметра: первый, обязательный — путь к конфигурации, и второй, необязательный — название пакета с nginx (в Debian, например, есть пакеты nginx, nginx-light, nginx-full).


# переменные описываются сразу после имени класса в круглых скобках
class nginx_example (
  $conf_source,
  $package_name = 'nginx-light', # параметр со значением по умолчанию
) {
  package { $package_name:
    ensure => installed,
  }
  -> file { '/etc/nginx':
    ensure  => directory,
    source  => $conf_source,
    recurse => true,
    purge   => true,
    force   => true,
  }
  ~> service { 'nginx':
    ensure => running,
    enable => true,
  }
}

node 'server2.testdomain' {
  # если мы хотим задать параметры класса, функция include не подойдёт* — нужно использовать resource-style declaration
  # *на самом деле подойдёт, но про это расскажу в следующей серии. Ключевое слово "Hiera".
  class { 'nginx_example':
    conf_source => 'puppet:///modules/example/nginx-conf',   # задаём параметры класса точно так же, как параметры для других ресурсов
  }
}

В Puppet переменные типизированы. Есть много типов данных. Типы данных обычно используются для валидации значений параметров, передаваемых в классы и дефайны. Если переданный параметр не соответствует указанному типу, произойдёт ошибка компиляции.


Тип пишется непосредственно перед именем параметра:


class example (
  String $param1,
  Integer $param2,
  Array $param3,
  Hash $param4,
  Hash[String, String] $param5,
) {
  ...
}

Классы: include classname vs class{'classname':}


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


Если попробовать добавить класс на одну и ту же ноду два раза с помощью class { 'classname':} (без разницы, с разными или с одинаковыми параметрами), будет ошибка компиляции. Зато в случае использования класса в стиле ресурса можно тут же в манифесте явно задать все его параметры.


Однако если использовать include, то класс можно добавлять сколько угодно раз. Дело в том, что include — идемпотентная функция, которая проверяет, добавлен ли класс в каталог. Если класса в каталоге нет — добавляет его, а если уже есть, то ничего не делает. Но в случае использования include нельзя задать параметры класса во время объявления класса — все обязательные параметры должны быть заданы во внешнем источнике данных — Hiera или ENC. О них мы поговорим в следующей статье.


Дефайны


Как было сказано в предыдущем блоке, один и тот же класс не может присутствовать на ноде более одного раза. Однако в некоторых случаях нужно иметь возможность применять один и тот же блок кода с разными параметрами на одной ноде. Иными словами, есть потребность в собственном типе ресурса.


Например, для того, чтобы установить модуль PHP, мы в Авито делаем следующее:


  1. Устанавливаем пакет с этим модулем.
  2. Создаём конфигурационный файл для этого модуля.
  3. Создаём симлинк на конфиг для php-fpm.
  4. Создаём симлинк на конфиг для php cli.

В таких случаях используется такая конструкция, как дефайн (define, defined type, defined resource type). Дефайн похож на класс, но есть отличия: во-первых, каждый дефайн является типом ресурса, а не ресурсом; во-вторых, у каждого дефайна есть неявный параметр $title, куда попадает имя ресурса при его объявлении. Так же как и в случае с классами, дефайн сначала нужно описать, после этого его можно использовать.


Упрощённый пример с модулем для PHP:


define php74::module (
  $php_module_name = $title,
  $php_package_name = "php7.4-${title}",
  $version = 'installed',
  $priority = '20',
  $data = "extension=${title}.so\n",
  $php_module_path = '/etc/php/7.4/mods-available',
) {
  package { $php_package_name:
    ensure          => $version,
    install_options => ['-o', 'DPkg::NoTriggers=true'],  # триггеры дебиановских php-пакетов сами создают симлинки и перезапускают сервис php-fpm - нам это не нужно, так как и симлинками, и сервисом мы управляем с помощью Puppet
  }
  -> file { "${php_module_path}/${php_module_name}.ini":
    ensure  => $ensure,
    content => $data,
  }
  file { "/etc/php/7.4/cli/conf.d/${priority}-${php_module_name}.ini":
    ensure  => link,
    target  => "${php_module_path}/${php_module_name}.ini",
  }
  file { "/etc/php/7.4/fpm/conf.d/${priority}-${php_module_name}.ini":
    ensure  => link,
    target  => "${php_module_path}/${php_module_name}.ini",
  }
}

node server3.testdomain {
  php74::module { 'sqlite3': }
  php74::module { 'amqp': php_package_name => 'php-amqp' }
  php74::module { 'msgpack': priority => '10' }
}

В дефайне проще всего поймать ошибку Duplicate declaration. Это происходит, если в дефайне есть ресурс с константным именем, и на какой-то ноде два и более экземпляра этого дефайна.


Защититься от этого просто: все ресурсы внутри дефайна должны иметь название, зависящее от $title. В качестве альтернативы — идемпотентное добавление ресурсов, в простейшем случае достаточно вынести общие для всех экземпляров дефайна ресурсы в отдельный класс и инклюдить этот класс в дефайне — функция include идемпотентна.


Есть и другие способы достигнуть идемпотентности при добавлении ресурсов, а именно использование функций defined и ensure_resources, но про это расскажу в следующей серии.


Зависимости и уведомления для классов и дефайнов


Классы и дефайны добавляют следующие правила к обработке зависимостей и уведомлений:


  • зависимость от класса/дефайна добавляет зависимости от всех ресурсов класса/дефайна;
  • зависимость класса/дефайна добавляет зависимости всем ресурсам класса/дефайна;
  • уведомление класса/дефайна уведомляет все ресурсы класса/дефайна;
  • подписка на класс/дефайн подписывает на все ресурсы класса/дефайна.

Условные операторы и селекторы


Документация здесь.


if


Тут всё просто:


if ВЫРАЖЕНИЕ1 {
  ...
} elsif ВЫРАЖЕНИЕ2 {
  ...
} else {
  ...
}

unless


unless — это if наоборот: блок кода будет выполнен, если выражение ложно.


unless ВЫРАЖЕНИЕ {
  ...
}

case


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


case ВЫРАЖЕНИЕ {
  ЗНАЧЕНИЕ1: { ... }
  ЗНАЧЕНИЕ2, ЗНАЧЕНИЕ3: { ... }
  default: { ... }
}

Селекторы


Селектор — это языковая конструкция, похожая на case, только вместо выполнения блока кода она возвращает значение.


$var = $othervar ? { 'val1' => 1, 'val2' => 2, default => 3 }

Модули


Когда конфигурация маленькая, её легко можно держать в одном манифесте. Но чем больше конфигурации мы описываем, тем больше классов и нод становится в манифесте, он разрастается, с ним становится неудобно работать.


Кроме того, есть проблема переиспользования кода — когда весь код в одном манифесте, сложно этим кодом делиться с другими. Для решения этих двух проблем в Puppet есть такая сущность, как модули.


Модули — это наборы классов, дефайнов и прочих Puppet-сущностей, вынесенных в отдельную директорию. Иными словами, модуль — это независимый кусок Puppet-логики. Например, может быть модуль для работы с nginx, и в нём будет то и только то, что нужно для работы с nginx, а может быть модуль для работы с PHP, и так далее.


Модули версионируются, также поддерживаются зависимости модулей друг от друга. Есть открытый репозиторий модулей — Puppet Forge.


На паппет-сервере модули лежат в поддиректории modules корневой директории. Внутри каждого модуля стандартная схема директорий — manifests, files, templates, lib и так далее.


Структура файлов в модуле


В корне модуля могут быть следующие директории с говорящими названиями:


  • manifests — в ней лежат манифесты
  • files — в ней лежат файлы
  • templates — в ней лежат шаблоны
  • lib — в ней лежит Ruby-код

Это не полный список директорий и файлов, но для этой статьи пока достаточно.


Названия ресурсов и имена файлов в модуле


Документация здесь.


Ресурсы (классы, дефайны) в модуле нельзя называть как угодно. Кроме того, есть прямое соответствие между названием ресурса и именем файла, в котором Puppet будет искать описание этого ресурса. Если нарушать правила именования, то Puppet просто не найдёт описание ресурсов, и получится ошибка компиляции.


Правила простые:


  • Все ресурсы в модуле должны быть в неймспейсе модуля. Если модуль называется foo, то все ресурсы в нём должны называться foo::<anything>, либо просто foo.
  • Ресурс с названием модуля должен быть в файле init.pp.
  • Для остальных ресурсов схема именования файлов следующая:
    • префикс с именем модуля отбрасывается
    • все двойные двоеточия, если они есть, заменяются на слеши
    • дописывается расширение .pp

Продемонстрирую на примере. Предположим, я пишу модуль nginx. В нём есть следующие ресурсы:


  • класс nginx описан в манифесте init.pp;
  • класс nginx::service описан в манифесте service.pp;
  • дефайн nginx::server описан в манифесте server.pp;
  • дефайн nginx::server::location описан в манифесте server/location.pp.

Шаблоны


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


Как использовать шаблоны: значение шаблона можно раскрыть с помощью функции template, которой передаётся путь к шаблону. Для ресурсов типа file используем вместе с параметром content. Например, так:


file { '/tmp/example': content => template('modulename/templatename.erb')

Путь вида <modulename>/<filename> подразумевает файл <rootdir>/modules/<modulename>/templates/<filename>.


Кроме того, есть функция inline_template — ей на вход передаётся текст шаблона, а не имя файла.


Внутри шаблонов можно использовать все переменные Puppet в текущей области видимости.


Puppet поддерживает шаблоны в формате ERB и EPP:



Вкратце про ERB


Управляющие конструкции:


  • <%= ВЫРАЖЕНИЕ %> — вставить значение выражения
  • <% ВЫРАЖЕНИЕ %> — вычислить значение выражение (не вставляя его). Сюда обычный идут условные операторы (if), циклы (each).
  • <%# КОММЕНТАРИЙ %>

Выражения в ERB пишутся на Ruby (собственно, ERB — это Embedded Ruby).


Для доступа к переменным из манифеста нужно дописать @ к имени переменной. Чтобы убрать перевод строки, появляющийся после управляющей конструкции, нужно использовать закрывающий тег -%>.


Пример использования шаблона


Предположим, я пишу модуль для управления ZooKeeper. Класс, отвечающий за создание конфига, выглядит примерно так:


class zookeeper::configure (
  Array[String] $nodes,
  Integer $port_client,
  Integer $port_quorum,
  Integer $port_leader,
  Hash[String, Any] $properties,
  String $datadir,
) {
  file { '/etc/zookeeper/conf/zoo.cfg':
    ensure  => present,
    content => template('zookeeper/zoo.cfg.erb'),
  }
}

А соответствующий ему шаблон zoo.cfg.erb — так:


<% if @nodes.length > 0 -%>
<% @nodes.each do |node, id| -%>
server.<%= id %>=<%= node %>:<%= @port_leader %>:<%= @port_quorum %>;<%= @port_client %>
<% end -%>
<% end -%>

dataDir=<%= @datadir %>

<% @properties.each do |k, v| -%>
<%= k %>=<%= v %>
<% end -%>

Факты и встроенные переменные


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


Для получения информации о нодах в Puppet есть такой механизм, как факты. Факты — это информация о ноде, доступная в манифестах в виде обычных переменных в глобальном пространстве имён. Например, имя хоста, версия операционной системы, архитектура процессора, список пользователей, список сетевых интерфейсов и их адресов, и многое, многое другое. Факты доступны в манифестах и шаблонах как обычные переменные.


Пример работы с фактами:


notify { "Running OS ${facts['os']['name']} version ${facts['os']['release']['full']}": }
# ресурс типа notify просто выводит сообщение в лог

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


Во время работы паппет-агент сначала копирует с паппетсервера на ноду все доступные сборщики фактов, после чего запускает их и отправляет на сервер собранные факты; уже после этого сервер начинает компиляцию каталога.


Факты в виде исполняемых файлов


Такие факты кладутся в модули в директорию facts.d. Разумеется, файлы должны быть исполняемыми. При запуске они должны выводить на стандартный вывод информацию либо в формате YAML, либо в формате "ключ=значение".


Не забывайте, что факты распространяются на все ноды, которые находятся под управлением паппет-сервера, на который выкатывается ваш модуль. Поэтому в скрипте озаботьтесь проверкой того, что в системе есть все необходимые для работы вашего факта программы и файлы.


#!/bin/sh
echo "testfact=success"

#!/bin/sh
echo '{"testyamlfact":"success"}'

Факты на Ruby


Такие факты кладутся в модули в директорию lib/facter.


# всё начинается с вызова функции Facter.add с именем факта и блоком кода
Facter.add('ladvd') do
# в блоках confine описываются условия применимости факта — код внутри блока должен вернуть true, иначе значение факта не вычисляется и не возвращается
  confine do
    Facter::Core::Execution.which('ladvdc') # проверим, что в PATH есть такой исполняемый файл
  end
  confine do
    File.socket?('/var/run/ladvd.sock') # проверим, что есть такой UNIX-domain socket
  end
# в блоке setcode происходит собственно вычисление значения факта
  setcode do
    hash = {}
    if (out = Facter::Core::Execution.execute('ladvdc -b'))
      out.split.each do |l|
        line = l.split('=')
        next if line.length != 2
        name, value = line
        hash[name.strip.downcase.tr(' ', '_')] = value.strip.chomp('\'').reverse.chomp('\'').reverse
      end
    end
    hash  # значение последнего выражения в блоке setcode является значением факта
  end
end

Текстовые факты


Такие факты кладутся на ноды в директорию /etc/facter/facts.d в старом Паппете или /etc/puppetlabs/facts.d в новом Паппете.


examplefact=examplevalue

---
examplefact2: examplevalue2
anotherfact: anothervalue

Обращение к фактам


Обратиться к фактам можно двумя способами:


  • через словарь $facts: $facts['fqdn'];
  • используя имя факта как имя переменной: $fqdn.

Лучше всего использовать словарь $facts, а ещё лучше указывать глобальный неймспейс ($::facts).


Вот нужный раздел документации.


Встроенные переменные


Кроме фактов, есть ещё некоторые переменные, доступные в глобальном пространстве имён.


  • trusted facts — переменные, которые берутся из сертификата клиента (так как сертификат обычно выпускается на паппет-сервере, агент не может просто так взять и поменять свой сертификат, поэтому переменные и «доверенные»): название сертификата, имя хоста и домена, расширения из сертификата.
  • server facts —переменные, относящиеся к информации о сервере — версия, имя, IP-адрес сервера, окружение.
  • agent facts — переменные, добавляемые непосредственно puppet-agent'ом, а не facter'ом — название сертификата, версия агента, версия паппета.
  • master variables — переменные паппетмастера (sic!). Там примерно то же самое, что в server facts, плюс доступны значения конфигурационных параметров.
  • compiler variables — переменные компилятора, которые различаются в каждой области видимости: имя текущего модуля и имя модуля, в котором было обращение к текущему объекту. Их можно использовать, например, чтобы проверять, что ваши приватные классы не используют напрямую из других модулей.

Дополнение 1: как это всё запускать и дебажить?


В статье было много примеров puppet-кода, но совсем не было рассказано, как же этот код запускать. Что ж, исправляюсь.


Для работы Puppet достаточно агента, но для большинства случаев нужен будет и сервер.


Агент


Как минимум с пятой версии пакеты puppet-agent из официального репозитория Puppetlabs содержат в себе все зависимости (ruby и соответствующие gem'ы), поэтому сложностей с установкой никаких нет (говорю про Debian-based дистрибутивы — RPM-based дистрибутивами мы не пользуемся).


В простейшем случае для применения puppet-конфигурации достаточно запустить агент в беcсерверном режиме: при условии, что puppet-код скопирован на ноду, запускаете puppet apply <путь к манифесту>:


atikhonov@atikhonov ~/puppet-test $ cat helloworld.pp 
node default {
    notify { 'Hello world!': }
}
atikhonov@atikhonov ~/puppet-test $ puppet apply helloworld.pp 
Notice: Compiled catalog for atikhonov.localdomain in environment production in 0.01 seconds
Notice: Hello world!
Notice: /Stage[main]/Main/Node[default]/Notify[Hello world!]/message: defined 'message' as 'Hello world!'
Notice: Applied catalog in 0.01 seconds

Лучше, конечно, поднять сервер и запустить агенты на нодах в режиме демона — тогда раз в полчаса они будут применять конфигурацию, скачанную с сервера.


Можно имитировать push-модель работы — зайти на интересующую вас ноду и запустить sudo puppet agent -t. Ключ -t (--test) на самом деле включает несколько опций, которые можно включать и по отдельности. Среди этих опций следующие:


  • не работать в режиме демона (по умолчанию агент запускается в режиме демона);
  • завершить работу после применения каталога (по умолчанию агент продолжит работу и будет применять конфигурацию раз в полчаса);
  • писать подробный лог работы;
  • показывать изменения в файлах.

У агента есть режим работы без изменений — им можно пользоваться в случае, когда вы не уверены, что написали корректную конфигурацию, и хотите проверить, что именно поменяет агент во время работы. Включается этот режим параметром --noop в командной строке: sudo puppet agent -t --noop.


Кроме того, можно включить отладочный лог работы — в нём puppet пишет обо всех действиях, которые он производит: о ресурсе, который в данный момент обрабатывает, о параметрах этого ресурса, о том, какие программы запускает. Разумеется, это параметр --debug.


Сервер


Полноценную настройку паппетсервера и деплой на него кода в этой статье я не буду рассматривать, скажу лишь, что из коробки ставится вполне работоспособная версия сервера, не требующая дополнительной настройки для работы в условиях небольшого количества нод (скажем, до ста). Большее количество нод уже потребует тюнинга — по умолчанию puppetserver запускает не больше четырёх воркеров, для большей производительности нужно увеличить их число и не забыть увеличить лимиты памяти, иначе большую часть времени сервер будет garbage collect'ить.


Деплой кода — если нужно быстро и просто, то смотрите (на r10k)[https://github.com/puppetlabs/r10k], для небольших инсталляций его вполне должно хватить.


Дополнение 2: рекомендации по написанию кода


  1. Выносите всю логику в классы и дефайны.
  2. Держите классы и дефайны в модулях, а не в манифестах с описанием нод.
  3. Пользуйтесь фактами.
  4. Не делайте if'ов по хостнеймам.
  5. Не стесняйтесь добавлять параметры для классов и дефайнов — это лучше, чем неявная логика, спрятанная в теле класса/дефайна.

А почему я рекомендую так делать — объясню в следующей статье.


Заключение


На этом закончим со введением. В следующей статье расскажу про Hiera, ENC и PuppetDB.