Введение в Puppet

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

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

izehu6zdsp0cjgeteijju4fjnc0.jpeg

Схема работы 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/
│   └── site.pp
└── modules/
    └── example/
        └── files/
            └── nginx-conf/
                ├── nginx.conf
                ├── mime.types
                └── conf.d/
                    └── 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::, либо просто 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')

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

Кроме того, есть функция 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 стоит, нужно установить ту или иную версию пакета. Можно следить за этим всем вручную, переписывая манифесты в случае изменения нод. Но эт

© Habrahabr.ru